mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
* [Fizz] Improve text separator byte efficiency Previously text separators were inserted following any Text node in Fizz. This increases bytes sent when streaming and in some cases such as title elements these separators are not interpreted as comment nodes and leak into the visual aspects of a page as escaped text. The reason simple tracking on the last pushed type doesn't work is that Segments can be filled in asynchronously later and so you cannot know in a single pass whether the preceding content was a text node or not. This commit adds a concept of TextEmbedding which provides a best effort signal to Segments on whether they are embedded within text. This allows the later resolution of that Segment to add text separators when possibly necessary but avoid them when they are surely not. The current implementation can only "peek" head if the segment is a the Root Segment or a Suspense Boundary Segment. In these cases we know there is no trailing text embedding and we can eliminate the separator at the end of the segment if the last emitted element was Text. In normal Segments we cannot peek and thus have to assume there might be a trailing text embedding and we issue a separator defensively. This should be rare in practice as it is assumed most components that will cause segment creation will also emit some markup at the edges. * [Fizz] Improve separator efficiency when flushing delayed segments The method by which we get segment markup into the DOM differs depending on when the Segment resolves. If a Segment resolves before flushing begins for it's parent it will be emitted inline with the parent markup. In these cases separators may be necessary because they are how we clue the browser into breakup up text into distinct nodes that will later match up with what will be hydrated on the client. If a Segment resolves after flushing has happened a script will be used to patch up the DOM in the client. when this happens if there are any text nodes on the boundary of the patch they won't be "merged" and thus will continue to have distinct representation as Nodes in the DOM. Thus we can avoid doing any separators at the boundaries in these cases. After applying these changes the only time you will get text separators as follows * in between serial text nodes that emit at the same time - these are necessary and cannot be eliminated unless we stop relying on the browser to automatically parse the correct text nodes when processing this HTML * after a final text node in a non-boundary segment that resolves before it's parent has flushed - these are sometimes extraneous, like when the next emitted thing is a non-Text node. In all other cases text separators should be omitted which means the general byte efficiency of this approach should be pretty good
637 lines
15 KiB
JavaScript
637 lines
15 KiB
JavaScript
/**
|
|
* Copyright (c) Facebook, Inc. and its 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
|
|
*/
|
|
|
|
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>
|
|
`);
|
|
});
|
|
});
|