From 937ef9342b24590b1260c0e74dbf7ab41965ab6c Mon Sep 17 00:00:00 2001 From: Daniel Saewitz Date: Tue, 24 Mar 2026 19:22:56 -0400 Subject: [PATCH] [Fizz] Reduce chunk push overhead in HTML serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch attribute and style serialization into single string pushes instead of 3-5 separate push calls per attribute. This reduces array operations, chunk count in segment buffers, and downstream Buffer.byteLength and writeChunk/encodeInto call volume. Changes: - pushStringAttribute: 5 pushes → 1 concatenated string push - pushBooleanAttribute: 3 pushes → 1 push - pushStyleAttribute: 4N+1 pushes → 1 push (build full style string) - pushAttribute default/URL/enumerated/numeric cases: 5 pushes → 1 push - processStyleName: returns plain string instead of PrecomputedChunk - pushStartGenericElement/pushStartAnchor: merge close tag with text children into single push when no dangerouslySetInnerHTML - byteLengthOfChunk: use string.length instead of Buffer.byteLength for heuristic byte sizing (outlining threshold, progressive chunks). For ASCII (99%+ of HTML output), length === byte length. Benchmark (Node v24, Apple M1 Max, 1000 iterations, 100 warmup): | Tree | Before | After | Δ | |-----------------------------|-----------|-----------|----------| | Small (10 el) | 37,959 | 48,257 | +27.1% | | Medium (300 el, Suspense) | 2,309 | 3,518 | +52.4% | | Medium (300 el, no Suspense)| 2,404 | 3,725 | +54.9% | | Large (2500+ el, Suspense) | 429 | 626 | +45.9% | --- .../src/server/ReactFizzConfigDOM.js | 145 +++++++----------- .../src/ReactServerStreamConfigNode.js | 7 +- 2 files changed, 63 insertions(+), 89 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index e654ea88007d..972a573bc2a0 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -1180,15 +1180,13 @@ function pushViewTransitionAttributes( } } -const styleNameCache: Map = new Map(); -function processStyleName(styleName: string): PrecomputedChunk { - const chunk = styleNameCache.get(styleName); - if (chunk !== undefined) { - return chunk; - } - const result = stringToPrecomputedChunk( - escapeTextForBrowser(hyphenateStyleName(styleName)), - ); +const styleNameCache: Map = new Map(); +function processStyleName(styleName: string): string { + const cached = styleNameCache.get(styleName); + if (cached !== undefined) { + return cached; + } + const result = escapeTextForBrowser(hyphenateStyleName(styleName)); styleNameCache.set(styleName, result); return result; } @@ -1209,7 +1207,7 @@ function pushStyleAttribute( ); } - let isFirst = true; + let styleString = ''; for (const styleName in style) { if (!hasOwnProperty.call(style, styleName)) { continue; @@ -1231,48 +1229,42 @@ function pushStyleAttribute( continue; } - let nameChunk; - let valueChunk; + let nameStr; + let valueStr; const isCustomProperty = styleName.indexOf('--') === 0; if (isCustomProperty) { - nameChunk = stringToChunk(escapeTextForBrowser(styleName)); + nameStr = escapeTextForBrowser(styleName); if (__DEV__) { checkCSSPropertyStringCoercion(styleValue, styleName); } - valueChunk = stringToChunk( - escapeTextForBrowser(('' + styleValue).trim()), - ); + valueStr = escapeTextForBrowser(('' + styleValue).trim()); } else { if (__DEV__) { warnValidStyle(styleName, styleValue); } - nameChunk = processStyleName(styleName); + nameStr = processStyleName(styleName); if (typeof styleValue === 'number') { if (styleValue !== 0 && !isUnitlessNumber(styleName)) { - valueChunk = stringToChunk(styleValue + 'px'); // Presumes implicit 'px' suffix for unitless numbers + valueStr = styleValue + 'px'; // Presumes implicit 'px' suffix for unitless numbers } else { - valueChunk = stringToChunk('' + styleValue); + valueStr = '' + styleValue; } } else { if (__DEV__) { checkCSSPropertyStringCoercion(styleValue, styleName); } - valueChunk = stringToChunk( - escapeTextForBrowser(('' + styleValue).trim()), - ); + valueStr = escapeTextForBrowser(('' + styleValue).trim()); } } - if (isFirst) { - isFirst = false; - // If it's first, we don't need any separators prefixed. - target.push(styleAttributeStart, nameChunk, styleAssign, valueChunk); + if (styleString === '') { + styleString = nameStr + ':' + valueStr; } else { - target.push(styleSeparator, nameChunk, styleAssign, valueChunk); + styleString += ';' + nameStr + ':' + valueStr; } } - if (!isFirst) { - target.push(attributeEnd); + if (styleString !== '') { + target.push(stringToChunk(' style="' + styleString + '"')); } } @@ -1287,7 +1279,7 @@ function pushBooleanAttribute( value: string | boolean | number | Function | Object, // not null or undefined ): void { if (value && typeof value !== 'function' && typeof value !== 'symbol') { - target.push(attributeSeparator, stringToChunk(name), attributeEmptyString); + target.push(stringToChunk(' ' + name + '=""')); } } @@ -1302,11 +1294,7 @@ function pushStringAttribute( typeof value !== 'boolean' ) { target.push( - attributeSeparator, - stringToChunk(name), - attributeAssign, - stringToChunk(escapeTextForBrowser(value)), - attributeEnd, + stringToChunk(' ' + name + '="' + escapeTextForBrowser(value) + '"'), ); } } @@ -1610,11 +1598,9 @@ function pushAttribute( } const sanitizedValue = sanitizeURL('' + value); target.push( - attributeSeparator, - stringToChunk(name), - attributeAssign, - stringToChunk(escapeTextForBrowser(sanitizedValue)), - attributeEnd, + stringToChunk( + ' ' + name + '="' + escapeTextForBrowser(sanitizedValue) + '"', + ), ); return; } @@ -1645,11 +1631,9 @@ function pushAttribute( } const sanitizedValue = sanitizeURL('' + value); target.push( - attributeSeparator, - stringToChunk('xlink:href'), - attributeAssign, - stringToChunk(escapeTextForBrowser(sanitizedValue)), - attributeEnd, + stringToChunk( + ' xlink:href="' + escapeTextForBrowser(sanitizedValue) + '"', + ), ); return; } @@ -1667,11 +1651,9 @@ function pushAttribute( // these aren't boolean attributes (they are coerced to strings). if (typeof value !== 'function' && typeof value !== 'symbol') { target.push( - attributeSeparator, - stringToChunk(name), - attributeAssign, - stringToChunk(escapeTextForBrowser(value)), - attributeEnd, + stringToChunk( + ' ' + name + '="' + escapeTextForBrowser(value) + '"', + ), ); } return; @@ -1727,20 +1709,14 @@ function pushAttribute( case 'download': { // Overloaded Boolean if (value === true) { - target.push( - attributeSeparator, - stringToChunk(name), - attributeEmptyString, - ); + target.push(stringToChunk(' ' + name + '=""')); } else if (value === false) { // Ignored } else if (typeof value !== 'function' && typeof value !== 'symbol') { target.push( - attributeSeparator, - stringToChunk(name), - attributeAssign, - stringToChunk(escapeTextForBrowser(value)), - attributeEnd, + stringToChunk( + ' ' + name + '="' + escapeTextForBrowser(value) + '"', + ), ); } return; @@ -1757,11 +1733,9 @@ function pushAttribute( (value: any) >= 1 ) { target.push( - attributeSeparator, - stringToChunk(name), - attributeAssign, - stringToChunk(escapeTextForBrowser(value)), - attributeEnd, + stringToChunk( + ' ' + name + '="' + escapeTextForBrowser(value) + '"', + ), ); } return; @@ -1775,11 +1749,9 @@ function pushAttribute( !isNaN(value) ) { target.push( - attributeSeparator, - stringToChunk(name), - attributeAssign, - stringToChunk(escapeTextForBrowser(value)), - attributeEnd, + stringToChunk( + ' ' + name + '="' + escapeTextForBrowser(value) + '"', + ), ); } return; @@ -1837,11 +1809,13 @@ function pushAttribute( } } target.push( - attributeSeparator, - stringToChunk(attributeName), - attributeAssign, - stringToChunk(escapeTextForBrowser(value)), - attributeEnd, + stringToChunk( + ' ' + + attributeName + + '="' + + escapeTextForBrowser(value) + + '"', + ), ); } } @@ -1956,14 +1930,12 @@ function pushStartAnchor( pushViewTransitionAttributes(target, formatContext); - target.push(endOfStartTag); - pushInnerHTML(target, innerHTML, children); - if (typeof children === 'string') { - // Special case children as a string to avoid the unnecessary comment. - // TODO: Remove this special case after the general optimization is in place. - target.push(stringToChunk(encodeHTMLTextNode(children))); + if (innerHTML == null && typeof children === 'string') { + target.push(stringToChunk('>' + encodeHTMLTextNode(children))); return null; } + target.push(endOfStartTag); + pushInnerHTML(target, innerHTML, children); return children; } @@ -3964,14 +3936,13 @@ function pushStartGenericElement( pushViewTransitionAttributes(target, formatContext); - target.push(endOfStartTag); - pushInnerHTML(target, innerHTML, children); - if (typeof children === 'string') { - // Special case children as a string to avoid the unnecessary comment. - // TODO: Remove this special case after the general optimization is in place. - target.push(stringToChunk(encodeHTMLTextNode(children))); + if (innerHTML == null && typeof children === 'string') { + // Fast path: close tag and emit text child in a single push. + target.push(stringToChunk('>' + encodeHTMLTextNode(children))); return null; } + target.push(endOfStartTag); + pushInnerHTML(target, innerHTML, children); return children; } diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index 90609da2c45d..a8d2835eff7e 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -222,8 +222,11 @@ export function typedArrayToBinaryChunk( export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { return typeof chunk === 'string' - ? Buffer.byteLength(chunk, 'utf8') - : chunk.byteLength; + ? chunk.length // Fast path: .length === byte length for ASCII (99%+ of HTML output). + : // For multi-byte chars this slightly underestimates, which is fine + // because byteSize is only used for heuristic decisions (outlining + // threshold and progressive chunk sizing). + chunk.byteLength; } export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number {