[Fizz] Improve text separator byte efficiency (#24630)

* [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
This commit is contained in:
Josh Story
2022-05-28 08:30:38 -07:00
committed by GitHub
parent 2745ca76e9
commit d13652a8e7
11 changed files with 579 additions and 59 deletions
@@ -234,6 +234,11 @@ describe('ReactDOMFizzServer', () => {
return readText(text);
}
function AsyncTextWrapped({as, text}) {
const As = as;
return <As>{readText(text)}</As>;
}
// @gate experimental
it('should asynchronously load a lazy component', async () => {
let resolveA;
@@ -3577,4 +3582,421 @@ describe('ReactDOMFizzServer', () => {
</div>,
);
});
describe('text separators', () => {
// To force performWork to start before resolving AsyncText but before piping we need to wait until
// after scheduleWork which currently uses setImmediate to delay performWork
function afterImmediate() {
return new Promise(resolve => {
setImmediate(resolve);
});
}
// @gate experimental
it('it only includes separators between adjacent text nodes', async () => {
function App({name}) {
return (
<div>
hello<b>world, {name}</b>!
</div>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<App name="Foo" />,
);
pipe(writable);
});
expect(container.innerHTML).toEqual(
'<div>hello<b>world, <!-- -->Foo</b>!</div>',
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App name="Foo" />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div>
hello<b>world, {'Foo'}</b>!
</div>,
);
});
// @gate experimental
it('it does not insert text separators even when adjacent text is in a delayed segment', async () => {
function App({name}) {
return (
<Suspense fallback={'loading...'}>
<div id="app-div">
hello
<b>
world, <AsyncText text={name} />
</b>
!
</div>
</Suspense>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(
<App name="Foo" />,
);
pipe(writable);
});
expect(document.getElementById('app-div').outerHTML).toEqual(
'<div id="app-div">hello<b>world, <template id="P:1"></template></b>!</div>',
);
await act(() => resolveText('Foo'));
expect(container.firstElementChild.outerHTML).toEqual(
'<div id="app-div">hello<b>world, Foo</b>!</div>',
);
// there are extra script nodes at the end of container
expect(container.childNodes.length).toBe(5);
const div = container.childNodes[1];
expect(div.childNodes.length).toBe(3);
const b = div.childNodes[1];
expect(b.childNodes.length).toBe(2);
expect(b.childNodes[0]).toMatchInlineSnapshot('world, ');
expect(b.childNodes[1]).toMatchInlineSnapshot('Foo');
const errors = [];
ReactDOMClient.hydrateRoot(container, <App name="Foo" />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div id="app-div">
hello<b>world, {'Foo'}</b>!
</div>,
);
});
// @gate experimental
it('it works with multiple adjacent segments', async () => {
function App() {
return (
<Suspense fallback={'loading...'}>
<div id="app-div">
h<AsyncText text={'ello'} />
w<AsyncText text={'orld'} />
</div>
</Suspense>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
pipe(writable);
});
expect(document.getElementById('app-div').outerHTML).toEqual(
'<div id="app-div">h<template id="P:1"></template>w<template id="P:2"></template></div>',
);
await act(() => resolveText('orld'));
expect(document.getElementById('app-div').outerHTML).toEqual(
'<div id="app-div">h<template id="P:1"></template>world</div>',
);
await act(() => resolveText('ello'));
expect(container.firstElementChild.outerHTML).toEqual(
'<div id="app-div">helloworld</div>',
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App name="Foo" />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div id="app-div">{['h', 'ello', 'w', 'orld']}</div>,
);
});
// @gate experimental
it('it works when some segments are flushed and others are patched', async () => {
function App() {
return (
<Suspense fallback={'loading...'}>
<div id="app-div">
h<AsyncText text={'ello'} />
w<AsyncText text={'orld'} />
</div>
</Suspense>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
await afterImmediate();
await act(() => resolveText('ello'));
pipe(writable);
});
expect(document.getElementById('app-div').outerHTML).toEqual(
'<div id="app-div">h<!-- -->ello<!-- -->w<template id="P:1"></template></div>',
);
await act(() => resolveText('orld'));
expect(container.firstElementChild.outerHTML).toEqual(
'<div id="app-div">h<!-- -->ello<!-- -->world</div>',
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div id="app-div">{['h', 'ello', 'w', 'orld']}</div>,
);
});
// @gate experimental
it('it does not prepend a text separators if the segment follows a non-Text Node', async () => {
function App() {
return (
<Suspense fallback={'loading...'}>
<div>
hello
<b>
<AsyncText text={'world'} />
</b>
</div>
</Suspense>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
await afterImmediate();
await act(() => resolveText('world'));
pipe(writable);
});
expect(container.firstElementChild.outerHTML).toEqual(
'<div>hello<b>world<!-- --></b></div>',
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div>
hello<b>world</b>
</div>,
);
});
// @gate experimental
it('it does not prepend a text separators if the segments first emission is a non-Text Node', async () => {
function App() {
return (
<Suspense fallback={'loading...'}>
<div>
hello
<AsyncTextWrapped as={'b'} text={'world'} />
</div>
</Suspense>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
await afterImmediate();
await act(() => resolveText('world'));
pipe(writable);
});
expect(container.firstElementChild.outerHTML).toEqual(
'<div>hello<b>world</b></div>',
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div>
hello<b>world</b>
</div>,
);
});
// @gate experimental
it('should not insert separators for text inside Suspense boundaries even if they would otherwise be considered text-embedded', async () => {
function App() {
return (
<Suspense fallback={'loading...'}>
<div id="app-div">
start
<Suspense fallback={'[loading first]'}>
firststart
<AsyncText text={'first suspended'} />
firstend
</Suspense>
<Suspense fallback={'[loading second]'}>
secondstart
<b>
<AsyncText text={'second suspended'} />
</b>
</Suspense>
end
</div>
</Suspense>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
await afterImmediate();
await act(() => resolveText('world'));
pipe(writable);
});
expect(document.getElementById('app-div').outerHTML).toEqual(
'<div id="app-div">start<!--$?--><template id="B:0"></template>[loading first]<!--/$--><!--$?--><template id="B:1"></template>[loading second]<!--/$-->end</div>',
);
await act(async () => {
resolveText('first suspended');
});
expect(document.getElementById('app-div').outerHTML).toEqual(
'<div id="app-div">start<!--$-->firststartfirst suspendedfirstend<!--/$--><!--$?--><template id="B:1"></template>[loading second]<!--/$-->end</div>',
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div id="app-div">
{'start'}
{'firststart'}
{'first suspended'}
{'firstend'}
{'[loading second]'}
{'end'}
</div>,
);
await act(async () => {
resolveText('second suspended');
});
expect(container.firstElementChild.outerHTML).toEqual(
'<div id="app-div">start<!--$-->firststartfirst suspendedfirstend<!--/$--><!--$-->secondstart<b>second suspended</b><!--/$-->end</div>',
);
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div id="app-div">
{'start'}
{'firststart'}
{'first suspended'}
{'firstend'}
{'secondstart'}
<b>second suspended</b>
{'end'}
</div>,
);
});
// @gate experimental
it('(only) includes extraneous text separators in segments that complete before flushing, followed by nothing or a non-Text node', async () => {
function App() {
return (
<div>
<Suspense fallback={'text before, nothing after...'}>
hello
<AsyncText text="world" />
</Suspense>
<Suspense fallback={'nothing before or after...'}>
<AsyncText text="world" />
</Suspense>
<Suspense fallback={'text before, element after...'}>
hello
<AsyncText text="world" />
<br />
</Suspense>
<Suspense fallback={'nothing before, element after...'}>
<AsyncText text="world" />
<br />
</Suspense>
</div>
);
}
await act(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
await afterImmediate();
await act(() => resolveText('world'));
pipe(writable);
});
expect(container.innerHTML).toEqual(
'<div><!--$-->hello<!-- -->world<!-- --><!--/$--><!--$-->world<!-- --><!--/$--><!--$-->hello<!-- -->world<!-- --><br><!--/$--><!--$-->world<!-- --><br><!--/$--></div>',
);
const errors = [];
ReactDOMClient.hydrateRoot(container, <App />, {
onRecoverableError(error) {
errors.push(error.message);
},
});
expect(Scheduler).toFlushAndYield([]);
expect(errors).toEqual([]);
expect(getVisibleChildren(container)).toEqual(
<div>
{/* first boundary */}
{'hello'}
{'world'}
{/* second boundary */}
{'world'}
{/* third boundary */}
{'hello'}
{'world'}
<br />
{/* fourth boundary */}
{'world'}
<br />
</div>,
);
});
});
});
@@ -101,7 +101,7 @@ describe('ReactDOMServerIntegration', () => {
) {
// For plain server markup result we have comments between.
// If we're able to hydrate, they remain.
expect(e.childNodes.length).toBe(render === streamRender ? 6 : 5);
expect(e.childNodes.length).toBe(5);
expectTextNode(e.childNodes[0], ' ');
expectTextNode(e.childNodes[2], ' ');
expectTextNode(e.childNodes[4], ' ');
@@ -119,8 +119,8 @@ describe('ReactDOMServerIntegration', () => {
Text<span>More Text</span>
</div>,
);
expect(e.childNodes.length).toBe(render === streamRender ? 3 : 2);
const spanNode = e.childNodes[render === streamRender ? 2 : 1];
expect(e.childNodes.length).toBe(2);
const spanNode = e.childNodes[1];
expectTextNode(e.childNodes[0], 'Text');
expect(spanNode.tagName).toBe('SPAN');
expect(spanNode.childNodes.length).toBe(1);
@@ -147,19 +147,19 @@ describe('ReactDOMServerIntegration', () => {
itRenders('a custom element with text', async render => {
const e = await render(<custom-element>Text</custom-element>);
expect(e.tagName).toBe('CUSTOM-ELEMENT');
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
expect(e.childNodes.length).toBe(1);
expectNode(e.firstChild, TEXT_NODE_TYPE, 'Text');
});
itRenders('a leading blank child with a text sibling', async render => {
const e = await render(<div>{''}foo</div>);
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
expect(e.childNodes.length).toBe(1);
expectTextNode(e.childNodes[0], 'foo');
});
itRenders('a trailing blank child with a text sibling', async render => {
const e = await render(<div>foo{''}</div>);
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
expect(e.childNodes.length).toBe(1);
expectTextNode(e.childNodes[0], 'foo');
});
@@ -176,7 +176,7 @@ describe('ReactDOMServerIntegration', () => {
render === streamRender
) {
// In the server render output there's a comment between them.
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
expect(e.childNodes.length).toBe(3);
expectTextNode(e.childNodes[0], 'foo');
expectTextNode(e.childNodes[2], 'bar');
} else {
@@ -203,7 +203,7 @@ describe('ReactDOMServerIntegration', () => {
render === streamRender
) {
// In the server render output there's a comment between them.
expect(e.childNodes.length).toBe(render === streamRender ? 6 : 5);
expect(e.childNodes.length).toBe(5);
expectTextNode(e.childNodes[0], 'a');
expectTextNode(e.childNodes[2], 'b');
expectTextNode(e.childNodes[4], 'c');
@@ -240,7 +240,11 @@ describe('ReactDOMServerIntegration', () => {
e
</div>,
);
if (render === serverRender || render === clientRenderOnServerString) {
if (
render === serverRender ||
render === streamRender ||
render === clientRenderOnServerString
) {
// In the server render output there's comments between text nodes.
expect(e.childNodes.length).toBe(5);
expectTextNode(e.childNodes[0], 'a');
@@ -249,15 +253,6 @@ describe('ReactDOMServerIntegration', () => {
expectTextNode(e.childNodes[3].childNodes[0], 'c');
expectTextNode(e.childNodes[3].childNodes[2], 'd');
expectTextNode(e.childNodes[4], 'e');
} else if (render === streamRender) {
// In the server render output there's comments after each text node.
expect(e.childNodes.length).toBe(7);
expectTextNode(e.childNodes[0], 'a');
expectTextNode(e.childNodes[2], 'b');
expect(e.childNodes[4].childNodes.length).toBe(4);
expectTextNode(e.childNodes[4].childNodes[0], 'c');
expectTextNode(e.childNodes[4].childNodes[2], 'd');
expectTextNode(e.childNodes[5], 'e');
} else {
expect(e.childNodes.length).toBe(4);
expectTextNode(e.childNodes[0], 'a');
@@ -296,7 +291,7 @@ describe('ReactDOMServerIntegration', () => {
render === streamRender
) {
// In the server markup there's a comment between.
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
expect(e.childNodes.length).toBe(3);
expectTextNode(e.childNodes[0], 'foo');
expectTextNode(e.childNodes[2], '40');
} else {
@@ -335,13 +330,13 @@ describe('ReactDOMServerIntegration', () => {
itRenders('null children as blank', async render => {
const e = await render(<div>{null}foo</div>);
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
expect(e.childNodes.length).toBe(1);
expectTextNode(e.childNodes[0], 'foo');
});
itRenders('false children as blank', async render => {
const e = await render(<div>{false}foo</div>);
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
expect(e.childNodes.length).toBe(1);
expectTextNode(e.childNodes[0], 'foo');
});
@@ -353,7 +348,7 @@ describe('ReactDOMServerIntegration', () => {
{false}
</div>,
);
expect(e.childNodes.length).toBe(render === streamRender ? 2 : 1);
expect(e.childNodes.length).toBe(1);
expectTextNode(e.childNodes[0], 'foo');
});
@@ -740,10 +735,10 @@ describe('ReactDOMServerIntegration', () => {
</div>,
);
expect(e.id).toBe('parent');
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
expect(e.childNodes.length).toBe(3);
const child1 = e.childNodes[0];
const textNode = e.childNodes[1];
const child2 = e.childNodes[render === streamRender ? 3 : 2];
const child2 = e.childNodes[2];
expect(child1.id).toBe('child1');
expect(child1.childNodes.length).toBe(0);
expectTextNode(textNode, ' ');
@@ -757,10 +752,10 @@ describe('ReactDOMServerIntegration', () => {
async render => {
// prettier-ignore
const e = await render(<div id="parent"> <div id="child" /> </div>); // eslint-disable-line no-multi-spaces
expect(e.childNodes.length).toBe(render === streamRender ? 5 : 3);
expect(e.childNodes.length).toBe(3);
const textNode1 = e.childNodes[0];
const child = e.childNodes[render === streamRender ? 2 : 1];
const textNode2 = e.childNodes[render === streamRender ? 3 : 2];
const child = e.childNodes[1];
const textNode2 = e.childNodes[2];
expect(e.id).toBe('parent');
expectTextNode(textNode1, ' ');
expect(child.id).toBe('child');
@@ -783,9 +778,7 @@ describe('ReactDOMServerIntegration', () => {
) {
// For plain server markup result we have comments between.
// If we're able to hydrate, they remain.
expect(parent.childNodes.length).toBe(
render === streamRender ? 6 : 5,
);
expect(parent.childNodes.length).toBe(5);
expectTextNode(parent.childNodes[0], 'a');
expectTextNode(parent.childNodes[2], 'b');
expectTextNode(parent.childNodes[4], 'c');
@@ -817,7 +810,7 @@ describe('ReactDOMServerIntegration', () => {
render === clientRenderOnServerString ||
render === streamRender
) {
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
expect(e.childNodes.length).toBe(3);
expectTextNode(e.childNodes[0], '<span>Text1&quot;</span>');
expectTextNode(e.childNodes[2], '<span>Text2&quot;</span>');
} else {
@@ -868,7 +861,7 @@ describe('ReactDOMServerIntegration', () => {
);
if (render === serverRender || render === streamRender) {
// We have three nodes because there is a comment between them.
expect(e.childNodes.length).toBe(render === streamRender ? 4 : 3);
expect(e.childNodes.length).toBe(3);
// Everything becomes LF when parsed from server HTML.
// Null character is ignored.
expectNode(e.childNodes[0], TEXT_NODE_TYPE, 'foo\nbar');
@@ -343,7 +343,6 @@ describe('useId', () => {
id="container"
>
:R0:, :R0H1:, :R0H2:
<!-- -->
</div>
`);
});
@@ -369,7 +368,6 @@ describe('useId', () => {
id="container"
>
:R0:
<!-- -->
</div>
`);
});
@@ -24,7 +24,6 @@ export function flushBuffered(destination: Destination) {}
export function beginWriting(destination: Destination) {}
let prevWasCommentSegmenter = false;
export function writeChunk(
destination: Destination,
chunk: Chunk | PrecomputedChunk,
@@ -36,16 +35,6 @@ export function writeChunkAndReturn(
destination: Destination,
chunk: Chunk | PrecomputedChunk,
): boolean {
if (prevWasCommentSegmenter) {
prevWasCommentSegmenter = false;
if (chunk[0] !== '<') {
destination.push('<!-- -->');
}
}
if (chunk === '<!-- -->') {
prevWasCommentSegmenter = true;
return true;
}
return destination.push(chunk);
}
+21 -4
View File
@@ -284,13 +284,30 @@ export function pushTextInstance(
target: Array<Chunk | PrecomputedChunk>,
text: string,
responseState: ResponseState,
): void {
textEmbedded: boolean,
): boolean {
if (text === '') {
// Empty text doesn't have a DOM node representation and the hydration is aware of this.
return;
return textEmbedded;
}
if (textEmbedded) {
target.push(textSeparator);
}
target.push(stringToChunk(encodeHTMLTextNode(text)));
return true;
}
// Called when Fizz is done with a Segment. Currently the only purpose is to conditionally
// emit a text separator when we don't know for sure it is safe to omit
export function pushSegmentFinale(
target: Array<Chunk | PrecomputedChunk>,
responseState: ResponseState,
lastPushedText: boolean,
textEmbedded: boolean,
): void {
if (lastPushedText && textEmbedded) {
target.push(textSeparator);
}
// TODO: Avoid adding a text separator in common cases.
target.push(stringToChunk(encodeHTMLTextNode(text)), textSeparator);
}
const styleNameCache: Map<string, PrecomputedChunk> = new Map();
@@ -12,6 +12,7 @@ import type {FormatContext} from './ReactDOMServerFormatConfig';
import {
createResponseState as createResponseStateImpl,
pushTextInstance as pushTextInstanceImpl,
pushSegmentFinale as pushSegmentFinaleImpl,
writeStartCompletedSuspenseBoundary as writeStartCompletedSuspenseBoundaryImpl,
writeStartClientRenderedSuspenseBoundary as writeStartClientRenderedSuspenseBoundaryImpl,
writeEndCompletedSuspenseBoundary as writeEndCompletedSuspenseBoundaryImpl,
@@ -105,11 +106,31 @@ export function pushTextInstance(
target: Array<Chunk | PrecomputedChunk>,
text: string,
responseState: ResponseState,
): void {
textEmbedded: boolean,
): boolean {
if (responseState.generateStaticMarkup) {
target.push(stringToChunk(escapeTextForBrowser(text)));
return false;
} else {
pushTextInstanceImpl(target, text, responseState);
return pushTextInstanceImpl(target, text, responseState, textEmbedded);
}
}
export function pushSegmentFinale(
target: Array<Chunk | PrecomputedChunk>,
responseState: ResponseState,
lastPushedText: boolean,
textEmbedded: boolean,
): void {
if (responseState.generateStaticMarkup) {
return;
} else {
return pushSegmentFinaleImpl(
target,
responseState,
lastPushedText,
textEmbedded,
);
}
}
@@ -122,7 +122,9 @@ export function pushTextInstance(
target: Array<Chunk | PrecomputedChunk>,
text: string,
responseState: ResponseState,
): void {
// This Renderer does not use this argument
textEmbedded: boolean,
): boolean {
target.push(
INSTANCE,
RAW_TEXT, // Type
@@ -130,6 +132,7 @@ export function pushTextInstance(
// TODO: props { text: text }
END, // End of children
);
return false;
}
export function pushStartInstance(
@@ -156,6 +159,14 @@ export function pushEndInstance(
target.push(END);
}
// In this Renderer this is a noop
export function pushSegmentFinale(
target: Array<Chunk | PrecomputedChunk>,
responseState: ResponseState,
lastPushedText: boolean,
textEmbedded: boolean,
): void {}
export function writeCompletedRoot(
destination: Destination,
responseState: ResponseState,
+15 -1
View File
@@ -98,12 +98,18 @@ const ReactNoopServer = ReactFizzServer({
return null;
},
pushTextInstance(target: Array<Uint8Array>, text: string): void {
pushTextInstance(
target: Array<Uint8Array>,
text: string,
responseState: ResponseState,
textEmbedded: boolean,
): boolean {
const textInstance: TextInstance = {
text,
hidden: false,
};
target.push(Buffer.from(JSON.stringify(textInstance), 'utf8'), POP);
return false;
},
pushStartInstance(
target: Array<Uint8Array>,
@@ -128,6 +134,14 @@ const ReactNoopServer = ReactFizzServer({
target.push(POP);
},
// This is a noop in ReactNoop
pushSegmentFinale(
target: Array<Uint8Array>,
responseState: ResponseState,
lastPushedText: boolean,
textEmbedded: boolean,
): void {},
writeCompletedRoot(
destination: Destination,
responseState: ResponseState,
@@ -93,9 +93,7 @@ describe('ReactDOMServerFB', () => {
await jest.runAllTimers();
const result = readResult(stream);
expect(result).toMatchInlineSnapshot(
`"<div><!--$-->Done<!-- --><!--/$--></div>"`,
);
expect(result).toMatchInlineSnapshot(`"<div><!--$-->Done<!--/$--></div>"`);
});
it('should throw an error when an error is thrown at the root', () => {
+59 -3
View File
@@ -56,6 +56,7 @@ import {
pushEndInstance,
pushStartCompletedSuspenseBoundary,
pushEndCompletedSuspenseBoundary,
pushSegmentFinale,
UNINITIALIZED_SUSPENSE_BOUNDARY_ID,
assignSuspenseBoundaryID,
getChildFormatContext,
@@ -169,6 +170,9 @@ type Segment = {
formatContext: FormatContext,
// If this segment represents a fallback, this is the content that will replace that fallback.
+boundary: null | SuspenseBoundary,
// used to discern when text separator boundaries are needed
lastPushedText: boolean,
textEmbedded: boolean,
};
const OPEN = 0;
@@ -267,7 +271,15 @@ export function createRequest(
onFatalError: onFatalError === undefined ? noop : onFatalError,
};
// This segment represents the root fallback.
const rootSegment = createPendingSegment(request, 0, null, rootFormatContext);
const rootSegment = createPendingSegment(
request,
0,
null,
rootFormatContext,
// Root segments are never embedded in Text on either edge
false,
false,
);
// There is no parent so conceptually, we're unblocked to flush this segment.
rootSegment.parentFlushed = true;
const rootTask = createTask(
@@ -346,6 +358,8 @@ function createPendingSegment(
index: number,
boundary: null | SuspenseBoundary,
formatContext: FormatContext,
lastPushedText: boolean,
textEmbedded: boolean,
): Segment {
return {
status: PENDING,
@@ -356,6 +370,8 @@ function createPendingSegment(
children: [],
formatContext,
boundary,
lastPushedText,
textEmbedded,
};
}
@@ -459,8 +475,13 @@ function renderSuspenseBoundary(
insertionIndex,
newBoundary,
parentSegment.formatContext,
// boundaries never require text embedding at their edges because comment nodes bound them
false,
false,
);
parentSegment.children.push(boundarySegment);
// The parentSegment has a child Segment at this index so we reset the lastPushedText marker on the parent
parentSegment.lastPushedText = false;
// This segment is the actual child content. We can start rendering that immediately.
const contentRootSegment = createPendingSegment(
@@ -468,6 +489,9 @@ function renderSuspenseBoundary(
0,
null,
parentSegment.formatContext,
// boundaries never require text embedding at their edges because comment nodes bound them
false,
false,
);
// We mark the root segment as having its parent flushed. It's not really flushed but there is
// no parent segment so there's nothing to wait on.
@@ -486,6 +510,12 @@ function renderSuspenseBoundary(
try {
// We use the safe form because we don't handle suspending here. Only error handling.
renderNode(request, task, content);
pushSegmentFinale(
contentRootSegment.chunks,
request.responseState,
contentRootSegment.lastPushedText,
contentRootSegment.textEmbedded,
);
contentRootSegment.status = COMPLETED;
queueCompletedSegment(newBoundary, contentRootSegment);
if (newBoundary.pendingTasks === 0) {
@@ -561,15 +591,18 @@ function renderHostElement(
request.responseState,
segment.formatContext,
);
segment.lastPushedText = false;
const prevContext = segment.formatContext;
segment.formatContext = getChildFormatContext(prevContext, type, props);
// We use the non-destructive form because if something suspends, we still
// need to pop back up and finish this subtree of HTML.
renderNode(request, task, children);
// We expect that errors will fatal the whole task and that we don't need
// the correct context. Therefore this is not in a finally.
segment.formatContext = prevContext;
pushEndInstance(segment.chunks, type, props);
segment.lastPushedText = false;
popComponentStackInDEV(task);
}
@@ -1216,15 +1249,23 @@ function renderNodeDestructive(
}
if (typeof node === 'string') {
pushTextInstance(task.blockedSegment.chunks, node, request.responseState);
const segment = task.blockedSegment;
segment.lastPushedText = pushTextInstance(
task.blockedSegment.chunks,
node,
request.responseState,
segment.lastPushedText,
);
return;
}
if (typeof node === 'number') {
pushTextInstance(
const segment = task.blockedSegment;
segment.lastPushedText = pushTextInstance(
task.blockedSegment.chunks,
'' + node,
request.responseState,
segment.lastPushedText,
);
return;
}
@@ -1268,8 +1309,14 @@ function spawnNewSuspendedTask(
insertionIndex,
null,
segment.formatContext,
// Adopt the parent segment's leading text embed
segment.lastPushedText,
// Assume we are text embedded at the trailing edge
true,
);
segment.children.push(newSegment);
// Reset lastPushedText for current Segment since the new Segment "consumed" it
segment.lastPushedText = false;
const newTask = createTask(
request,
task.node,
@@ -1545,6 +1592,12 @@ function retryTask(request: Request, task: Task): void {
// We call the destructive form that mutates this task. That way if something
// suspends again, we can reuse the same task instead of spawning a new one.
renderNodeDestructive(request, task, task.node);
pushSegmentFinale(
segment.chunks,
request.responseState,
segment.lastPushedText,
segment.textEmbedded,
);
task.abortSet.delete(task);
segment.status = COMPLETED;
@@ -1625,6 +1678,9 @@ function flushSubtree(
// We're emitting a placeholder for this segment to be filled in later.
// Therefore we'll need to assign it an ID - to refer to it by.
const segmentID = (segment.id = request.nextSegmentId++);
// When this segment finally completes it won't be embedded in text since it will flush separately
segment.lastPushedText = false;
segment.textEmbedded = false;
return writePlaceholder(destination, request.responseState, segmentID);
}
case COMPLETED: {
@@ -43,6 +43,7 @@ export const pushStartCompletedSuspenseBoundary =
$$$hostConfig.pushStartCompletedSuspenseBoundary;
export const pushEndCompletedSuspenseBoundary =
$$$hostConfig.pushEndCompletedSuspenseBoundary;
export const pushSegmentFinale = $$$hostConfig.pushSegmentFinale;
export const writeCompletedRoot = $$$hostConfig.writeCompletedRoot;
export const writePlaceholder = $$$hostConfig.writePlaceholder;
export const writeStartCompletedSuspenseBoundary =