[Fizz] Assign an ID to the first DOM element in a fallback or insert a dummy (and testing infra) (#21020)

* Patches

* Add Fizz testing infra structure

* Assign an ID to the first DOM node in a fallback or insert a dummy

* unstable_createRoot
This commit is contained in:
Sebastian Markbåge 2021-03-16 17:05:52 -04:00 committed by GitHub
parent 533aed8de6
commit 1d1e49cfa4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 389 additions and 21 deletions

View File

@ -0,0 +1,269 @@
/**
* 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
*/
'use strict';
let JSDOM;
let Stream;
let Scheduler;
let React;
let ReactDOM;
let ReactDOMFizzServer;
let Suspense;
let textCache;
let document;
let writable;
let buffer = '';
let hasErrored = false;
let fatalError = undefined;
describe('ReactDOMFizzServer', () => {
beforeEach(() => {
jest.resetModules();
JSDOM = require('jsdom').JSDOM;
Scheduler = require('scheduler');
React = require('react');
ReactDOM = require('react-dom');
if (__EXPERIMENTAL__) {
ReactDOMFizzServer = require('react-dom/unstable-fizz');
}
Stream = require('stream');
Suspense = React.Suspense;
textCache = new Map();
// Test Environment
const jsdom = new JSDOM('<!DOCTYPE html><html><head></head><body>', {
runScripts: 'dangerously',
});
document = jsdom.window.document;
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 act(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);
document.body.appendChild(script);
} else {
document.body.appendChild(node);
}
}
}
function getVisibleChildren(element) {
const children = [];
let node = element.firstChild;
while (node) {
if (node.nodeType === 1) {
if (node.tagName !== 'SCRIPT' && !node.hasAttribute('hidden')) {
const props = {};
const attributes = node.attributes;
for (let i = 0; i < attributes.length; i++) {
props[attributes[i].name] = attributes[i].value;
}
props.children = getVisibleChildren(node);
children.push(React.createElement(node.tagName.toLowerCase(), props));
}
} else if (node.nodeType === 3) {
children.push(node.data);
}
node = node.nextSibling;
}
return children.length === 0
? null
: children.length === 1
? children[0]
: children;
}
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 rejectText(text, error) {
const record = textCache.get(text);
if (record === undefined) {
const newRecord = {
status: 'rejected',
value: error,
};
textCache.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'rejected';
record.value = error;
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 {
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}) {
return text;
}
function AsyncText({text}) {
return readText(text);
}
// @gate experimental
it('should asynchronously load the suspense boundary', async () => {
await act(async () => {
ReactDOMFizzServer.pipeToNodeWritable(
<div>
<Suspense fallback={<Text text="Loading..." />}>
<AsyncText text="Hello World" />
</Suspense>
</div>,
writable,
);
});
expect(getVisibleChildren(document.body)).toEqual(<div>Loading...</div>);
await act(async () => {
resolveText('Hello World');
});
expect(getVisibleChildren(document.body)).toEqual(<div>Hello World</div>);
});
// @gate experimental
it('waits for pending content to come in from the server and then hydrates it', async () => {
const ref = React.createRef();
function App() {
return (
<div>
<Suspense fallback="Loading...">
<h1 ref={ref}>
<AsyncText text="Hello" />
</h1>
</Suspense>
</div>
);
}
await act(async () => {
ReactDOMFizzServer.pipeToNodeWritable(
// We currently have to wrap the server node in a container because
// otherwise the Fizz nodes get deleted during hydration.
<div id="container">
<App />
</div>,
writable,
);
});
// We're still showing a fallback.
// Attempt to hydrate the content.
const container = document.body.firstChild;
const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
root.render(<App />);
Scheduler.unstable_flushAll();
// We're still loading because we're waiting for the server to stream more content.
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
// The server now updates the content in place in the fallback.
await act(async () => {
resolveText('Hello');
});
// The final HTML is now in place.
expect(getVisibleChildren(container)).toEqual(
<div>
<h1>Hello</h1>
</div>,
);
const h1 = container.getElementsByTagName('h1')[0];
// But it is not yet hydrated.
expect(ref.current).toBe(null);
Scheduler.unstable_flushAll();
// Now it's hydrated.
expect(ref.current).toBe(h1);
});
});

View File

@ -24,6 +24,7 @@ import invariant from 'shared/invariant';
// Per response,
export type ResponseState = {
nextSuspenseID: number,
sentCompleteSegmentFunction: boolean,
sentCompleteBoundaryFunction: boolean,
sentClientRenderFunction: boolean,
@ -32,6 +33,7 @@ export type ResponseState = {
// Allows us to keep track of what we've already written so we can refer back to it.
export function createResponseState(): ResponseState {
return {
nextSuspenseID: 0,
sentCompleteSegmentFunction: false,
sentCompleteBoundaryFunction: false,
sentClientRenderFunction: false,
@ -42,13 +44,13 @@ export function createResponseState(): ResponseState {
// We can't assign an ID up front because the node we're attaching it to might already
// have one. So we need to lazily use that if it's available.
export type SuspenseBoundaryID = {
id: null | string,
formattedID: null | PrecomputedChunk,
};
export function createSuspenseBoundaryID(
responseState: ResponseState,
): SuspenseBoundaryID {
return {id: null};
return {formattedID: null};
}
function encodeHTMLIDAttribute(value: string): string {
@ -59,23 +61,86 @@ function encodeHTMLTextNode(text: string): string {
return escapeTextForBrowser(text);
}
function assignAnID(
responseState: ResponseState,
id: SuspenseBoundaryID,
): PrecomputedChunk {
// TODO: This approach doesn't yield deterministic results since this is assigned during render.
const generatedID = responseState.nextSuspenseID++;
return (id.formattedID = stringToPrecomputedChunk(
'B:' + generatedID.toString(16),
));
}
const dummyNode1 = stringToPrecomputedChunk('<span hidden id="');
const dummyNode2 = stringToPrecomputedChunk('"></span>');
function pushDummyNodeWithID(
target: Array<Chunk | PrecomputedChunk>,
responseState: ResponseState,
assignID: SuspenseBoundaryID,
): void {
const id = assignAnID(responseState, assignID);
target.push(dummyNode1, id, dummyNode2);
}
export function pushEmpty(
target: Array<Chunk | PrecomputedChunk>,
responseState: ResponseState,
assignID: null | SuspenseBoundaryID,
): void {
if (assignID !== null) {
pushDummyNodeWithID(target, responseState, assignID);
}
}
export function pushTextInstance(
target: Array<Chunk | PrecomputedChunk>,
text: string,
responseState: ResponseState,
assignID: null | SuspenseBoundaryID,
): void {
if (assignID !== null) {
pushDummyNodeWithID(target, responseState, assignID);
}
target.push(stringToChunk(encodeHTMLTextNode(text)));
}
const startTag1 = stringToPrecomputedChunk('<');
const startTag2 = stringToPrecomputedChunk('>');
const idAttr = stringToPrecomputedChunk(' id="');
const attrEnd = stringToPrecomputedChunk('"');
export function pushStartInstance(
target: Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
responseState: ResponseState,
assignID: null | SuspenseBoundaryID,
): void {
// TODO: Figure out if it's self closing and everything else.
target.push(startTag1, stringToChunk(type), startTag2);
if (assignID !== null) {
let encodedID;
if (typeof props.id === 'string') {
// We can reuse the existing ID for our purposes.
encodedID = assignID.formattedID = stringToPrecomputedChunk(
encodeHTMLIDAttribute(props.id),
);
} else {
encodedID = assignAnID(responseState, assignID);
}
target.push(
startTag1,
stringToChunk(type),
idAttr,
encodedID,
attrEnd,
startTag2,
);
} else {
target.push(startTag1, stringToChunk(type), startTag2);
}
}
const endTag1 = stringToPrecomputedChunk('</');
@ -144,7 +209,7 @@ export function writeEndSuspenseBoundary(destination: Destination): boolean {
const startSegment = stringToPrecomputedChunk('<div hidden id="');
const startSegment2 = stringToPrecomputedChunk('S:');
const startSegment3 = stringToPrecomputedChunk('">');
const endSegment = stringToPrecomputedChunk('"></div>');
const endSegment = stringToPrecomputedChunk('</div>');
export function writeStartSegment(
destination: Destination,
id: number,
@ -297,7 +362,7 @@ export function writeCompletedSegmentInstruction(
responseState: ResponseState,
contentSegmentID: number,
): boolean {
if (responseState.sentCompleteSegmentFunction) {
if (!responseState.sentCompleteSegmentFunction) {
// The first time we write this, we'll need to include the full implementation.
responseState.sentCompleteSegmentFunction = true;
writeChunk(destination, completeSegmentScript1Full);
@ -328,7 +393,7 @@ export function writeCompletedBoundaryInstruction(
boundaryID: SuspenseBoundaryID,
contentSegmentID: number,
): boolean {
if (responseState.sentCompleteBoundaryFunction) {
if (!responseState.sentCompleteBoundaryFunction) {
// The first time we write this, we'll need to include the full implementation.
responseState.sentCompleteBoundaryFunction = true;
writeChunk(destination, completeBoundaryScript1Full);
@ -337,13 +402,11 @@ export function writeCompletedBoundaryInstruction(
writeChunk(destination, completeBoundaryScript1Partial);
}
// TODO: Use the identifierPrefix option to make the prefix configurable.
const formattedBoundaryID = boundaryID.formattedID;
invariant(
boundaryID.id !== null,
formattedBoundaryID !== null,
'An ID must have been assigned before we can complete the boundary.',
);
const formattedBoundaryID = stringToChunk(
encodeHTMLIDAttribute(boundaryID.id),
);
const formattedContentID = stringToChunk(contentSegmentID.toString(16));
writeChunk(destination, formattedBoundaryID);
writeChunk(destination, completeBoundaryScript2);
@ -362,7 +425,7 @@ export function writeClientRenderBoundaryInstruction(
responseState: ResponseState,
boundaryID: SuspenseBoundaryID,
): boolean {
if (responseState.sentClientRenderFunction) {
if (!responseState.sentClientRenderFunction) {
// The first time we write this, we'll need to include the full implementation.
responseState.sentClientRenderFunction = true;
writeChunk(destination, clientRenderScript1Full);
@ -370,13 +433,11 @@ export function writeClientRenderBoundaryInstruction(
// Future calls can just reuse the same function.
writeChunk(destination, clientRenderScript1Partial);
}
const formattedBoundaryID = boundaryID.formattedID;
invariant(
boundaryID.id !== null,
formattedBoundaryID !== null,
'An ID must have been assigned before we can complete the boundary.',
);
const formattedBoundaryID = stringToPrecomputedChunk(
encodeHTMLIDAttribute(boundaryID.id),
);
writeChunk(destination, formattedBoundaryID);
return writeChunk(destination, clientRenderScript2);
}

View File

@ -73,14 +73,25 @@ export type SuspenseBoundaryID = number;
export function createSuspenseBoundaryID(
responseState: ResponseState,
): SuspenseBoundaryID {
// TODO: This is not deterministic since it's created during render.
return responseState.nextSuspenseID++;
}
const RAW_TEXT = stringToPrecomputedChunk('RCTRawText');
export function pushEmpty(
target: Array<Chunk | PrecomputedChunk>,
responseState: ResponseState,
assignID: null | SuspenseBoundaryID,
): void {
// This is not used since we don't need to assign any IDs.
}
export function pushTextInstance(
target: Array<Chunk | PrecomputedChunk>,
text: string,
responseState: ResponseState,
assignID: null | SuspenseBoundaryID,
): void {
target.push(
INSTANCE,
@ -95,6 +106,8 @@ export function pushStartInstance(
target: Array<Chunk | PrecomputedChunk>,
type: string,
props: Object,
responseState: ResponseState,
assignID: null | SuspenseBoundaryID,
): void {
target.push(
INSTANCE,

View File

@ -39,6 +39,7 @@ import {
writeClientRenderBoundaryInstruction,
writeCompletedBoundaryInstruction,
writeCompletedSegmentInstruction,
pushEmpty,
pushTextInstance,
pushStartInstance,
pushEndInstance,
@ -218,11 +219,26 @@ function renderNode(
parentBoundary: Root | SuspenseBoundary,
segment: Segment,
node: ReactNodeList,
assignID: null | SuspenseBoundaryID,
): void {
if (typeof node === 'string') {
pushTextInstance(segment.chunks, node);
pushTextInstance(segment.chunks, node, request.responseState, assignID);
return;
}
if (Array.isArray(node)) {
if (node.length > 0) {
// Only the first node gets assigned an ID.
renderNode(request, parentBoundary, segment, node[0], assignID);
for (let i = 1; i < node.length; i++) {
renderNode(request, parentBoundary, segment, node[i], null);
}
} else {
pushEmpty(segment.chunks, request.responseState, assignID);
}
return;
}
if (
typeof node !== 'object' ||
!node ||
@ -236,7 +252,7 @@ function renderNode(
if (typeof type === 'function') {
try {
const result = type(props);
renderNode(request, parentBoundary, segment, result);
renderNode(request, parentBoundary, segment, result, assignID);
} catch (x) {
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
// Something suspended, we'll need to create a new segment and resolve it later.
@ -248,7 +264,7 @@ function renderNode(
node,
parentBoundary,
newSegment,
null,
assignID,
);
const ping = suspendedWork.ping;
x.then(ping, ping);
@ -259,10 +275,18 @@ function renderNode(
}
}
} else if (typeof type === 'string') {
pushStartInstance(segment.chunks, type, props);
renderNode(request, parentBoundary, segment, props.children);
pushStartInstance(
segment.chunks,
type,
props,
request.responseState,
assignID,
);
renderNode(request, parentBoundary, segment, props.children, null);
pushEndInstance(segment.chunks, type, props);
} else if (type === REACT_SUSPENSE_TYPE) {
// We need to push an "empty" thing here to identify the parent suspense boundary.
pushEmpty(segment.chunks, request.responseState, assignID);
// Each time we enter a suspense boundary, we split out into a new segment for
// the fallback so that we can later replace that segment with the content.
// This also lets us split out the main content even if it doesn't suspend,
@ -418,7 +442,7 @@ function retryWork(request: Request, work: SuspendedWork): void {
node = element.type(element.props);
}
renderNode(request, boundary, segment, node);
renderNode(request, boundary, segment, node, work.assignID);
completeWork(request, boundary, segment);
} catch (x) {

View File

@ -30,6 +30,7 @@ export opaque type SuspenseBoundaryID = mixed;
export const createResponseState = $$$hostConfig.createResponseState;
export const createSuspenseBoundaryID = $$$hostConfig.createSuspenseBoundaryID;
export const pushEmpty = $$$hostConfig.pushEmpty;
export const pushTextInstance = $$$hostConfig.pushTextInstance;
export const pushStartInstance = $$$hostConfig.pushStartInstance;
export const pushEndInstance = $$$hostConfig.pushEndInstance;