Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Mar 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>');
Copy link
Collaborator Author

@sebmarkbage sebmarkbage Mar 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is a little difficult to do correctly in practice because there's no explicit signal when a root is flushed. I think the way you'd do that is explicitly wait to start writing until the root is signaled as ready, and then startWriting and assume that it's done writing after a setImmediate tick or something but that's a little flawed. What you probably want is a way to explicitly write the whole root synchronously after it is done loading.

The main pattern we want to support is the legacy synchronous render. This test shows that we can support it for streaming multiple containers though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently startWriting will synchronously emit the root if it's available. We can probably preserve that property.


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>,
]);
});
});
6 changes: 5 additions & 1 deletion packages/react-dom/src/server/ReactDOMFizzServerBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -39,6 +42,7 @@ function renderToReadableStream(
request = createRequest(
children,
controller,
createResponseState(options ? options.identifierPrefix : undefined),
options ? options.progressiveChunkSize : undefined,
);
startWork(request);
Expand Down
4 changes: 4 additions & 0 deletions packages/react-dom/src/server/ReactDOMFizzServerNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand All @@ -39,6 +42,7 @@ function pipeToNodeWritable(
const request = createRequest(
children,
destination,
createResponseState(options ? options.identifierPrefix : undefined),
options ? options.progressiveChunkSize : undefined,
);
let hasStartedFlowing = false;
Expand Down
47 changes: 27 additions & 20 deletions packages/react-dom/src/server/ReactDOMServerFormatConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,25 @@ import invariant from 'shared/invariant';

// Per response,
export type ResponseState = {
placeholderPrefix: PrecomputedChunk,
segmentPrefix: PrecomputedChunk,
boundaryPrefix: string,
opaqueIdentifierPrefix: PrecomputedChunk,
nextSuspenseID: number,
sentCompleteSegmentFunction: boolean,
sentCompleteBoundaryFunction: boolean,
sentClientRenderFunction: boolean,
};

// 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,
Expand Down Expand Up @@ -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),
));
}

Expand Down Expand Up @@ -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>');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting naming scheme 🙂

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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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:',
);
const completeSegmentScript1Partial = stringToPrecomputedChunk(
'<script>$RS("S:',
'<script>' + completeSegmentFunction + ';$RS("',
);
const completeSegmentScript2 = stringToPrecomputedChunk('","P:');
const completeSegmentScript1Partial = stringToPrecomputedChunk('<script>$RS("');
const completeSegmentScript2 = stringToPrecomputedChunk('","');
const completeSegmentScript3 = stringToPrecomputedChunk('")</script>');

export function writeCompletedSegmentInstruction(
Expand All @@ -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);
}
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -179,6 +180,7 @@ export function writeEndSuspenseBoundary(destination: Destination): boolean {

export function writeStartSegment(
destination: Destination,
responseState: ResponseState,
id: number,
): boolean {
writeChunk(destination, SEGMENT);
Expand Down
16 changes: 11 additions & 5 deletions packages/react-noop-renderer/src/ReactNoopServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: []};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: [],
};
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down