aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ffi.mjs174
-rw-r--r--src/lustre.ffi.mjs198
-rw-r--r--src/lustre.gleam12
-rw-r--r--src/lustre/attribute.gleam2
-rw-r--r--src/lustre/element.gleam12
5 files changed, 209 insertions, 189 deletions
diff --git a/src/ffi.mjs b/src/ffi.mjs
deleted file mode 100644
index b6883d4..0000000
--- a/src/ffi.mjs
+++ /dev/null
@@ -1,174 +0,0 @@
-import * as Cmd from "./lustre/cmd.mjs";
-import * as Gleam from "./gleam.mjs";
-import * as React from "react";
-import * as ReactDOM from "react-dom";
-
-// -----------------------------------------------------------------------------
-
-export const mount = ({ init, update, render }, selector) => {
- const el = document.querySelector(selector);
-
- if (!el) {
- console.warn(
- [
- "[lustre] Oops, it looks like I couldn't find an element on the ",
- 'page matching the selector "' + selector + '".',
- "",
- "Hint: make sure you aren't 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 can't happen.",
- ].join("\n")
- );
-
- return new Gleam.Error();
- }
-
- // OK this looks sus, what's going on here? We want to be able to return the
- // dispatch function given to us from `useReducer` so that the rest of our
- // Gleam code *outside* the application and dispatch commands.
- //
- // That's great but because `useReducer` is part of the callback passed to
- // `createElement` we can't just save it to a variable and return it. We
- // immediately render the app so this all happens synchronously. There's no
- // chance of accidentally returning `null` and our Gleam code consuming it as
- // if it was a function.
- //
- let dispatch = null;
-
- let init_cmds = null;
-
- const App = React.createElement(() => {
- const [state, $dispatch] = React.useReducer(
- (state, action) => {
- let [new_state, cmds] = update(state, action)
- // Handle cmds immediately, they're not React-kind-of-state
- for (const cmd of Cmd.to_list(cmds)) {
- cmd(dispatch);
- }
- return new_state
- },
- undefined,
- () => {
- let [state, cmds] = init
- // postpone handling cmds, as we do not have the dispatch, yet
- init_cmds = cmds
- return state
- }
- );
-
- if (dispatch === null) dispatch = $dispatch;
-
- const el = render(state);
-
- React.useEffect(() => {
- for (const cmd of Cmd.to_list(init_cmds)) {
- cmd($dispatch);
- }
- init_cmds = undefined; // Just so we get an error, rather than re-execute
- }, []); // empty deps array means this effect will only run on initial render
-
- return typeof el == "string" ? el : el($dispatch);
- });
-
- ReactDOM.createRoot(el).render(App);
-
- return new Gleam.Ok(dispatch);
-};
-
-// -----------------------------------------------------------------------------
-
-export const node = (tag, attributes, children) => (dispatch) => {
- return React.createElement(
- tag,
- toProps(attributes, dispatch),
- // Recursively pass down the dispatch function to all children. Text nodes
- // – constructed below – aren't functions
- //
- ...children.toArray().map((child) => (typeof child === "function" ? child(dispatch) : child))
- );
-};
-
-export const stateful = (init, render) => (dispatch) => {
- const [state, setState] = React.useState(init);
-
- return render(state, setState)(dispatch);
-};
-
-export const fragment = (children) => (dispatch) => {
- return React.createElement(
- React.Fragment,
- null,
- ...children.toArray().map((child) => (typeof child === "function" ? child(dispatch) : child))
- );
-};
-
-// This is just an identity function! We need it because we want to trick Gleam
-// into converting a `String` into an `Element(action)` .
-//
-export const text = (content) => content;
-
-// -----------------------------------------------------------------------------
-
-export const map = (element, f) => (dispatch) => {
- return element((action) => dispatch(f(action)));
-};
-
-// React expects an object of "props" where the keys are attribute names
-// like "class" or "onClick" and their corresponding values. Lustre's API
-// works with lists, though.
-//
-// The snippet below converts our Gleam list of attributes to a a plain ol'
-// object. Duplicate attributes are replaced with the last one in the list,
-// except for string attributes which are concatenated together.
-//
-export const toProps = (attributes, dispatch) => {
- const capitalise = (s) => s && s[0].toUpperCase() + s.slice(1);
-
- 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 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);
-
- return { ...props, [name]: handler };
- }
-
- // 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 object = (entries) => Object.fromEntries(entries);
diff --git a/src/lustre.ffi.mjs b/src/lustre.ffi.mjs
new file mode 100644
index 0000000..4713750
--- /dev/null
+++ b/src/lustre.ffi.mjs
@@ -0,0 +1,198 @@
+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();
+ }
+
+ 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;
+};
+
+// 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 "";
+ }
+};
+
+//
+export const stateful = (init, render) => {
+ const [state, setState] = React.useState(init);
+
+ return React.createElement(() => render(state, setState));
+};
+
+//
+export const text = (content) => content;
+
+//
+export const fragment = (children) => {
+ return React.createElement(React.Fragment, {}, ...children.toArray());
+};
+
+//
+export const map = (element, f) =>
+ React.createElement(() => {
+ const dispatch = React.useContext(Dispatcher);
+ const mappedDispatch = React.useCallback(
+ (msg) => dispatch(f(msg)),
+ [dispatch]
+ );
+
+ return React.createElement(
+ Dispatcher.Provider,
+ { value: mappedDispatch },
+ React.createElement(element)
+ );
+ });
+
+// HOOKS -----------------------------------------------------------------------
+
+export const useLustreInternalDispatch = () => {
+ return React.useContext(Dispatcher);
+};
+
+// 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 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);
+
+ return { ...props, [name]: handler };
+ }
+
+ // 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;
+ }
+ }, {});
+};
diff --git a/src/lustre.gleam b/src/lustre.gleam
index bdf6225..54f6c55 100644
--- a/src/lustre.gleam
+++ b/src/lustre.gleam
@@ -4,7 +4,7 @@
import lustre/cmd.{Cmd}
import lustre/element.{Element}
-import gleam/result
+import gleam/javascript/promise.{Promise}
// TYPES -----------------------------------------------------------------------
@@ -242,16 +242,12 @@ pub fn application(
/// function from your `main` (or elsewhere) you can get events into your Lustre
/// app from the outside world.
///
-pub fn start(
- app: App(model, msg),
- selector: String,
-) -> Result(fn(msg) -> Nil, Error) {
+pub fn start(app: App(model, msg), selector: String) -> Promise(fn(msg) -> Nil) {
mount(app, selector)
- |> result.replace_error(ElementNotFound)
}
external fn mount(
app: App(model, msg),
selector: String,
-) -> Result(fn(msg) -> Nil, Nil) =
- "./ffi.mjs" "mount"
+) -> Promise(fn(msg) -> Nil) =
+ "./lustre.ffi.mjs" "mount"
diff --git a/src/lustre/attribute.gleam b/src/lustre/attribute.gleam
index 6fa609f..bac3b0c 100644
--- a/src/lustre/attribute.gleam
+++ b/src/lustre/attribute.gleam
@@ -39,7 +39,7 @@ pub fn style(properties: List(#(String, String))) -> Attribute(msg) {
}
external fn style_object(properties: List(#(String, String))) -> Dynamic =
- "../ffi.mjs" "object"
+ "../lustre.ffi.mjs" "to_object"
///
pub fn class(name: String) -> Attribute(msg) {
diff --git a/src/lustre/element.gleam b/src/lustre/element.gleam
index d9692e8..85c6852 100644
--- a/src/lustre/element.gleam
+++ b/src/lustre/element.gleam
@@ -21,7 +21,7 @@ pub external fn node(
attrs: List(Attribute(msg)),
children: List(Element(msg)),
) -> Element(msg) =
- "../ffi.mjs" "node"
+ "../lustre.ffi.mjs" "node"
/// A stateful element is exactly what it sounds like: an element with local
/// encapsulated state! The `render` function we must provide is called with the
@@ -66,18 +66,18 @@ pub external fn stateful(
init: state,
render: fn(state, fn(state) -> Nil) -> Element(msg),
) -> Element(msg) =
- "../ffi.mjs" "stateful"
+ "../lustre.ffi.mjs" "stateful"
/// A fragment doesn't appear in the DOM, but allows us to treat a list of elements
/// as if it were a single one.
///
pub external fn fragment(children: List(Element(msg))) -> Element(msg) =
- "../ffi.mjs" "fragment"
+ "../lustre.ffi.mjs" "fragment"
/// Render a Gleam string as an HTML text node.
///
pub external fn text(content: String) -> Element(msg) =
- "../ffi.mjs" "text"
+ "../lustre.ffi.mjs" "text"
// MANIPULATIONS ---------------------------------------------------------------
@@ -177,8 +177,8 @@ pub external fn text(content: String) -> Element(msg) =
/// If this feels like a lt of work... sometimes it is! Take a look at the docs
/// for [`stateful`](#stateful) elements to see how all this can be encapsulated.
///
-pub external fn map(element: Element(a), f: fn(a) -> b) -> Element(b) =
- "../ffi.mjs" "map"
+pub external fn map(element: fn() -> Element(a), f: fn(a) -> b) -> Element(b) =
+ "../lustre.ffi.mjs" "map"
// CONSTRUCTING NODES ----------------------------------------------------------
// This list and grouping of nodes has been taken from the MDN reference at: