Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/useObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,44 @@ export function useObservable<T = unknown>(observableId: string, source: Observa
return observable.immutableStatus;
}, [observable]);

const update = useSyncExternalStore(subscribe, getSnapshot);
// `useSyncExternalStore` requires a third argument when the tree is rendered on a
// server or hydrated; without it React throws "Missing getServerSnapshot, which is
// required for server-rendered content" and the surrounding subtree falls back to
// client rendering.
//
// This 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 safe to read here, because it comes from the caller on this render.
//
// The result is memoized per component instance because React compares the value it
// returns across renders, and a fresh object each time is what triggers the
// "The result of getSnapshot should be cached" error.
const serverSnapshotRef = React.useRef<ObservableStatus<T> | undefined>(undefined);
const getServerSnapshot = React.useCallback<() => ObservableStatus<T>>(() => {
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<T>;
}

return serverSnapshotRef.current;
// `config.initialData` and `config.startWithValue` are read above but deliberately left
// out of the dependency array. Callers routinely pass a fresh `config` literal on every
// render, so including them would rebuild this callback constantly, and the ref means
// the value is computed once per component instance regardless.
// 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
Expand Down
58 changes: 57 additions & 1 deletion test/useObservable.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import '@testing-library/jest-dom/extend-expect';
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react';
import * as React from 'react';
import { renderToString } from 'react-dom/server';
import { of, Subject, BehaviorSubject, throwError } from 'rxjs';
import { useObservable, FirebaseAppProvider } from '../src/index';
import { useObservable, FirebaseAppProvider, ReactFireOptions } from '../src/index';
import { initializeApp } from 'firebase/app';
import { baseConfig } from './appConfig';

Expand Down Expand Up @@ -391,4 +392,59 @@ describe('useObservable', () => {
window.removeEventListener('error', onError);
});
});

describe('Server rendering', () => {
// Renders `status` and `data` so the assertions read the snapshot React actually used,
// rather than a value the test computed for itself.
const Probe = ({ observableId, observable$, config }: { observableId: string; observable$: Subject<any>; config?: ReactFireOptions }) => {
const { status, data } = useObservable(observableId, observable$, { suspense: false, ...config });
// A single interpolated child, because adjacent JSX text nodes render with `<!-- -->`
// separators between them and the assertions below match on the plain string.
return <div>{`${status}:${String(data)}`}</div>;
};

it('renders on the server instead of throwing', () => {
const observable$: Subject<any> = new Subject();

// Without a getServerSnapshot, React throws "Missing getServerSnapshot, which is
// required for server-rendered content" and the whole subtree falls back to client
// rendering. This is the #748 regression test: delete the third argument to
// useSyncExternalStore and this assertion fails.
expect(() => renderToString(<Probe observableId="ssr-renders" observable$={observable$} />)).not.toThrow();
});

it('reports loading on the server when there is no initialData', () => {
const observable$: Subject<any> = new Subject();

const html = renderToString(<Probe observableId="ssr-loading" observable$={observable$} />);

expect(html).toContain('loading:undefined');
});

it('reports initialData on the server when it is provided', () => {
const observable$: Subject<any> = new Subject();

const html = renderToString(<Probe observableId="ssr-initial-data" observable$={observable$} config={{ initialData: 'seeded' }} />);

expect(html).toContain('success:seeded');
});

it('does not leak a cached value from another request into the server snapshot', async () => {
// `preloadedObservables` lives on `globalThis` and is keyed only by observableId, so on
// a server every concurrent request shares it. A getServerSnapshot that read
// `observable.immutableStatus` would render whatever the previous request left behind.
// Here the first render stands in for that earlier request.
const observable$: Subject<any> = 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(<Probe observableId={observableId} observable$={observable$} />);

expect(html).not.toContain('first-request-secret');
expect(html).toContain('loading:undefined');
});
});
});
Loading