aboutsummaryrefslogtreecommitdiff
path: root/src/lustre.ffi.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'src/lustre.ffi.mjs')
-rw-r--r--src/lustre.ffi.mjs290
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());