From 2655c9354d8e1c54ba888444220f63e836925caa Mon Sep 17 00:00:00 2001 From: Jimmy Lai Date: Tue, 22 Nov 2022 01:33:41 +0100 Subject: [PATCH] Fizz Browser: fix precomputed chunk being cleared on Node 18 (#25645) ## 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). ## Summary Someone reported [a bug](https://github.com/vercel/next.js/issues/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. Co-authored-by: Sebastian Markbage --- .../ReactDOMLegacyServerStreamConfig.js | 6 ++++ .../src/server/ReactDOMServerFormatConfig.js | 6 +++- .../src/ReactNoopFlightServer.js | 3 ++ .../src/ReactServerStreamConfigFB.js | 6 ++++ .../src/ReactServerStreamConfigBrowser.js | 27 ++++++++++++++++- .../src/ReactServerStreamConfigBun.js | 6 ++++ .../src/ReactServerStreamConfigNode.js | 29 ++++++++++++++++++- .../forks/ReactServerStreamConfig.custom.js | 1 + 8 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js index d1b68be1b350e..ed7e32f65d5c6 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js @@ -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); diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js index b6a5727314cbe..91d241e4e7929 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerFormatConfig.js @@ -37,6 +37,7 @@ import { writeChunkAndReturn, stringToChunk, stringToPrecomputedChunk, + clonePrecomputedChunk, } from 'react-server/src/ReactServerStreamConfig'; import { @@ -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); diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 522620c6f5a50..586512bc963f4 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -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'); }, diff --git a/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js b/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js index a1874ce362386..bc5e97680018a 100644 --- a/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js +++ b/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js @@ -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; diff --git a/packages/react-server/src/ReactServerStreamConfigBrowser.js b/packages/react-server/src/ReactServerStreamConfigBrowser.js index 082e0edbaf46c..2d9346efe54db 100644 --- a/packages/react-server/src/ReactServerStreamConfigBrowser.js +++ b/packages/react-server/src/ReactServerStreamConfigBrowser.js @@ -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 @@ -117,8 +126,24 @@ export function stringToChunk(content: string): Chunk { return textEncoder.encode(content); } +const precomputedChunkSet: Set = __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 { diff --git a/packages/react-server/src/ReactServerStreamConfigBun.js b/packages/react-server/src/ReactServerStreamConfigBun.js index c50ce77fa9a7b..fd90c17a3d1e0 100644 --- a/packages/react-server/src/ReactServerStreamConfigBun.js +++ b/packages/react-server/src/ReactServerStreamConfigBun.js @@ -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') { diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index 5790682d301c9..458c84e076d87 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -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 @@ -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 { diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js index 8a5fd3173c96b..0e7bafab961aa 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js @@ -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;