From cd79e697c94fdce5f74ca4c31a9d2e28f0f9a3c3 Mon Sep 17 00:00:00 2001 From: Eli Knebel Date: Mon, 13 Jan 2025 23:18:28 -0500 Subject: [PATCH] Improve reconnections and refactor client hooks (#12) * convert basic reconnect to use reconnecting websocket * remove comment * client hook improvements * more client hook improvements * format * remove debug meta statements * handle client hook events at the top level * format * clean up client emit names --- client/src/events.ts | 3 +- client/src/hooks.ts | 200 ++++++++++++------ client/src/render.ts | 10 +- client/src/sprocket.ts | 62 +++--- package.json | 3 +- src/sprocket.gleam | 71 +++++-- src/sprocket/context.gleam | 48 ++++- src/sprocket/internal/logger.gleam | 1 + src/sprocket/internal/patch.gleam | 14 +- src/sprocket/internal/reconcile.gleam | 2 +- .../internal/reconcilers/recursive.gleam | 13 +- src/sprocket/renderers/json.gleam | 4 +- src/sprocket/runtime.gleam | 126 +++++++---- yarn.lock | 5 + 14 files changed, 374 insertions(+), 188 deletions(-) diff --git a/client/src/events.ts b/client/src/events.ts index bdc11e7..ec910ed 100644 --- a/client/src/events.ts +++ b/client/src/events.ts @@ -1,4 +1,5 @@ import { On } from "snabbdom"; +import ReconnectingWebSocket from "reconnecting-websocket"; export type EventIdentifier = { kind: string; @@ -12,7 +13,7 @@ export type EventHandlerProvider = ( export const initEventHandlerProvider = ( - socket: WebSocket, + socket: ReconnectingWebSocket, customEventEncoders: Record = {} ): EventHandlerProvider => (elementTag, events: EventIdentifier[]) => diff --git a/client/src/hooks.ts b/client/src/hooks.ts index d4d924e..94a45b8 100644 --- a/client/src/hooks.ts +++ b/client/src/hooks.ts @@ -1,97 +1,169 @@ +import ReconnectingWebSocket from "reconnecting-websocket"; import { Module } from "snabbdom"; type PushEvent = (event: string, payload: any) => void; +type ElementId = string; +type HookName = string; + export type Hook = { - el: Element; + el: Node; pushEvent: PushEvent; handleEvent: (event: string, handler: (payload: any) => any) => void; + [key: string]: any; }; export interface HookIdentifier { name: string; - id: string; } -export type ClientHookProvider = (elementHooks: HookIdentifier[]) => Module; +type Emit = { + id: ElementId; + hook: HookName; + kind: string; + payload: any; +}; + +export type ClientHookProvider = { + hook: (elementHooks: HookIdentifier[]) => Module; + handle_emit: (emit: Emit) => void; +}; export const initClientHookProvider = ( - socket: WebSocket, + socket: ReconnectingWebSocket, hooks: Record = {} ): ClientHookProvider => { - let clientHookMap: Record = {}; - - return (elementHooks: HookIdentifier[]) => ({ - create: (emptyVNode, vnode) => { - elementHooks.forEach((h) => { - const { id: hookId, name: hookName } = h; - - const pushEvent = (name: string, payload: any) => { - socket.send( - JSON.stringify(["hook:event", { id: hookId, name, payload }]) - ); - }; - - const handleEvent = (event: string, handler: (payload: any) => any) => { - socket.addEventListener("message", function (msg) { - let parsed = JSON.parse(msg.data); - - if (Array.isArray(parsed)) { - switch (parsed[0]) { - case "hook:event": - if (parsed[1].id === hookId && parsed[1].kind === event) { - handler(parsed[1].payload); - } - break; - } - } - }); - }; - - clientHookMap[hookId] = { - el: vnode.elm, - name: hookName, - pushEvent, - handleEvent, - }; - - execClientHook(hooks, clientHookMap, hookName, hookId, "create"); - }); - }, - insert: (vnode) => { - elementHooks.forEach((h) => { - const { id: hookId, name: hookName } = h; - execClientHook(hooks, clientHookMap, hookName, hookId, "insert"); - }); - }, - update: (oldVNode, vnode) => { - elementHooks.forEach((h) => { - const { id: hookId, name: hookName } = h; - execClientHook(hooks, clientHookMap, hookName, hookId, "update"); - }); - }, - destroy: (vnode) => { - elementHooks.forEach((h) => { - const { id: hookId, name: hookName } = h; - execClientHook(hooks, clientHookMap, hookName, hookId, "destroy"); + let clientHookMap: Record> = {}; + + return { + hook: (elementHooks: HookIdentifier[]) => ({ + create: (emptyVNode, vnode) => { + const elementId = vnode.data.elementId; + + elementHooks.forEach((h) => { + const { name: hookName } = h; + + const pushEvent = (kind: string, payload: any) => { + socket.send( + JSON.stringify([ + "hook:event", + { id: vnode.data.elementId, hook: hookName, kind, payload }, + ]) + ); + }; + + const handleEvent = ( + kind: string, + handler: (payload: any) => any + ) => { + clientHookMap[elementId][hookName].handlers = [ + ...(clientHookMap[elementId][hookName].handlers || []), + { kind, handler }, + ]; + }; + + // Initialize the client hook map if it doesn't already exist and add the hook + clientHookMap[vnode.data.elementId] = { + ...(clientHookMap[vnode.data.elementId] || {}), + [hookName]: { + el: vnode.elm, + pushEvent, + handleEvent, + }, + }; + + execClientHook(hooks, clientHookMap, elementId, hookName, "create"); + }); + }, + insert: (vnode) => { + const elementId = vnode.data.elementId; + + elementHooks.forEach((h) => { + const { name: hookName } = h; + + execClientHook(hooks, clientHookMap, elementId, hookName, "insert"); + }); + }, + update: (oldVNode, vnode) => { + const elementId = vnode.data.elementId; + + // If the element id has changed, we need to update the client hook map to reflect the new element id + if (oldVNode.data.elementId !== vnode.data.elementId) { + // Move the hook state to the new element id + clientHookMap[vnode.data.elementId] = + clientHookMap[oldVNode.data.elementId]; + + delete clientHookMap[oldVNode.data.elementId]; + } + + elementHooks.forEach((h) => { + const { name: hookName } = h; + + // If the element id has changed, we also need to update the pushEvent function for each hook + if (oldVNode.data.elementId !== vnode.data.elementId) { + // Update the pushEvent function to use the new element id + const pushEvent = (kind: string, payload: any) => { + socket.send( + JSON.stringify([ + "hook:event", + { id: vnode.data.elementId, hook: hookName, kind, payload }, + ]) + ); + }; + + clientHookMap[vnode.data.elementId][hookName].pushEvent = pushEvent; + } + + execClientHook(hooks, clientHookMap, elementId, hookName, "update"); + }); + }, + destroy: (vnode) => { + const elementId = vnode.data.elementId; + + elementHooks.forEach((h) => { + const { name: hookName } = h; + + execClientHook(hooks, clientHookMap, elementId, hookName, "destroy"); + + delete clientHookMap[elementId]; + }); + }, + }), + handle_emit: (emit) => { + // find handler by elementId + const { id: elementId, hook: hookName, kind: eventKind, payload } = emit; + + const handlers = + clientHookMap[elementId] && + clientHookMap[elementId][hookName] && + clientHookMap[elementId][hookName].handlers; - delete clientHookMap[hookId]; - }); + if (handlers) { + handlers.forEach((h) => { + if (h.kind === eventKind) { + h.handler(payload); + } + }); + } }, - }); + }; }; function execClientHook( hooks: Record, clientHookMap: Record, + elementId: string, hookName: string, - hookId: string, method: string ) { const hook = hooks[hookName]; if (hook) { - hook[method] && hook[method](clientHookMap[hookId]); + hook[method] && + hook[method].call( + clientHookMap[elementId][hookName], + clientHookMap[elementId][hookName] + ); } else { throw new Error(`Client hook ${hookName} not found`); } diff --git a/client/src/render.ts b/client/src/render.ts index 5e121d3..b9a9179 100644 --- a/client/src/render.ts +++ b/client/src/render.ts @@ -32,6 +32,7 @@ export function render( interface Element { type: "element"; + id: string; tag: string; attrs: Record; events: EventIdentifier[]; @@ -44,17 +45,22 @@ function renderElement(element: Element, providers: Providers): VNode { let { clientHookProvider, eventHandlerProvider } = providers; let data: VNodeData = { attrs: element.attrs }; + // It's important that we set the elementId on the vnode data here + // so that we can reference it in the client hooks when we receive + // and update to check if the elementId has changed and update the + // client hook map accordingly. + data.elementId = element.id; + if (element.key) { data.key = element.key; } - // TODO: figure out how to actually ignore updates with snabbdom if (element.ignore) { data.ignore = true; } if (element.hooks.length > 0) { - data.hook = clientHookProvider(element.hooks); + data.hook = clientHookProvider.hook(element.hooks); } // wire up event handlers diff --git a/client/src/sprocket.ts b/client/src/sprocket.ts index 999e43a..3af335a 100644 --- a/client/src/sprocket.ts +++ b/client/src/sprocket.ts @@ -1,4 +1,5 @@ import topbar from "topbar"; +import ReconnectingWebSocket from "reconnecting-websocket"; import { init, attributesModule, eventListenersModule, VNode } from "snabbdom"; import { render, Providers } from "./render"; import { applyPatch } from "./patch"; @@ -14,7 +15,7 @@ export type ClientHook = { }; type Patcher = ( - oldVNode: VNode | Element | DocumentFragment, + currentVNode: VNode | Element | DocumentFragment, vnode: VNode ) => VNode; @@ -35,11 +36,13 @@ export function connect( if (!csrfToken) throw new Error("csrfToken is required"); const ws_protocol = location.protocol === "https:" ? "wss:" : "ws:"; - const socket = new WebSocket(ws_protocol + "//" + location.host + path); + const socket = new ReconnectingWebSocket( + ws_protocol + "//" + location.host + path + ); let dom: Record; - let oldVNode: VNode; - let reconnectAttempt = opts.reconnectAttempt || 0; + let currentVNode: VNode; + let firstConnect = true; const patcher = init( [attributesModule, eventListenersModule, rawHtmlModule], @@ -66,7 +69,7 @@ export function connect( topbar.config({ barColors: { 0: "#29d" }, barThickness: 2 }); topbar.show(500); - socket.addEventListener("open", function (event) { + socket.addEventListener("open", function (_event) { socket.send( JSON.stringify([ "join", @@ -75,22 +78,29 @@ export function connect( ); }); - socket.addEventListener("message", function (event) { - let parsed = JSON.parse(event.data); + socket.addEventListener("message", function (msg) { + let parsed = JSON.parse(msg.data); if (Array.isArray(parsed)) { switch (parsed[0]) { case "ok": topbar.hide(); - reconnectAttempt = 0; - // Render the full initial DOM dom = parsed[1]; - oldVNode = render(dom, providers) as VNode; + const rendered = render(dom, providers) as VNode; + + if (firstConnect) { + firstConnect = false; - // Patch the target element - patcher(targetEl, oldVNode); + // Patch the target element + patcher(targetEl, rendered); + } else { + // Patch the currentVNode element + patcher(currentVNode, rendered); + } + + currentVNode = rendered; break; @@ -101,7 +111,12 @@ export function connect( dom = applyPatch(dom, patch, updateOpts) as Element; // Update the target DOM element - oldVNode = update(patcher, oldVNode, dom, providers); + currentVNode = update(patcher, currentVNode, dom, providers); + + break; + + case "hook:emit": + clientHookProvider.handle_emit(parsed[1]); break; @@ -116,36 +131,19 @@ export function connect( socket.addEventListener("close", function (_event) { topbar.show(); - - // Attempt to reconnect after a delay (e.g., 5 seconds) - setTimeout(() => { - console.log("Attempting to reconnect..."); - - // Reinitialize the socket connection - connect(path, oldVNode || targetEl, csrfToken, { - ...opts, - reconnectAttempt: reconnectAttempt + 1, - }); - }, backoffReconnectAfterMs(reconnectAttempt)); }); } // update the target DOM element using a given JSON DOM function update( patcher: Patcher, - oldVNode: VNode, + currentVNode: VNode, patched: Record, providers: Providers ) { const rendered = render(patched, providers) as VNode; - patcher(oldVNode, rendered); + patcher(currentVNode, rendered); return rendered; } - -function backoffReconnectAfterMs(reconnectAttempt = 0) { - return ( - [10, 50, 100, 150, 200, 250, 500, 1000, 2000][reconnectAttempt] || 5000 - ); -} diff --git a/package.json b/package.json index 1efd98d..6f59db6 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "build": "gleam build && yarn run client:build" }, "dependencies": { + "reconnecting-websocket": "^4.4.0", "snabbdom": "^3.5.1", "topbar": "^2.0.1" } -} \ No newline at end of file +} diff --git a/src/sprocket.gleam b/src/sprocket.gleam index 39f46b9..bf1dd19 100644 --- a/src/sprocket.gleam +++ b/src/sprocket.gleam @@ -57,7 +57,12 @@ pub fn new( type Payload { JoinPayload(csrf_token: String, initial_props: Option(Dict(String, String))) EventPayload(element_id: String, kind: String, payload: Dynamic) - HookEventPayload(id: String, event: String, payload: Option(Dynamic)) + HookEventPayload( + element_id: String, + hook: String, + kind: String, + payload: Option(Dynamic), + ) EmptyPayload(nothing: Option(String)) } @@ -77,7 +82,7 @@ pub fn handle_ws(spkt: Sprocket(p), msg: String) -> Result(Response(p), String) case json.decode( msg, - dynamic.any([decode_join, decode_event, decode_hook_event, decode_empty]), + dynamic.any([decode_join, decode_hook_event, decode_event, decode_empty]), ) { Ok(#("join", JoinPayload(csrf, initial_props))) -> { @@ -99,26 +104,41 @@ pub fn handle_ws(spkt: Sprocket(p), msg: String) -> Result(Response(p), String) } } } - Ok(#("event", EventPayload(element_id, kind, payload))) -> { - logger.debug("Event: element " <> element_id <> " " <> kind) + Ok(#("hook:event", HookEventPayload(element_id, hook_name, kind, payload))) -> { + logger.debug( + "Hook Event: element " <> element_id <> " " <> hook_name <> " " <> kind, + ) use runtime <- require_runtime(spkt) - runtime.process_event(runtime, element_id, kind, payload) + let reply_emitter = fn(kind, payload) { + let _ = + hook_emit_to_json(element_id, hook_name, kind, payload) + |> spkt.ws_send() + |> result.map_error(fn(e) { + logger.error_meta("Error sending hook event reply", e) + }) + + Nil + } + + runtime.process_client_hook( + runtime, + element_id, + hook_name, + kind, + payload, + reply_emitter, + ) Ok(Empty) } - Ok(#("hook:event", HookEventPayload(id, event, payload))) -> { - logger.debug("Hook Event: " <> event <> " " <> id) + Ok(#("event", EventPayload(element_id, kind, payload))) -> { + logger.debug("Event: element " <> element_id <> " " <> kind) use runtime <- require_runtime(spkt) - let reply_emitter = fn(event, payload) { - hook_event_to_json(id, event, payload) - |> spkt.ws_send() - } - - runtime.process_client_hook(runtime, id, event, payload, reply_emitter) + runtime.process_event(runtime, element_id, kind, payload) Ok(Empty) } @@ -164,9 +184,9 @@ fn connect( |> spkt.ws_send() }) - let emitter = fn(id, event, payload) { + let emitter = fn(element_id, hook, kind, payload) { let _ = - hook_event_to_json(id, event, payload) + hook_emit_to_json(element_id, hook, kind, payload) |> spkt.ws_send() Ok(Nil) @@ -221,10 +241,11 @@ fn decode_hook_event(data: Dynamic) { data |> dynamic.tuple2( dynamic.string, - dynamic.decode3( + dynamic.decode4( HookEventPayload, field("id", dynamic.string), - field("name", dynamic.string), + field("hook", dynamic.string), + field("kind", dynamic.string), optional_field("payload", dynamic.dynamic), ), ) @@ -255,22 +276,28 @@ fn update_to_json(update: RenderedUpdate, debug: Bool) -> Json { } } -fn hook_event_to_json( +fn hook_emit_to_json( id: String, - event: String, + hook: String, + kind: String, payload: Option(String), ) -> String { json.preprocessed_array([ - json.string("hook:event"), + json.string("hook:emit"), case payload { Some(payload) -> json.object([ #("id", json.string(id)), - #("kind", json.string(event)), + #("hook", json.string(hook)), + #("kind", json.string(kind)), #("payload", json.string(payload)), ]) None -> - json.object([#("id", json.string(id)), #("kind", json.string(event))]) + json.object([ + #("id", json.string(id)), + #("hook", json.string(hook)), + #("kind", json.string(kind)), + ]) }, ]) |> json.to_string() diff --git a/src/sprocket/context.gleam b/src/sprocket/context.gleam index cd58ae9..9cb2f37 100644 --- a/src/sprocket/context.gleam +++ b/src/sprocket/context.gleam @@ -16,6 +16,14 @@ pub type EventHandler { EventHandler(id: Unique(ElementId), kind: String, cb: fn(Dynamic) -> Nil) } +pub type ClientHookId { + ClientHookId( + element_id: Unique(ElementId), + name: String, + hook_id: Unique(HookId), + ) +} + pub type Attribute { Attribute(name: String, value: Dynamic) Event(kind: String, cb: fn(Dynamic) -> Nil) @@ -45,7 +53,10 @@ pub type Updater(r) { } pub type EventEmitter = - fn(String, String, Option(String)) -> Result(Nil, Nil) + fn(String, String, String, Option(String)) -> Result(Nil, Nil) + +pub type ClientHookEmitter = + fn(Unique(HookId), String, Option(String)) -> Nil pub type ComponentHooks = OrderedMap(Int, Hook) @@ -69,7 +80,7 @@ pub type MemoResult { } pub type ClientDispatcher = - fn(String, Option(String)) -> Result(Nil, Nil) + fn(String, Option(String)) -> Nil pub type ClientEventHandler = fn(String, Option(Dynamic), ClientDispatcher) -> Nil @@ -154,9 +165,10 @@ pub type Context { view: Element, wip: ComponentWip, handlers: List(EventHandler), + client_hooks: List(ClientHookId), render_update: fn() -> Nil, update_hook: fn(Unique(HookId), fn(Hook) -> Hook) -> Nil, - emit: fn(Unique(HookId), String, Option(String)) -> Result(Nil, Nil), + emit: fn(Unique(HookId), String, Option(String)) -> Nil, cuid_channel: Subject(cuid.Message), providers: Dict(String, Dynamic), ) @@ -165,7 +177,7 @@ pub type Context { pub fn new( view: Element, cuid_channel: Subject(cuid.Message), - emitter: Option(EventEmitter), + emit: Option(ClientHookEmitter), render_update: fn() -> Nil, update_hook: fn(Unique(HookId), fn(Hook) -> Hook) -> Nil, ) -> Context { @@ -173,21 +185,17 @@ pub fn new( view: view, wip: ComponentWip(hooks: ordered_map.new(), index: 0, is_first_render: True), handlers: [], + client_hooks: [], render_update: render_update, update_hook: update_hook, - emit: fn(id, name, payload) { - case emitter { - Some(emitter) -> emitter(unique.to_string(id), name, payload) - None -> Error(Nil) - } - }, + emit: option.unwrap(emit, fn(_, _, _) { Nil }), cuid_channel: cuid_channel, providers: dict.new(), ) } pub fn prepare_for_reconciliation(ctx: Context) { - Context(..ctx, handlers: []) + Context(..ctx, handlers: [], client_hooks: []) } pub fn fetch_or_init_hook( @@ -267,6 +275,24 @@ pub fn get_event_handler( #(ctx, handler) } +pub fn push_client_hook(ctx: Context, hook: ClientHookId) -> Context { + Context(..ctx, client_hooks: [hook, ..ctx.client_hooks]) +} + +pub fn get_client_hook( + ctx: Context, + id: Unique(ElementId), + name: String, +) -> #(Context, Result(ClientHookId, Nil)) { + let hook = + list.find(ctx.client_hooks, fn(h) { + let ClientHookId(i, n, _) = h + i == id && n == name + }) + + #(ctx, hook) +} + pub fn emit_event( ctx: Context, id: Unique(HookId), diff --git a/src/sprocket/internal/logger.gleam b/src/sprocket/internal/logger.gleam index ed11a5e..5b7beb6 100644 --- a/src/sprocket/internal/logger.gleam +++ b/src/sprocket/internal/logger.gleam @@ -30,6 +30,7 @@ pub fn log_meta(level: Level, message: String, meta: a) -> a { erlang_log(level, message) // TODO: Do something interesting to capture metadata. For now, just log it. + // This will print regardless of the log level which is an issue. io.debug(meta) } diff --git a/src/sprocket/internal/patch.gleam b/src/sprocket/internal/patch.gleam index 866508f..c147abf 100644 --- a/src/sprocket/internal/patch.gleam +++ b/src/sprocket/internal/patch.gleam @@ -252,8 +252,8 @@ fn attr_key(attribute) { ReconciledEventHandler(element_id: id, kind: kind) -> { unique.to_string(id) <> "-" <> kind } - ReconciledClientHook(id: id, ..) -> { - id + ReconciledClientHook(name) -> { + name } } } @@ -534,14 +534,8 @@ fn attrs_to_json(attrs: List(ReconciledAttribute)) -> Json { ), ] } - ReconciledClientHook(name, id) -> { - [ - #(constants.event_attr_prefix, json.string(name)), - #( - string.concat([constants.event_attr_prefix, "-id"]), - json.string(id), - ), - ] + ReconciledClientHook(name) -> { + [#(constants.client_hook_attr_prefix, json.string(name))] } } }) diff --git a/src/sprocket/internal/reconcile.gleam b/src/sprocket/internal/reconcile.gleam index 069a1ab..67115ef 100644 --- a/src/sprocket/internal/reconcile.gleam +++ b/src/sprocket/internal/reconcile.gleam @@ -9,7 +9,7 @@ import sprocket/internal/utils/unique.{type Unique} pub type ReconciledAttribute { ReconciledAttribute(name: String, value: String) ReconciledEventHandler(element_id: Unique(ElementId), kind: String) - ReconciledClientHook(name: String, id: String) + ReconciledClientHook(name: String) } pub type ReconciledElement { diff --git a/src/sprocket/internal/reconcilers/recursive.gleam b/src/sprocket/internal/reconcilers/recursive.gleam index 6e101ee..3850ff0 100644 --- a/src/sprocket/internal/reconcilers/recursive.gleam +++ b/src/sprocket/internal/reconcilers/recursive.gleam @@ -4,8 +4,9 @@ import gleam/list import gleam/option.{type Option, None, Some} import sprocket/context.{ type AbstractFunctionalComponent, type Attribute, type Context, type Element, - Attribute, ClientHook, Component, ComponentWip, Context, Custom, Debug, - Element, Event, EventHandler, Fragment, IgnoreUpdate, Keyed, Provider, Text, + Attribute, ClientHook, ClientHookId, Component, ComponentWip, Context, Custom, + Debug, Element, Event, EventHandler, Fragment, IgnoreUpdate, Keyed, Provider, + Text, } import sprocket/internal/logger import sprocket/internal/reconcile.{ @@ -139,10 +140,10 @@ fn element( #(ctx, [ReconciledEventHandler(element_id, kind), ..rendered_attrs]) } ClientHook(id, name) -> { - #(ctx, [ - ReconciledClientHook(name, unique.to_string(id)), - ..rendered_attrs - ]) + let ctx = + context.push_client_hook(ctx, ClientHookId(element_id, name, id)) + + #(ctx, [ReconciledClientHook(name), ..rendered_attrs]) } } }) diff --git a/src/sprocket/renderers/json.gleam b/src/sprocket/renderers/json.gleam index fa8b4e8..afdfd1e 100644 --- a/src/sprocket/renderers/json.gleam +++ b/src/sprocket/renderers/json.gleam @@ -63,9 +63,9 @@ fn element( hooks, ) } - ReconciledClientHook(name, id) -> { + ReconciledClientHook(name) -> { #(attrs, events, [ - [#("name", json.string(name)), #("id", json.string(id))] + [#("name", json.string(name))] |> json.object(), ..hooks ]) diff --git a/src/sprocket/runtime.gleam b/src/sprocket/runtime.gleam index 6335d58..958b6e4 100644 --- a/src/sprocket/runtime.gleam +++ b/src/sprocket/runtime.gleam @@ -8,11 +8,11 @@ import gleam/otp/actor.{type StartError, Spec} import gleam/result import ids/cuid import sprocket/context.{ - type ComponentHooks, type Context, type EffectCleanup, type EffectResult, - type Element, type ElementId, type EventEmitter, type Hook, + type ClientHookId, type ComponentHooks, type Context, type EffectCleanup, + type EffectResult, type Element, type ElementId, type EventEmitter, type Hook, type HookDependencies, type HookId, type Updater, Callback, Changed, Client, - Context, Effect, EffectResult, EventHandler, Memo, Reducer, Unchanged, Updater, - compare_deps, + ClientHookId, Context, Effect, EffectResult, EventHandler, Memo, Reducer, + Unchanged, Updater, compare_deps, } import sprocket/internal/constants.{call_timeout} import sprocket/internal/exceptions.{throw_on_unexpected_hook_result} @@ -45,6 +45,7 @@ pub opaque type State { updater: Updater(RenderedUpdate), reconciled: Option(ReconciledElement), cuid_channel: Subject(cuid.Message), + emitter: Option(EventEmitter), ) } @@ -61,12 +62,18 @@ pub opaque type Message { payload: Dynamic, ) ProcessClientHook( - id: String, + element_id: Unique(ElementId), + hook_name: String, event: String, payload: Option(Dynamic), - reply_emitter: fn(String, Option(String)) -> Result(Nil, Nil), + reply_emitter: fn(String, Option(String)) -> Nil, ) UpdateHookState(Unique(HookId), fn(Hook) -> Hook) + EmitClientHookEvent( + hook_id: Unique(HookId), + event: String, + payload: Option(String), + ) ReconcileImmediate(reply_with: Subject(ReconciledElement)) RenderUpdate } @@ -144,42 +151,41 @@ fn handle_message(message: Message, state: State) -> actor.Next(Message, State) } } - ProcessClientHook(id, event, payload, reply_emitter) -> { - let client_hook = case state.reconciled { + ProcessClientHook(element_id, hook_name, kind, payload, reply_emitter) -> { + case state.reconciled { Some(reconciled) -> { - let hook = - find_reconciled_hook(reconciled, fn(hook) { - case hook { - context.Client(i, _, _) -> unique.to_string(i) == id - _ -> False - } + let _ = + list.find(state.ctx.client_hooks, fn(h) { + h.element_id == element_id && h.name == hook_name + }) + |> result.map(fn(h) { + find_reconciled_hook(reconciled, fn(hook) { + case hook { + context.Client(hook_id, _, _) -> hook_id == h.hook_id + _ -> False + } + }) + |> option.map(fn(hook) { + let assert Client(_id, _name, handle_event) = hook + + option.map(handle_event, fn(handle_event) { + handle_event(kind, payload, reply_emitter) + }) + }) }) - option.to_result(hook, Nil) + Nil } None -> { logger.error( "Runtime must be reconciled before processing client hooks", ) - Error(Nil) + Nil } } - case client_hook { - Ok(Client(_id, _name, handle_event)) -> { - option.map(handle_event, fn(handle_event) { - handle_event(event, payload, reply_emitter) - }) - - actor.continue(state) - } - _ -> { - logger.error("No client hook found with id: " <> id) - - actor.continue(state) - } - } + actor.continue(state) } UpdateHookState(hook_id, update_fn) -> { @@ -205,6 +211,37 @@ fn handle_message(message: Message, state: State) -> actor.Next(Message, State) actor.continue(State(..state, reconciled: updated)) } + EmitClientHookEvent(hook_id, kind, payload) -> { + case state.reconciled { + Some(reconciled) -> { + let _ = + list.find(state.ctx.client_hooks, fn(h) { + let ClientHookId(_element_id, _name, client_hook_id) = h + client_hook_id == hook_id + }) + |> result.map(fn(h) { + let ClientHookId(element_id, hook_name, _client_hook_id) = h + + state.emitter + |> option.map(fn(emitter) { + emitter(unique.to_string(element_id), hook_name, kind, payload) + }) + }) + + Nil + } + None -> { + logger.error( + "Runtime must be reconciled before emitting client hook events", + ) + + Nil + } + } + + actor.continue(state) + } + ReconcileImmediate(reply_with) -> { let prev_reconciled = state.reconciled let view = state.ctx.view @@ -282,18 +319,25 @@ pub fn start( error }) + let emit = fn(id, name, payload) { + logger.debug("actor.send EmitClientHookEvent") + + actor.send(self, EmitClientHookEvent(id, name, payload)) + } + let state = State( ctx: context.new( view, cuid_channel, - emitter, + Some(emit), render_update, update_hook, ), updater: updater, reconciled: None, cuid_channel: cuid_channel, + emitter: emitter, ) let selector = process.selecting(process.new_selector(), self, identity) @@ -359,14 +403,24 @@ pub fn process_event_immediate( /// Get the client hook for a given id pub fn process_client_hook( actor, - id: String, + element_id: String, + hook_name: String, event: String, payload: Option(Dynamic), - reply_emitter: fn(String, Option(String)) -> Result(Nil, Nil), + reply_emitter: fn(String, Option(String)) -> Nil, ) { - logger.debug("process.try_call GetClientHook") - - actor.send(actor, ProcessClientHook(id, event, payload, reply_emitter)) + logger.debug("actor.send ProcessClientHook") + + actor.send( + actor, + ProcessClientHook( + unique.from_string(element_id), + hook_name, + event, + payload, + reply_emitter, + ), + ) } pub fn render_update(actor) { diff --git a/yarn.lock b/yarn.lock index ba9e16e..82deed6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3328,6 +3328,11 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +reconnecting-websocket@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783" + integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng== + regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.7: version "0.13.11" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"