feat(router): load-time query prefetch helper for tanstack-query sub-entry#2347
Conversation
Adds `definePageLoadQueries` under `@analogjs/router/tanstack-query/server`,
a thin wrapper around `definePageLoad` that constructs a per-request
`QueryClient` and exposes it to the handler. The handler runs in the
Nitro `.server.ts` context — call `client.prefetchQuery` /
`ensureQueryData` to warm the cache, return optional supplemental data.
The wrapper returns `{ __analogQueries: DehydratedState, data: TData }`;
the dehydrated payload is what the router hydrator (added separately)
merges into the active client on `ResolveEnd`. Per-request isolation:
a fresh `QueryClient` is built every invocation.
Inherits `params` / `query` Standard Schema validation from
`definePageLoad`; validation failures short-circuit with `fail(422)`
before any prefetch runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a second `ENVIRONMENT_INITIALIZER` to `provideAnalogQuery()` that subscribes to `Router.events` and, on every `ResolveEnd`, walks the activated snapshot tree and merges any `data['load'].__analogQueries` dehydrated payloads into the active `QueryClient`. Runs on both server and client: - **Server**: after `load()` returns, the router emits `ResolveEnd`, the hydrator merges the dehydrated cache, then components render against the warm client; `BEFORE_APP_SERIALIZED` captures the merged state into `TransferState` as before. - **Client**: subsequent navigations re-fetch `/_analog/pages/...` for the new route; the same merge runs. Initial paint is still covered by the existing TransferState hydration. `hydrate` is idempotent — re-merging the same payload is a no-op. `Router` is injected optionally so apps that don't use `@angular/router` still bootstrap. Subscription is torn down via `DestroyRef.onDestroy`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the two new code paths added under `@analogjs/router/tanstack-query`:
`define-page-load-queries.spec.ts` (new):
- result shape — `{ __analogQueries, data }` with the dehydrated cache
- fresh `QueryClient` per invocation
- user-supplied `client` factory honored (so default options flow)
- params validation failure returns a 422 `Response` before the handler runs
`provide-analog-query.spec.ts` (extended):
- `ResolveEnd` with `data['load'][__analogQueries]` hydrates the active client
- snapshot walk merges dehydrated state from nested child routes
- load data without an `__analogQueries` field is a no-op
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 'Prefetching Queries in load()' section to the TanStack Query integration docs, showing the .server.ts handler shape, the matching page component, params validation pass-through, and the optional client factory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the hand-rolled `ENVIRONMENT_INITIALIZER` that was inlining the exact code `provideAnalogQuery()` ships, and calls the helper directly. Behavior on the existing TransferState path is identical; the helper additionally subscribes to `Router.events` so the load-time prefetch helper added in @analogjs/router can merge route-data payloads on `ResolveEnd`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`event.context` is undefined for sub-handler dispatches that didn't go through h3's router-params layer — e.g. the internal `fetchWithEvent` calls Angular SSR uses to reach `.server.ts` page-load endpoints. Accessing `event.context.params` then threw `TypeError: Cannot read properties of undefined (reading 'params')` before the user's `load` function ever ran, surfacing as a 500 on every page that defined a load handler. Mirrors the optional chaining already used for `event.node?.req` / `event.node?.res`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The route-events hydrator added in 9d6bdaa merges dehydrated load payloads into the active `QueryClient` during navigation, but on the server that wasn't enough to feed the client: Angular's built-in `TRANSFER_STATE_SERIALIZATION_PROVIDERS` callback runs as part of `BEFORE_APP_SERIALIZED`, ahead of `provideServerAnalogQuery()`'s `dehydrate(client)` hook, so anything the dehydrate path wrote was serialized too late to land in the `ng-state` script. The hydrator runs during `ResolveEnd` — *before* Angular's serializer fires — so it's the right place to write. When `PLATFORM_ID` resolves to the server, the hydrator now also copies each load route's dehydrated state into the same `TransferState` key (`ANALOG_QUERY_STATE_KEY`) the existing client-side initializer reads on bootstrap. Multiple parent/child route payloads are merged by `queryHash` so duplicates don't accumulate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new `/tanstack-query-load` demo page that prefetches the existing `/api/v1/query-posts` server route inside its `.server.ts` `load()` handler via `definePageLoadQueries`. The component reads `postsQuery` through `serverQueryOptions` with the same queryKey, so on first render the cache is already warm — fetch count stays at 1 across page reloads and the client issues no `/api/v1/query-posts` request. Includes a home-page card and a Playwright e2e covering the no-client-request and scope-isolation behaviors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…P_SERIALIZED Component-issued queries weren't reaching the client because Angular's `TRANSFER_STATE_SERIALIZATION_PROVIDERS` registers its own `BEFORE_APP_SERIALIZED` callback in `provideServerRendering()` — typically declared *before* `provideServerAnalogQuery()` — and runs in declaration order. Angular's serializer read `TransferState.toJson()` and emitted the `ng-state` script *before* our dehydrate hook ever fired, so anything we wrote in `BEFORE_APP_SERIALIZED` was serialized too late to reach the client. Move the dehydrate to an `ENVIRONMENT_INITIALIZER` that subscribes to `ApplicationRef.isStable`, with `skipWhile(stable => stable)` to step past the initial 'no pending tasks yet' value the underlying `BehaviorSubject` emits on subscribe. The first `true` after rendering kicks off (queries fire, become unstable, settle) is the post-render stable, and that's where we dehydrate. This fires before `renderApplication`'s own `await whenStable()` resumes — both subscriptions are on the same observable and ours is registered first via the environment initializer — so the `TransferState` write happens before `BEFORE_APP_SERIALIZED` runs and Angular's serializer picks up our key. Order-independent of how the user declares providers. Removes the brittle `BEFORE_APP_SERIALIZED` registration entirely; the unit test now drives a mocked `isStable` subject through `true → false → true` and asserts the dehydrate only fires on the post-render transition. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`provideTanStackQuery(new QueryClient())` evaluates the constructor once at module-load time, so every SSR request on the same Node process shares the same `QueryClient` instance — query state from one response leaks into the next request's dehydrated `TransferState`. Pass an `InjectionToken<QueryClient>` with `factory: () => new QueryClient()` instead. `bootstrapApplication` creates a fresh root injector per SSR call, so the factory runs once per request and each response ships only its own queries. On the browser the same provider still resolves to a single instance. Updates the tanstack-query-app config and the docs page (with a prominent warning callout), since the bad pattern came from the docs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
✅ Deploy Preview for analog-blog ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
✅ Deploy Preview for analog-app ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds server-side definePageLoadQueries to prefetch TanStack Query queries with a per-request QueryClient and return dehydrated cache under ANALOG_QUERIES_KEY. provideAnalogQuery() now hydrates QueryClient from route load payloads on ResolveEnd and mirrors merged payloads into TransferState on the server. provideServerAnalogQuery() uses an ENVIRONMENT_INITIALIZER to serialize after post-render stability. App bootstrap switched to a QUERY_CLIENT InjectionToken. Nitro page-endpoints generation was hardened to use optional chaining for event.context params. Demo page, RFC, unit tests, and E2E tests were added. Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
This PR touches multiple package scopes: Please confirm the changes are closely related. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
apps/tanstack-query-app-e2e/tests/tanstack-query-load.spec.ts (1)
26-32: ⚡ Quick winConsider asserting data content differs between scopes.
The test validates that each scope triggers one fetch, but doesn't verify that the rendered data actually differs between
load-scope-aandload-scope-b. If a cache-key collision bug caused both scopes to share state, the test would still pass as long as each performed one fetch. To strengthen validation of the per-request QueryClient isolation guarantee, consider adding assertions that compare the actual rendered content (e.g., post titles or IDs) between the two navigations.🧪 Example enhanced isolation assertion
test('isolates scope state across requests', async ({ page }) => { await page.goto('/tanstack-query-load?scope=load-scope-a'); await expect(page.locator('`#load-fetch-count`')).toContainText('1'); + const scopeAContent = await page.locator('`#load-scope`').textContent(); await page.goto('/tanstack-query-load?scope=load-scope-b'); await expect(page.locator('`#load-fetch-count`')).toContainText('1'); + const scopeBContent = await page.locator('`#load-scope`').textContent(); + expect(scopeAContent).not.toBe(scopeBContent); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/tanstack-query-app-e2e/tests/tanstack-query-load.spec.ts` around lines 26 - 32, The test "isolates scope state across requests" currently only asserts fetch counts; capture the rendered data after each navigation (e.g., text from the page locator that shows the loaded item such as '`#load-data`' or '`#post-title`') and assert the two values differ to ensure per-scope isolation; update the steps after page.goto('/tanstack-query-load?scope=load-scope-a') and page.goto('/tanstack-query-load?scope=load-scope-b') to read and store the content from the appropriate data locator (in addition to '`#load-fetch-count`') and add an assertion that the stored values are not equal.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/tanstack-query-app/src/app/pages/`(home).page.ts:
- Around line 134-147: The homepage copy still shows "Examples 4" and only lists
four patterns even though a new demo card ("Prefetch in load()" represented by
the anchor/card with badge "Load-time prefetch" and title "Prefetch in load()")
was added; update the summary/count text and the examples list to include this
new demo (increment the displayed examples count to 5 and add an entry for the
load-time prefetch/demo) so the summary and list match the added card.
In `@packages/router/tanstack-query/RFC-LOAD-QUERIES.md`:
- Around line 3-5: Update the RFC metadata "Status:" line in RFC-LOAD-QUERIES.md
to reflect that work has been implemented and validated rather than "Draft —
implementation not yet started"; locate the "Status:" metadata string in the
file and change it to an accurate state such as "Implemented — implementation
and validation complete" or "In review — implementation and validation complete"
so the branch status no longer conflicts with included code.
In `@packages/router/tanstack-query/src/provide-analog-query.ts`:
- Around line 106-116: mergeDehydrated currently keeps the first query for a
given queryHash (base wins) which can cause stale data; change it to
last-writer-wins by merging queries so that when queryHash duplicates exist the
entry from next overrides base. Locate mergeDehydrated and adjust the queries
merge logic (using DehydratedState, queries, and queryHash) to produce a
map/ordered list where entries from next replace base entries with the same
queryHash, preserve order predictably, keep mutations concatenation as-is, and
add/extend a unit test asserting that when the same queryHash exists in base and
next the next entry is the one kept.
---
Nitpick comments:
In `@apps/tanstack-query-app-e2e/tests/tanstack-query-load.spec.ts`:
- Around line 26-32: The test "isolates scope state across requests" currently
only asserts fetch counts; capture the rendered data after each navigation
(e.g., text from the page locator that shows the loaded item such as
'`#load-data`' or '`#post-title`') and assert the two values differ to ensure
per-scope isolation; update the steps after
page.goto('/tanstack-query-load?scope=load-scope-a') and
page.goto('/tanstack-query-load?scope=load-scope-b') to read and store the
content from the appropriate data locator (in addition to '`#load-fetch-count`')
and add an assertion that the stored values are not equal.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 127707ce-0637-4007-917f-0e5a90799d21
📒 Files selected for processing (16)
apps/docs-app/docs/integrations/tanstack-query/index.mdapps/tanstack-query-app-e2e/tests/tanstack-query-load.spec.tsapps/tanstack-query-app/src/app/app.config.tsapps/tanstack-query-app/src/app/pages/(home).page.tsapps/tanstack-query-app/src/app/pages/tanstack-query-load.page.tsapps/tanstack-query-app/src/app/pages/tanstack-query-load.server.tspackages/platform/src/lib/nitro/page-endpoints-plugin.spec.tspackages/platform/src/lib/nitro/page-endpoints-plugin.tspackages/router/tanstack-query/RFC-LOAD-QUERIES.mdpackages/router/tanstack-query/server/src/define-page-load-queries.spec.tspackages/router/tanstack-query/server/src/define-page-load-queries.tspackages/router/tanstack-query/server/src/index.tspackages/router/tanstack-query/src/constants.tspackages/router/tanstack-query/src/provide-analog-query.spec.tspackages/router/tanstack-query/src/provide-analog-query.tspackages/router/tanstack-query/src/provide-server-analog-query.ts
| <a | ||
| routerLink="/tanstack-query-load" | ||
| class="card card-border bg-base-100 shadow-md transition-all hover:-translate-y-1 hover:shadow-xl" | ||
| > | ||
| <div class="card-body"> | ||
| <div class="badge badge-info badge-outline">Load-time prefetch</div> | ||
| <h2 class="card-title text-xl">Prefetch in <code>load()</code></h2> | ||
| <p class="text-base-content/70"> | ||
| Declare a route's queries in its <code>.server.ts</code> handler | ||
| with <code>definePageLoadQueries</code> — components render | ||
| against a warm cache, no SSR-to-client refetch. | ||
| </p> | ||
| </div> | ||
| </a> |
There was a problem hiding this comment.
Update homepage summary stats to reflect the new demo card.
Adding this card makes the set effectively 5 demos, but the summary still says Examples 4 and lists only four patterns (Line 56 and Line 57), so homepage copy is now inconsistent.
Suggested copy update
- <div class="stat-value">4</div>
- <div class="stat-desc">Basic, multi, infinite, optimistic</div>
+ <div class="stat-value">5</div>
+ <div class="stat-desc">Basic, multi, infinite, optimistic, load</div>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/tanstack-query-app/src/app/pages/`(home).page.ts around lines 134 - 147,
The homepage copy still shows "Examples 4" and only lists four patterns even
though a new demo card ("Prefetch in load()" represented by the anchor/card with
badge "Load-time prefetch" and title "Prefetch in load()") was added; update the
summary/count text and the examples list to include this new demo (increment the
displayed examples count to 5 and add an entry for the load-time prefetch/demo)
so the summary and list match the added card.
| **Branch:** `feat/load-queries-tanstack-query` (off `alpha`) | ||
| **Status:** Draft — implementation not yet started, awaiting approval | ||
| **Scope:** Sub-entry-point only. No new package, no new top-level entry. |
There was a problem hiding this comment.
RFC status metadata is stale for this PR.
Line 4 says implementation has not started, but this branch already includes implementation and validation work; updating status wording will avoid confusing rollout state.
Suggested metadata tweak
-**Status:** Draft — implementation not yet started, awaiting approval
+**Status:** Draft — implementation prototype exists in PR `#2347` (pending merge/approval)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| **Branch:** `feat/load-queries-tanstack-query` (off `alpha`) | |
| **Status:** Draft — implementation not yet started, awaiting approval | |
| **Scope:** Sub-entry-point only. No new package, no new top-level entry. | |
| **Branch:** `feat/load-queries-tanstack-query` (off `alpha`) | |
| **Status:** Draft — implementation prototype exists in PR `#2347` (pending merge/approval) | |
| **Scope:** Sub-entry-point only. No new package, no new top-level entry. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/router/tanstack-query/RFC-LOAD-QUERIES.md` around lines 3 - 5,
Update the RFC metadata "Status:" line in RFC-LOAD-QUERIES.md to reflect that
work has been implemented and validated rather than "Draft — implementation not
yet started"; locate the "Status:" metadata string in the file and change it to
an accurate state such as "Implemented — implementation and validation complete"
or "In review — implementation and validation complete" so the branch status no
longer conflicts with included code.
… merge `mergeDehydrated()` was keeping the first `queryHash` from `base` and dropping later duplicates in `next` — so if a parent and child route both prefetched the same key, the parent's (older) dehydrated entry was serialized into `TransferState` and the client could hydrate stale data on first paint. Switch to a `Map`-based merge keyed by `queryHash` so `next` overrides `base` for duplicate hashes, matching `hydrate()`'s own newer-wins semantics for the live QueryClient. Adds a focused spec that drives a parent → child route tree with overlapping query keys and asserts the TransferState entry reflects the child (later) write. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR Checklist
Adds load-time query prefetching for
@analogjs/router/tanstack-queryvia a newdefinePageLoadQuerieshelper plus an automatic Router-events hydrator. Surfaced two SSR-correctness bugs while integrating intoapps/tanstack-query-app; both are fixed in-tree. Updates the docs page and adds an end-to-end demo with Playwright coverage.Closes #
Affected scope
routerplatformRecommended merge strategy for maintainer [optional]
Commit preservation note [optional]
The 10 commits intentionally cross three concerns and the bisect lanes matter:
definePageLoadQueries+ the Router-events hydrator + the SSRTransferStatemirror under@analogjs/router/tanstack-query, with tests and docs.fix(platform): safe-access event.context.params in page endpoint wrapperis unrelated to TanStack Query. It's a pre-existing bug in the page-endpoint wrapper (added in feat(platform)!: migrate to nitro/vite and split analog() into analog() + angular() + nitro() #2343's Nitro v3 / h3 v2 migration) that surfaced because no current app exercises a non-trivialload(). Squashing it into the feature loses that history; rebase-merge keepsgit blameon the wrapper pointing at a focused fix.fix(router): dehydrate on first post-render app-stableandfix: isolate QueryClient per SSR request via InjectionToken factory— both fix pre-existing bugs in the SSR pipeline. Worth their own commits so a future bisect on TanStack-Query-related issues lands on the actual fix, not "the big PR".Squash would collapse all of this into one commit and hurt blame/bisect for
@analogjs/platformand the pre-existing SSR flow.What is the new behavior?
@analogjs/router/tanstack-query/serverAdds
definePageLoadQueries— adefinePageLoadwrapper that constructs a per-requestQueryClientand exposes it to the.server.tsload()handler:The handler runs in the Nitro page-load context (per-request
QueryClient, fullparams/query/event/fetchfromdefinePageLoad's typed context, Standard Schema validation inherited). It returns{ __analogQueries: DehydratedState, data: TData }; the component reads vanillainjectQuery(() => postsQuery)and gets a warm cache on first render.@analogjs/router/tanstack-queryprovideAnalogQuery()gains a secondENVIRONMENT_INITIALIZERthat subscribes toRouter.eventsand, onResolveEnd, walks the activated snapshot tree and merges anydata['load'].__analogQueriesdehydrated payloads into the activeQueryClient. Runs on both server and client; on the server it also mirrors the dehydrated state intoTransferState(ANALOG_QUERY_STATE_KEY) so the existing client-side hydration picks it up on bootstrap.@analogjs/router/tanstack-query/serverprovideServerAnalogQuery()moves offBEFORE_APP_SERIALIZEDand ontoApplicationRef.isStable. Angular'sTRANSFER_STATE_SERIALIZATION_PROVIDERScallback runs in declaration order ahead ofprovideServerAnalogQuery()'s old hook — so anything we wrote toTransferStateinBEFORE_APP_SERIALIZEDwas serialized too late to reach the client. The new path subscribes toappRef.isStable.pipe(skipWhile(s => s), filter(Boolean), take(1))— theskipWhilesteps past the initial "no pending tasks yet"BehaviorSubjectemission so the dehydrate fires on the post-render stable, with all queries settled, before Angular's serializer runs. Order-independent of how the user declares providers.@analogjs/platformevent.context?.paramsinstead ofevent.context.paramsin the page-endpoint wrapper.event.contextisundefinedfor sub-handler dispatches that didn't go through h3's router-params layer — e.g. the internalfetchWithEventcalls Angular SSR uses to reach.server.tspage-load endpoints — so accessing.paramsthrewTypeError: Cannot read properties of undefined (reading 'params')before the user'sloadfunction ever ran. Mirrors the optional chaining already used forevent.node?.req/event.node?.res.Docs + demo app
load()" section inapps/docs-app/docs/integrations/tanstack-query/index.md, plus a:::warningcallout about theprovideTanStackQuerySSR isolation footgun (see below).InjectionToken<QueryClient>withfactory: () => new QueryClient()instead ofprovideTanStackQuery(new QueryClient())— the latter pattern shares a singleQueryClientacross every SSR request in the Node process, leaking query state between responses. TheInjectionTokenfactory pattern gives eachbootstrapApplicationcall its own client. No library change required —provideTanStackQueryalready accepts anInjectionToken<QueryClient>upstream./tanstack-query-loaddemo page inapps/tanstack-query-appshowing the helper end-to-end, plus a Playwright e2e (tanstack-query-load.spec.ts) asserting (a) the list renders from the prefetch, (b) the client issues no/api/v1/query-postsrequest, (c) scope isolation across requests.app.config.tsswapped the hand-rolledENVIRONMENT_INITIALIZERforprovideAnalogQuery()(which is what it was inlining) and adopted theInjectionTokenfactory.Test plan
nx format:check(lint passes onrouterandplatform)nx build router/nx build platform/nx build tanstack-query-appnx test router— 320/320 (was 318; +7 new tests across the two specs intanstack-query/)nx test platform— 363/363 (incl. newpage-endpoints-plugincoverage)ng-statecontains only its own queries and the load page renders withfetch count = 1(load-time prefetch only; no client request)nx e2e tanstack-query-app-e2e— added but not yet run locally (CI will pick up)Does this PR introduce a breaking change?
All additions to
@analogjs/router/tanstack-query.provideServerAnalogQuery()'s implementation changed (moved fromBEFORE_APP_SERIALIZEDto anApplicationRef.isStablesubscription) but its export name, signature, and contract are unchanged — and the new path strictly fixes a case that didn't work before (component-issued queries weren't reaching the client at all).Other information
The
provideTanStackQuery(new QueryClient())SSR isolation issue is the kind of footgun that's hard to spot locally (single-process dev server, refresh hides it) and easy to ship to production. The docs change with the warning callout should keep future users out of it without requiring a library change —provideTanStackQueryalready supports the safe pattern upstream.The route-tree codegen (
apps/tanstack-query-app/src/routeTree.gen.ts) is not updated to include the new/tanstack-query-loadroute becauseexperimental.typedRouteris off in that app's vite config. The route works at runtime; only typed-route helpers would miss it. Enabling the flag is a separate decision.🤖 Generated with Claude Code