posit-dev / posit-dev/shinyreact
JSON Patch wire format for partial output updates
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 14
- Forks
- 3
- Avg merge
- 9h 12m
- Merged PRs (30d)
- 74
Description
Summary
When a reactive_output output ships a large value (a 50-field state dict, a 10k-row table, a long spec), and the next update changes only a small slice of it, today we re-serialize and re-send the entire payload. For SPA-first apps that gravitate toward sending state to the client, this is a significant wire-size and latency cost.
We should support RFC 6902 JSON Patch as a wire format for outputs: when a value's diff against the last sent value is meaningfully smaller than the value itself, ship the patch and apply it client-side.
Tracked previously as a TODO in docs/STATUS.md:54-58; this issue extracts and expands it.
Background
@json-render/react (used by the legacy shinyreact) already has an internal applyPatch for spec mutations. The new shinyreact package doesn't have a patch story yet — reactive_output always replaces the full value on the consumer side.
JSON Patch (add, remove, replace, move, copy, test) is well-defined, has mature implementations on both sides (jsonpatch in Python, fast-json-patch in JS), and maps cleanly onto the dict/list/scalar values useShinyOutput consumers already work with.
Proposed shape
Wire format
Two message kinds for the same output channel:
- Snapshot: the current full value. Used on first send, on reconnect, and whenever a patch would be larger than the snapshot. Today's behavior.
- Patch: an RFC 6902 patch array against the last-acknowledged snapshot. Used when the diff is small.
The client consumer (useShinyOutput) yields the same shape regardless of how the value arrived — patches are applied transparently.
Server-side API
Two paths:
-
Automatic.
reactive_outputdiffs the new value against the last sent value and chooses snapshot vs. patch by size. User code unchanged. Pairs with the value-equality dedup issue (filed alongside this one) — that issue handles "value didn't change at all"; this one handles "value changed a little." -
Manual. A new
shinyreact.patch(session, output_id, ops)helper for cases where the user already knows the diff (streaming inserts, AI token-by-token UI building) and shouldn't pay forjsonpatch.make_patch.
Both should exist. Automatic is the default; manual is the escape hatch for streaming and large-state cases where diff computation itself is the bottleneck.
Diff strategy
jsonpatch.make_patch(old, new) works but is O(n*m) for nested structures. For typical SPA state dicts (dozens of keys, mostly scalars) it's fast enough. For large arrays of records (a 10k-row table where one row changed), naive diff is bad — it'll emit a replace on the array and we're back to a full payload.
Two mitigations:
- Keyed arrays. Allow the user to mark a list as keyed (
@reactive_output(key=\"id\")) so the differ uses object identity byidrather than positional comparison. Standard React-list-key trick; same insight applies on the wire. - Custom differ.
@reactive_output(differ=...)for users who can produce patches more cheaply than the generic algorithm (incremental indexes, change-log replay).
Client side
useShinyOutput<T> consumers see T as today. Internally:
- The hook tracks the last acknowledged snapshot.
- On receiving a patch, applies it via
fast-json-patchand re-renders. - On receiving a snapshot, replaces wholesale.
- On reconnect or first mount, requests a snapshot.
Adds ~5KB gzipped (fast-json-patch) to the bundle. Acceptable.
Open questions
- Default on or off? Snapshot-only is correct but wasteful; patch-by-default saves bytes but adds CPU on both sides for diff/apply. Probably opt-in at the renderer (
@reactive_output(diff=True)) for v1, with a path to default-on once we have measurements. - Snapshot/patch threshold heuristic. "Send patch if patch_size < snapshot_size * 0.5" is a reasonable starter; needs tuning with real apps.
- Reconnect semantics. On websocket reconnect, the client's last-acknowledged snapshot may be stale. Cleanest answer: server tracks per-session-per-output sequence numbers; on reconnect, client sends its last seq, server either patches forward or sends a fresh snapshot. Adds a small protocol layer.
- Patch validity / failure mode. If the client somehow desyncs (a patch can't apply), it should request a snapshot rather than crash. The protocol needs an explicit "resync me" message.
- Type stability. TS types declared via #30 must hold across patches — the patched-in value is the same
Tas the snapshot. Validating this in dev mode (zod/Pydantic re-check after apply) is worth offering. - Streaming UI use case. AI-generated UI that builds incrementally is the headline reason to expose the manual
shinyreact.patch(session, id, ops)API. Worth a dedicated example to validate the ergonomics. - Interaction with the dedup issue. Both reduce wire traffic; their orderings are: dedup first (skip if equal), then patch vs snapshot for what remains. The implementations should compose without either being aware of the other.
- Rendering ordering guarantees. A snapshot followed quickly by a patch must apply in order.
useShinyOutput's subscription model needs to guarantee this — or the server needs to coalesce on the send side.
Relationship to other issues
- Pairs with the value-equality dedup issue (filed alongside) — covers two ends of the wire-savings spectrum (no-change vs. partial-change vs. full replacement).
- Cross-cuts #27 (bookmarking/initial state) — bookmark restoration is conceptually a snapshot send; same machinery.
- Cross-cuts #31 (enhanced renderers) — round-trip inputs and command messages are not output values, but custom renderers may want to expose patch-based outputs for component-internal state.
- Cross-cuts #34 (scaffolding skill) — the skill should know when to recommend
diff=True(large stable structures with small mutations) and when not to (small or fully-replaced values).
References
docs/STATUS.md:54-58— original TODO.- RFC 6902 — JSON Patch spec.
@json-render/react's internalapplyPatch— prior art in the legacyshinyreactpath.
Out of scope
- Operational transforms (OT) / CRDTs — this is server→client one-way patching, not collaborative editing.
- Patches as the transport for inputs (client→server).
useShinyInputpayloads are already small; adding patching there is unnecessary. - R-side equivalents.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Begin with docs/STATUS.md:54-58, the reactive_output path, and useShinyOutput; compare the legacy @json-render/react applyPatch behavior with the new shinyreact package. Scope is not settled: snapshot and patch protocol, diff strategy, reconnect/resync behavior, and API choices must be resolved before implementation can be considered done.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, react, typescript
- Domain
- api, backend, frontend
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 30/100