Skip to content

perf(tables): stop a table write refetching every loaded page in the tab that made it - #6698

Merged
waleedlatif1 merged 1 commit into
stagingfrom
perf/tables-refetch-storm
Aug 14, 2026
Merged

perf(tables): stop a table write refetching every loaded page in the tab that made it#6698
waleedlatif1 merged 1 commit into
stagingfrom
perf/tables-refetch-storm

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Stop a single-row table write refetching every loaded page in the tab that made it
  • Give the v1 Query Rows tool an end-of-data signal, and fix pagination docs that were instructing row-skipping

The refetch storm

#5991 added a live-collaboration signal: a row write appends an edit event that every subscriber of the table refetches on. The stream carries no originator, so the writing client refetches its own writerowsRoot is a prefix over the infinite query, so one cell edit costs N sequential GET /rows (up to 1000 rows each, plus a COUNT(*) on page 0) about 250–750ms after the write.

For the acting tab that work is pure duplication, and it actively undoes existing optimism: useUpdateTableRow merges the server row in onSuccess and deliberately never invalidates rows; useCreateTableRow avoids the rows root specifically to prevent insert flicker. The echo invalidates it anyway, a second later. On delete it's a second full refetch racing the one the hook already issued — and since invalidateQueries defaults to cancelRefetch: true, it can cancel and restart that one.

lib/table/events.ts had carried a standing TODO describing this and naming the fix. This is that fix.

Writes now optionally carry the originating tab, and a client ignores its own echo. Every tab sends an opaque per-tab id (x-sim-client-id, generated once per page load) — matching the existing pattern for cross-cutting headers (execution-deadline-header.ts, call-chain.ts, mcp/constants.ts): a small module owning the constant plus both sides' accessors.

Attribution is deliberately narrow. Only three routes stamp it — single-row create, update, delete — because only their hooks reconcile the server's answer across every cached rows query. Bulk, filter-scoped, upsert, import, copilot, and run-dispatch writes stay unattributed and refetch exactly as before; suppressing those would strand the writer on stale rows. That invariant is a claim about a client hook, which nothing in the type system ties to the call site, so events.attribution.test.ts pins the allowlist — a fourth call site fails the build.

Per-tab rather than per-user: a shared id would make your second tab ignore the first tab's edits.

The nextCursor holes

Fallout from #6582, which turned byte-cut pages on by default. Two in-repo consumers still assumed a full page meant more data:

  • apps/docs/.../using-in-workflows.mdx told users to advance Offset by the Limit — "0, then 100, then 200". Since a page can now end early at the byte budget, stepping by the requested limit rather than the received count silently skips rows, starting around 5KB average rows. Rewritten to advance by rowCount and stop on nextCursor.
  • tools/table/query_rows.ts (the v1 Table block) dropped nextCursor in transformResponse, leaving workflow authors with no correct termination signal. Now surfaced, matching query_rows_v2.

What was investigated and deliberately NOT changed

Four other suspected regressions were verified and declined on evidence:

  • Per-cell cache walk — benchmarked: 2.86ms per 500 events on 1000 loaded rows. An id-index early-exit would recover ~3ms and add an index to keep in sync with every page mutation.
  • Saved-views double page-0 fetch — real, but gating the rows query on views serializes two requests that are currently parallel, and adds latency for tables with no default view. The right fix is resolving the view in the page prefetch.
  • Duplicate useTable mount — real (network deduped, memos and renders not), but a provider refactor of a 1746 + 4616-line pair for unmeasured gain.
  • #6582 byte cut / firstBatchCap — both bounds are load-bearing. Reverting the byte cut restores a 400MB worst-case page; every route to a single first batch either re-opens unbounded memory or puts a byte-accounting mismatch on the hottest table read.

Type of Change

  • Bug fix
  • Performance

Testing

9 unit tests: the header is sent in the browser and omitted on the server, an over-long id is dropped rather than broadcast, absent reads as unattributed, and the attributed call-site allowlist is pinned.

Not browser-verified. The check that would confirm it end-to-end: edit a cell on a scrolled table with devtools Network open — today that triggers one GET /rows per loaded page ~250–750ms later; it should now trigger none in the acting tab, while a second tab still refetches.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview Aug 14, 2026 6:13pm

Request Review

@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches real-time table sync and cache invalidation; mis-attribution could leave a tab stale, but unattributed fallback, fingerprinting, and a pinned allowlist limit that surface area.

Overview
Stops the writing browser tab from refetching every loaded rows page after its own single-row create, update, or delete. Live-collab edit events used to make all subscribers (including the actor) debounce-invalidate rowsRoot, which duplicated work the mutation hooks already do and could race deletes on scrolled tables.

Each tab now sends x-sim-client-id on API requests; single-row write routes call signalTableRowsChangedByActor, which publishes a SHA-256 fingerprint (not the raw id) as originatorId on the edit event. The SSE handler skips the rows refetch when that fingerprint matches the tab. Bulk/filter writes stay on the unattributed path so writers that do not reconcile locally still refetch. An allowlist test pins which routes may use attributed signals.

useUpdateTableRow additionally invalidates non-default filtered/sorted row queries on success, since the acting tab no longer relies on the broadcast for those views. Docs for workflow Query Rows pagination now say to advance offset by rowCount and terminate on nextCursor, because pages can end early under the response size budget.

Reviewed by Cursor Bugbot for commit 8e18553. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR prevents an originating table tab from refetching locally reconciled single-row writes and exposes a reliable end-of-pagination signal for Query Rows.

  • Adds per-tab request attribution using a non-replayable SHA-256 fingerprint.
  • Suppresses only the originating tab’s redundant edit-event invalidation while preserving collaborator refreshes.
  • Refetches non-default filtered or sorted caches after row updates.
  • Adds nullable nextCursor output metadata and corrects offset-pagination guidance.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/api/client-id.ts Generates a per-page client identifier and hashes it before attribution is exposed to table subscribers.
apps/sim/lib/table/events.ts Adds an attributed edit signal while retaining unattributed signaling for mutation paths that require refetching.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts Skips edit invalidation only when the event fingerprint matches the current tab.
apps/sim/hooks/queries/tables.ts Invalidates non-default row queries whose filtering, ordering, or counts cannot be repaired by an in-place row patch.
apps/sim/tools/table/query_rows.ts Exposes a nullable pagination cursor in both transformed output and tool metadata.
apps/docs/content/docs/en/tables/using-in-workflows.mdx Corrects pagination instructions to advance by received row count and terminate using nextCursor.

Reviews (3): Last reviewed commit: "perf(tables): stop a table write refetch..." | Re-trigger Greptile

Comment thread apps/sim/lib/api/client-id.ts
Comment thread apps/sim/tools/table/query_rows.ts
@waleedlatif1
waleedlatif1 force-pushed the perf/tables-refetch-storm branch 2 times, most recently from c4f94c5 to 4b94836 Compare August 14, 2026 17:25
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@waleedlatif1
waleedlatif1 force-pushed the perf/tables-refetch-storm branch from ee2cdd6 to 8e18553 Compare August 14, 2026 18:07
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 8e18553. Configure here.

@waleedlatif1
waleedlatif1 merged commit 7f64d5e into staging Aug 14, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the perf/tables-refetch-storm branch August 14, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant