perf(prefetch): stop calling our own API over the wire during server render - #6657
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryMedium Risk Overview Routing and hydration fixes: Home drops duplicate folder/file prefetches (layout already seeds those keys) and no longer wraps Shared modules: Query client defaults: Server Guardrails: Cleanup: Hundreds of decorative Reviewed by Cursor Bugbot for commit d258fc3. Configure here. |
Greptile SummaryThe PR replaces server-render self-HTTP prefetches with authorized in-process reads while preserving React Query keys and wire projections. It also extracts lightweight table and pinned-item helpers, adjusts server query defaults, and adds a module-count regression guard.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/app/workspace/[workspaceId]/prefetch.ts | Reworks workspace sidebar prefetching around an already-authorized host context and direct contract-shaped reads. |
| apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts | Uses the route’s authentication policy, application use case, presenter, and response contract for in-process knowledge-base prefetching. |
| apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts | Prefetches active tables directly after proving workspace access and applies the shared route projection. |
| apps/sim/app/workspace/[workspaceId]/files/prefetch.ts | Seeds the workspace-files cache from a contract-parsed direct read after viewer authorization. |
| apps/sim/lib/table/wire.ts | Centralizes lightweight table wire normalization and list projection without importing the heavier table barrel. |
| apps/sim/lib/pinned-items/queries.ts | Extracts the pinned-item list query so the API route and server prefetch share filtering and serialization behavior. |
| apps/sim/app/_shell/providers/get-query-client.ts | Restores environment-specific React Query retry and garbage-collection behavior and removes pending-query dehydration. |
| scripts/check-tool-registry-boundary.ts | Adds route module-count regression checks and improves import-edge coverage for the boundary audit. |
Sequence Diagram
sequenceDiagram
participant RSC as Server-rendered page
participant Auth as Viewer authorization
participant Read as Use case / data read
participant QC as React Query cache
participant UI as Hydrated client
RSC->>Auth: Prove workspace viewer
alt Viewer authorized
Auth-->>RSC: Authorized context
RSC->>Read: Read data in process
Read-->>RSC: Contract-compatible result
RSC->>QC: Seed client query key
QC-->>UI: Dehydrate and hydrate
else Viewer not proven
Auth-->>RSC: No context
RSC-->>QC: Do not seed query
UI->>UI: Client query reaches API route
end
Reviews (5): Last reviewed commit: "chore: remove section separator comments" | Re-trigger Greptile
|
@cursor review |
|
@cursor review |
…er the wire Four server-render prefetches went out over HTTP to our own routes. With INTERNAL_API_BASE_URL unset in prod, getInternalApiBaseUrl() falls back to the public base URL, so each was RSC -> public HTTPS -> load balancer -> back into the app, awaited inside the render with a second round of auth. - /home fetched the workflow folder list that the workspace layout had already fetched, under the identical query key. Since getQueryClient() builds a new client per call on the server, the two never deduped: same data, twice a request, once directly and once over the wire. Dropped; the layout's entry already hydrates it. - /home cached raw route JSON under workspaceFilesKeys.list, while files/prefetch.ts seeds that same key from listWorkspaceFilesWithShares. The contract declares the date fields z.coerce.date(), so consumers hold Dates — a file record's type depended on which page the viewer landed on. Now reads the same function files/prefetch.ts does. - tables and knowledge folder reads now call listFoldersForWorkspace, matching the sidebar prefetch. These reads carry no authorization of their own, so each surface proves the viewer through getWorkspaceHostContextForViewer first and caches nothing when it fails, leaving the client fetch to reach the route for the real 403. Both it and getSession are cache()d and already resolved by the layout, so the proof costs no extra queries. Left on the wire, deliberately: the tables and knowledge lists, whose cached shape is the serialized wire shape, and pinned items and members, which have no exported data-layer function.
Passing an empty-string userId ran a real permission query that could only return null. Take an optional userId instead and skip straight to the unauthorized path, matching how the home prefetch is called.
…egacy helper Converts the last four server-render prefetches that called our own API over HTTP, and deletes prefetch-internal-fetch.ts now that nothing imports it. - knowledge bases: runs the route's own listInternalKnowledgeBases use case with a principal from the same internalSessionAuth policy the route declares, then projects through the same presenter and contract. Not a bypass of the application boundary — the same path, called in-process. - tables: extracts the route's list projection into lib/table/wire.ts as toTableListItem, which the route and the prefetch now both call. This matters because listTablesContract's response schema is a passthrough z.custom, so a client fetch caches the route's JSON verbatim. Seeding listTables() directly would have put Date objects and the server-only metadata field under a key the hook never sees them on. - pinned items: extracts the route's inline query into lib/pinned-items/queries.ts as listPinnedItemsForUser, which the route now calls too. - workspace members: getWorkspaceMemberProfiles already existed; the prefetch calls it directly. normalizeColumn moves from app/api/table/utils.ts to lib/table/wire.ts with ten importers repointed. That also removes a pre-existing lib/* -> app/api/* boundary violation in lib/table/import-runner.ts. No response shape changes: the v1/v2 edits are import-path moves only. Every converted read proves the viewer first and caches nothing when that fails, so an unauthorized viewer's client fetch still reaches the route for the real 403. Authorization equivalence was checked by unfolding both paths to checkWorkspaceAccess rather than assumed.
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
1 issue from previous review remains unresolved.
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 9596043. Configure here.
…ify the call shape - Extract prefetchResourceFolders. The same eight-line folder prefetch was written three times, varying only by resourceType, with the key, stale time and mapper kept in sync by hand. - Adopting it removes the conditional spread from the tables and knowledge prefetches. Tables can now early-return, matching prefetchFilesBrowser: prefetchResourceListChrome already self-guards on the same cached host context, so a null context meant the function did nothing either way. - Take userId as string | undefined everywhere and guard inside, so every prefetch module has one calling convention rather than two. - Export toWireTimestamp and use it for the create-table response's own copy of the same idiom, and drop a cast that the extraction made dead: the parameter is already TableDefinition, whose schema is TableSchema. - Read params and the session concurrently on the tables and knowledge pages, matching the files page, and drop TSDoc that restated each prefetch's own.
… edge Reading listTables from a page prefetch put the executable tool registry into the Tables page server graph — ~4,700 modules, which check:tool-registry-boundary rejects. lib/table/service reaches workflow-columns by several independent paths (directly, and through jobs/service and rows/service), so severing one edge is not enough; untangling that belongs in its own change. - The tables list goes back through GET /api/table, with the reason recorded so the next person does not repeat the attempt. Folders and chrome on that page stay on the data layer. - stripGroupDeps moves to its own leaf module. It is a pure projection over a WorkflowGroup, but living beside the group runtime meant every importer of lib/table/service paid for the executor to get it. Net effect on the Tables page graph: 2,186 modules to 1,742.
…et page graphs Answers the question the previous commit left open: the tables list did not have to stay on HTTP. lib/table/service reached the executor through jobs/service -> rows/service -> workflow-columns, for one symbol. pendingDeleteMask is a delete-visibility SQL clause with no executor involvement, so it moves to its own leaf and that chain is cut. The tables prefetch now reads the data layer like every other one, and prefetch-internal-fetch.ts is deleted: nothing in the app calls its own API over HTTP during a server render any more. stripGroupDeps likewise moves to a leaf rather than being re-exported through workflow-columns, so its importers no longer pull the executor to get a pure projection. React Query mechanism fixes, all found by audit: - settings/[section] fired two prefetches without awaiting them. Only a settled query is dehydrated, so those were shipped mid-flight; a rejection hydrated into an error state retryOnMount: false never retries, leaving the panel broken for the session. Awaited now, and the pending-dehydration opt-in is removed since nothing streams. - The viewer profile was prefetched by both the layout and the settings page. Separate server QueryClients mean that was a real second read per request. - prefetchSubscriptionData was dead, and hand-rolled an unannotated raw fetch. - retry is scoped to the browser. Query core defaults it to 0 on the server; stating one value for both opted awaited prefetches into a retry backoff. The gcTime default is dropped entirely — 5 minutes is already the browser default, and setting it explicitly overrode the server's Infinity, leaving a live timer and payload per request. check:tool-registry-boundary now also ratchets per-page module counts against a committed baseline, attributing a regression to the import that caused it via a dominator tree. It caught a +444 regression in this branch by hand; it would have caught it in CI. Its import regex also missed bare side-effect imports, so `import '@/tools/registry'` could have slipped past it entirely. Prefetch guidance added to .claude/rules/sim-queries.md.
9596043 to
ea4577e
Compare
…tionale Audit findings from the migration. - pending-delete-mask imported its schema tables from @sim/db rather than @sim/db/schema, which the module it came from was careful to split. The global test mocks are bound per-entrypoint and only the schema mock exports tables, so every suite that reaches pendingDeleteMask would have failed on a missing mock export. Restored to the original convention, and the same split applied to the new pinned-items queries module before it grows a test. - The settings prefetch and page justified awaiting with a mechanism this branch removed — pending queries being shipped with their promise. Only a settled query is dehydrated now, so an unawaited prefetch is dropped from the payload entirely. Same conclusion, correct reason, and no longer contradicting the rule this branch added. - Removed the doc block left orphaned above validateSchema when stripGroupDeps moved out of workflow-columns. Skill projections regenerated after trimming the boundary skill.
|
@cursor review |
Separators like these are non-TSDoc decoration that CLAUDE.md already rules out. This is the only one in a file this branch touches; the rest of the repo is swept separately.
There was a problem hiding this comment.
✅ 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 7199af8. Configure here.
CLAUDE.md already rules these out ("No ==== separators. No non-TSDoc
comments"), but 546 of them had accumulated across 48 files. They decorate
rather than explain, and they drift: a separator says "Validation" while the
code beneath it moved elsewhere, as one in workflow-columns already had.
Pure deletion — no source line was touched, and lines inside template
literals were skipped so nothing in a generated string changed.
|
@cursor review |
There was a problem hiding this comment.
✅ 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 d258fc3. Configure here.

Summary
Eight server-render prefetches called our own API routes over HTTP. When
INTERNAL_API_BASE_URLis unset,getInternalApiBaseUrl()falls back to the public base URL, so each became RSC → public HTTPS → whatever fronts the app → back in, awaited inside the render. Even with an internal base URL set it is still a server-to-server request plus a duplicate authentication for data the process can read directly.All eight are gone and
prefetch-internal-fetch.tsis deleted./homeworkflow folders + file listprefetchResourceFolderslistWorkspaceFilesWithSharestoTableListItemprojectionlistPinnedItemsForUsergetWorkspaceMemberProfilesBugs fixed along the way
/homefetched the workflow folder list twice per request.getQueryClient()builds a new client per server call, so the layout's copy and the page's never deduped./homeseeded the wrong type. It cached raw route JSON underworkspaceFilesKeys.listwhilefiles/prefetch.tsseeded the same key from a direct read; the contract declares those datesz.coerce.date(), so a file record's type depended on which page you landed on./settingsshipped two prefetches without awaiting them. Only a settled query is dehydrated, so they were dropped from the payload and the panel waterfalled anyway.prefetchSubscriptionDatawas dead code carrying an unannotated rawfetch.Why these aren't boundary bypasses
Knowledge runs
listInternalKnowledgeBases— the route's own use case — with a principal from the sameinternalSessionAuthpolicy the route declares, then the same presenter and contract parse. Tables and pinned items extract the route's own logic intolib/table/wire.tsandlib/pinned-items/queries.ts, which the routes now call too, so there is one source of truth rather than a copy that can drift. Every converted read proves the viewer first and caches nothing on failure, so an unauthorized viewer's client fetch still reaches the route for the real 403 — verified by unfolding both paths tocheckWorkspaceAccess.React Query mechanism
retryis now scoped to the browser: query-core defaults it to 0 on the server, and stating one value for both opted awaited prefetches into a retry backoff of document latency. ThegcTimedefault is dropped — 5 minutes is already the browser default, and setting it explicitly overrode the server'sInfinity, leaving a live timer and payload per request. The pending-query dehydration opt-in is removed now that nothing streams.Module-graph guard
check:tool-registry-boundarynow also ratchets per-page module counts against a committed baseline, attributing a regression to the import that caused it via a dominator tree. This branch hit a +444 module regression when a prefetch importedlistTablesfrom a barrel that reaches the executor; the guard caught it. Its import regex also missed bare side-effect imports, soimport '@/tools/registry'could have slipped past entirely — fixed.Cutting that edge properly (
stripGroupDepsandpendingDeleteMaskmoved to leaf modules) took the Tables page from 2,186 → 1,767 modules while keeping the direct read.Prefetch guidance added to
.claude/rules/sim-queries.md.Also in this PR
Removes 546 section separator comments (
// ═════ Foo ═════) across 48 files.CLAUDE.mdalready rules them out; they decorate rather than explain, and they drift — one inworkflow-columns.tsstill labelled a section whose function had moved away. Pure deletion, no source line touched, and lines inside template literals were skipped so no generated string changed.Type of Change
Testing
prefetch.test.ts21/21 (from 12), covering every converted read plus a viewer-cannot-be-proved case for each.type-check,biome,lint:check, andcheck:auditsall pass.Not verified locally: the
lib/tablesuites cannot load in a git worktree — a pre-existing postcss/tailwind resolution failure that also hits files this PR never touches. They exercisependingDeleteMask, which this PR moves, so please confirmLint and Testis green before merging. Nothing here is browser-verified.Checklist