From 97a4b7aceeb464b685985e9abaa345685c4c6586 Mon Sep 17 00:00:00 2001 From: rebecca <802632-arkandos@users.noreply.gitlab.com> Date: Thu, 30 Apr 2026 16:30:38 +0200 Subject: [PATCH] synthetic storage event emit --- src/off_topic.gleam | 26 ++++++++++---- src/off_topic/internal/commands.gleam | 8 ++--- src/off_topic/internal/commands_ffi.mjs | 28 ++++++++++----- src/off_topic/internal/runtime.gleam | 45 ++++++++++++------------- 4 files changed, 66 insertions(+), 41 deletions(-) diff --git a/src/off_topic.gleam b/src/off_topic.gleam index cc39cd3..88efc39 100644 --- a/src/off_topic.gleam +++ b/src/off_topic.gleam @@ -1427,7 +1427,8 @@ fn media_query( /// /// Dispatches `None` when the key is absent and `Some(value)` otherwise. Only /// detects changes made in other tabs or windows — updates made in the current -/// page are not forwarded by the browser's `storage` event. +/// page are not forwarded by the browser's `storage` event unless +/// [`set_local_storage`](#set_local_storage) is called with `notify: True`. /// /// See also: [session_storage](#session_storage), [set_local_storage](#set_local_storage) pub fn local_storage( @@ -1454,7 +1455,8 @@ pub fn local_storage( /// /// Dispatches `None` when the key is absent and `Some(value)` otherwise. Only /// detects changes made in other tabs or windows — updates made in the current -/// page are not forwarded by the browser's `storage` event. +/// page are not forwarded by the browser's `storage` event unless +/// [`set_session_storage`](#set_session_storage) is called with `notify: True`. /// /// See also: [local_storage](#local_storage) pub fn session_storage( @@ -1479,17 +1481,22 @@ pub fn session_storage( /// Write a value to `localStorage`, or remove the key when `value` is `None`. /// +/// When `notify` is `True`, a synthetic `storage` event is dispatched so that +/// [`local_storage`](#local_storage) subscriptions are notified of the change +/// immediately. +/// /// See also: [local_storage](#local_storage), [set_session_storage](#set_session_storage) pub fn set_local_storage( key key: String, value value: Option(String), + notify notify: Bool, ) -> Effect(message) { case lustre.is_browser() { True -> effect.from(fn(_) { case value { - Some(v) -> commands.set_local_storage(key, v) - None -> commands.remove_local_storage(key) + Some(v) -> commands.set_local_storage(key, v, notify) + None -> commands.remove_local_storage(key, notify) } }) False -> @@ -1499,23 +1506,29 @@ pub fn set_local_storage( Some(v) -> json.string(v) None -> json.null() }, + json.bool(notify), ]) } } /// Write a value to `sessionStorage`, or remove the key when `value` is `None`. /// +/// When `notify` is `True`, a synthetic `storage` event is dispatched so that +/// [`session_storage`](#session_storage) subscriptions are notified of the +/// change immediately. +/// /// See also: [session_storage](#session_storage), [set_local_storage](#set_local_storage) pub fn set_session_storage( key key: String, value value: Option(String), + notify notify: Bool, ) -> Effect(message) { case lustre.is_browser() { True -> effect.from(fn(_) { case value { - Some(v) -> commands.set_session_storage(key, v) - None -> commands.remove_session_storage(key) + Some(v) -> commands.set_session_storage(key, v, notify) + None -> commands.remove_session_storage(key, notify) } }) False -> @@ -1525,6 +1538,7 @@ pub fn set_session_storage( Some(v) -> json.string(v) None -> json.null() }, + json.bool(notify), ]) } } diff --git a/src/off_topic/internal/commands.gleam b/src/off_topic/internal/commands.gleam index c24f122..df05fc3 100644 --- a/src/off_topic/internal/commands.gleam +++ b/src/off_topic/internal/commands.gleam @@ -1,22 +1,22 @@ import gleam/dynamic/decode.{type Dynamic} @external(javascript, "./commands_ffi.mjs", "set_local_storage") -pub fn set_local_storage(_key: String, _value: String) -> Nil { +pub fn set_local_storage(_key: String, _value: String, _emit: Bool) -> Nil { Nil } @external(javascript, "./commands_ffi.mjs", "remove_local_storage") -pub fn remove_local_storage(_key: String) -> Nil { +pub fn remove_local_storage(_key: String, _emit: Bool) -> Nil { Nil } @external(javascript, "./commands_ffi.mjs", "set_session_storage") -pub fn set_session_storage(_key: String, _value: String) -> Nil { +pub fn set_session_storage(_key: String, _value: String, _emit: Bool) -> Nil { Nil } @external(javascript, "./commands_ffi.mjs", "remove_session_storage") -pub fn remove_session_storage(_key: String) -> Nil { +pub fn remove_session_storage(_key: String, _emit: Bool) -> Nil { Nil } diff --git a/src/off_topic/internal/commands_ffi.mjs b/src/off_topic/internal/commands_ffi.mjs index 6226d39..2667cf2 100644 --- a/src/off_topic/internal/commands_ffi.mjs +++ b/src/off_topic/internal/commands_ffi.mjs @@ -1,21 +1,33 @@ import { resolveElement } from "./utils_ffi.mjs"; -export function set_local_storage(key, value) { - if (value === null) localStorage.removeItem(key); - else localStorage.setItem(key, value); +function dispatchStorageEvent(storage, key, oldValue, newValue) { + window.dispatchEvent(new StorageEvent("storage", { + key, oldValue, newValue, storageArea: storage, url: window.location.href, + })); } -export function remove_local_storage(key) { +export function set_local_storage(key, value, emit) { + const oldValue = emit ? localStorage.getItem(key) : null; + localStorage.setItem(key, value); + if (emit) dispatchStorageEvent(localStorage, key, oldValue, value); +} + +export function remove_local_storage(key, emit) { + const oldValue = emit ? localStorage.getItem(key) : null; localStorage.removeItem(key); + if (emit) dispatchStorageEvent(localStorage, key, oldValue, null); } -export function set_session_storage(key, value) { - if (value === null) sessionStorage.removeItem(key); - else sessionStorage.setItem(key, value); +export function set_session_storage(key, value, emit) { + const oldValue = emit ? sessionStorage.getItem(key) : null; + sessionStorage.setItem(key, value); + if (emit) dispatchStorageEvent(sessionStorage, key, oldValue, value); } -export function remove_session_storage(key) { +export function remove_session_storage(key, emit) { + const oldValue = emit ? sessionStorage.getItem(key) : null; sessionStorage.removeItem(key); + if (emit) dispatchStorageEvent(sessionStorage, key, oldValue, null); } export function scroll_to(selector, x, y, behavior, root) { diff --git a/src/off_topic/internal/runtime.gleam b/src/off_topic/internal/runtime.gleam index daba353..f1c11b3 100644 --- a/src/off_topic/internal/runtime.gleam +++ b/src/off_topic/internal/runtime.gleam @@ -66,8 +66,8 @@ pub type Dependency /// up off_topic manually instead of through [application](#application). pub opaque type Model(model, message) { Dormant(model: model) - Application(model: model, tick: Int, state: State(message)) - Component(model: model, tick: Int, state: State(message), clients: Int) + Application(model: model, tick: Int, state: Running(message)) + Component(model: model, tick: Int, state: Running(message), clients: Int) } /// The subscription runtime's wrapper around your application message type. @@ -75,7 +75,7 @@ pub opaque type Model(model, message) { /// Use with [init](#init), [update](#update), and [view](#view) when wiring /// up off_topic manually instead of through [application](#application). pub opaque type Message(message) { - Mounted + ClientRuntimeMounted ToApp(message: message) GotStarted(tick: Int, path: List(Int), cleanup: fn() -> Nil) FromRemote(tick: Int, path: List(Int), message: Dynamic) @@ -85,7 +85,7 @@ pub opaque type Message(message) { ComponentDisconnected(forward: Option(message)) } -type State(message) { +type Running(message) { Running( tick: Int, element: Bool, @@ -94,14 +94,14 @@ type State(message) { last_fired: Int, delay_tick: Int, ) - Connected( + Listening( tick: Int, config: Config, name: String, params: List(Json), decoder: Decoder(message), ) - Group(group: List(State(message))) + Group(group: List(Running(message))) } // -- MAKING SUBSCRIPTIONS ---------------------------------------------------- @@ -181,7 +181,7 @@ pub fn map( Remote(decoder:, ..) -> Remote(..subscription, decoder: decode.map(decoder, tagger)) - Batch(subscriptions) -> Batchs(list.map(subscriptions, map(_, tagger))) + Batch(subscriptions) -> Batch(list.map(subscriptions, map(_, tagger))) } } @@ -294,8 +294,6 @@ pub fn watching( // -- FUNDAMENTAL EFFECT ------------------------------------------------------ /// Dispatch a message after a delay. -/// -/// See also: [every](#every), [now](#now) pub fn after(ms: Int, send message: message) -> Effect(message) { use dispatch <- effect.from do_send_after(ms, message, dispatch) @@ -338,7 +336,7 @@ pub fn application( let eff = effect.batch([ effect.map(app_effect, ToApp), - effect.from(fn(dispatch) { dispatch(Mounted) }), + effect.from(fn(dispatch) { dispatch(ClientRuntimeMounted) }), ]) #(Dormant(model: app_model), eff) } @@ -406,8 +404,8 @@ fn wrap_runtime(app_update, app_subscriptions, app_view) { /// /// See also: [update](#update), [view](#view), [application](#application) pub fn init( - flags: flags, - init: fn(flags) -> #(model, Effect(message)), + flags flags: flags, + init init: fn(flags) -> #(model, Effect(message)), ) -> #(Model(model, message), Effect(Message(message))) { let #(model, app_effect) = init(flags) @@ -419,7 +417,7 @@ pub fn init( let check_mount = effect.before_paint(fn(dispatch, root) { case is_shadow_root(root) { - False -> dispatch(Mounted) + False -> dispatch(ClientRuntimeMounted) True -> Nil } }) @@ -450,7 +448,7 @@ pub fn update( case message { ToApp(message:) -> to_app(model, message, subscriptions, update) - Mounted -> + ClientRuntimeMounted -> case model { Dormant(model: app_model) -> { let #(state, effects) = start_subscriptions(app_model, subscriptions) @@ -533,7 +531,7 @@ pub fn update( FromRemote(tick:, path:, message:) -> { use state <- emit_message(model, path, subscriptions, update) case state { - Connected(decoder:, ..) if state.tick == tick -> + Listening(decoder:, ..) if state.tick == tick -> decode.run(message, decoder) |> result.replace_error(Nil) _ -> Error(Nil) } @@ -585,14 +583,14 @@ fn start_subscriptions(app_model, subscriptions) { #(state, effect.batch(effects)) } -fn get_state(model: Model(a, b)) -> Result(State(b), Nil) { +fn get_state(model: Model(a, b)) -> Result(Running(b), Nil) { case model { Application(state:, ..) | Component(state:, ..) -> Ok(state) Dormant(..) -> Error(Nil) } } -fn set_state(model: Model(a, b), state: State(b)) -> Model(a, b) { +fn set_state(model: Model(a, b), state: Running(b)) -> Model(a, b) { case model { Application(..) -> Application(..model, state:) Component(..) -> Component(..model, state:) @@ -633,7 +631,8 @@ pub fn view( event.on("off-topic:dispatch", decode.at(["detail"], detail_decoder)) |> server_component.include(["detail"]) - let on_mount = event.on("off-topic:mount", decode.success(Mounted)) + let on_mount = + event.on("off-topic:mount", decode.success(ClientRuntimeMounted)) element.element("ot-client-runtime", [on_dispatch, on_mount], []) } @@ -756,7 +755,7 @@ fn diff(tick, path, left, right, effects) { if c1.dependencies == c2.dependencies -> #(Running(..left, config: c2), effects) - Connected(name: n1, params: p1, config: c1, ..), + Listening(name: n1, params: p1, config: c1, ..), Remote(name: n2, params: p2, config: c2, ..) if n1 == n2 && p1 == p2 && c1.dependencies == c2.dependencies -> #(left, effects) @@ -789,7 +788,7 @@ fn cleanup(state, path, effects) { Running(cleanup:, ..) -> { [effect.from(fn(_) { cleanup() }), ..effects] } - Connected(tick:, name:, ..) -> { + Listening(tick:, name:, ..) -> { let details = json.object([ #("tick", json.int(tick)), @@ -805,7 +804,7 @@ fn cleanup(state, path, effects) { fn re_emit(state, path, effects) { case state { Running(..) -> effects - Connected(tick:, name:, params:, config:, ..) -> { + Listening(tick:, name:, params:, config:, ..) -> { [subscribe_effect(tick, path, name, params, config), ..effects] } Group(children) -> fold_children(children, 0, path, effects, re_emit) @@ -844,7 +843,7 @@ fn start(tick, path, subscription, effects) { } Remote(name:, params:, decoder:, config:) -> { let effect = subscribe_effect(tick, path, name, params, config) - let state = Connected(tick:, name:, params:, decoder:, config:) + let state = Listening(tick:, name:, params:, decoder:, config:) #(state, [effect, ..effects]) } Batch(subscriptions:) -> @@ -862,7 +861,7 @@ fn start_children(tick, index, path, right, out, effects) { } } -fn running(tick: Int, element: Bool, config: Config) -> State(message) { +fn running(tick: Int, element: Bool, config: Config) -> Running(message) { Running( tick:, element:, -- 2.51.2