diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2023-07-10 23:11:32 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-07-10 23:11:32 +0100 |
commit | f350db196bcab490b8a6b67f9536f0b9b7322073 (patch) | |
tree | 59664ca671be2cfdb473424ca1bf737cf12622e8 /src/lustre.ffi.mjs | |
parent | 6d314230346336ba5b452b1df39b908ffa666f45 (diff) | |
download | lustre-f350db196bcab490b8a6b67f9536f0b9b7322073.tar.gz lustre-f350db196bcab490b8a6b67f9536f0b9b7322073.zip |
♻️ Replace React with diffhtml (#10)
* :wrench: Remove react dependency, add vite for running examples.
* :heavy_plus_sign: Update stdlib version to 0.29
* :fire: Remove old examples.
* :sparkles: Vendor diffhtml and update runtime ffi code to replace react.
* :recycle: Refactor all the things now react is gone.
* :memo: Remove references to react in the readme.
* :sparkles: Create a simple counter example.
Diffstat (limited to 'src/lustre.ffi.mjs')
-rw-r--r-- | src/lustre.ffi.mjs | 290 |
1 files changed, 108 insertions, 182 deletions
diff --git a/src/lustre.ffi.mjs b/src/lustre.ffi.mjs index b39db16..74923d1 100644 --- a/src/lustre.ffi.mjs +++ b/src/lustre.ffi.mjs @@ -1,201 +1,127 @@ -import * as Cmd from "./lustre/cmd.mjs"; -import * as React from "react"; -import * as ReactDOM from "react-dom/client"; - -const Dispatcher = React.createContext(null); - -export const mount = (app, selector) => { - const el = document.querySelector(selector); - - if (!el) { - console.warn( - [ - "[lustre] Oops, it looks like I couldnt find an element on the ", - 'page matching the selector "' + selector + '".', - "", - "Hint: make sure you arent running your script before the rest of ", - "the HTML document has been parsed! you can add the `defer` attribute ", - "to your script tag to make sure that cant happen.", - ].join("\n") - ); - - return Promise.reject(); +import { innerHTML, createTree } from "./runtime.ffi.mjs"; +import { Ok, Error, List } from "./gleam.mjs"; +import { Some, Option } from "../gleam_stdlib/gleam/option.mjs"; + +// RUNTIME --------------------------------------------------------------------- + +/// +/// +export class App { + #root = null; + #state = null; + #queue = []; + #commands = []; + #willUpdate = false; + #didUpdate = false; + + // These are the three functions that the user provides to the runtime. + #__init; + #__update; + #__render; + + constructor(init, update, render) { + this.#__init = init; + this.#__update = update; + this.#__render = render; } - let dispatchRef = null; - let dispatchPromise = new Promise((resolve) => (dispatchRef = resolve)); - - ReactDOM.createRoot(el).render( - React.createElement( - React.StrictMode, - null, - React.createElement( - React.forwardRef((_, ref) => { - // When wrapped in `<React.StrictMode />` and when in development - // mode, React will run effects (and some other hooks) twice to - // help us debug potential issues that arise from impurity. - // - // This is a problem for our cmds because they are intentionally - // impure. We can/should expect user code to be pure, but we want - // to allow top-level impurity in the form of cmds. - // - // So we can keep the benefits of strict mode, we add an additional - // bit of state to track whether we need to run the cmds we have or - // not. - const [shouldRunCmds, setShouldRunCmds] = React.useState(true); - const [[state, cmds], dispatch] = React.useReducer( - ([state, _], msg) => { - // Every time we call the user's update function we'll get back a - // new lot of cmds to run, so we need to set this flag to true to - // let our `useEffect` know it can run them! - setShouldRunCmds(true); - return app.update(state, msg); - }, - app.init - ); - - React.useImperativeHandle(ref, () => dispatch, [dispatch]); - React.useEffect(() => { - if (shouldRunCmds && cmds) { - for (const cmd of Cmd.to_list(cmds)) { - cmd(dispatch); - } - - // Once we've performed the side effects, we'll toggle this flag - // back to false so we don't run them again on subsequent renders - // or during development. - setShouldRunCmds(false); - } - }, [cmds, shouldRunCmds]); - - return React.createElement( - Dispatcher.Provider, - { value: dispatch }, - React.createElement(({ state }) => app.render(state), { state }) - ); - }), - { ref: dispatchRef } - ) - ) - ); - - return dispatchPromise; -}; + start(selector = "body") { + if (this.#root) return this; -// ELEMENTS -------------------------------------------------------------------- - -// -export const node = (tag, attributes, children) => { - const dispatch = React.useContext(Dispatcher); - const props = to_props(attributes, dispatch); - - try { - return React.createElement(tag, props, ...children.toArray()); - } catch (_) { - console.warn([ - "[lustre] Something went wrong while trying to render a node with the ", - 'tag "' + tag + "\". To prevent a runtime crash, I'm going to render an ", - "empty text node instead.", - "", - "Hint: make sure you arent trying to render a node with a tag that ", - "is compatible with the renderer you are using. For example, you can't ", - 'render a "div" node with the terminal renderer.', - "", - "If you think this might be a bug, please open an issue at ", - "https://github.com/hayleigh-dot-dev/gleam-lustre/issues", - ]); - return ""; - } -}; + try { + this.#root = document.querySelector(selector); + } catch (_) { + return new Error(undefined); + } -// -export const stateful = (init, render) => { - const [state, setState] = React.useState(init); + const [next, cmds] = this.#__init(); + this.#state = next; + this.#commands = cmds[0].toArray(); + this.#didUpdate = true; - return React.createElement(() => render(state, setState)); -}; + window.requestAnimationFrame(this.#tick.bind(this)); + return new Ok((msg) => this.dispatch(msg)); + } -// -export const text = (content) => content; + dispatch(msg) { + if (!this.#willUpdate) window.requestAnimationFrame(this.#tick.bind(this)); -// -export const fragment = (children) => { - return React.createElement(React.Fragment, {}, ...children.toArray()); -}; + this.#queue.push(msg); + this.#willUpdate = true; + } -// -export const map = (element, f) => - React.createElement(() => { - const dispatch = React.useContext(Dispatcher); - const mappedDispatch = React.useCallback( - (msg) => dispatch(f(msg)), - [dispatch] + #render() { + const node = this.#__render(this.#state); + const tree = createTree( + map(node, (msg) => { + if (msg instanceof Some) this.dispatch(msg[0]); + }) ); - return React.createElement( - Dispatcher.Provider, - { value: mappedDispatch }, - React.createElement(element) - ); - }); + innerHTML(this.#root, tree); + } -// HOOKS ----------------------------------------------------------------------- + #tick() { + this.#flush(); + this.#didUpdate && this.#render(); + this.#willUpdate = false; + } -export const useLustreInternalDispatch = () => { - return React.useContext(Dispatcher); -}; + #flush(times = 0) { + if (this.#queue.length) { + while (this.#queue.length) { + const [next, cmds] = this.#__update(this.#state, this.#queue.shift()); -// UTILS ----------------------------------------------------------------------- - -// This function takes a Gleam `List` of key/value pairs (in the form of a Gleam -// tuple, which is in turn a JavaScript array) and converts it into a normal -// JavaScript object. -// -export const to_object = (entries) => Object.fromEntries(entries.toArray()); - -const capitalise = (s = "") => s[0].toUpperCase() + s.slice(1); -const to_props = (attributes, dispatch) => { - return attributes.toArray().reduce((props, attr) => { - // The constructors for the `Attribute` type are not public in the - // gleam source to prevent users from constructing them directly. - // This has the unfortunate side effect of not letting us `instanceof` - // the constructors to pattern match on them and instead we have to - // rely on the structure to work out what kind of attribute it is. - // - if ("name" in attr && "value" in attr) { - const prop = - attr.name in props && typeof props[attr.name] === "string" - ? props[attr.name] + " " + attr.value - : attr.value; - - return { ...props, [attr.name]: prop }; + this.#state = next; + this.#commands.concat(cmds[0].toArray()); + } + + this.#didUpdate = true; } - // This case handles `Event` variants. - else if ("name" in attr && "handler" in attr) { - const name = "on" + capitalise(attr.name); - const handler = (e) => attr.handler(e, dispatch); + // Each update can produce commands which must now be executed. + while (this.#commands.length) this.#commands.shift()(this.dispatch); - return { ...props, [name]: handler }; + // Synchronous commands will immediately queue a message to be processed. If + // it is reasonable, we can process those updates too before proceeding to + // the next render. + if (this.#queue.length) { + times >= 5 ? console.warn(tooManyUpdates) : this.#flush(++times); } + } +} - // This should Never Happen™️ but if it does we don't want everything - // to explode, so we'll print a friendly error, ignore the attribute - // and carry on as normal. - // - else { - console.warn( - [ - "[lustre] Oops, I'm not sure how to handle attributes with ", - 'the type "' + attr.constructor.name + '". Did you try calling ', - "this function from JavaScript by mistake?", - "", - "If not, it might be an error in lustre itself. Please open ", - "an issue at https://github.com/hayleigh-dot-dev/gleam-lustre/issues", - ].join("\n") - ); - - return props; - } - }, {}); +export const setup = (init, update, render) => new App(init, update, render); +export const start = (app, selector = "body") => app.start(selector); + +// VDOM ------------------------------------------------------------------------ + +export const node = (tag, attrs, children) => + createTree(tag, Object.fromEntries(attrs.toArray()), children.toArray()); +export const text = (content) => content; +export const attr = (key, value) => { + if (value instanceof List) return [key, value.toArray()]; + if (value instanceof Option) return [key, value?.[0]]; + + return [key, value]; }; +export const on = (event, handler) => [`on${event}`, handler]; +export const map = (node, f) => ({ + ...node, + attributes: Object.entries(node.attributes).reduce((attrs, [key, value]) => { + // It's safe to mutate the `attrs` object here because we created it at + // the start of the reduce: it's not shared with any other code. + + // If the attribute is an event handler, wrap it in a function that + // transforms + if (key.startsWith("on") && typeof value === "function") { + attrs[key] = (e) => f(value(e)); + } else { + attrs[key] = value; + } + + return attrs; + }, {}), + childNodes: node.childNodes.map((child) => map(child, f)), +}); +export const styles = (list) => Object.fromEntries(list.toArray()); |