posit-dev / posit-dev/shinyreact

JSON Patch wire format for partial output updates

Open
#36 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
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:

  1. Automatic. reactive_output diffs 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."

  2. 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 for jsonpatch.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 by id rather 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-patch and 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 T as 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 internal applyPatch — prior art in the legacy shinyreact path.

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). useShinyInput payloads are already small; adding patching there is unnecessary.
  • R-side equivalents.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.