fix(ssr): add getServerSnapshot to useObservable's useSyncExternalStore - #779
fix(ssr): add getServerSnapshot to useObservable's useSyncExternalStore#779tyler-reitz wants to merge 2 commits into
Conversation
useObservable called useSyncExternalStore with two arguments. React requires a third, getServerSnapshot, whenever the tree is server rendered or hydrated; without it React throws "Missing getServerSnapshot, which is required for server-rendered content" and the surrounding subtree silently falls back to client rendering. The server snapshot deliberately does not return observable.immutableStatus the way getSnapshot does. preloadedObservables is a globalThis cache keyed only by observableId, so on a server it is shared by every concurrent request; seeding the server snapshot from it would let one request render data another request fetched for the same path. Only config is read here, because it arrives from the caller on this render. Today that leak is unreachable because SSR throws first, so fixing the crash without this constraint would trade a crash for a cross-request data disclosure. Adds four tests under a "Server rendering" block, all mutation verified: - dropping the third argument fails all four with React's own error - returning observable.immutableStatus instead (the straightforward implementation) passes three and fails only the leak test Fixes FirebaseExtended#748.
armando-navarro
left a comment
There was a problem hiding this comment.
Thanks Tyler. This is a good fix and the call you flagged is the right one. I reproduced the failure on v5 first, then went after the reasoning rather than taking it on trust. Approving. Everything below is non-blocking.
On the decision you asked about: you were right to refuse what #748 proposed, and it is worth stating more strongly than "unsafe". Take your fix out, so getServerSnapshot returns observable.immutableStatus the way the issue asked, and the server HTML comes back as <div>success:first-request-secret</div>, a value an earlier render had left in the shared cache. One render's data really does reach another render's markup. Yours does not, which is the point.
Two things the code gets right that nothing holds in place
- The
initialDatabranch ofgetServerSnapshotis uncovered. Change it so it always returnsloading,falseandundefined, ignoringinitialDataentirely, and all 22 tests still pass. "reports initialData on the server" never reaches it, because the overlay atsrc/useObservable.ts:149-164setsstatus,dataandhasEmitteditself whenever!observable.hasValue && hasData. Same reason it survived your mutation B. The branch matters when the shared cache already holds a value for that id and the caller also passesinitialData: the overlay is skipped and your server snapshot is what ships, renderingsuccess:my-own-data, correctly preferring the caller's value over the stored one. - The natural test is your leak test with a seed added. Do the first render exactly as
:432already does, so the shared cache is holding a value for that id, then server-render the sameobservableIdwithinitialData, asserting the caller's seed renders and the stored value does not appear. That is the scenario the branch protects, and it doubles as a second cross-request check. I checked that such a test would actually catch a regression rather than just pass: with that branch changed to ignoreinitialData, the same case rendersloading:undefinedand the assertion fails. - The ref is uncovered too, though I am flagging that rather than asking for it. All 22 still pass if the ref is taken out and a fresh object is built on every call, since
renderToStringcallsgetServerSnapshotonce and only hydration exercises it. A test for it would have to render on the server, hydrate that markup on the client, and watch what React logs. Your file already spies onconsole.errorat:176, so the genuinely new part is the hydrating, but that is still more setup than the rest of the suite needs.
On the ref, the half of your question I nearly skipped
You wrote that the ref keeps the value stable for React's cached-result check. That is right, and it earns its place:
- Take the ref out so a fresh object is built on every call, and hydrating the no-
initialDatacase warnsThe result of getServerSnapshot should be cached to avoid an infinite loop. Put the ref back and the same run is silent. - Small correction: React's string names
getServerSnapshot, notgetSnapshotas your description has it. - Your
exhaustive-depssuppression looks safe. I tried to make a stale value visible and could not: re-rendering one instance withinitialDatachanged renders the new value, because the overlay recomputes from the currentconfig. It is the overlay, not the ref, keeping it fresh.
What I verified
- The failure reproduces on
v5with your exact error, and also under streaming, which is what App Router actually uses. My other checks usedrenderToString, so I redid these onrenderToPipeableStream: without the third argumentonShellErrorfires with the same message and the server emits an empty string, and with your fix the stream completes with zero errors while the case where the shared cache already holds a value still rendersloading:undefined, with that value absent from the markup. - Both your mutations reproduce exactly as you reported them.
- Hydration is clean in three cases with zero React warnings, including one where the server said
loadingbut the client's cache already held a value. Your description undersells this: taking the renderedstatusanddatafromconfigrather than from the cache also makes hydration deterministic, since both sides compute them from the same props. - Public type surface unchanged: byte-identical
.d.tsbetweenv5and the branch. tscclean on both configs,vite buildfine, eslint 0 errors.src/useObservable.tscarries the same 6 warnings as base, so the new source adds none. The test file goes 11 to 16, all of them the existingno-explicit-anyrule on the newSubject<any>annotations.- Your shim note and your
Fixes #748note are both correct, so nothing needed on the latter.
Three smaller notes
- The comment overclaims a little.
src/useObservable.ts:115-116says onlyconfigis safe to read, but:132just below readsfirstValuePromise: observable.firstEmissionfrom the shared cache. Nothing leaks through it, since that is aPromise<void>, and it sits inside the boundary you already drew. Worth changing the wording rather than the code, though. I tried removing the value, and because the field is not optional,status.firstValuePromise.then(...)on the server then fails withCannot read properties of undefined. Neithertscnor the suite catches that, because theas ObservableStatus<T>cast covers the missing field. - The protection is React 18 and up. Below that the shim ignores the third argument on the server too, not only on the client:
useSyncExternalStore$1names it and then returnsgetSnapshot(). On React 17 both forms render identically so nothing regresses, but a clause scoping the comment to 18+ would stop the next reader over-trusting it. I had this backwards at first. - Suspense SSR is still unfixed, and without
initialDataserver rendering produces onlyloadingplaceholders. With suspense on, the throw at:80happens before the hook, sogetServerSnapshotnever runs: cold cache throwsA component suspended while responding to synchronous input, withinitialDatait renders, and suspense off renders. Both are the right scope, and the placeholder behavior is the necessary cost of your model rather than a flaw in it, sinceobservableIdalone cannot tell your own data from another request's. A line in the description would stop anyone reading the title as "SSR works now".
Approving. If I have misread anything, especially the two coverage gaps, point me at it and I will look again.
Armando found that the branch was uncovered: neutering it to always return loading/false/undefined leaves all 22 tests passing. Verified independently before writing this. The reason is that the overlay below handles the ordinary case. Whenever `!observable.hasValue && hasData`, it sets status, data and hasEmitted itself, so the server snapshot never gets to decide anything. The branch is only reachable when the shared cache ALREADY holds a value for the id and the caller also passes `initialData`: the overlay is skipped and the server snapshot ships, correctly preferring the caller's value over the stored one. The new test seeds the cache the way the leak test does, then server-renders the same observableId with `initialData`. Mutation-verified: against the neutered branch it fails with `loading:undefined`, and it passes against the real one. It doubles as a second cross-request check. Also corrects two things in the comment, both his: - It claimed only `config` is read here, while `firstValuePromise` reads `observable.firstEmission` from the shared cache. Nothing leaks, since that is a `Promise<void>`, but the wording was wrong. The comment now says why it is safe, and records that the `as ObservableStatus<T>` cast would hide the field going missing from both tsc and the suite. - React's warning names `getServerSnapshot`, not `getSnapshot`. Adds a clause scoping the protection to React 18+, since below that the shim ignores the third argument on the server too.
|
You read all of it right, and the coverage gap was real. Pushed I reproduced the gap before acting on it. Neutering the branch to always return Your diagnosis of why was the useful part. The overlay handles the ordinary case, so the branch only decides anything when the cache already holds a value for the id and the caller passes The comment is fixed on both counts. It now says why The description now carries the scope caveat, in its own section: suspense mode still cannot server-render, and without Not done: the ref coverage. You flagged it rather than asked, and I agree with your reasoning about the setup cost, so I have left it. Worth revisiting when something else in the suite needs a hydration harness. Two things of yours I am carrying forward rather than answering here. The CI has not run on this push. GitHub Actions has been in a major outage since 15:22 UTC and no workflow runs are being created, so the checks you see are stale against |
|
Your In suspense mode under streaming, this hook waits for the data and then renders the placeholder anyway. The sequence: the component suspends at Net effect: the caller pays the full latency and gets the placeholder. This is not an argument against the PR and I am not proposing a change here. Reading that cache is the cross-request leak you reproduced at the top of your review. Refusing to read it is right, and the alternative is the One other thing from the same run, since it is worse operationally than what we knew: with a cold cache in suspense mode, streaming HANGS rather than erroring. Under Both of these sharpen the scope note I added to the description rather than contradicting it. Nothing here needs action from you, and the approved diff is unchanged. Also worth saying plainly: my SSR checks were all |
Fixes #748.
v5is not the default branch, so that keyword will not fire on merge. #748 needs hand-closing.useObservablecalleduseSyncExternalStorewith two arguments. React requires a third,getServerSnapshot, whenever the tree is server rendered or hydrated. Without it React throwsMissing getServerSnapshot, which is required for server-rendered contentand the surrounding subtree silently falls back to client rendering, which is why a Next.js App Router page using any reactfire hook loses SSR for that subtree.The part worth reviewing
The issue proposes returning "the same seeded
immutableStatusthe client snapshot returns". That version is unsafe and this PR deliberately does not do it.preloadedObservablesis aMaponglobalThis, keyed only byobservableId. In a browser that is one user's cache. On a server it is shared by every concurrent request, so agetServerSnapshotthat readobservable.immutableStatuswould render data request A fetched into request B's HTML whenever both touch the same path.That leak is unreachable today only because SSR throws before it can happen. So fixing the crash the obvious way would trade a crash for a cross-request data disclosure, which is the worse of the two.
This implementation reads only
configfor anything data-bearing, andconfigarrives from the caller on the current render, so it is per-request. WithinitialDatait reportssuccessand that value; without it,loading. (firstValuePromisedoes read the shared observable, but it is aPromise<void>that resolves without a value, so nothing crosses through it.) The result is held in a ref so the value is stable across renders, which is what React's "The result of getServerSnapshot should be cached" check wants.Verification
Four tests under a new
Server renderingblock, and both mutations were run rather than assumed:Missing getServerSnapshoterror. So the tests are load-bearing rather than decorative.observable.immutableStatusinstead (the straightforward implementation): 3 of 4 pass and only the leak test fails. That is the test that earns its place, and it is why the constraint above is written down in the code rather than left to reviewer memory.tscpasses on bothtsconfig.jsonandtsconfig.test.json;useObservableis 22/22; eslint reports 0 errors on both changed files.Notes
use-sync-external-store/shimis a red herring. Its own implementation ignoresgetServerSnapshoton purpose, but it resolves toReact.useSyncExternalStorewhenever React exposes it, so on React 18 and 19 the third argument reaches React's real implementation. Reading only the shim would suggest this fix cannot work.globalThiscache is still a cross-request hazard for anything that does read it on a server. This PR contains the hazard at the one place it would otherwise become reachable; it does not fix the cache itself. That is separate SSR work.eslint-disableforreact-hooks/exhaustive-deps, with the reason in a comment above it: callers routinely pass a freshconfigliteral each render, and the ref means the value is computed once per component instance anyway.Scope: this does not make SSR work, and the title should not be read that way
Suspense mode is unchanged and still cannot server-render. With
suspense: truethe throw atsrc/useObservable.ts:80happens before the hook runs, sogetServerSnapshotis never reached: a cold cache fails withA component suspended while responding to synchronous input, and onlyinitialDatagets you a render.In non-suspense mode without
initialData, a server render producesloadingplaceholders, not data. That is inherent to the model rather than a gap in it:observableIdalone cannot distinguish this request's data from another request's, which is the whole reason this PR refuses to read the cache. Real server-side data needs per-request cache scoping, which is a separate piece of work.What this PR does is narrower and worth stating plainly: the subtree renders instead of crashing, and it hydrates cleanly.