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
28 changes: 27 additions & 1 deletion src/useObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,33 @@ export function useObservable<T = unknown>(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<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;
// 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
Expand Down
100 changes: 99 additions & 1 deletion test/useObservable.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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<any>; config?: ReactFireOptions }) => {
const { status, data } = useObservable(observableId, observable$, { suspense: false, ...config });
// One interpolated child: adjacent JSX text nodes render with `<!-- -->` between them.
return <div>{`${status}:${String(data)}`}</div>;
};

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

// The #748 regression test: delete the third argument to useSyncExternalStore and
// this fails with "Missing getServerSnapshot".
expect(() => renderToString(<Probe observableId="ssr-renders" observable$={observable$} />)).not.toThrow();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you confirm that this test fails in the current version of reactfire? just want to make sure renderToString does SSR the same way as a Server Component would

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed both halves.

Fails on current reactfire: reverted src/useObservable.ts to upstream/main, which has no getServerSnapshot in it, and ran the new block against that. All six fail with Missing getServerSnapshot.

On the renderer, good instinct, so I added a test rather than argue it. 22feb8e covers renderToPipeableStream alongside renderToString, using a real Writable sink:

Renderer current main this PR
renderToString fails passes
renderToPipeableStream fails passes

Same error both ways, so renderToString wasn't flattering it. Test-only, about 30 lines.

});

// 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<any> = new Subject();
let error: unknown;

const html = await new Promise<string>((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(<Probe observableId="ssr-streaming" observable$={observable$} />, {
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<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` 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<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');
});

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<any> = 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(<Probe observableId={observableId} observable$={observable$} config={{ initialData: 'my-own-data' }} />);

expect(html).toContain('success:my-own-data');
expect(html).not.toContain('another-requests-value');
});
});
});
Loading