Add opt-in incremental (append-semantics) element data delivery - #70
Open
mrubash1 wants to merge 5 commits into
Open
Add opt-in incremental (append-semantics) element data delivery#70mrubash1 wants to merge 5 commits into
mrubash1 wants to merge 5 commits into
Conversation
Adds subscribeToIncrementalElementData, which advertises incremental
(append-semantics) delivery by posting the existing
wb:plugin:element:subscribe:data message with a { mode: 'incremental' }
capability option, and delivers WorkbookElementDataChunk envelopes
({ data, offset, isComplete, totalRows? }) to its callback.
Hosts without incremental support ignore the extra subscribe argument
and keep sending cumulative WorkbookElementData payloads; those are
detected (every value in a legacy payload is a column array, so typed
non-array offset/isComplete/data fields unambiguously identify the
envelope) and normalized into replace-everything chunks at offset 0,
so consumers need no branching code. This defines the plugin half of
the protocol and is a safe no-op against current hosts. All existing
methods, events, and types are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtbehD5eW7ci5awuCz1QcM
Accumulates WorkbookElementDataChunk payloads internally and returns
[data, loadMore, { rowCount, isComplete, totalRows }], making migration
from usePaginatedElementData a one-line hook swap. Assembly trusts each
chunk's absolute offset — rows before it are kept, rows at or after it
are overwritten — so overlapping or re-sent chunks apply idempotently
and cumulative payloads from hosts without incremental support (which
arrive normalized to offset 0) replace state wholesale, matching
today's behavior exactly with no branching in plugin code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtbehD5eW7ci5awuCz1QcM
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtbehD5eW7ci5awuCz1QcM
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtbehD5eW7ci5awuCz1QcM
…chunks Findings from a multi-perspective review of the initial implementation: - A chunk omitting a column — or an empty terminal chunk that only flips isComplete — silently dropped accumulated data. Chunks at offset > 0 now carry the accumulated columns forward; offset 0 remains a wholesale replace, preserving degraded-mode and refresh semantics. - Rows now always land at their absolute offset: the head is padded when a column first appears mid-stream or a host skips ahead, instead of silently compacting the gap and misaligning rows. - Column ids are applied own-property-safely: '__proto__' is skipped (a crafted payload could otherwise reparent the accumulator) and ids like 'constructor' can no longer collide with inherited members and throw. - The chunk type guard requires offset to be a non-negative integer, so NaN/negative/fractional offsets degrade to legacy normalization instead of corrupting assembly. - totalRows re-baselines on an offset-0 restart rather than carrying a stale value across a refresh; hook state resets when configId goes falsy. - Docs now warn prominently that isComplete stays false forever on hosts without incremental support (never gate load-more loops on it alone), state the per-element single-subscription-mode constraint, and spell out the chunk invariants hosts must satisfy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UtbehD5eW7ci5awuCz1QcM
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.
I build Sigma plugins against large sources — think detail grids and
data-dense visualizations over hundreds of thousands of rows — and I've hit a
scaling wall in the SDK's data delivery contract that makes any element larger
than one page quadratically expensive to load. This PR adds a minimal,
fully backward-compatible, opt-in path to linear delivery. It defines the
plugin half of the protocol only; it is a safe no-op until the host implements
the other half, and it degrades gracefully in the meantime.
Problem
The current contract forces cumulative delivery:
WorkbookElementDatais bare column arrays —{ [colId: string]: any[] }.There is no field where an offset, page index, completion flag, or total row
count could even be expressed today. (This also means "the plugin author is
holding it wrong" isn't available as an explanation: the types give a plugin
no way to do better.)
subscribeToElementDatadelivers via a callback whose payload consumers useto replace their entire state — the SDK's own
useElementDataandusePaginatedElementDatahooks do exactly this (setData). Becausedelivery is replace-semantics, the host must ship the full accumulated array
on every page; sending only the new rows would discard all prior data.
fetchMoreElementDatapostswb:plugin:element:fetch-morecarrying onlythe
configId. The client holds no pagination state and cannot request aslice.
Instrumenting the host page during one initial load of the same 546k×58
source described below: the host translates each SDK
fetch-moreinto afresh
POST /api/v2/db/ir/evalwithoutputIR.pager.offset = 0and alimit that grows by 25,000 per round (25001 → 50001 → 75001 → …). Across 17
captured consecutive requests, a field-level diff of the decompressed
request bodies (~690 leaf fields) shows only three fields changing:
pager.limit(+25000 each round) and two wall-clock timestamps. The offsetis present and pinned at 0 in every request. The server returns exactly
what it is asked for — the full accumulated prefix, every round.
The consequence is that loading
nrows at page sizePdeliversΣ k·P for k = 1..⌈n/P⌉ ≈ n²/2Prow-deliveries — O(n²) bytes serialized,structure-cloned, and parsed across the iframe boundary.
Concretely, as a Sigma user: on a 546k-row × 58-column source, the element
loads in ~22 pages of 25k rows. I instrumented the data callback and each
delivery carried the entire accumulated set — callback #9 carried 200,001
rows, #13 carried 300,001, #17 carried 400,001. Total delivery cost was ~6.4M
row-deliveries to convey 546k rows — roughly ~4 GB of cumulative
serialize/clone/parse to deliver a ~295 MB final dataset. Per-page latency
degraded from ~2.6s for early pages to ~7.3s as the accumulated payload grew,
for a total load time of 5–6 minutes. The same plugin logic against a sub-25k
source is instant.
Measured cost of the network leg for one (truncated, 17-of-~19-round) load:
~119 MB compressed on the wire, ~2.07 GB decoded, to deliver a ~294 MB final
dataset; extrapolated full-load wire cost ~147 MB. Per-page wall time degraded
from ~2s to ~20s+ as the limit grew. This establishes that the quadratic
behavior exists on both sides of the plugin boundary — warehouse→browser
(host re-queries the full prefix) and browser→plugin (host re-delivers the
full accumulated array) — and that the host's grow-the-limit pager is the
implementation the replace-semantics contract naturally induces: since the
SDK obligates delivery of the full accumulated set on every page, re-querying
it wholesale is the consistent implementation. The contract is the root
cause; the network waste is its symptom.
Why this design
Additive envelope + capability handshake. The change adds one type, one
client method, and one hook — nothing existing is touched:
WorkbookElementDataChunk—{ data, offset, isComplete, totalRows? },where
datacontains only the chunk's rows andoffsetis the absolute rowoffset to apply them at.
client.elements.subscribeToIncrementalElementData(configId, callback)—identical to
subscribeToElementDataexcept the subscribe message carries acapability option:
wb:plugin:element:subscribe:datais posted with args[configId, { mode: 'incremental' }]. Hosts that don't recognize the extraargument ignore it and keep sending cumulative payloads.
useIncrementalElementData(configId)— returns[data, loadMore, { rowCount, isComplete, totalRows }]. The first twopositions match
usePaginatedElementData, so migrating a plugin is aone-line hook swap.
Why append-semantics must be opt-in. Every existing plugin is written
against replace-semantics — the entire ecosystem's callbacks do
setData(payload). Changing the meaning of the existing subscription wouldsilently truncate every existing plugin's data to the last page. Opt-in via a
new method keeps the blast radius at exactly zero.
Why the hook accumulates internally. Chunk assembly (splicing rows at
offsets, tracking completion) is mechanical and easy to get subtly wrong.
Doing it inside the hook keeps the migration to a one-line swap and gives
every consumer the same well-tested assembly logic. Plugins that want raw
chunks (e.g., to stream into WebGL buffers or a worker) can use the client
method directly.
Why degraded mode matters. The host and this SDK ship on independent
cadences, and plugin authors don't control which Sigma version renders their
iframe. A plugin built against the new API must work correctly today,
against hosts that have never heard of the capability flag. The design makes
this fall out naturally rather than requiring branching code:
WorkbookElementDatapayloadis a column array, so a payload with a numeric
offset, booleanisComplete, and non-array objectdatacan only be the chunk envelope.The client checks exactly that.
callback as
{ data: payload, offset: 0, isComplete: false }— areplace-everything chunk.
offset— rows before itare kept, rows at or after it are overwritten, and rows are padded to their
absolute offset if a column first appears mid-stream. A re-sent or
overlapping chunk applies idempotently; a chunk that omits a column (or an
empty chunk that only flips
isComplete) carries the accumulated columnsforward instead of dropping them; and a normalized legacy payload at offset
0 becomes an exact wholesale replace, which is precisely today's behavior.
One code path handles both hosts. Column ids are applied own-property-safely
(
__proto__is skipped; ids likeconstructorcannot collide withinherited members), and envelopes with malformed offsets (negative,
fractional, NaN) are rejected by the type guard rather than corrupting
assembly.
One honest limitation of degraded mode: legacy hosts never signal completion,
so
isCompleteremainsfalseagainst them — the same (absence of)information plugins have today. Because that is true of 100% of hosts until
the host half ships, the README, JSDoc, and hook docs all warn explicitly:
never drive an auto-load loop or a "load more" affordance from
isCompletealone; use
rowCountto detect whether a fetch made progress.One scoping note: the delivery mode belongs to the (plugin, element)
subscription — both subscription styles share the element's data channel — so
mixing
subscribeToElementDataandsubscribeToIncrementalElementDataonthe same config element is documented as unsupported (JSDoc and README). A
plugin migrates an element wholesale, which the one-line hook swap makes
easy.
Alternatives considered
still O(n²) in bytes, just with a different constant — and it inflates the
cost of every individual clone/parse and worsens peak memory on both sides
of the boundary.
formats, compression). Worth doing on its own merits, but it still ships
O(n²) bytes across the boundary; a 546k-row source still delivers ~6.4M
row-equivalents. Orthogonal to, not a substitute for, incremental delivery.
surface, but it silently breaks every existing plugin. Unacceptable
ecosystem cost.
Legitimate and documented, and I use it where the use case allows. But it
shouldn't be the only viable pattern the SDK supports, and it doesn't serve
detail-grid or record-level use cases at all, which are exactly the plugins
that need large sources.
previously fetched rows, advance the warehouse pager's offset (the
pager.offsetfield already exists in its query IR), and still delivercumulative payloads to plugins. This would eliminate the network-side waste
and is worth doing independently. Rejected as sufficient: the
browser→plugin boundary would still re-serialize, re-clone, and re-parse
the full accumulated set every page — O(n²) at the exact hop where the
browser's main thread pays for it. Incremental delivery fixes both legs;
caching alone fixes one.
Compatibility
{ mode: 'incremental' }useIncrementalElementData)isCompletestaysfalseuseIncrementalElementData)Existing plugins recompile and run unchanged; the new API is purely additive.
What the host needs to do
This PR defines the plugin half of the protocol. The host-side counterpart:
wb:plugin:element:subscribe:data, read the optional second argument.If it is
{ mode: 'incremental' }, mark the subscription incremental.(Current hosts ignore extra args, which is what makes this a safe no-op.)
wb:plugin:element:{configId}:dataevent with a chunk envelope instead ofthe cumulative array:
{ data: <rows in this page only>, offset: <absolute row offset>, isComplete: <no more rows>, totalRows?: <if known> }.offsetmust be a non-negative integer; every chunk should carry thesubscription's full column set with all column arrays the same length. An
empty
dataobject atoffset > 0is a valid metadata-only chunk (e.g.to flip
isCompletewhen a fetch-more finds no new rows) — the clienttreats it as such rather than as a data reset.
refresh (source re-query, filter change), restart from a chunk at
offset: 0— the client treats an offset-0 chunk as replacing allaccumulated state, so no separate reset message is needed.
offsetandoverwrites on overlap, so in-order at-least-once delivery per range is
sufficient. (Out-of-order delivery below the high-water mark is not —
an offset-0 chunk always means restart.)
envelopes to subscriptions that advertised
{ mode: 'incremental' }, andkeep sending cumulative payloads to ones that did not.
wb:plugin:element:fetch-moreandwb:plugin:element:unsubscribe:dataareunchanged.
Note for implementers: from the outside, the host's query IR already carries
pager.offsetandpager.limit, and measured traffic shows offset pinned at0 with a growing limit. If that observation holds internally, the counterpart
change may be as small as advancing
offsetby the page size and holdinglimitat 25,000 — though you are in a far better position than I am tojudge that.
Testing
src/client/__tests__/initialize.test.ts): the new subscribemethod posts the subscribe message with the capability option, dispatches
chunk envelopes to the callback, unsubscribes cleanly, normalizes legacy
cumulative payloads into replace-chunks at offset 0, and rejects envelopes
with malformed offsets (negative, fractional, NaN) as legacy payloads.
src/react/__tests__/hooks.test.tsx): chunks at offsets 0 and 2concatenate correctly across multiple columns;
isCompleteandtotalRowsare surfaced; an overlapping chunk re-sent twice applies idempotently; an
empty terminal chunk and a chunk omitting a column preserve accumulated
data; an offset-0 restart replaces state wholesale and re-baselines
totalRows; a column first appearing mid-stream lands at its absoluteoffset; column ids colliding with
Object.prototypemembers(
constructor,toString,__proto__) are handled safely;loadMorewiring and unmount/falsy-configId behavior mirror the existing hook tests.
messageevents carrying legacy cumulative payloads (the exact shapecurrent hosts send) are dispatched at an incremental subscription, and the
hook's state matches the final cumulative payload exactly — no double
append.
host-page + plugin-iframe harness (Playwright Chromium, real
postMessageacross the iframe boundary) simulating both host behaviors: degraded mode
delivers exact data with the capability ignored; an incremental host with a
deliberately duplicated chunk stays correct and linear; and both
legacy-API-regression scenarios confirm the existing subscribe message and
behavior are byte-identical to today.
yarn build,yarn types, andyarn lintare clean.Happy to adjust any part of the envelope or handshake to fit host-side
constraints I can't see from the client repo — the shape of the degraded-mode
guarantee is the part I'd like to preserve. I'm also happy to drop the
version bump and CHANGELOG dating from this PR if you'd rather control
releases separately — say the word and I'll rebase them out.
This change was developed and reviewed with the assistance of Claude
(Anthropic's AI assistant); I directed, measured, validated, and take
responsibility for all of it.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UtbehD5eW7ci5awuCz1QcM