Skip to content

fix(solid-query): restore observer subscription after SSR hydration#11017

Open
brenelz wants to merge 1 commit into
TanStack:solid-query-v6-prefrom
brenelz:fix/solid-query-hydration-observer-recovery
Open

fix(solid-query): restore observer subscription after SSR hydration#11017
brenelz wants to merge 1 commit into
TanStack:solid-query-v6-prefrom
brenelz:fix/solid-query-hydration-observer-recovery

Conversation

@brenelz

@brenelz brenelz commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Problem

useQuery components hydrated from SSR are permanently non-reactive: the DOM renders the server snapshot correctly, but setQueryData, refetches, and invalidations never update it. With a loader that does not await the query this also surfaced as hydration errors.

During hydration, Solid restores useBaseQuery's resource memo from the value the server serialized, so the compute's wiring never runs on the client:

  • sync-serialized values (query settled on the server before the shell flushed — the common case): the compute is skipped entirely
  • async-serialized values (query streamed as a pending promise): the compute is replayed under a constructor-less MockPromise stub (subFetch/readHydratedValue) that silently drops the Promise executor's side effects

Either way, the QueryObserver subscription that lives inside the resource Promise executor is never established — the observer ends up with zero subscribers and cache updates never reach the component's store.

Fix

Recovery runs on unowned setTimeout polls after hydration has fully completed, in two stages (packages/solid-query/src/useBaseQuery.ts):

  1. recoverAfterHydration() — for all components created by the hydration walk: waits for sharedConfig.done (boundary hydration is asynchronous; subscribing earlier delivers an immediate updateResult() notification whose store write re-renders the component mid-walk and breaks DOM claiming), then re-establishes the observer subscription — unless the executor ran natively and wired itself — plus a guarded store catch-up that only writes when the result actually changed.
  2. finishPendingRecovery() — streamed-pending queries only (hydratedAsPending && !computeWired): waits for the hydrated resource to settle, seeds the cache from the snapshot's hydrationData when nothing fresher exists (standalone Solid SSR without a router integration), then refresh(queryResource) re-runs the compute under the native Promise for the full normal wiring.

Why no owned computations

Two earlier approaches using a recovery createRenderEffect regressed hydration itself: a client-only owned computation created during the hydration walk shifts the hydration key (getNextChildId) of every subsequent computation in the component, so JSX claiming fails — "Hydration completed with N unclaimed server-rendered node(s)" warnings and duplicated inert DOM. Both recovery stages therefore run on unowned timers guarded by the component's disposed flag.

Verification

  • @tanstack/solid-query unit tests: 322/322 passing; eslint and tsc clean
  • TanStack Router monorepo e2e (solid-start SSR + streaming, built against this branch):
    • query-integration suite 8/8, including a new liveness regression test: visit a route whose loader kicks off prefetchQuery without awaiting, then setQueryData after hydration must re-render the region
    • solid-query-layout-suspense (repro for [solid-start] Page fails to render after client navigation router#7195) passing
    • basic-solid-query / basic-solid-query-file-based (CSR) 3/3 each
  • Dev-mode probes show no unclaimed-node warnings and live post-hydration updates

Solid restores useBaseQuery's resource memo from the serialized server
value during hydration, so the compute's wiring never runs on the client:

- sync-serialized values (query settled on the server before the shell
  flushed) skip the compute entirely
- async-serialized values (query streamed as a pending promise) replay it
  under a constructor-less MockPromise stub that silently drops the
  Promise executor's side effects

Either way the QueryObserver subscription that lives inside the resource
Promise executor is never established: the component renders the hydrated
snapshot correctly but is permanently inert — setQueryData, refetches and
invalidations notify zero subscribers.

Recovery now runs on unowned setTimeout polls after hydration has fully
completed:

1. recoverAfterHydration() waits for sharedConfig.done (boundary
   hydration is asynchronous; subscribing earlier delivers an immediate
   updateResult() notification whose store write re-renders the component
   mid-walk and breaks DOM claiming), then re-establishes the observer
   subscription with a guarded store catch-up that only writes when the
   result actually changed.
2. finishPendingRecovery() (streamed-pending queries only) waits for the
   hydrated resource to settle, seeds the cache from the snapshot's
   hydrationData when nothing fresher exists, then refreshes the resource
   so the compute re-runs under the native Promise and performs the full
   normal wiring.

Owned computations are deliberately avoided in both stages: a client-only
createRenderEffect created during the hydration walk shifts the hydration
key of every subsequent computation in the component, so JSX claiming
fails ("unclaimed server-rendered node" warnings, duplicated inert DOM).
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce922302-546d-4edf-ba63-3712e2e80b2e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jul 2, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit b0b0726

Command Status Duration Result
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 23s View ↗
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 2m 10s View ↗

☁️ Nx Cloud last updated this comment at 2026-07-02 22:53:12 UTC

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@11017

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@11017

@tanstack/lit-query

npm i https://pkg.pr.new/@tanstack/lit-query@11017

@tanstack/preact-query

npm i https://pkg.pr.new/@tanstack/preact-query@11017

@tanstack/preact-query-devtools

npm i https://pkg.pr.new/@tanstack/preact-query-devtools@11017

@tanstack/preact-query-persist-client

npm i https://pkg.pr.new/@tanstack/preact-query-persist-client@11017

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@11017

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@11017

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@11017

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@11017

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@11017

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@11017

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@11017

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@11017

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@11017

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@11017

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@11017

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@11017

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@11017

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@11017

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@11017

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@11017

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@11017

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@11017

commit: b0b0726

@brenelz brenelz marked this pull request as draft July 2, 2026 23:01
@brenelz brenelz marked this pull request as ready for review July 3, 2026 01:01
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