From 0e858a5912445af2b2e57f39c931b8e80dc8126d 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 480eefec0f2e0..fe79a7273edc8 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js @@ -51,6 +51,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 39ef573ec39d2..054821734d185 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 bd0badc535a8e..c2600f31a13be 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 f591bba428c91..66bfc0056d398 100644 --- a/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.js +++ b/packages/react-server-dom-relay/src/ReactServerStreamConfigFB.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 { destination.done = true; destination.fatal = true; diff --git a/packages/react-server/src/ReactServerStreamConfigBrowser.js b/packages/react-server/src/ReactServerStreamConfigBrowser.js index ea1db2f6a6df1..e8024eb0650ef 100644 --- a/packages/react-server/src/ReactServerStreamConfigBrowser.js +++ b/packages/react-server/src/ReactServerStreamConfigBrowser.js @@ -39,6 +39,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 @@ -110,8 +119,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 edc8221136968..a1adf63975541 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -89,6 +89,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 @@ -179,8 +188,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 21ad11266bff0..f8beba8e47597 100644 --- a/packages/react-server/src/forks/ReactServerStreamConfig.custom.js +++ b/packages/react-server/src/forks/ReactServerStreamConfig.custom.js @@ -39,3 +39,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;