mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
## Summary - yarn.lock diff +-6249, **small pr** - use jest-environment-jsdom by default - uncaught error from jsdom is an error object instead of strings - abortSignal.reason is read-only in jsdom and node, https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/reason ## How did you test this change? ci green --------- Co-authored-by: Sebastian Silbermann <silbermann.sebastian@gmail.com>
702 lines
17 KiB
JavaScript
702 lines
17 KiB
JavaScript
/**
|
|
* 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 ReactDOMClient;
|
|
let Scheduler;
|
|
let clientAct;
|
|
let ReactDOMFizzServer;
|
|
let Stream;
|
|
let Suspense;
|
|
let useId;
|
|
let useState;
|
|
let document;
|
|
let writable;
|
|
let container;
|
|
let buffer = '';
|
|
let hasErrored = false;
|
|
let fatalError = undefined;
|
|
|
|
describe('useId', () => {
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
JSDOM = require('jsdom').JSDOM;
|
|
React = require('react');
|
|
ReactDOMClient = require('react-dom/client');
|
|
Scheduler = require('scheduler');
|
|
clientAct = require('jest-react').act;
|
|
ReactDOMFizzServer = require('react-dom/server');
|
|
Stream = require('stream');
|
|
Suspense = React.Suspense;
|
|
useId = React.useId;
|
|
useState = React.useState;
|
|
|
|
// 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;
|
|
});
|
|
});
|
|
|
|
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 normalizeTreeIdForTesting(id) {
|
|
const result = id.match(/:(R|r)([a-z0-9]*)(H([0-9]*))?:/);
|
|
if (result === undefined) {
|
|
throw new Error('Invalid id format');
|
|
}
|
|
const [, serverClientPrefix, base32, hookIndex] = result;
|
|
if (serverClientPrefix.endsWith('r')) {
|
|
// Client ids aren't stable. For testing purposes, strip out the counter.
|
|
return (
|
|
'CLIENT_GENERATED_ID' +
|
|
(hookIndex !== undefined ? ` (${hookIndex})` : '')
|
|
);
|
|
}
|
|
// Formats the tree id as a binary sequence, so it's easier to visualize
|
|
// the structure.
|
|
return (
|
|
parseInt(base32, 32).toString(2) +
|
|
(hookIndex !== undefined ? ` (${hookIndex})` : '')
|
|
);
|
|
}
|
|
|
|
function DivWithId({children}) {
|
|
const id = normalizeTreeIdForTesting(useId());
|
|
return <div id={id}>{children}</div>;
|
|
}
|
|
|
|
test('basic example', async () => {
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<div>
|
|
<DivWithId />
|
|
<DivWithId />
|
|
</div>
|
|
<DivWithId />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div>
|
|
<div>
|
|
<div
|
|
id="101"
|
|
/>
|
|
<div
|
|
id="1001"
|
|
/>
|
|
</div>
|
|
<div
|
|
id="10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
`);
|
|
});
|
|
|
|
test('indirections', async () => {
|
|
function App() {
|
|
// There are no forks in this tree, but the parent and the child should
|
|
// have different ids.
|
|
return (
|
|
<DivWithId>
|
|
<div>
|
|
<div>
|
|
<div>
|
|
<DivWithId />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DivWithId>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div
|
|
id="0"
|
|
>
|
|
<div>
|
|
<div>
|
|
<div>
|
|
<div
|
|
id="1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
});
|
|
|
|
test('StrictMode double rendering', async () => {
|
|
const {StrictMode} = React;
|
|
|
|
function App() {
|
|
return (
|
|
<StrictMode>
|
|
<DivWithId />
|
|
</StrictMode>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div
|
|
id="0"
|
|
/>
|
|
</div>
|
|
`);
|
|
});
|
|
|
|
test('empty (null) children', async () => {
|
|
// We don't treat empty children different from non-empty ones, which means
|
|
// they get allocated a slot when generating ids. There's no inherent reason
|
|
// to do this; Fiber happens to allocate a fiber for null children that
|
|
// appear in a list, which is not ideal for performance. For the purposes
|
|
// of id generation, though, what matters is that Fizz and Fiber
|
|
// are consistent.
|
|
function App() {
|
|
return (
|
|
<>
|
|
{null}
|
|
<DivWithId />
|
|
{null}
|
|
<DivWithId />
|
|
</>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div
|
|
id="10"
|
|
/>
|
|
<div
|
|
id="100"
|
|
/>
|
|
</div>
|
|
`);
|
|
});
|
|
|
|
test('large ids', async () => {
|
|
// The component in this test outputs a recursive tree of nodes with ids,
|
|
// where the underlying binary representation is an alternating series of 1s
|
|
// and 0s. In other words, they are all of the form 101010101.
|
|
//
|
|
// Because we use base 32 encoding, the resulting id should consist of
|
|
// alternating 'a' (01010) and 'l' (10101) characters, except for the the
|
|
// 'R:' prefix, and the first character after that, which may not correspond
|
|
// to a complete set of 5 bits.
|
|
//
|
|
// Example: :Rclalalalalalalala...:
|
|
//
|
|
// We can use this pattern to test large ids that exceed the bitwise
|
|
// safe range (32 bits). The algorithm should theoretically support ids
|
|
// of any size.
|
|
|
|
function Child({children}) {
|
|
const id = useId();
|
|
return <div id={id}>{children}</div>;
|
|
}
|
|
|
|
function App() {
|
|
let tree = <Child />;
|
|
for (let i = 0; i < 50; i++) {
|
|
tree = (
|
|
<>
|
|
<Child />
|
|
{tree}
|
|
</>
|
|
);
|
|
}
|
|
return tree;
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
const divs = container.querySelectorAll('div');
|
|
|
|
// Confirm that every id matches the expected pattern
|
|
for (let i = 0; i < divs.length; i++) {
|
|
// Example: :Rclalalalalalalala...:
|
|
expect(divs[i].id).toMatch(/^:R.(((al)*a?)((la)*l?))*:$/);
|
|
}
|
|
});
|
|
|
|
test('multiple ids in a single component', async () => {
|
|
function App() {
|
|
const id1 = useId();
|
|
const id2 = useId();
|
|
const id3 = useId();
|
|
return `${id1}, ${id2}, ${id3}`;
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
// We append a suffix to the end of the id to distinguish them
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
:R0:, :R0H1:, :R0H2:
|
|
</div>
|
|
`);
|
|
});
|
|
|
|
test('local render phase updates', async () => {
|
|
function App({swap}) {
|
|
const [count, setCount] = useState(0);
|
|
if (count < 3) {
|
|
setCount(count + 1);
|
|
}
|
|
return useId();
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
:R0:
|
|
</div>
|
|
`);
|
|
});
|
|
|
|
test('basic incremental hydration', async () => {
|
|
function App() {
|
|
return (
|
|
<div>
|
|
<Suspense fallback="Loading...">
|
|
<DivWithId label="A" />
|
|
<DivWithId label="B" />
|
|
</Suspense>
|
|
<DivWithId label="C" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div>
|
|
<!--$-->
|
|
<div
|
|
id="101"
|
|
/>
|
|
<div
|
|
id="1001"
|
|
/>
|
|
<!--/$-->
|
|
<div
|
|
id="10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
`);
|
|
});
|
|
|
|
test('inserting/deleting siblings outside a dehydrated Suspense boundary', async () => {
|
|
const span = React.createRef(null);
|
|
function App({swap}) {
|
|
// Note: Using a dynamic array so these are treated as insertions and
|
|
// deletions instead of updates, because Fiber currently allocates a node
|
|
// even for empty children.
|
|
const children = [
|
|
<DivWithId key="A" />,
|
|
swap ? <DivWithId key="C" /> : <DivWithId key="B" />,
|
|
<DivWithId key="D" />,
|
|
];
|
|
return (
|
|
<>
|
|
{children}
|
|
<Suspense key="boundary" fallback="Loading...">
|
|
<DivWithId />
|
|
<span ref={span} />
|
|
</Suspense>
|
|
</>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
const dehydratedSpan = container.getElementsByTagName('span')[0];
|
|
await clientAct(async () => {
|
|
const root = ReactDOMClient.hydrateRoot(container, <App />);
|
|
expect(Scheduler).toFlushUntilNextPaint([]);
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div
|
|
id="101"
|
|
/>
|
|
<div
|
|
id="1001"
|
|
/>
|
|
<div
|
|
id="1101"
|
|
/>
|
|
<!--$-->
|
|
<div
|
|
id="110"
|
|
/>
|
|
<span />
|
|
<!--/$-->
|
|
</div>
|
|
`);
|
|
|
|
// The inner boundary hasn't hydrated yet
|
|
expect(span.current).toBe(null);
|
|
|
|
// Swap B for C
|
|
root.render(<App swap={true} />);
|
|
});
|
|
// The swap should not have caused a mismatch.
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div
|
|
id="101"
|
|
/>
|
|
<div
|
|
id="CLIENT_GENERATED_ID"
|
|
/>
|
|
<div
|
|
id="1101"
|
|
/>
|
|
<!--$-->
|
|
<div
|
|
id="110"
|
|
/>
|
|
<span />
|
|
<!--/$-->
|
|
</div>
|
|
`);
|
|
// Should have hydrated successfully
|
|
expect(span.current).toBe(dehydratedSpan);
|
|
});
|
|
|
|
test('inserting/deleting siblings inside a dehydrated Suspense boundary', async () => {
|
|
const span = React.createRef(null);
|
|
function App({swap}) {
|
|
// Note: Using a dynamic array so these are treated as insertions and
|
|
// deletions instead of updates, because Fiber currently allocates a node
|
|
// even for empty children.
|
|
const children = [
|
|
<DivWithId key="A" />,
|
|
swap ? <DivWithId key="C" /> : <DivWithId key="B" />,
|
|
<DivWithId key="D" />,
|
|
];
|
|
return (
|
|
<Suspense key="boundary" fallback="Loading...">
|
|
{children}
|
|
<span ref={span} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
const dehydratedSpan = container.getElementsByTagName('span')[0];
|
|
await clientAct(async () => {
|
|
const root = ReactDOMClient.hydrateRoot(container, <App />);
|
|
expect(Scheduler).toFlushUntilNextPaint([]);
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<!--$-->
|
|
<div
|
|
id="101"
|
|
/>
|
|
<div
|
|
id="1001"
|
|
/>
|
|
<div
|
|
id="1101"
|
|
/>
|
|
<span />
|
|
<!--/$-->
|
|
</div>
|
|
`);
|
|
|
|
// The inner boundary hasn't hydrated yet
|
|
expect(span.current).toBe(null);
|
|
|
|
// Swap B for C
|
|
root.render(<App swap={true} />);
|
|
});
|
|
// The swap should not have caused a mismatch.
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<!--$-->
|
|
<div
|
|
id="101"
|
|
/>
|
|
<div
|
|
id="CLIENT_GENERATED_ID"
|
|
/>
|
|
<div
|
|
id="1101"
|
|
/>
|
|
<span />
|
|
<!--/$-->
|
|
</div>
|
|
`);
|
|
// Should have hydrated successfully
|
|
expect(span.current).toBe(dehydratedSpan);
|
|
});
|
|
|
|
test('identifierPrefix option', async () => {
|
|
function Child() {
|
|
const id = useId();
|
|
return <div>{id}</div>;
|
|
}
|
|
|
|
function App({showMore}) {
|
|
return (
|
|
<>
|
|
<Child />
|
|
<Child />
|
|
{showMore && <Child />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
|
|
identifierPrefix: 'custom-prefix-',
|
|
});
|
|
pipe(writable);
|
|
});
|
|
let root;
|
|
await clientAct(async () => {
|
|
root = ReactDOMClient.hydrateRoot(container, <App />, {
|
|
identifierPrefix: 'custom-prefix-',
|
|
});
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div>
|
|
:custom-prefix-R1:
|
|
</div>
|
|
<div>
|
|
:custom-prefix-R2:
|
|
</div>
|
|
</div>
|
|
`);
|
|
|
|
// Mount a new, client-only id
|
|
await clientAct(async () => {
|
|
root.render(<App showMore={true} />);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div>
|
|
:custom-prefix-R1:
|
|
</div>
|
|
<div>
|
|
:custom-prefix-R2:
|
|
</div>
|
|
<div>
|
|
:custom-prefix-r0:
|
|
</div>
|
|
</div>
|
|
`);
|
|
});
|
|
|
|
// https://github.com/vercel/next.js/issues/43033
|
|
// re-rendering in strict mode caused the localIdCounter to be reset but it the rerender hook does not
|
|
// increment it again. This only shows up as a problem for subsequent useId's because it affects child
|
|
// and sibling counters not the initial one
|
|
it('does not forget it mounted an id when re-rendering in dev', async () => {
|
|
function Parent() {
|
|
const id = useId();
|
|
return (
|
|
<div>
|
|
{id} <Child />
|
|
</div>
|
|
);
|
|
}
|
|
function Child() {
|
|
const id = useId();
|
|
return <div>{id}</div>;
|
|
}
|
|
|
|
function App({showMore}) {
|
|
return (
|
|
<React.StrictMode>
|
|
<Parent />
|
|
</React.StrictMode>
|
|
);
|
|
}
|
|
|
|
await serverAct(async () => {
|
|
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
|
|
pipe(writable);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div>
|
|
:R0:
|
|
<!-- -->
|
|
|
|
<div>
|
|
:R7:
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
|
|
await clientAct(async () => {
|
|
ReactDOMClient.hydrateRoot(container, <App />);
|
|
});
|
|
expect(container).toMatchInlineSnapshot(`
|
|
<div
|
|
id="container"
|
|
>
|
|
<div>
|
|
:R0:
|
|
<!-- -->
|
|
|
|
<div>
|
|
:R7:
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`);
|
|
});
|
|
});
|