bug: UI elements do not reactively update after mutations — page reload required #70
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Across the app, many UI elements do not update after operations that should change their state. The user must do a full page reload to see the correct state. This is a systemic pattern, not isolated to one feature.
Known cases:
content/background/Root Cause Pattern
Mutation handlers call narrow refresh functions (
loadBgPanel(), etc.) that only redraw their own panel. They do not call the broader deck/slide refresh (refreshCurrentDeck(),loadSlidesForDeck()) that would re-fetch staleness, slide state, and other derived UI.The app has the right refresh functions but they are not wired up after mutations.
Short-Term Fix
Audit every mutation path and call
refreshCurrentDeck()after operations that should affect slide card state. Targeted, mechanical, but fragile — every new mutation must remember to wire the refresh manually.Long-Term Fix: Server-Sent Mutation Events
The architecture already supports this —
GET /api/events/jobsis a live SSE stream from the server, proxied by the admin and consumed byjobsPanelviaEventSource. The same pattern is extended to a general mutation event channel.Plan
1. Server: mutation event broadcaster
Add a broadcast channel in
ServerState. Any RPC handler that mutates deck state (bg file ops, slide saves, theme saves, staleness-affecting changes) sends an event after the mutation succeeds:Expose
GET /eventsas an SSE stream (declared withx-sseinopenrpc.json) that fans out events to all connected subscribers.2. Admin: proxy the stream
Add
GET /api/eventsroute using the existingopen_sse_upstreamhelper, same asjobs_events_proxy.3. Dashboard: subscribe and refresh
On deck load, open an
EventSourceon/api/events. Ondeck.changedevents matching the currently selected collection+deck, callrefreshCurrentDeck(). Close and reopen when the selected deck changes.Result
Any mutation — from this browser tab, another tab, or a direct RPC call — automatically triggers a UI refresh. No manual wiring per mutation path required.
bug: stale tags only appear/disappear on page reload after background changesto bug: UI elements do not reactively update after mutations — page reload requiredImplementation Spec: SSE Mutation Broadcast System
Issue: #70 — UI elements do not reactively update after mutations — page reload required
Objective
Add a Server-Sent Events (SSE) mutation broadcast channel to
hero_slides_server. Any RPC handler that mutates deck state will fire adeck.changedevent. The admin UI will subscribe to this stream and reactively callrefreshCurrentDeck()when an event matches the currently-loaded deck.Requirements
tokio::sync::broadcastchannel inServerStatecarriesDeckChangedEvent { collection, deck }values.GET /eventsis added as an Axum route that fans out events as SSE, modelled directly on the existingGET /events/jobspattern.openrpc.json: A newdeck.eventsmethod entry is added with"x-sse"annotation declaring the endpoint and emitted event type.GET /api/eventsis added tohero_slides_admin/src/routes.rs, reusingopen_sse_upstreamto relay bytes fromGET /eventson the backend socket.EventSourceonBASE + '/api/events'. Ondeck.changedevents matching the currentselectedCollection+selectedDeckName, callrefreshCurrentDeck(). Close and reopen when the selected deck changes.Files to Modify / Create
crates/hero_slides_server/src/main.rsmutation_txfield toServerState; registerGET /eventsroutecrates/hero_slides_server/src/rpc.rscrates/hero_slides_server/src/mutation_sse.rsGET /eventscrates/hero_slides_server/openrpc.jsondeck.eventsmethod withx-sseannotationcrates/hero_slides_admin/src/routes.rsGET /api/eventsproxy routecrates/hero_slides_admin/static/js/dashboard.jsdeckEventSourcesubscriptionImplementation Plan
Step 1 — Add
DeckChangedEventand broadcast channel toServerStateFile:
crates/hero_slides_server/src/main.rsuse tokio::sync::broadcast;DeckChangedEventas#[derive(Clone, Debug, serde::Serialize)]withcollection: Stringanddeck: Stringpub mutation_tx: broadcast::Sender<DeckChangedEvent>toServerStatemain(), create the channel:let (mutation_tx, _) = broadcast::channel::<DeckChangedEvent>(256);.route("/events", get(mutation_sse::events_handler))pub mod mutation_sse;Dependencies: none
Step 2 — Create
mutation_sse.rs— the/eventsSSE handlerFile:
crates/hero_slides_server/src/mutation_sse.rs(new file)crates/hero_slides_server/src/jobs/sse.rsstate.mutation_tx.subscribe()DeckChangedEventas the SSEdata:payloadKeepAlive15-second ping interval andRecvError::LaggedguardDependencies: Step 1
Step 3 — Emit
DeckChangedEventfrom mutation RPC handlersFile:
crates/hero_slides_server/src/rpc.rshandle_request, check ifresult.is_ok()and method is a mutationcollection+deckfromreq.paramsand callstate.mutation_tx.send(...)slide.saveContent,slide.insert,slide.delete,slide.move,slide.duplicate,slide.bulkDelete,slide.bulkMove,slide.copyTo,slide.rename,slide.deleteVersions,slide.setHidden,slide.setLink,slide.clearLink,slide.addSourceImage,slide.removeSourceImage,deck.saveTheme,deck.create,deck.delete,deck.rename,deck.duplicate,bg.createFolder,bg.deleteFolder,bg.deleteFile,bg.moveFile,bg.uploadFileDependencies: Step 1
Step 4 — Add
deck.eventstoopenrpc.jsonFile:
crates/hero_slides_server/openrpc.jsonx-sse: { endpoint: "/events", emits: ["deck.changed"] }{ type: "deck.changed", collection: string, deck: string }Dependencies: none
Step 5 — Proxy
GET /api/eventsin the adminFile:
crates/hero_slides_admin/src/routes.rsopen_sse_upstreamto acceptpath: &strparameter (rename toopen_sse_upstream_path)jobs_events_proxyto call it with"/events/jobs"deck_events_proxycalling it with"/events".route("/api/events", get(deck_events_proxy))Dependencies: none
Step 6 — Subscribe in dashboard JS and call
refreshCurrentDeck()File:
crates/hero_slides_admin/static/js/dashboard.jslet deckEventSource = null;module-level variableopenDeckEventStream()function: opensEventSourceonBASE + '/api/events', handlesdeck.changedevents matchingselectedCollection+selectedDeckName, callsrefreshCurrentDeck()openDeckEventStream()at the end ofloadSlidesForDeck()after successful deck loadopenDeckEventStream()when clearing deck selection (to close the stream)Dependencies: Step 5
Acceptance Criteria
GET /api/eventsreturnsContent-Type: text/event-streamNotes
256is sufficient for low-frequency, user-driven mutation eventsslide.copyTomutates both source and destination deck; emit two events (one per deck)collection.*mutations are out of scope; acollection.changedevent type is a separate concernTest Results
All tests passed. Breakdown:
Note: hero_slides_examples failed to compile due to missing
tokiodependency in that crate, but it contains no test cases — only example binaries.Implementation Complete
Changes Made
crates/hero_slides_server/src/main.rsDeckChangedEventstruct (#[derive(Clone, Debug, serde::Serialize)]withcollectionanddeckfields)mutation_tx: broadcast::Sender<DeckChangedEvent>toServerStatecapacity 256) inmain()and wired it intoServerStateGET /eventsroute pointing tomutation_sse::events_handlerpub mod mutation_sse;crates/hero_slides_server/src/mutation_sse.rs(new file)GET /events, modelled onjobs/sse.rsdeck.changedevents serialized as JSONevent: lagframe on receiver lagcrates/hero_slides_server/src/rpc.rsDeckChangedEventwith the affectedcollectionanddeckslide.*,deck.*,bg.*slide.copyToemits two events (source deck and destination deck)is_deck_mutation()helpercrates/hero_slides_server/openrpc.jsondeck.eventsmethod withx-sseannotation documenting the SSE endpoint and payload shapecrates/hero_slides_admin/src/routes.rsopen_sse_upstreamto acceptpath: &strparameterdeck_events_proxyhandler proxyingGET /eventsfrom the backend socketGET /api/eventsroutecrates/hero_slides_admin/static/js/dashboard.jsdeckEventSourcemodule-level variableopenDeckEventStream()function: opensEventSourceon/api/events, handlesdeck.changedevents matching current collection+deck, callsrefreshCurrentDeck()loadSlidesForDeck()(re-opens on deck switch)!hasSelectionbranch (closes stream when no deck is selected)Test Results