[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:
parent
533aed8de6
commit
1d1e49cfa4
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue