[Fizz] Use identifierPrefix to avoid conflicts within the same response (#21037)

* Use identifierPrefix to avoid conflicts within the same response

identifierPrefix as an option exists to avoid useOpaqueIdentifier conflicting
when different renders are used within one HTML response.

This lets this be configured for the DOM renderer specifically since it's DOM
specific whether they will conflict across trees or not.

* Add test for using multiple containers in one HTML document
This commit is contained in:
Sebastian Markbåge 2021-03-22 16:10:57 -04:00 committed by GitHub
parent dcdf8de7e1
commit 6c3202b1e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 133 additions and 31 deletions

View File

@ -320,4 +320,84 @@ describe('ReactDOMFizzServer', () => {
</div>,
);
});
// @gate experimental
it('should allow for two containers to be written to the same document', async () => {
// We create two passthrough streams for each container to write into.
// Notably we don't implement a end() call for these. Because we don't want to
// close the underlying stream just because one of the streams is done. Instead
// we manually close when both are done.
const writableA = new Stream.Writable();
writableA._write = (chunk, encoding, next) => {
writable.write(chunk, encoding, next);
};
const writableB = new Stream.Writable();
writableB._write = (chunk, encoding, next) => {
writable.write(chunk, encoding, next);
};
writable.write('<div id="container-A">');
await act(async () => {
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
<Suspense fallback={<Text text="Loading A..." />}>
<Text text="This will show A: " />
<div>
<AsyncText text="A" />
</div>
</Suspense>,
writableA,
{identifierPrefix: 'A_'},
);
startWriting();
});
writable.write('</div>');
writable.write('<div id="container-B">');
await act(async () => {
const {startWriting} = ReactDOMFizzServer.pipeToNodeWritable(
<Suspense fallback={<Text text="Loading B..." />}>
<Text text="This will show B: " />
<div>
<AsyncText text="B" />
</div>
</Suspense>,
writableB,
{identifierPrefix: 'B_'},
);
startWriting();
});
writable.write('</div>');
expect(getVisibleChildren(container)).toEqual([
<div id="container-A">Loading A...</div>,
<div id="container-B">Loading B...</div>,
]);
await act(async () => {
resolveText('B');
});
expect(getVisibleChildren(container)).toEqual([
<div id="container-A">Loading A...</div>,
<div id="container-B">
This will show B: <div>B</div>
</div>,
]);
await act(async () => {
resolveText('A');
});
// We're done writing both streams now.
writable.end();
expect(getVisibleChildren(container)).toEqual([
<div id="container-A">
This will show A: <div>A</div>
</div>,
<div id="container-B">
This will show B: <div>B</div>
</div>,
]);
});
});

View File

@ -16,9 +16,12 @@ import {
abort,
} from 'react-server/src/ReactFizzServer';
import {createResponseState} from './ReactDOMServerFormatConfig';
type Options = {
signal?: AbortSignal,
identifierPrefix?: string,
progressiveChunkSize?: number,
signal?: AbortSignal,
};
function renderToReadableStream(
@ -39,6 +42,7 @@ function renderToReadableStream(
request = createRequest(
children,
controller,
createResponseState(options ? options.identifierPrefix : undefined),
options ? options.progressiveChunkSize : undefined,
);
startWork(request);

View File

@ -17,11 +17,14 @@ import {
abort,
} from 'react-server/src/ReactFizzServer';
import {createResponseState} from './ReactDOMServerFormatConfig';
function createDrainHandler(destination, request) {
return () => startFlowing(request);
}
type Options = {
identifierPrefix?: string,
progressiveChunkSize?: number,
};
@ -39,6 +42,7 @@ function pipeToNodeWritable(
const request = createRequest(
children,
destination,
createResponseState(options ? options.identifierPrefix : undefined),
options ? options.progressiveChunkSize : undefined,
);
let hasStartedFlowing = false;

View File

@ -24,6 +24,10 @@ import invariant from 'shared/invariant';
// Per response,
export type ResponseState = {
placeholderPrefix: PrecomputedChunk,
segmentPrefix: PrecomputedChunk,
boundaryPrefix: string,
opaqueIdentifierPrefix: PrecomputedChunk,
nextSuspenseID: number,
sentCompleteSegmentFunction: boolean,
sentCompleteBoundaryFunction: boolean,
@ -31,8 +35,14 @@ 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 {
export function createResponseState(
identifierPrefix: string = '',
): ResponseState {
return {
placeholderPrefix: stringToPrecomputedChunk(identifierPrefix + 'P:'),
segmentPrefix: stringToPrecomputedChunk(identifierPrefix + 'S:'),
boundaryPrefix: identifierPrefix + 'B:',
opaqueIdentifierPrefix: stringToPrecomputedChunk(identifierPrefix + 'R:'),
nextSuspenseID: 0,
sentCompleteSegmentFunction: false,
sentCompleteBoundaryFunction: false,
@ -68,7 +78,7 @@ function assignAnID(
// 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),
responseState.boundaryPrefix + generatedID.toString(16),
));
}
@ -160,20 +170,19 @@ export function pushEndInstance(
// A placeholder is a node inside a hidden partial tree that can be filled in later, but before
// display. It's never visible to users.
const placeholder1 = stringToPrecomputedChunk('<span id="');
const placeholder2 = stringToPrecomputedChunk('P:');
const placeholder3 = stringToPrecomputedChunk('"></span>');
const placeholder2 = stringToPrecomputedChunk('"></span>');
export function writePlaceholder(
destination: Destination,
responseState: ResponseState,
id: number,
): boolean {
// TODO: This needs to be contextually aware and switch tag since not all parents allow for spans like
// <select> or <tbody>. E.g. suspending a component that renders a table row.
writeChunk(destination, placeholder1);
// TODO: Use the identifierPrefix option to make the prefix configurable.
writeChunk(destination, placeholder2);
writeChunk(destination, responseState.placeholderPrefix);
const formattedID = stringToChunk(id.toString(16));
writeChunk(destination, formattedID);
return writeChunk(destination, placeholder3);
return writeChunk(destination, placeholder2);
}
// Suspense boundaries are encoded as comments.
@ -207,20 +216,19 @@ export function writeEndSuspenseBoundary(destination: Destination): boolean {
}
const startSegment = stringToPrecomputedChunk('<div hidden id="');
const startSegment2 = stringToPrecomputedChunk('S:');
const startSegment3 = stringToPrecomputedChunk('">');
const startSegment2 = stringToPrecomputedChunk('">');
const endSegment = stringToPrecomputedChunk('</div>');
export function writeStartSegment(
destination: Destination,
responseState: ResponseState,
id: number,
): boolean {
// TODO: What happens with special children like <tr> if they're inserted in a div? Maybe needs contextually aware containers.
writeChunk(destination, startSegment);
// TODO: Use the identifierPrefix option to make the prefix configurable.
writeChunk(destination, startSegment2);
writeChunk(destination, responseState.segmentPrefix);
const formattedID = stringToChunk(id.toString(16));
writeChunk(destination, formattedID);
return writeChunk(destination, startSegment3);
return writeChunk(destination, startSegment2);
}
export function writeEndSegment(destination: Destination): boolean {
return writeChunk(destination, endSegment);
@ -349,12 +357,10 @@ const clientRenderFunction =
'function $RX(b){if(b=document.getElementById(b)){do b=b.previousSibling;while(8!==b.nodeType||"$?"!==b.data);b.data="$!";b._reactRetry&&b._reactRetry()}}';
const completeSegmentScript1Full = stringToPrecomputedChunk(
'<script>' + completeSegmentFunction + ';$RS("S:',
'<script>' + completeSegmentFunction + ';$RS("',
);
const completeSegmentScript1Partial = stringToPrecomputedChunk(
'<script>$RS("S:',
);
const completeSegmentScript2 = stringToPrecomputedChunk('","P:');
const completeSegmentScript1Partial = stringToPrecomputedChunk('<script>$RS("');
const completeSegmentScript2 = stringToPrecomputedChunk('","');
const completeSegmentScript3 = stringToPrecomputedChunk('")</script>');
export function writeCompletedSegmentInstruction(
@ -370,10 +376,11 @@ export function writeCompletedSegmentInstruction(
// Future calls can just reuse the same function.
writeChunk(destination, completeSegmentScript1Partial);
}
// TODO: Use the identifierPrefix option to make the prefix configurable.
writeChunk(destination, responseState.segmentPrefix);
const formattedID = stringToChunk(contentSegmentID.toString(16));
writeChunk(destination, formattedID);
writeChunk(destination, completeSegmentScript2);
writeChunk(destination, responseState.placeholderPrefix);
writeChunk(destination, formattedID);
return writeChunk(destination, completeSegmentScript3);
}
@ -384,7 +391,7 @@ const completeBoundaryScript1Full = stringToPrecomputedChunk(
const completeBoundaryScript1Partial = stringToPrecomputedChunk(
'<script>$RC("',
);
const completeBoundaryScript2 = stringToPrecomputedChunk('","S:');
const completeBoundaryScript2 = stringToPrecomputedChunk('","');
const completeBoundaryScript3 = stringToPrecomputedChunk('")</script>');
export function writeCompletedBoundaryInstruction(
@ -401,7 +408,6 @@ export function writeCompletedBoundaryInstruction(
// Future calls can just reuse the same function.
writeChunk(destination, completeBoundaryScript1Partial);
}
// TODO: Use the identifierPrefix option to make the prefix configurable.
const formattedBoundaryID = boundaryID.formattedID;
invariant(
formattedBoundaryID !== null,
@ -410,6 +416,7 @@ export function writeCompletedBoundaryInstruction(
const formattedContentID = stringToChunk(contentSegmentID.toString(16));
writeChunk(destination, formattedBoundaryID);
writeChunk(destination, completeBoundaryScript2);
writeChunk(destination, responseState.segmentPrefix);
writeChunk(destination, formattedContentID);
return writeChunk(destination, completeBoundaryScript3);
}

View File

@ -145,6 +145,7 @@ function formatID(id: number): Uint8Array {
// display. It's never visible to users.
export function writePlaceholder(
destination: Destination,
responseState: ResponseState,
id: number,
): boolean {
writeChunk(destination, PLACEHOLDER);
@ -179,6 +180,7 @@ export function writeEndSuspenseBoundary(destination: Destination): boolean {
export function writeStartSegment(
destination: Destination,
responseState: ResponseState,
id: number,
): boolean {
writeChunk(destination, SEGMENT);

View File

@ -77,9 +77,6 @@ const ReactNoopServer = ReactFizzServer({
closeWithError(destination: Destination, error: mixed): void {},
flushBuffered(destination: Destination): void {},
createResponseState(): null {
return null;
},
createSuspenseBoundaryID(): SuspenseInstance {
// The ID is a pointer to the boundary itself.
return {state: 'pending', children: []};
@ -114,7 +111,11 @@ const ReactNoopServer = ReactFizzServer({
target.push(POP);
},
writePlaceholder(destination: Destination, id: number): boolean {
writePlaceholder(
destination: Destination,
responseState: ResponseState,
id: number,
): boolean {
const parent = destination.stack[destination.stack.length - 1];
destination.placeholders.set(id, {
parent: parent,
@ -153,7 +154,11 @@ const ReactNoopServer = ReactFizzServer({
destination.stack.pop();
},
writeStartSegment(destination: Destination, id: number): boolean {
writeStartSegment(
destination: Destination,
responseState: ResponseState,
id: number,
): boolean {
const segment = {
children: [],
};
@ -227,6 +232,7 @@ function render(children: React$Element<any>, options?: Options): Destination {
const request = ReactNoopServer.createRequest(
children,
destination,
null,
options ? options.progressiveChunkSize : undefined,
);
ReactNoopServer.startWork(request);

View File

@ -44,7 +44,6 @@ import {
pushStartInstance,
pushEndInstance,
createSuspenseBoundaryID,
createResponseState,
} from './ReactServerFormatConfig';
import {REACT_ELEMENT_TYPE, REACT_SUSPENSE_TYPE} from 'shared/ReactSymbols';
import ReactSharedInternals from 'shared/ReactSharedInternals';
@ -133,13 +132,14 @@ const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800;
export function createRequest(
children: ReactNodeList,
destination: Destination,
responseState: ResponseState,
progressiveChunkSize: number = DEFAULT_PROGRESSIVE_CHUNK_SIZE,
): Request {
const pingedWork = [];
const abortSet: Set<SuspendedWork> = new Set();
const request = {
destination,
responseState: createResponseState(),
responseState,
progressiveChunkSize,
status: BUFFERING,
nextSegmentId: 0,
@ -590,7 +590,7 @@ 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++);
return writePlaceholder(destination, segmentID);
return writePlaceholder(destination, request.responseState, segmentID);
}
case COMPLETED: {
segment.status = FLUSHED;
@ -712,7 +712,7 @@ function flushSegmentContainer(
destination: Destination,
segment: Segment,
): boolean {
writeStartSegment(destination, segment.id);
writeStartSegment(destination, request.responseState, segment.id);
flushSegment(request, destination, segment);
return writeEndSegment(destination);
}

View File

@ -28,7 +28,6 @@ export opaque type Destination = mixed; // eslint-disable-line no-undef
export opaque type ResponseState = mixed;
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;