|
1 | 1 | import '@testing-library/jest-dom/extend-expect'; |
2 | 2 | import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'; |
3 | 3 | import * as React from 'react'; |
| 4 | +import { Writable } from 'node:stream'; |
| 5 | +import { renderToString, renderToPipeableStream } from 'react-dom/server'; |
4 | 6 | import { of, Subject, BehaviorSubject, throwError } from 'rxjs'; |
5 | | -import { useObservable, FirebaseAppProvider } from '../src/index'; |
| 7 | +import { useObservable, ReactFireOptions, FirebaseAppProvider } from '../src/index'; |
6 | 8 | import { initializeApp } from 'firebase/app'; |
7 | 9 | import { baseConfig } from './appConfig'; |
8 | 10 |
|
@@ -391,4 +393,100 @@ describe('useObservable', () => { |
391 | 393 | window.removeEventListener('error', onError); |
392 | 394 | }); |
393 | 395 | }); |
| 396 | + |
| 397 | + describe('Server rendering', () => { |
| 398 | + // Renders `status` and `data` so assertions read the snapshot React actually used. |
| 399 | + const Probe = ({ observableId, observable$, config }: { observableId: string; observable$: Subject<any>; config?: ReactFireOptions }) => { |
| 400 | + const { status, data } = useObservable(observableId, observable$, { suspense: false, ...config }); |
| 401 | + // One interpolated child: adjacent JSX text nodes render with `<!-- -->` between them. |
| 402 | + return <div>{`${status}:${String(data)}`}</div>; |
| 403 | + }; |
| 404 | + |
| 405 | + it('renders on the server instead of throwing', () => { |
| 406 | + const observable$: Subject<any> = new Subject(); |
| 407 | + |
| 408 | + // The #748 regression test: delete the third argument to useSyncExternalStore and |
| 409 | + // this fails with "Missing getServerSnapshot". |
| 410 | + expect(() => renderToString(<Probe observableId="ssr-renders" observable$={observable$} />)).not.toThrow(); |
| 411 | + }); |
| 412 | + |
| 413 | + // The App Router streams rather than calling renderToString, and streaming surfaces |
| 414 | + // failures the synchronous renderer does not, so the fix is checked against both. |
| 415 | + it('renders on the server under the streaming renderer', async () => { |
| 416 | + const observable$: Subject<any> = new Subject(); |
| 417 | + let error: unknown; |
| 418 | + |
| 419 | + const html = await new Promise<string>((resolve, reject) => { |
| 420 | + const chunks: string[] = []; |
| 421 | + const sink = new Writable({ |
| 422 | + write(chunk, _encoding, callback) { |
| 423 | + chunks.push(chunk.toString()); |
| 424 | + callback(); |
| 425 | + } |
| 426 | + }); |
| 427 | + sink.on('finish', () => resolve(chunks.join(''))); |
| 428 | + sink.on('error', reject); |
| 429 | + |
| 430 | + const stream = renderToPipeableStream(<Probe observableId="ssr-streaming" observable$={observable$} />, { |
| 431 | + onError(caughtError) { |
| 432 | + error = caughtError; |
| 433 | + }, |
| 434 | + onAllReady() { |
| 435 | + stream.pipe(sink); |
| 436 | + } |
| 437 | + }); |
| 438 | + }); |
| 439 | + |
| 440 | + expect(error).toBeUndefined(); |
| 441 | + expect(html).toContain('loading:undefined'); |
| 442 | + }); |
| 443 | + |
| 444 | + it('reports loading on the server when there is no initialData', () => { |
| 445 | + const observable$: Subject<any> = new Subject(); |
| 446 | + |
| 447 | + const html = renderToString(<Probe observableId="ssr-loading" observable$={observable$} />); |
| 448 | + |
| 449 | + expect(html).toContain('loading:undefined'); |
| 450 | + }); |
| 451 | + |
| 452 | + it('reports initialData on the server when it is provided', () => { |
| 453 | + const observable$: Subject<any> = new Subject(); |
| 454 | + |
| 455 | + const html = renderToString(<Probe observableId="ssr-initial-data" observable$={observable$} config={{ initialData: 'seeded' }} />); |
| 456 | + |
| 457 | + expect(html).toContain('success:seeded'); |
| 458 | + }); |
| 459 | + |
| 460 | + it('does not leak a cached value from another request into the server snapshot', async () => { |
| 461 | + // `preloadedObservables` is on `globalThis`, keyed only by observableId, so concurrent |
| 462 | + // server requests share it. The first render below stands in for an earlier request. |
| 463 | + const observable$: Subject<any> = new Subject(); |
| 464 | + const observableId = 'ssr-no-cross-request-leak'; |
| 465 | + |
| 466 | + const { result } = renderHook(() => useObservable(observableId, observable$, { suspense: false })); |
| 467 | + act(() => observable$.next('first-request-secret')); |
| 468 | + await waitFor(() => expect(result.current.data).toEqual('first-request-secret')); |
| 469 | + |
| 470 | + const html = renderToString(<Probe observableId={observableId} observable$={observable$} />); |
| 471 | + |
| 472 | + expect(html).not.toContain('first-request-secret'); |
| 473 | + expect(html).toContain('loading:undefined'); |
| 474 | + }); |
| 475 | + |
| 476 | + it('prefers the callers initialData over a value already in the shared cache', async () => { |
| 477 | + // The `initialData` branch is only reachable when the cache already holds a value for |
| 478 | + // this id; otherwise `useObservable`'s overlay decides and the snapshot never does. |
| 479 | + const observable$: Subject<any> = new Subject(); |
| 480 | + const observableId = 'ssr-initial-data-beats-cache'; |
| 481 | + |
| 482 | + const { result } = renderHook(() => useObservable(observableId, observable$, { suspense: false })); |
| 483 | + act(() => observable$.next('another-requests-value')); |
| 484 | + await waitFor(() => expect(result.current.data).toEqual('another-requests-value')); |
| 485 | + |
| 486 | + const html = renderToString(<Probe observableId={observableId} observable$={observable$} config={{ initialData: 'my-own-data' }} />); |
| 487 | + |
| 488 | + expect(html).toContain('success:my-own-data'); |
| 489 | + expect(html).not.toContain('another-requests-value'); |
| 490 | + }); |
| 491 | + }); |
394 | 492 | }); |
0 commit comments