Skip to content

Commit

Permalink
Fizz Browser: fix precomputed chunk being cleared on Node 18 (#25645)
Browse files Browse the repository at this point in the history
## Edit

Went for another approach after talking with @gnoff. The approach is
now:
- add a dev-only error when a precomputed chunk is too big to be written
- suggest to copy it before passing it to `writeChunk`

This PR also includes porting the React Float tests to use the browser
build of Fizz so that we can test it out on that environment (which is
the one used by next).

<!--
  Thanks for submitting a pull request!
We appreciate you spending the time to work on these changes. Please
provide enough information so that others can review your pull request.
The three fields below are mandatory.

Before submitting a pull request, please make sure the following is
done:

1. Fork [the repository](https://github.com/facebook/react) and create
your branch from `main`.
  2. Run `yarn` in the repository root.
3. If you've fixed a bug or added code that should be tested, add tests!
4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch
TestName` is helpful in development.
5. Run `yarn test --prod` to test in the production environment. It
supports the same options as `yarn test`.
6. If you need a debugger, run `yarn debug-test --watch TestName`, open
`chrome://inspect`, and press "Inspect".
7. Format your code with
[prettier](https://github.com/prettier/prettier) (`yarn prettier`).
8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only
check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

Learn more about contributing:
https://reactjs.org/docs/how-to-contribute.html
-->

## Summary

Someone reported [a bug](vercel/next.js#42466)
in Next.js that pointed to an issue with Node 18 in the streaming
renderer when using importing a CSS module where it only returned a
malformed bootstraping script only after loading the page once.

After investigating a bit, here's what I found:

- when using a CSS module in Next, we go into this code path, which
writes the aforementioned bootstrapping script


https://github.com/facebook/react/blob/5f7ef8c4cbe824ef126a947b7ae0e1c07b143357/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js#L2443-L2447

- the reason for the malformed script is that
`completeBoundaryWithStylesScript1FullBoth` is emptied after the call to
`writeChunk`
- it gets emptied in `writeChunk` because we stream the chunk directly
without copying it in this codepath

https://github.com/facebook/react/blob/a438590144d2ad40865b58e0c0e69595fc1aa377/packages/react-server/src/ReactServerStreamConfigBrowser.js#L63
- the reason why it only happens from Node 18 is because the Webstreams
APIs are available natively from that version and in their
implementation, [`enqueue` transfers the array buffer
ownership](https://github.com/nodejs/node/blob/9454ba6138d11e8a4d18b073de25781cad4bd2c8/lib/internal/webstreams/readablestream.js#L2641),
thus making it unavailable/empty for subsequent calls. In older Node
versions, we don't encounter the bug because we are using a polyfill in
Next.js, [which does not implement properly the array buffer transfer
behaviour](https://cs.github.com/MattiasBuelens/web-streams-polyfill/blob/d354a7457ca8a24030dbd0a135ee40baed7c774d/src/lib/abstract-ops/ecmascript.ts#L16).

I think the proper fix for this is to clone the array buffer before
enqueuing it. (we do this in the other code paths in the function later
on, see ```((currentView: any): Uint8Array).set(bytesToWrite,
writtenBytes);```





## How did you test this change?

Manually tested by applying the change in the compiled Next.js version.

<!--
Demonstrate the code is solid. Example: The exact commands you ran and
their output, screenshots / videos if the pull request changes the user
interface.
How exactly did you verify that your PR solves the issue you wanted to
solve?
  If you leave this empty, your PR will very likely be closed.
-->

Co-authored-by: Sebastian Markbage <sebastian@calyptus.eu>
  • Loading branch information
feedthejim and sebmarkbage committed Nov 22, 2022
1 parent 799ee65 commit 2655c93
Show file tree
Hide file tree
Showing 8 changed files with 81 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
return content;
}

export function clonePrecomputedChunk(
chunk: PrecomputedChunk,
): PrecomputedChunk {
return chunk;
}

export function closeWithError(destination: Destination, error: mixed): void {
// $FlowFixMe: This is an Error object or the destination accepts other types.
destination.destroy(error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
writeChunkAndReturn,
stringToChunk,
stringToPrecomputedChunk,
clonePrecomputedChunk,
} from 'react-server/src/ReactServerStreamConfig';

import {
Expand Down Expand Up @@ -2443,7 +2444,10 @@ export function writeCompletedBoundaryInstruction(
if (!responseState.sentCompleteBoundaryFunction) {
responseState.sentCompleteBoundaryFunction = true;
responseState.sentStyleInsertionFunction = true;
writeChunk(destination, completeBoundaryWithStylesScript1FullBoth);
writeChunk(
destination,
clonePrecomputedChunk(completeBoundaryWithStylesScript1FullBoth),
);
} else if (!responseState.sentStyleInsertionFunction) {
responseState.sentStyleInsertionFunction = true;
writeChunk(destination, completeBoundaryWithStylesScript1FullPartial);
Expand Down
3 changes: 3 additions & 0 deletions packages/react-noop-renderer/src/ReactNoopFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const ReactNoopFlightServer = ReactFlightServer({
stringToPrecomputedChunk(content: string): string {
return content;
},
clonePrecomputedChunk(chunk: string): string {
return chunk;
},
isModuleReference(reference: Object): boolean {
return reference.$$typeof === Symbol.for('react.module.reference');
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
return content;
}

export function clonePrecomputedChunk(
chunk: PrecomputedChunk,
): PrecomputedChunk {
return chunk;
}

export function closeWithError(destination: Destination, error: mixed): void {
destination.done = true;
destination.fatal = true;
Expand Down
27 changes: 26 additions & 1 deletion packages/react-server/src/ReactServerStreamConfigBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ export function writeChunk(
}

if (chunk.length > VIEW_SIZE) {
if (__DEV__) {
if (precomputedChunkSet.has(chunk)) {
console.error(
'A large precomputed chunk was passed to writeChunk without being copied.' +
' Large chunks get enqueued directly and are not copied. This is incompatible with precomputed chunks because you cannot enqueue the same precomputed chunk twice.' +
' Use "cloneChunk" to make a copy of this large precomputed chunk before writing it. This is a bug in React.',
);
}
}
// this chunk may overflow a single view which implies it was not
// one that is cached by the streaming renderer. We will enqueu
// it directly and expect it is not re-used
Expand Down Expand Up @@ -117,8 +126,24 @@ export function stringToChunk(content: string): Chunk {
return textEncoder.encode(content);
}

const precomputedChunkSet: Set<Chunk> = __DEV__ ? new Set() : (null: any);

export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
return textEncoder.encode(content);
const precomputedChunk = textEncoder.encode(content);

if (__DEV__) {
precomputedChunkSet.add(precomputedChunk);
}

return precomputedChunk;
}

export function clonePrecomputedChunk(
precomputedChunk: PrecomputedChunk,
): PrecomputedChunk {
return precomputedChunk.length > VIEW_SIZE
? precomputedChunk.slice()
: precomputedChunk;
}

export function closeWithError(destination: Destination, error: mixed): void {
Expand Down
6 changes: 6 additions & 0 deletions packages/react-server/src/ReactServerStreamConfigBun.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
return content;
}

export function clonePrecomputedChunk(
chunk: PrecomputedChunk,
): PrecomputedChunk {
return chunk;
}

export function closeWithError(destination: Destination, error: mixed): void {
// $FlowFixMe[method-unbinding]
if (typeof destination.error === 'function') {
Expand Down
29 changes: 28 additions & 1 deletion packages/react-server/src/ReactServerStreamConfigNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ function writeViewChunk(destination: Destination, chunk: PrecomputedChunk) {
return;
}
if (chunk.byteLength > VIEW_SIZE) {
if (__DEV__) {
if (precomputedChunkSet && precomputedChunkSet.has(chunk)) {
console.error(
'A large precomputed chunk was passed to writeChunk without being copied.' +
' Large chunks get enqueued directly and are not copied. This is incompatible with precomputed chunks because you cannot enqueue the same precomputed chunk twice.' +
' Use "cloneChunk" to make a copy of this large precomputed chunk before writing it. This is a bug in React.',
);
}
}
// this chunk may overflow a single view which implies it was not
// one that is cached by the streaming renderer. We will enqueu
// it directly and expect it is not re-used
Expand Down Expand Up @@ -185,8 +194,26 @@ export function stringToChunk(content: string): Chunk {
return content;
}

const precomputedChunkSet = __DEV__ ? new Set() : null;

export function stringToPrecomputedChunk(content: string): PrecomputedChunk {
return textEncoder.encode(content);
const precomputedChunk = textEncoder.encode(content);

if (__DEV__) {
if (precomputedChunkSet) {
precomputedChunkSet.add(precomputedChunk);
}
}

return precomputedChunk;
}

export function clonePrecomputedChunk(
precomputedChunk: PrecomputedChunk,
): PrecomputedChunk {
return precomputedChunk.length > VIEW_SIZE
? precomputedChunk.slice()
: precomputedChunk;
}

export function closeWithError(destination: Destination, error: mixed): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ export const close = $$$hostConfig.close;
export const closeWithError = $$$hostConfig.closeWithError;
export const stringToChunk = $$$hostConfig.stringToChunk;
export const stringToPrecomputedChunk = $$$hostConfig.stringToPrecomputedChunk;
export const clonePrecomputedChunk = $$$hostConfig.clonePrecomputedChunk;

0 comments on commit 2655c93

Please sign in to comment.