diff --git a/src/useObservable.ts b/src/useObservable.ts index f66a5522..f6b45079 100644 --- a/src/useObservable.ts +++ b/src/useObservable.ts @@ -104,7 +104,33 @@ export function useObservable(observableId: string, source: Observa return observable.immutableStatus; }, [observable]); - const update = useSyncExternalStore(subscribe, getSnapshot); + // Reads only `config`, never `observable.immutableStatus`: `preloadedObservables` is a + // `globalThis` cache keyed only by `observableId`, so a server shares it across concurrent + // requests, and seeding from it would render one request's data into another's HTML. + // The `as` cast below hides a missing `firstValuePromise` from `tsc` and the tests. + // Held in a ref because React requires a stable value across renders. + const serverSnapshotRef = React.useRef | undefined>(undefined); + const getServerSnapshot = React.useCallback<() => ObservableStatus>(() => { + if (serverSnapshotRef.current === undefined) { + const initialDataValue = config?.initialData ?? config?.startWithValue; + + serverSnapshotRef.current = { + status: hasInitialData ? 'success' : 'loading', + hasEmitted: hasInitialData, + isComplete: false, + data: initialDataValue, + error: undefined, + firstValuePromise: observable.firstEmission + } as ObservableStatus; + } + + return serverSnapshotRef.current; + // Callers pass a fresh `config` literal each render, so the fields read above are kept + // out of the deps; the ref computes the value once per instance anyway. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [observable, hasInitialData]); + + const update = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // Return a new object with initialData overlaid rather than mutating the shared // _immutableStatus reference, which is the same object across all components diff --git a/test/useObservable.test.tsx b/test/useObservable.test.tsx index f16d327a..56fb96a0 100644 --- a/test/useObservable.test.tsx +++ b/test/useObservable.test.tsx @@ -1,8 +1,10 @@ import '@testing-library/jest-dom/extend-expect'; import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'; import * as React from 'react'; +import { Writable } from 'node:stream'; +import { renderToString, renderToPipeableStream } from 'react-dom/server'; import { of, Subject, BehaviorSubject, throwError } from 'rxjs'; -import { useObservable } from '../src/index'; +import { useObservable, ReactFireOptions } from '../src/index'; describe('useObservable', () => { afterEach(cleanup); @@ -329,4 +331,100 @@ describe('useObservable', () => { expect(refreshedComp).toHaveTextContent('James'); }); }); + + describe('Server rendering', () => { + // Renders `status` and `data` so assertions read the snapshot React actually used. + const Probe = ({ observableId, observable$, config }: { observableId: string; observable$: Subject; config?: ReactFireOptions }) => { + const { status, data } = useObservable(observableId, observable$, { suspense: false, ...config }); + // One interpolated child: adjacent JSX text nodes render with `` between them. + return
{`${status}:${String(data)}`}
; + }; + + it('renders on the server instead of throwing', () => { + const observable$: Subject = new Subject(); + + // The #748 regression test: delete the third argument to useSyncExternalStore and + // this fails with "Missing getServerSnapshot". + expect(() => renderToString()).not.toThrow(); + }); + + // The App Router streams rather than calling renderToString, and streaming surfaces + // failures the synchronous renderer does not, so the fix is checked against both. + it('renders on the server under the streaming renderer', async () => { + const observable$: Subject = new Subject(); + let error: unknown; + + const html = await new Promise((resolve, reject) => { + const chunks: string[] = []; + const sink = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + callback(); + } + }); + sink.on('finish', () => resolve(chunks.join(''))); + sink.on('error', reject); + + const stream = renderToPipeableStream(, { + onError(e) { + error = e; + }, + onAllReady() { + stream.pipe(sink); + } + }); + }); + + expect(error).toBeUndefined(); + expect(html).toContain('loading:undefined'); + }); + + it('reports loading on the server when there is no initialData', () => { + const observable$: Subject = new Subject(); + + const html = renderToString(); + + expect(html).toContain('loading:undefined'); + }); + + it('reports initialData on the server when it is provided', () => { + const observable$: Subject = new Subject(); + + const html = renderToString(); + + expect(html).toContain('success:seeded'); + }); + + it('does not leak a cached value from another request into the server snapshot', async () => { + // `preloadedObservables` is on `globalThis`, keyed only by observableId, so concurrent + // server requests share it. The first render below stands in for an earlier request. + const observable$: Subject = new Subject(); + const observableId = 'ssr-no-cross-request-leak'; + + const { result } = renderHook(() => useObservable(observableId, observable$, { suspense: false })); + act(() => observable$.next('first-request-secret')); + await waitFor(() => expect(result.current.data).toEqual('first-request-secret')); + + const html = renderToString(); + + expect(html).not.toContain('first-request-secret'); + expect(html).toContain('loading:undefined'); + }); + + it('prefers the callers initialData over a value already in the shared cache', async () => { + // The `initialData` branch is only reachable when the cache already holds a value for + // this id; otherwise `useObservable`'s overlay decides and the snapshot never does. + const observable$: Subject = new Subject(); + const observableId = 'ssr-initial-data-beats-cache'; + + const { result } = renderHook(() => useObservable(observableId, observable$, { suspense: false })); + act(() => observable$.next('another-requests-value')); + await waitFor(() => expect(result.current.data).toEqual('another-requests-value')); + + const html = renderToString(); + + expect(html).toContain('success:my-own-data'); + expect(html).not.toContain('another-requests-value'); + }); + }); });