From 143d3e1b89d7f64d607bbfc844d1324b39ed93dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 25 Apr 2025 11:52:28 -0400 Subject: [PATCH 1/2] [Fizz] Emit link rel="expect" to block render before the shell has fully loaded (#33016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantics of React is that anything outside of Suspense boundaries in a transition doesn't display until it has fully unsuspended. With SSR streaming the intention is to preserve that. We explicitly don't want to support the mode of document streaming normally supported by the browser where it can paint content as tags stream in since that leads to content popping in and thrashing in unpredictable ways. This should instead be modeled explictly by nested Suspense boundaries or something like SuspenseList. After the first shell any nested Suspense boundaries are only revealed, by script, once they're fully streamed in to the next boundary. So this is already the case there. However, for the initial shell we have been at the mercy of browser heuristics for how long it decides to stream before the first paint. Chromium now has [an API explicitly for this use case](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API/Using#stabilizing_page_state_to_make_cross-document_transitions_consistent) that lets us model the semantics that we want. This is always important but especially so with MPA View Transitions. After this a simple document looks like this: ```html

hello world

... ``` The `rel="expect"` tag indicates that we want to wait to paint until we have streamed far enough to be able to paint the id `"«R»"` which indicates the shell. Ideally this `id` would be assigned to the root most HTML element in the body. However, this is tricky in our implementation because there can be multiple and we can render them out of order. So instead, we assign the id to the first bootstrap script if there is one since these are always added to the end of the shell. If there isn't a bootstrap script then we emit an empty `` instead as a marker. Since we currently put as much as possible in the shell if it's loaded by the time we render, this can have some negative effects for very large documents. We should instead apply the heuristic where very large Suspense boundaries get outlined outside the shell even if they're immediately available. This means that even prerenders can end up with script tags. We only emit the `rel="expect"` if you're rendering a whole document. I.e. if you rendered either a `` or `` tag. If you're rendering a partial document, then we don't really know where the streaming parts are anyway and can't provide such guarantees. This does apply whether you're streaming or not because we still want to block rendering until the end, but in practice any serialized state that needs hydrate should still be embedded after the completion id. --- fixtures/ssr/server/render.js | 36 ++++- fixtures/ssr/src/components/Chrome.js | 1 + .../src/server/ReactFizzConfigDOM.js | 136 +++++++++++++++--- .../src/__tests__/ReactDOMFizzServer-test.js | 11 +- .../ReactDOMFizzServerBrowser-test.js | 8 +- .../__tests__/ReactDOMFizzServerEdge-test.js | 2 +- .../__tests__/ReactDOMFizzServerNode-test.js | 4 +- .../src/__tests__/ReactDOMFizzStatic-test.js | 5 +- .../ReactDOMFizzStaticBrowser-test.js | 20 ++- .../__tests__/ReactDOMFizzStaticNode-test.js | 4 +- .../src/__tests__/ReactDOMFloat-test.js | 9 +- .../src/__tests__/ReactDOMLegacyFloat-test.js | 3 +- .../ReactDOMSingletonComponents-test.js | 5 +- .../src/__tests__/ReactRenderDocument-test.js | 34 +++-- .../react-dom/src/test-utils/FizzTestUtils.js | 5 +- .../react-markup/src/ReactFizzConfigMarkup.js | 32 ++++- .../ReactDOMServerFB-test.internal.js | 2 +- .../src/__tests__/ReactFlightDOM-test.js | 13 +- .../__tests__/ReactFlightDOMBrowser-test.js | 4 +- packages/react-server/src/ReactFizzServer.js | 6 +- 20 files changed, 274 insertions(+), 66 deletions(-) diff --git a/fixtures/ssr/server/render.js b/fixtures/ssr/server/render.js index a4fe698858a..e20b9a35dc5 100644 --- a/fixtures/ssr/server/render.js +++ b/fixtures/ssr/server/render.js @@ -1,5 +1,6 @@ import React from 'react'; import {renderToPipeableStream} from 'react-dom/server'; +import {Writable} from 'stream'; import App from '../src/components/App'; @@ -14,11 +15,41 @@ if (process.env.NODE_ENV === 'development') { assets = require('../build/asset-manifest.json'); } +class ThrottledWritable extends Writable { + constructor(destination) { + super(); + this.destination = destination; + this.delay = 150; + } + + _write(chunk, encoding, callback) { + let o = 0; + const write = () => { + this.destination.write(chunk.slice(o, o + 100), encoding, x => { + o += 100; + if (o < chunk.length) { + setTimeout(write, this.delay); + } else { + callback(x); + } + }); + }; + setTimeout(write, this.delay); + } + + _final(callback) { + setTimeout(() => { + this.destination.end(callback); + }, this.delay); + } +} + export default function render(url, res) { res.socket.on('error', error => { // Log fatal errors console.error('Fatal', error); }); + console.log('hello'); let didError = false; const {pipe, abort} = renderToPipeableStream(, { bootstrapScripts: [assets['main.js']], @@ -26,7 +57,10 @@ export default function render(url, res) { // If something errored before we started streaming, we set the error code appropriately. res.statusCode = didError ? 500 : 200; res.setHeader('Content-type', 'text/html'); - pipe(res); + // To test the actual chunks taking time to load over the network, we throttle + // the stream a bit. + const throttledResponse = new ThrottledWritable(res); + pipe(throttledResponse); }, onShellError(x) { // Something errored before we could complete the shell so we emit an alternative shell. diff --git a/fixtures/ssr/src/components/Chrome.js b/fixtures/ssr/src/components/Chrome.js index 5cf81a877f7..984c726a026 100644 --- a/fixtures/ssr/src/components/Chrome.js +++ b/fixtures/ssr/src/components/Chrome.js @@ -37,6 +37,7 @@ export default class Chrome extends Component { +

This should appear in the first paint.

'); const startScriptSrc = stringToPrecomputedChunk(''); +const scriptNonce = stringToPrecomputedChunk(' nonce="'); +const scriptIntegirty = stringToPrecomputedChunk(' integrity="'); +const scriptCrossOrigin = stringToPrecomputedChunk(' crossorigin="'); +const endAsyncScript = stringToPrecomputedChunk(' async="">'); /** * This escaping function is designed to work with with inline scripts where the entire @@ -367,7 +368,7 @@ export function createRenderState( nonce === undefined ? startInlineScript : stringToPrecomputedChunk( - '', + '' + + '', ); }); @@ -4189,7 +4190,7 @@ describe('ReactDOMFizzServer', () => { renderOptions.unstable_externalRuntimeSrc, ).map(n => n.outerHTML), ).toEqual([ - '', + '', '', '', '', @@ -4276,7 +4277,7 @@ describe('ReactDOMFizzServer', () => { renderOptions.unstable_externalRuntimeSrc, ).map(n => n.outerHTML), ).toEqual([ - '', + '', '', '', '', @@ -4512,7 +4513,7 @@ describe('ReactDOMFizzServer', () => { // the html should be as-is expect(document.documentElement.innerHTML).toEqual( - '

hello world!

', + '

hello world!

', ); }); @@ -6492,7 +6493,7 @@ describe('ReactDOMFizzServer', () => { }); expect(document.documentElement.outerHTML).toEqual( - '', + '', ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js index 4022f227a8a..f5b01d24624 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerBrowser-test.js @@ -85,7 +85,7 @@ describe('ReactDOMFizzServerBrowser', () => { ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); @@ -99,7 +99,7 @@ describe('ReactDOMFizzServerBrowser', () => { ); const result = await readResult(stream); expect(result).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); @@ -529,7 +529,7 @@ describe('ReactDOMFizzServerBrowser', () => { const result = await readResult(stream); expect(result).toEqual( - 'foobar', + 'foobar', ); }); @@ -547,7 +547,7 @@ describe('ReactDOMFizzServerBrowser', () => { expect(result).toMatchInlineSnapshot( // TODO: remove interpolation because it prevents snapshot updates. // eslint-disable-next-line jest/no-interpolation-in-snapshots - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js index c442f181383..1eefe1a4082 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerEdge-test.js @@ -72,7 +72,7 @@ describe('ReactDOMFizzServerEdge', () => { }); expect(result).toMatchInlineSnapshot( - `"
hello
"`, + `"
hello
"`, ); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js index e97b4a29a74..2704c243eba 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServerNode-test.js @@ -79,7 +79,7 @@ describe('ReactDOMFizzServerNode', () => { }); // with Float, we emit empty heads if they are elided when rendering expect(output.result).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); @@ -97,7 +97,7 @@ describe('ReactDOMFizzServerNode', () => { pipe(writable); }); expect(output.result).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js index 96e6538cd21..de6e21b557a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStatic-test.js @@ -106,7 +106,10 @@ describe('ReactDOMFizzStatic', () => { node.tagName !== 'TEMPLATE' && node.tagName !== 'template' && !node.hasAttribute('hidden') && - !node.hasAttribute('aria-hidden') + !node.hasAttribute('aria-hidden') && + // Ignore the render blocking expect + (node.getAttribute('rel') !== 'expect' || + node.getAttribute('blocking') !== 'render') ) { const props = {}; const attributes = node.attributes; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index f973a5ed4d6..7eecb16cf82 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -187,7 +187,7 @@ describe('ReactDOMFizzStaticBrowser', () => { ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( - `"hello world"`, + `"hello world"`, ); }); @@ -201,7 +201,7 @@ describe('ReactDOMFizzStaticBrowser', () => { ); const prelude = await readContent(result.prelude); expect(prelude).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); @@ -1428,7 +1428,8 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(await readContent(content)).toBe( '' + '' + - 'Hello', + '' + + 'Hello', ); }); @@ -1474,7 +1475,8 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(await readContent(content)).toBe( '' + '' + - 'Hello', + '' + + 'Hello', ); }); @@ -1525,7 +1527,8 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(await readContent(content)).toBe( '' + '' + - '
Hello
', + '' + + '
Hello
', ); }); @@ -1607,7 +1610,8 @@ describe('ReactDOMFizzStaticBrowser', () => { let result = decoder.decode(value, {stream: true}); expect(result).toBe( - 'hello', + '' + + 'hello', ); await 1; @@ -1631,7 +1635,9 @@ describe('ReactDOMFizzStaticBrowser', () => { const slice = result.slice(0, instructionIndex + '$RC'.length); expect(slice).toBe( - 'hello"`, + `"
hello world
"`, ); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 7404cec64a0..5328a4ac9e0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -250,7 +250,10 @@ describe('ReactDOMFloat', () => { node.tagName !== 'TEMPLATE' && node.tagName !== 'template' && !node.hasAttribute('hidden') && - !node.hasAttribute('aria-hidden')) + !node.hasAttribute('aria-hidden') && + // Ignore the render blocking expect + (node.getAttribute('rel') !== 'expect' || + node.getAttribute('blocking') !== 'render')) ) { const props = {}; const attributes = node.attributes; @@ -690,7 +693,9 @@ describe('ReactDOMFloat', () => { pipe(writable); }); expect(chunks).toEqual([ - 'foobar', + '' + + 'foo' + + 'bar', '', ]); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js index 52c9746abdb..f2cabafc9f5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMLegacyFloat-test.js @@ -34,7 +34,8 @@ describe('ReactDOMFloat', () => { ); expect(result).toEqual( - 'title', + '' + + 'title', ); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js b/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js index 84db05bc779..d887972e92c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSingletonComponents-test.js @@ -104,7 +104,10 @@ describe('ReactDOM HostSingleton', () => { el.tagName !== 'TEMPLATE' && el.tagName !== 'template' && !el.hasAttribute('hidden') && - !el.hasAttribute('aria-hidden')) || + !el.hasAttribute('aria-hidden') && + // Ignore the render blocking expect + (node.getAttribute('rel') !== 'expect' || + node.getAttribute('blocking') !== 'render')) || el.hasAttribute('data-meaningful') ) { const props = {}; diff --git a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js index 9522a920bc2..2b54bc90090 100644 --- a/packages/react-dom/src/__tests__/ReactRenderDocument-test.js +++ b/packages/react-dom/src/__tests__/ReactRenderDocument-test.js @@ -77,12 +77,16 @@ describe('rendering React components at document', () => { await act(() => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe('Hello world'); + expect(testDocument.body.innerHTML).toBe( + 'Hello world' + '', + ); await act(() => { root.render(); }); - expect(testDocument.body.innerHTML).toBe('Hello moon'); + expect(testDocument.body.innerHTML).toBe( + 'Hello moon' + '', + ); expect(body === testDocument.body).toBe(true); }); @@ -107,7 +111,9 @@ describe('rendering React components at document', () => { await act(() => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe('Hello world'); + expect(testDocument.body.innerHTML).toBe( + 'Hello world' + '', + ); const originalDocEl = testDocument.documentElement; const originalHead = testDocument.head; @@ -118,8 +124,10 @@ describe('rendering React components at document', () => { expect(testDocument.firstChild).toBe(originalDocEl); expect(testDocument.head).toBe(originalHead); expect(testDocument.body).toBe(originalBody); - expect(originalBody.firstChild).toEqual(null); - expect(originalHead.firstChild).toEqual(null); + expect(originalBody.innerHTML).toBe(''); + expect(originalHead.innerHTML).toBe( + '', + ); }); it('should not be able to switch root constructors', async () => { @@ -157,13 +165,17 @@ describe('rendering React components at document', () => { root = ReactDOMClient.hydrateRoot(testDocument, ); }); - expect(testDocument.body.innerHTML).toBe('Hello world'); + expect(testDocument.body.innerHTML).toBe( + 'Hello world' + '', + ); await act(() => { root.render(); }); - expect(testDocument.body.innerHTML).toBe('Goodbye world'); + expect(testDocument.body.innerHTML).toBe( + '' + 'Goodbye world', + ); }); it('should be able to mount into document', async () => { @@ -192,7 +204,9 @@ describe('rendering React components at document', () => { ); }); - expect(testDocument.body.innerHTML).toBe('Hello world'); + expect(testDocument.body.innerHTML).toBe( + 'Hello world' + '', + ); }); it('cannot render over an existing text child at the root', async () => { @@ -325,7 +339,9 @@ describe('rendering React components at document', () => { : [], ); expect(testDocument.body.innerHTML).toBe( - favorSafetyOverHydrationPerf ? 'Hello world' : 'Goodbye world', + favorSafetyOverHydrationPerf + ? 'Hello world' + : 'Goodbye world', ); }); diff --git a/packages/react-dom/src/test-utils/FizzTestUtils.js b/packages/react-dom/src/test-utils/FizzTestUtils.js index 537c64a889a..12c768e1a00 100644 --- a/packages/react-dom/src/test-utils/FizzTestUtils.js +++ b/packages/react-dom/src/test-utils/FizzTestUtils.js @@ -150,7 +150,10 @@ function getVisibleChildren(element: Element): React$Node { node.tagName !== 'TEMPLATE' && node.tagName !== 'template' && !node.hasAttribute('hidden') && - !node.hasAttribute('aria-hidden') + !node.hasAttribute('aria-hidden') && + // Ignore the render blocking expect + (node.getAttribute('rel') !== 'expect' || + node.getAttribute('blocking') !== 'render') ) { const props: any = {}; const attributes = node.attributes; diff --git a/packages/react-markup/src/ReactFizzConfigMarkup.js b/packages/react-markup/src/ReactFizzConfigMarkup.js index 3d08ed1ee64..444952dc585 100644 --- a/packages/react-markup/src/ReactFizzConfigMarkup.js +++ b/packages/react-markup/src/ReactFizzConfigMarkup.js @@ -17,7 +17,10 @@ import type { FormatContext, } from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; -import {pushStartInstance as pushStartInstanceImpl} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +import { + pushStartInstance as pushStartInstanceImpl, + writePreambleStart as writePreambleStartImpl, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; import type { Destination, @@ -62,13 +65,11 @@ export { writeEndPendingSuspenseBoundary, writeHoistablesForBoundary, writePlaceholder, - writeCompletedRoot, createRootFormatContext, createRenderState, createResumableState, createPreambleState, createHoistableState, - writePreambleStart, writePreambleEnd, writeHoistables, writePostamble, @@ -203,5 +204,30 @@ export function writeEndClientRenderedSuspenseBoundary( return true; } +export function writePreambleStart( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, + willFlushAllSegments: boolean, + skipExpect?: boolean, // Used as an override by ReactFizzConfigMarkup +): void { + return writePreambleStartImpl( + destination, + resumableState, + renderState, + willFlushAllSegments, + true, // skipExpect + ); +} + +export function writeCompletedRoot( + destination: Destination, + resumableState: ResumableState, + renderState: RenderState, +): boolean { + // Markup doesn't have any bootstrap scripts nor shell completions. + return true; +} + export type TransitionStatus = FormStatus; export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js b/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js index 35b41cbd230..6d022ceb26c 100644 --- a/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js +++ b/packages/react-server-dom-fb/src/__tests__/ReactDOMServerFB-test.internal.js @@ -59,7 +59,7 @@ describe('ReactDOMServerFB', () => { }); const result = readResult(stream); expect(result).toMatchInlineSnapshot( - `"
hello world
"`, + `"
hello world
"`, ); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 0b16b3b3211..80562624eb1 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -193,7 +193,10 @@ describe('ReactFlightDOM', () => { node.tagName !== 'TEMPLATE' && node.tagName !== 'template' && !node.hasAttribute('hidden') && - !node.hasAttribute('aria-hidden')) + !node.hasAttribute('aria-hidden') && + // Ignore the render blocking expect + (node.getAttribute('rel') !== 'expect' || + node.getAttribute('blocking') !== 'render')) ) { const props = {}; const attributes = node.attributes; @@ -1917,11 +1920,15 @@ describe('ReactFlightDOM', () => { expect(content1).toEqual( '' + - '

hello world

', + '' + + '' + + '

hello world

', ); expect(content2).toEqual( '' + - '

hello world

', + '' + + '' + + '

hello world

', ); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index f3fa444fc15..4313c379b70 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -1899,8 +1899,8 @@ describe('ReactFlightDOMBrowser', () => { } expect(content).toEqual( - '' + - '

hello world

', + '' + + '

hello world

', ); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 487751c6be3..52d677ad1be 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -5157,7 +5157,11 @@ function flushCompletedQueues( ); flushSegment(request, destination, completedRootSegment, null); request.completedRootSegment = null; - writeCompletedRoot(destination, request.renderState); + writeCompletedRoot( + destination, + request.resumableState, + request.renderState, + ); } writeHoistables(destination, request.resumableState, request.renderState); From 0c28a09eefaa0e70a313644fd8e455c8ab7ba3eb Mon Sep 17 00:00:00 2001 From: mofeiZ <34200447+mofeiZ@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:26:59 -0400 Subject: [PATCH 2/2] [ci] Reduce non-deterministic builds for eslint-plugin-react-hooks (#33026) See https://github.com/rollup/plugins/issues/1425 Currently, `@babel/helper-string-parser/lib/index.js` is either emitted as a wrapped esmodule or inline depending on the ordering of async functions in `rollup/commonjs`. Specifically, `@babel/types/lib/definitions/core.js` is cyclic (i.e. transitively depends upon itself), but sometimes `@babel/helper-string-parser/lib/index.js` is emitted before this is realized. A relatively straightforward patch is to wrap all modules (see https://github.com/rollup/plugins/issues/1425#issuecomment-1465626736). This only regresses `eslint-plugin-react-hooks` bundle size by ~1.8% and is safer (see https://github.com/rollup/plugins/blob/master/packages/commonjs/README.md#strictrequires) > The default value of true will wrap all CommonJS files in functions which are executed when they are required for the first time, preserving NodeJS semantics. This is the safest setting and should be used if the generated code does not work correctly with "auto". Note that strictRequires: true can have a small impact on the size and performance of generated code, but less so if the code is minified. (note that we're on an earlier version of `@rollup/commonjs` which does not default to `strictRequires: true`) --- scripts/rollup/build.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js index 5cf0518c418..d745eaed4c0 100644 --- a/scripts/rollup/build.js +++ b/scripts/rollup/build.js @@ -393,7 +393,8 @@ function getPlugins( }; }, }, - bundle.tsconfig != null ? commonjs() : false, + // See https://github.com/rollup/plugins/issues/1425 + bundle.tsconfig != null ? commonjs({strictRequires: true}) : false, // Shim any modules that need forking in this environment. useForks(forks), // Ensure we don't try to bundle any fbjs modules.