/
githubmr
/
facebook-react
Обзор
Документация
Войти
/
githubmr
/
facebook-react
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js
659 строк
18 KB
Sebastian Markbåge
[Fiber] Use hydration lanes when scheduling hydration work (#31751)
13 дек 2024, 07:06
Не верифицирован
13 дек 2024, 07:06
d5e8f79
Код
Авторство
О чём код?
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @emails react-core * @jest-environment ./scripts/jest/ReactDOMServerIntegrationEnvironment */ let JSDOM; let React; let startTransition; let ReactDOMClient; let Scheduler; let clientAct; let ReactDOMFizzServer; let Stream; let document; let writable; let container; let buffer = ''; let hasErrored = false; let fatalError = undefined; let textCache; let assertLog; describe('ReactDOMFizzShellHydration', () => { beforeEach(() => { jest.resetModules(); JSDOM = require('jsdom').JSDOM; React = require('react'); ReactDOMClient = require('react-dom/client'); Scheduler = require('scheduler'); clientAct = require('internal-test-utils').act; ReactDOMFizzServer = require('react-dom/server'); Stream = require('stream'); const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; startTransition = React.startTransition; textCache = new Map(); // Test Environment const jsdom = new JSDOM( '<!DOCTYPE html><html><head></head><body><div id="container">', { runScripts: 'dangerously', }, ); document = jsdom.window.document; container = document.getElementById('container'); buffer = ''; hasErrored = false; writable = new Stream.PassThrough(); writable.setEncoding('utf8'); writable.on('data', chunk => { buffer += chunk; }); writable.on('error', error => { hasErrored = true; fatalError = error; }); }); afterEach(() => { jest.restoreAllMocks(); }); async function serverAct(callback) { await callback(); // Await one turn around the event loop. // This assumes that we'll flush everything we have so far. await new Promise(resolve => { setImmediate(resolve); }); if (hasErrored) { throw fatalError; } // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment. // We also want to execute any scripts that are embedded. // We assume that we have now received a proper fragment of HTML. const bufferedContent = buffer; buffer = ''; const fakeBody = document.createElement('body'); fakeBody.innerHTML = bufferedContent; while (fakeBody.firstChild) { const node = fakeBody.firstChild; if (node.nodeName === 'SCRIPT') { const script = document.createElement('script'); script.textContent = node.textContent; fakeBody.removeChild(node); container.appendChild(script); } else { container.appendChild(node); } } } function resolveText(text) { const record = textCache.get(text); if (record === undefined) { const newRecord = { status: 'resolved', value: text, }; textCache.set(text, newRecord); } else if (record.status === 'pending') { const thenable = record.value; record.status = 'resolved'; record.value = text; thenable.pings.forEach(t => t()); } } function readText(text) { const record = textCache.get(text); if (record !== undefined) { switch (record.status) { case 'pending': throw record.value; case 'rejected': throw record.value; case 'resolved': return record.value; } } else { Scheduler.log(`Suspend! [${text}]`); const thenable = { pings: [], then(resolve) { if (newRecord.status === 'pending') { thenable.pings.push(resolve); } else { Promise.resolve().then(() => resolve(newRecord.value)); } }, }; const newRecord = { status: 'pending', value: thenable, }; textCache.set(text, newRecord); throw thenable; } } function Text({text}) { Scheduler.log(text); return text; } function AsyncText({text}) { readText(text); Scheduler.log(text); return text; } function resetTextCache() { textCache = new Map(); } it('suspending in the shell during hydration', async () => { const div = React.createRef(null); function App() { return ( <div ref={div}> <AsyncText text="Shell" /> </div> ); } // Server render await resolveText('Shell'); await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />); pipe(writable); }); assertLog(['Shell']); const dehydratedDiv = container.getElementsByTagName('div')[0]; // Clear the cache and start rendering on the client resetTextCache(); // Hydration suspends because the data for the shell hasn't loaded yet await clientAct(async () => { ReactDOMClient.hydrateRoot(container, <App />); }); assertLog(['Suspend! [Shell]']); expect(div.current).toBe(null); expect(container.textContent).toBe('Shell'); // The shell loads and hydration finishes await clientAct(async () => { await resolveText('Shell'); }); assertLog(['Shell']); expect(div.current).toBe(dehydratedDiv); expect(container.textContent).toBe('Shell'); }); it('suspending in the shell during a normal client render', async () => { // Same as previous test but during a normal client render, no hydration function App() { return <AsyncText text="Shell" />; } const root = ReactDOMClient.createRoot(container); await clientAct(async () => { root.render(<App />); }); assertLog(['Suspend! [Shell]']); await clientAct(async () => { await resolveText('Shell'); }); assertLog(['Shell']); expect(container.textContent).toBe('Shell'); }); it( 'updating the root at lower priority than initial hydration does not ' + 'force a client render', async () => { function App() { return <Text text="Initial" />; } // Server render await resolveText('Initial'); await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />); pipe(writable); }); assertLog(['Initial']); await clientAct(async () => { const root = ReactDOMClient.hydrateRoot(container, <App />); // This has lower priority than the initial hydration, so the update // won't be processed until after hydration finishes. startTransition(() => { root.render(<Text text="Updated" />); }); }); assertLog(['Initial', 'Updated']); expect(container.textContent).toBe('Updated'); }, ); // @gate enableHydrationLaneScheduling it( 'updating the root at same priority as initial hydration does not ' + 'force a client render', async () => { function App() { return <Text text="Initial" />; } // Server render await resolveText('Initial'); await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />); pipe(writable); }); assertLog(['Initial']); await clientAct(async () => { let root; startTransition(() => { root = ReactDOMClient.hydrateRoot(container, <App />); }); // This has lower priority than the initial hydration, so the update // won't be processed until after hydration finishes. startTransition(() => { root.render(<Text text="Updated" />); }); }); assertLog(['Initial', 'Updated']); expect(container.textContent).toBe('Updated'); }, ); it('updating the root while the shell is suspended forces a client render', async () => { function App() { return <AsyncText text="Shell" />; } // Server render await resolveText('Shell'); await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />); pipe(writable); }); assertLog(['Shell']); // Clear the cache and start rendering on the client resetTextCache(); // Hydration suspends because the data for the shell hasn't loaded yet const root = await clientAct(async () => { return ReactDOMClient.hydrateRoot(container, <App />, { onRecoverableError(error) { Scheduler.log(error.message); }, }); }); assertLog(['Suspend! [Shell]']); expect(container.textContent).toBe('Shell'); await clientAct(async () => { root.render(<Text text="New screen" />); }); assertLog([ 'New screen', 'This root received an early update, before anything was able ' + 'hydrate. Switched the entire root to client rendering.', ]); expect(container.textContent).toBe('New screen'); }); it('TODO: A large component stack causes SSR to stack overflow', async () => { spyOnDevAndProd(console, 'error').mockImplementation(() => {}); function NestedComponent({depth}: {depth: number}) { if (depth <= 0) { return <AsyncText text="Shell" />; } return <NestedComponent depth={depth - 1} />; } // Server render await serverAct(async () => { ReactDOMFizzServer.renderToPipeableStream( <NestedComponent depth={3000} />, ); }); expect(console.error).toHaveBeenCalledTimes(1); expect(console.error.mock.calls[0][0].toString()).toBe( 'RangeError: Maximum call stack size exceeded', ); }); it('client renders when an error is thrown in an error boundary', async () => { function Throws() { throw new Error('plain error'); } class ErrorBoundary extends React.Component { state = {error: null}; static getDerivedStateFromError(error) { return {error}; } render() { if (this.state.error) { return <div>Caught an error: {this.state.error.message}</div>; } return this.props.children; } } function App() { return ( <ErrorBoundary> <Throws /> </ErrorBoundary> ); } // Server render let shellError; try { await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, { onError(error) { Scheduler.log('onError: ' + error.message); }, }); pipe(writable); }); } catch (x) { shellError = x; } expect(shellError).toEqual( expect.objectContaining({message: 'plain error'}), ); assertLog(['onError: plain error']); function ErroredApp() { return <span>loading</span>; } // Reset test environment buffer = ''; hasErrored = false; writable = new Stream.PassThrough(); writable.setEncoding('utf8'); writable.on('data', chunk => { buffer += chunk; }); writable.on('error', error => { hasErrored = true; fatalError = error; }); // The Server errored at the shell. The recommended approach is to render a // fallback loading state, which can then be hydrated with a mismatch. await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<ErroredApp />); pipe(writable); }); expect(container.innerHTML).toBe('<span>loading</span>'); // Hydration suspends because the data for the shell hasn't loaded yet await clientAct(async () => { ReactDOMClient.hydrateRoot(container, <App />, { onCaughtError(error) { Scheduler.log('onCaughtError: ' + error.message); }, onUncaughtError(error) { Scheduler.log('onUncaughtError: ' + error.message); }, onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + error.message); if (error.cause) { Scheduler.log('Cause: ' + error.cause.message); } }, }); }); assertLog(['onCaughtError: plain error']); expect(container.textContent).toBe('Caught an error: plain error'); }); it('client renders when a client error is thrown in an error boundary', async () => { let isClient = false; function Throws() { if (isClient) { throw new Error('plain error'); } return <div>Hello world</div>; } class ErrorBoundary extends React.Component { state = {error: null}; static getDerivedStateFromError(error) { return {error}; } render() { if (this.state.error) { return <div>Caught an error: {this.state.error.message}</div>; } return this.props.children; } } function App() { return ( <ErrorBoundary> <Throws /> </ErrorBoundary> ); } // Server render await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, { onError(error) { Scheduler.log('onError: ' + error.message); }, }); pipe(writable); }); assertLog([]); expect(container.innerHTML).toBe('<div>Hello world</div>'); isClient = true; // Hydration suspends because the data for the shell hasn't loaded yet await clientAct(async () => { ReactDOMClient.hydrateRoot(container, <App />, { onCaughtError(error) { Scheduler.log('onCaughtError: ' + error.message); }, onUncaughtError(error) { Scheduler.log('onUncaughtError: ' + error.message); }, onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + error.message); if (error.cause) { Scheduler.log('Cause: ' + error.cause.message); } }, }); }); assertLog(['onCaughtError: plain error']); expect(container.textContent).toBe('Caught an error: plain error'); }); it('client renders when a hydration pass error is thrown in an error boundary', async () => { let isClient = false; let isFirst = true; function Throws() { if (isClient && isFirst) { isFirst = false; // simulate a hydration or concurrent error throw new Error('plain error'); } return <div>Hello world</div>; } class ErrorBoundary extends React.Component { state = {error: null}; static getDerivedStateFromError(error) { return {error}; } render() { if (this.state.error) { return <div>Caught an error: {this.state.error.message}</div>; } return this.props.children; } } function App() { return ( <ErrorBoundary> <Throws /> </ErrorBoundary> ); } // Server render await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, { onError(error) { Scheduler.log('onError: ' + error.message); }, }); pipe(writable); }); assertLog([]); expect(container.innerHTML).toBe('<div>Hello world</div>'); isClient = true; // Hydration suspends because the data for the shell hasn't loaded yet await clientAct(async () => { ReactDOMClient.hydrateRoot(container, <App />, { onCaughtError(error) { Scheduler.log('onCaughtError: ' + error.message); }, onUncaughtError(error) { Scheduler.log('onUncaughtError: ' + error.message); }, onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + error.message); if (error.cause) { Scheduler.log('Cause: ' + error.cause.message); } }, }); }); assertLog([ 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering the entire root.', 'Cause: plain error', ]); expect(container.textContent).toBe('Hello world'); }); it( 'handles suspending while recovering from a hydration error (in the ' + 'shell, no Suspense boundary)', async () => { const useSyncExternalStore = React.useSyncExternalStore; let isClient = false; let resolve; const clientPromise = new Promise(res => { resolve = res; }); function App() { const state = useSyncExternalStore( function subscribe() { return () => {}; }, function getSnapshot() { return 'Client'; }, function getServerSnapshot() { const isHydrating = isClient; if (isHydrating) { // This triggers an error during hydration throw new Error('Oops!'); } return 'Server'; }, ); if (state === 'Client') { return React.use(clientPromise); } return state; } // Server render await serverAct(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />); pipe(writable); }); assertLog([]); expect(container.innerHTML).toBe('Server'); // During hydration, an error is thrown. React attempts to recover by // switching to client render isClient = true; await clientAct(async () => { ReactDOMClient.hydrateRoot(container, <App />, { onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + error.message); if (error.cause) { Scheduler.log('Cause: ' + error.cause.message); } }, }); }); expect(container.innerHTML).toBe('Server'); // Still suspended assertLog([]); await clientAct(async () => { resolve('Client'); }); assertLog([ 'onRecoverableError: There was an error while hydrating but React was ' + 'able to recover by instead client rendering the entire root.', 'Cause: Oops!', ]); expect(container.innerHTML).toBe('Client'); }, ); });