From cf5d7dd4dabc8f878cea8c2a36dd375b1fc8795d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Sun, 23 Feb 2025 15:50:38 +0100 Subject: [PATCH 01/17] Allow `nonce` to be used on hoistable styles --- .../src/server/ReactFizzConfigDOM.js | 44 +++++++++-- .../src/__tests__/ReactDOMFizzServer-test.js | 45 +++++++++++ .../src/__tests__/ReactDOMFloat-test.js | 79 +++++++++++++++++++ 3 files changed, 160 insertions(+), 8 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 6a86cbb2652fa..c191cbf283510 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -2712,6 +2712,7 @@ function pushStyle( } const precedence = props.precedence; const href = props.href; + const nonce = props.nonce; if ( insertionMode === SVG_MODE || @@ -2759,9 +2760,23 @@ function pushStyle( rules: ([]: Array), hrefs: [stringToChunk(escapeTextForBrowser(href))], sheets: (new Map(): Map), + nonce: nonce && stringToChunk(escapeTextForBrowser(nonce)), }; renderState.styles.set(precedence, styleQueue); } else { + if (!('nonce' in styleQueue)) { + // `styleQueue` could have been created by `preinit` where `nonce` is not required + styleQueue.nonce = nonce && stringToChunk(escapeTextForBrowser(nonce)); + } + if (__DEV__) { + if (nonce !== styleQueue.nonce) { + console.error( + 'React encountered a hoistable style tag with "%s" nonce. It doesn\'t match the previously encountered nonce "%s". They have to be the same', + nonce && stringToChunk(escapeTextForBrowser(nonce)), + styleQueue.nonce, + ); + } + } // We have seen this precedence before and need to track this href styleQueue.hrefs.push(stringToChunk(escapeTextForBrowser(href))); } @@ -4684,8 +4699,9 @@ function escapeJSObjectForInstructionScripts(input: Object): string { const lateStyleTagResourceOpen1 = stringToPrecomputedChunk( ''); // Tracks whether the boundary currently flushing is flushign style tags or has any @@ -4701,6 +4717,7 @@ function flushStyleTagsLateForBoundary( ) { const rules = styleQueue.rules; const hrefs = styleQueue.hrefs; + const nonce = styleQueue.nonce; if (__DEV__) { if (rules.length > 0 && hrefs.length === 0) { console.error( @@ -4712,13 +4729,17 @@ function flushStyleTagsLateForBoundary( if (hrefs.length) { writeChunk(this, lateStyleTagResourceOpen1); writeChunk(this, styleQueue.precedence); - writeChunk(this, lateStyleTagResourceOpen2); + if (nonce) { + writeChunk(this, lateStyleTagResourceOpen2); + writeChunk(this, nonce); + } + writeChunk(this, lateStyleTagResourceOpen3); for (; i < hrefs.length - 1; i++) { writeChunk(this, hrefs[i]); writeChunk(this, spaceSeparator); } writeChunk(this, hrefs[i]); - writeChunk(this, lateStyleTagResourceOpen3); + writeChunk(this, lateStyleTagResourceOpen4); for (i = 0; i < rules.length; i++) { writeChunk(this, rules[i]); } @@ -4805,9 +4826,10 @@ function flushStyleInPreamble( const styleTagResourceOpen1 = stringToPrecomputedChunk( ''); @@ -4822,22 +4844,27 @@ function flushStylesInPreamble( const rules = styleQueue.rules; const hrefs = styleQueue.hrefs; + const nonce = styleQueue.nonce; // If we don't emit any stylesheets at this precedence we still need to maintain the precedence // order so even if there are no rules for style tags at this precedence we emit an empty style // tag with the data-precedence attribute if (!hasStylesheets || hrefs.length) { writeChunk(this, styleTagResourceOpen1); writeChunk(this, styleQueue.precedence); + if (nonce) { + writeChunk(this, styleTagResourceOpen2); + writeChunk(this, nonce); + } let i = 0; if (hrefs.length) { - writeChunk(this, styleTagResourceOpen2); + writeChunk(this, styleTagResourceOpen3); for (; i < hrefs.length - 1; i++) { writeChunk(this, hrefs[i]); writeChunk(this, spaceSeparator); } writeChunk(this, hrefs[i]); } - writeChunk(this, styleTagResourceOpen3); + writeChunk(this, styleTagResourceOpen4); for (i = 0; i < rules.length; i++) { writeChunk(this, rules[i]); } @@ -5534,6 +5561,7 @@ export type StyleQueue = { rules: Array, hrefs: Array, sheets: Map, + nonce?: ?Chunk, }; export function createHoistableState(): HoistableState { diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 38cb7798d5f6f..8d9ef397e7c28 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -10331,4 +10331,49 @@ describe('ReactDOMFizzServer', () => { , ); }); + + it('can render styles with nonce', async () => { + CSPnonce = 'R4nd0m'; + await act(() => { + const {pipe} = renderToPipeableStream( + <> + + + , + ); + pipe(writable); + }); + expect(document.querySelector('style').nonce).toBe( + CSPnonce, + ); + }); + + // @gate __DEV__ + it('warns when it encounters a mismatched nonce on a style', async () => { + CSPnonce = 'R4nd0m'; + await act(() => { + const {pipe} = renderToPipeableStream( + <> + + + , + ); + pipe(writable); + }); + assertConsoleErrorDev([ + 'React encountered a hoistable style tag with "R4nd0mR4nd0m" nonce. It doesn\'t match the previously encountered nonce "R4nd0m". They have to be the same', + ]); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 4375ddb7d8deb..7b7d9115cb1ea 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -8435,6 +8435,85 @@ background-color: green; : '\n in body (at **)' + '\n in html (at **)'), ]); }); + + it('can emit styles with nonce', async () => { + const nonce = 'R4nD0m'; + const fooCss = '.foo { color: hotpink; }'; + const barCss = '.bar { background-color: blue; }'; + const bazCss = '.baz { border: 1px solid black; }'; + await act(() => { + renderToPipeableStream( + + + + +
first
+ + + +
second
+ +
+
+
+ + , + ).pipe(writable); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + , + ); + + await act(() => { + resolveText('first'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + , + ); + + await act(() => { + resolveText('second'); + }); + + expect(getMeaningfulChildren(document)).toEqual( + + + + + + +
first
+
second
+ + , + ); + }); }); describe('Script Resources', () => { From 947e2f188a85cc94190f03cc1756039e62987ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Sun, 6 Apr 2025 10:17:33 +0200 Subject: [PATCH 02/17] remove `@gate` --- packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 972c3d7b4836e..073f4d6e79697 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -10269,7 +10269,6 @@ describe('ReactDOMFizzServer', () => { ); }); - // @gate __DEV__ it('warns when it encounters a mismatched nonce on a style', async () => { CSPnonce = 'R4nd0m'; await act(() => { From 57b4c9efc125f38f0c7e855221723e529b0b21ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 25 Apr 2025 18:31:21 +0200 Subject: [PATCH 03/17] tweak logic --- .../src/server/ReactFizzConfigDOM.js | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index b7ff1be1abc48..d5c75cebb860e 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -2763,23 +2763,17 @@ function pushStyle( nonce: nonce && stringToChunk(escapeTextForBrowser(nonce)), }; renderState.styles.set(precedence, styleQueue); - } else { - if (!('nonce' in styleQueue)) { - // `styleQueue` could have been created by `preinit` where `nonce` is not required - styleQueue.nonce = nonce && stringToChunk(escapeTextForBrowser(nonce)); - } - if (__DEV__) { - if (nonce !== styleQueue.nonce) { - console.error( - 'React encountered a hoistable style tag with "%s" nonce. It doesn\'t match the previously encountered nonce "%s". They have to be the same', - nonce && stringToChunk(escapeTextForBrowser(nonce)), - styleQueue.nonce, - ); - } - } + } else if (nonce === styleQueue.nonce) { // We have seen this precedence before and need to track this href styleQueue.hrefs.push(stringToChunk(escapeTextForBrowser(href))); + } else if (__DEV__) { + console.error( + 'React encountered a hoistable style tag with "%s" nonce. It doesn\'t match the previously encountered nonce "%s". They have to be the same', + nonce && stringToChunk(escapeTextForBrowser(nonce)), + styleQueue.nonce, + ); } + pushStyleContents(styleQueue.rules, props); } if (styleQueue) { @@ -5561,7 +5555,7 @@ export type StyleQueue = { rules: Array, hrefs: Array, sheets: Map, - nonce?: ?Chunk, + nonce: ?Chunk, }; export function createHoistableState(): HoistableState { From cbf18c53e5dc7294a5e3406f38cbccd690d44ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 25 Apr 2025 21:19:02 +0200 Subject: [PATCH 04/17] tweak logic --- .../src/server/ReactFizzConfigDOM.js | 17 ++++------ .../src/__tests__/ReactDOMFizzServer-test.js | 34 ++++++++++++++++--- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index d5c75cebb860e..2efebf33c3564 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -2757,24 +2757,21 @@ function pushStyle( // to create a StyleQueue. styleQueue = { precedence: stringToChunk(escapeTextForBrowser(precedence)), - rules: ([]: Array), + rules: pushStyleContents(([]: Array), props), hrefs: [stringToChunk(escapeTextForBrowser(href))], sheets: (new Map(): Map), - nonce: nonce && stringToChunk(escapeTextForBrowser(nonce)), + nonce: nonce, }; renderState.styles.set(precedence, styleQueue); } else if (nonce === styleQueue.nonce) { // We have seen this precedence before and need to track this href styleQueue.hrefs.push(stringToChunk(escapeTextForBrowser(href))); + pushStyleContents(styleQueue.rules, props); } else if (__DEV__) { console.error( - 'React encountered a hoistable style tag with "%s" nonce. It doesn\'t match the previously encountered nonce "%s". They have to be the same', - nonce && stringToChunk(escapeTextForBrowser(nonce)), - styleQueue.nonce, + "React encountered a hoistable style tag with nonce. It doesn't match the previously encountered nonce. They have to be the same", ); } - - pushStyleContents(styleQueue.rules, props); } if (styleQueue) { // We need to track whether this boundary should wait on this resource or not. @@ -2898,7 +2895,7 @@ function pushStyleContents( target.push(stringToChunk(escapeStyleTextContent(child))); } pushInnerHTML(target, innerHTML, children); - return; + return target; } function pushImg( @@ -4725,7 +4722,7 @@ function flushStyleTagsLateForBoundary( writeChunk(this, styleQueue.precedence); if (nonce) { writeChunk(this, lateStyleTagResourceOpen2); - writeChunk(this, nonce); + writeChunk(this, stringToChunk(escapeTextForBrowser(nonce))); } writeChunk(this, lateStyleTagResourceOpen3); for (; i < hrefs.length - 1; i++) { @@ -4847,7 +4844,7 @@ function flushStylesInPreamble( writeChunk(this, styleQueue.precedence); if (nonce) { writeChunk(this, styleTagResourceOpen2); - writeChunk(this, nonce); + writeChunk(this, stringToChunk(escapeTextForBrowser(nonce))); } let i = 0; if (hrefs.length) { diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 073f4d6e79697..bf40ad26b4de0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -10264,12 +10264,25 @@ describe('ReactDOMFizzServer', () => { ); pipe(writable); }); - expect(document.querySelector('style').nonce).toBe( - CSPnonce, + expect(document.querySelector('style').nonce).toBe(CSPnonce); + expect(getVisibleChildren(document)).toEqual( + + + +
+ +
+ + , ); }); - it('warns when it encounters a mismatched nonce on a style', async () => { + it("shouldn't render styles with mismatched nonce", async () => { CSPnonce = 'R4nd0m'; await act(() => { const {pipe} = renderToPipeableStream( @@ -10287,7 +10300,20 @@ describe('ReactDOMFizzServer', () => { pipe(writable); }); assertConsoleErrorDev([ - 'React encountered a hoistable style tag with "R4nd0mR4nd0m" nonce. It doesn\'t match the previously encountered nonce "R4nd0m". They have to be the same', + 'React encountered a hoistable style tag with nonce. It doesn\'t match the previously encountered nonce. They have to be the same', ]); + expect(getVisibleChildren(document)).toEqual( + + + +
+ +
+ + , + ); }); }); From 7906af5de9aab4fba90e37103f831d4ba66a127c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 25 Apr 2025 21:21:38 +0200 Subject: [PATCH 05/17] fmt --- packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index a8e21ef355f1c..c4fe15c03602c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -10301,7 +10301,7 @@ describe('ReactDOMFizzServer', () => { pipe(writable); }); assertConsoleErrorDev([ - 'React encountered a hoistable style tag with nonce. It doesn\'t match the previously encountered nonce. They have to be the same', + "React encountered a hoistable style tag with nonce. It doesn't match the previously encountered nonce. They have to be the same", ]); expect(getVisibleChildren(document)).toEqual( From 3c152a91d0bd00649d4a170a98d8b0882b64d6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Fri, 25 Apr 2025 23:54:44 +0200 Subject: [PATCH 06/17] fix type --- packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 11b49822982f6..c7b28cda715e6 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -5712,7 +5712,7 @@ export type StyleQueue = { rules: Array, hrefs: Array, sheets: Map, - nonce: ?Chunk, + nonce: ?string, }; export function createHoistableState(): HoistableState { From 9967076c55298d0ebb3fc6de2daafd433adf2ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Sat, 26 Apr 2025 10:56:50 +0200 Subject: [PATCH 07/17] fix types --- packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js | 4 +++- packages/react-dom/src/shared/ReactDOMTypes.js | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index c7b28cda715e6..84d6b9ac11e99 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -2637,6 +2637,7 @@ function pushLink( rules: ([]: Array), hrefs: ([]: Array), sheets: (new Map(): Map), + nonce: props.nonce, }; renderState.styles.set(precedence, styleQueue); } @@ -2944,7 +2945,7 @@ function pushStyleImpl( function pushStyleContents( target: Array, props: Object, -): void { +): Array { let children = null; let innerHTML = null; for (const propKey in props) { @@ -6164,6 +6165,7 @@ function preinitStyle( rules: ([]: Array), hrefs: ([]: Array), sheets: (new Map(): Map), + nonce: options ? options.nonce : undefined, }; renderState.styles.set(precedence, styleQueue); } diff --git a/packages/react-dom/src/shared/ReactDOMTypes.js b/packages/react-dom/src/shared/ReactDOMTypes.js index dbfe07f9ec8f3..623ba1cd6511a 100644 --- a/packages/react-dom/src/shared/ReactDOMTypes.js +++ b/packages/react-dom/src/shared/ReactDOMTypes.js @@ -68,6 +68,7 @@ export type PreinitStyleOptions = { crossOrigin?: ?CrossOriginEnum, integrity?: ?string, fetchPriority?: ?string, + nonce?: ?string, }; export type PreinitScriptOptions = { crossOrigin?: ?CrossOriginEnum, From 6936609cc33eebc2456ae0caef3f194917466c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 27 May 2025 00:08:46 +0200 Subject: [PATCH 08/17] Refactor --- .../src/server/ReactFizzConfigDOM.js | 114 ++++++++++-------- .../src/server/ReactFizzConfigDOMLegacy.js | 2 + .../src/__tests__/ReactDOMFizzServer-test.js | 4 +- .../src/__tests__/ReactDOMFloat-test.js | 1 + .../src/server/ReactDOMFizzServerBrowser.js | 7 +- .../src/server/ReactDOMFizzServerBun.js | 5 +- .../src/server/ReactDOMFizzServerEdge.js | 7 +- .../react-dom/src/shared/ReactDOMTypes.js | 1 - 8 files changed, 88 insertions(+), 53 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index f69e40b798d99..69d9f9b442f8a 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -147,6 +147,8 @@ export type RenderState = { // inline script streaming format, unused if using external runtime / data startInlineScript: PrecomputedChunk, + startInlineStyle: PrecomputedChunk, + // the preamble must always flush before resuming, so all these chunks must // be null or empty when resuming. @@ -307,6 +309,8 @@ const scriptIntegirty = stringToPrecomputedChunk(' integrity="'); const scriptCrossOrigin = stringToPrecomputedChunk(' crossorigin="'); const endAsyncScript = stringToPrecomputedChunk(' async="">'); +const startInlineStyle = stringToPrecomputedChunk(' void), maxHeadersLength: void | number, ): RenderState { + const nonceScript = typeof nonce === 'string' ? nonce : nonce && nonce.script; const inlineScriptWithNonce = - nonce === undefined + nonceScript === undefined ? startInlineScript : stringToPrecomputedChunk( - '