fix(solid-query): restore observer subscription after SSR hydration#11017
fix(solid-query): restore observer subscription after SSR hydration#11017brenelz wants to merge 1 commit into
Conversation
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).
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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 |
|
View your CI Pipeline Execution ↗ for commit b0b0726
☁️ Nx Cloud last updated this comment at |
Problem
useQuerycomponents hydrated from SSR are permanently non-reactive: the DOM renders the server snapshot correctly, butsetQueryData, 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:MockPromisestub (subFetch/readHydratedValue) that silently drops the Promise executor's side effectsEither way, the
QueryObserversubscription 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
setTimeoutpolls after hydration has fully completed, in two stages (packages/solid-query/src/useBaseQuery.ts):recoverAfterHydration()— for all components created by the hydration walk: waits forsharedConfig.done(boundary hydration is asynchronous; subscribing earlier delivers an immediateupdateResult()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.finishPendingRecovery()— streamed-pending queries only (hydratedAsPending && !computeWired): waits for the hydrated resource to settle, seeds the cache from the snapshot'shydrationDatawhen nothing fresher exists (standalone Solid SSR without a router integration), thenrefresh(queryResource)re-runs the compute under the nativePromisefor the full normal wiring.Why no owned computations
Two earlier approaches using a recovery
createRenderEffectregressed 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'sdisposedflag.Verification
@tanstack/solid-queryunit tests: 322/322 passing;eslintandtsccleanquery-integrationsuite 8/8, including a new liveness regression test: visit a route whose loader kicks offprefetchQuerywithout awaiting, thensetQueryDataafter hydration must re-render the regionsolid-query-layout-suspense(repro for [solid-start] Page fails to render after client navigation router#7195) passingbasic-solid-query/basic-solid-query-file-based(CSR) 3/3 each