Files
react/packages/react-dom/src/__tests__/ReactDOMUseId-test.js
T
Andrew Clark 44d3807945 Move internalAct to internal-test-utils package (#26344)
This is not a public API. We only use it for our internal tests, the
ones in this repo. Let's move it to this private package. Practically
speaking this will also let us use async/await in the implementation.
2023-03-08 12:58:31 -05:00

704 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 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;
let waitForPaint;
describe('useId', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
React = require('react');
ReactDOMClient = require('react-dom/client');
clientAct = require('internal-test-utils').act;
ReactDOMFizzServer = require('react-dom/server');
Stream = require('stream');
Suspense = React.Suspense;
useId = React.useId;
useState = React.useState;
const InternalTestUtils = require('internal-test-utils');
waitForPaint = InternalTestUtils.waitForPaint;
// 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 />);
await waitForPaint([]);
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 />);
await waitForPaint([]);
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>
`);
});
});