diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/ffi.mjs | 174 | ||||
-rw-r--r-- | src/lustre.ffi.mjs | 198 | ||||
-rw-r--r-- | src/lustre.gleam | 12 | ||||
-rw-r--r-- | src/lustre/attribute.gleam | 2 | ||||
-rw-r--r-- | src/lustre/element.gleam | 12 |
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: |