Skip to content

fix(ssr): add getServerSnapshot to useObservable's useSyncExternalStore - #779

Open
tyler-reitz wants to merge 1 commit into
FirebaseExtended:v5from
tyler-reitz:fix/server-snapshot
Open

fix(ssr): add getServerSnapshot to useObservable's useSyncExternalStore#779
tyler-reitz wants to merge 1 commit into
FirebaseExtended:v5from
tyler-reitz:fix/server-snapshot

Conversation

@tyler-reitz

Copy link
Copy Markdown
Contributor

Fixes#748. v5 is not the default branch, so that keyword will not fire on merge. #748 needs hand-closing.

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, 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 immutableStatus the client snapshot returns". That version is unsafe and this PR deliberately does not do it.

preloadedObservables is a Map on globalThis, keyed only by observableId. In a browser that is one user's cache. On a server it is shared by every concurrent request, so a getServerSnapshot that read observable.immutableStatus would 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 config, which arrives from the caller on the current render and is therefore per-request. With initialData it reports success and that value; without it, loading. The result is held in a ref so the value is stable across renders, which is what React's "The result of getSnapshot should be cached" check wants.

Verification

Four tests under a new Server rendering block, and both mutations were run rather than assumed:

  • Drop the third argument: all four fail, with React's own Missing getServerSnapshot error. So the tests are load-bearing rather than decorative.
  • Return observable.immutableStatus instead (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.

tsc passes on both tsconfig.json and tsconfig.test.json; useObservable is 22/22; eslint reports 0 errors on both changed files.

Notes

  • The use-sync-external-store/shim is a red herring. Its own implementation ignores getServerSnapshot on purpose, but it resolves to React.useSyncExternalStore whenever 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.
  • No reference docs regeneration, since no exported symbol changed.
  • The globalThis cache 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.
  • One eslint-disable for react-hooks/exhaustive-deps, with the reason in a comment above it: callers routinely pass a fresh config literal each render, and the ref means the value is computed once per component instance anyway.

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
FixesFirebaseExtended#748.
Sign up for freeto 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

@tyler-reitz