feat: add personalized state payload broadcasts - #8
Merged
Conversation
Actors can declare broadcast_payload to send one personalized JSON state payload over the actor stream a page already has open, instead of one HTTP component refresh per changed component. The block runs once per subscriber with that subscriber's authorization context, so private actor state never crosses sessions. Payloads carry actor identity and the monotonic state revision, and the channel and browser both drop stale revisions. ERB component refreshes remain the default and are unchanged.
Greptile SummaryThe PR adds subscriber-personalized state payloads over existing actor streams. The latest change completes the payload-only mutation path by persisting a revision-only invalidation that triggers payload recomputation without exposing observable or private state.
Confidence Score: 5/5The PR appears safe to merge. The previously reported payload-only mutation failure is fixed: a committed state change without observable changes now creates a revision-only broadcast, and the channel uses that invalidation to recompute subscriber-specific payloads without forwarding private state. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart LR
A[Actor message commits] --> B{Observable changed?}
B -->|Yes| C[Persist observable broadcast]
B -->|No, but payload state changed| D[Persist revision-only broadcast]
C --> E[ActorChannel receives invalidation]
D --> E
E --> F[Load committed actor snapshot]
F --> G[Authorize and compute payload per subscriber]
G --> H[Transmit payload Turbo Stream]
H --> I[Dispatch solid-objects:payload event]
Reviews (2): Last reviewed commit: "fix: invalidate payloads on observable-f..." | Re-trigger Greptile |
A message that changed state a payload reads without changing a declared observable enqueued no broadcast, so subscribers kept stale personalized state until an unrelated observable changed. Actors with payload broadcasts now enqueue a revision-only broadcast for those commits. The renderer emits invalidation metadata without an observable turbo stream, and the channel does not forward it, so no actor value reaches the browser through this path. Queries still enqueue nothing.
Owner
Author
|
@greptileai review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds the first of the two capabilities requested for low-latency reactive updates: a
personalized state payload broadcast that collapses several component refreshes into one
message on the stream the page already has open.
Scope note up front: batched component refreshes are not in this PR. See "What is not here"
below. This PR satisfies acceptance criteria 2, 3, 4, 5, and 6; criterion 1 and the batching
half of 6 remain open.
The problem, measured against the existing protocol
Today one actor mutation that changes three observables produces three Action Cable
invalidations, three
<solid-objects-refresh>elements, and three HTTP round trips:ActorChannel#receive_broadcastfans out per invalidation, and each refresh element issues itsown fetch. That is the
mtg-playmatplayer / player-controls / library-search case.What this adds
After:
Design decisions
The payload is computed per subscriber, never broadcast pre-rendered. The Action Cable
broadcast still carries only invalidation metadata. Each subscribed channel computes its own
payload with its own connection as the authorization context. This is what makes session
isolation structural rather than a filtering step: there is no shared rendered payload that
could leak.
Transport is a Turbo Stream element, not a second socket.
<solid-objects-payload>mirrorsthe existing
<solid-objects-refresh>morph element, so payloads ride the existingturbo-cable-stream-sourcesubscription. No new channel, no second WebSocket system, nofrontend framework.
Framework-neutral client. 68 lines, one custom element, dispatches a DOM
CustomEvent. Theapplication owns rendering.
Authorization and isolation
The payload name is signed into the stream token, so a browser cannot request a payload the
server did not offer, and the channel rejects unknown names. Each payload passes the same
authorize_queryboundary components use, called with the payload name and the subscriber'sCable connection; a subscriber that fails is skipped rather than served a partial payload.
Payload blocks read committed state through the same snapshot components use and cannot write
application records. Return values must be a JSON object or array.
Revision fencing
Every payload carries
instance_idand the monotonicstate_revision. The channel tracks thelast revision it delivered and skips anything not newer; the browser does the same per scope and
name. A reconnecting client receives the current payload on subscribe.
Compatibility
payloadsis a new optional key in the stream token. Existing tokensverify unchanged, and tokens are short-lived page-scoped values, so a deploy needs no
coordination.
broadcast_payloadand a scope withno
payloads:behave exactly as before; the payload JS is only included when the option is used.What is not here
Batched component refreshes. The design I would implement: keep ERB rendering and return
HTML frames inside a JSON envelope at a new batch endpoint. HTML-only would force the client
to parse an undocumented document; JSON-only would mean a second renderer and would abandon
Turbo morph. A JSON envelope of
{target, revision, html}descriptors gives a documented,machine-readable contract while
ComponentRendererstill produces the frames. The harder partis server-side coalescing: broadcasts are one row per observable and arrive as separate Action
Cable messages, so emitting exactly one refresh event per actor/group/revision means grouping in
the broadcast executor rather than the channel. That is a delivery-path change worth reviewing on
its own.
Also outstanding: JS/browser-level tests (this repo has no JS test harness yet), a benchmark
comparing request counts and end-to-end latency, and concurrent-mutation and
disconnect/reconnect integration tests.
Validation
bundle exec rake254 runs, 1005 assertions, 0 failures. Standard Ruby, RuboCop, RBS, Steep, and Brakeman clean.
Nine new tests cover personalization across sessions, that one session's private state never
appears in another's payload, identity and monotonic revisions, authorization refusal, unknown
payload names, non-object return values, stream-token signing and tampering, the rendered
element, and that actors without payload broadcasts are unaffected.