diff --git a/tools/build-scripts/packages/codemod-react-legacy-element.mjs b/tools/build-scripts/packages/codemod-react-legacy-element.mjs index 9b43e0b1733491..1e100a45656566 100644 --- a/tools/build-scripts/packages/codemod-react-legacy-element.mjs +++ b/tools/build-scripts/packages/codemod-react-legacy-element.mjs @@ -174,16 +174,125 @@ function patchReference( refPath, legacyName, ms, filename ) { } /** - * Applies the legacy-element codemod to a bundled React script. + * Whether a node is a `.props.ref` member expression. * - * @param {string} code Source code to patch. - * @param {string} filename Source name, for error messages. - * @return {string} The patched source code. + * @param {Object} node AST node. + * @return {boolean} True when the node matches. */ -export function patchReactLegacyElement( code, filename = 'input.js' ) { - const ast = parse( code, { sourceType: 'unambiguous' } ); - const ms = new MagicString( code ); +function isPropsRefMember( node ) { + return ( + !! node && + node.type === 'MemberExpression' && + ! node.computed && + node.property.type === 'Identifier' && + node.property.name === 'ref' && + node.object.type === 'MemberExpression' && + ! node.object.computed && + node.object.property.type === 'Identifier' && + node.object.property.name === 'props' + ); +} + +/** + * Returns the `.props.ref` member expression when the statement is an + * assignment of the form `x = y.props.ref` (or `var x = y.props.ref`). + * + * @param {Object} statement AST statement node. + * @return {Object|undefined} The `props.ref` member expression, if matched. + */ +function getPropsRefAssignment( statement ) { + if ( ! statement ) { + return undefined; + } + if ( statement.type === 'ExpressionStatement' ) { + let expression = statement.expression; + // Minifiers join consecutive statements into a single comma sequence; + // the "first statement" is then the first expression of the sequence. + if ( expression.type === 'SequenceExpression' ) { + expression = expression.expressions[ 0 ]; + } + if ( + expression.type === 'AssignmentExpression' && + expression.operator === '=' && + isPropsRefMember( expression.right ) + ) { + return expression.right; + } + return undefined; + } + if ( + statement.type === 'VariableDeclaration' && + statement.declarations.length === 1 && + isPropsRefMember( statement.declarations[ 0 ].init ) + ) { + return statement.declarations[ 0 ].init; + } + return undefined; +} + +/** + * Patches React DOM's `coerceRef` so it falls back to the legacy + * `element.ref` when `element.props.ref` is absent. Elements created by older + * React runtimes keep the ref on the element itself instead of in props. + * + * `coerceRef` is identified structurally: its first body statement assigns + * `.props.ref` (e.g. `t = t.props.ref`). Bundles without such a function + * (e.g. the `react` bundle) are left untouched, and more than one match is + * treated as an error since the assumption no longer holds. + * + * @param {Object} ast Parsed AST (File node). + * @param {MagicString} ms Magic string for the source being edited. + * @param {string} filename Source name, for error messages. + */ +function patchCoerceRef( ast, ms, filename ) { + const matches = []; + + traverse( ast, { + Function( path ) { + const body = path.node.body; + if ( + ! body || + body.type !== 'BlockStatement' || + body.body.length === 0 + ) { + return; + } + const propsRef = getPropsRefAssignment( body.body[ 0 ] ); + if ( propsRef ) { + matches.push( propsRef ); + } + }, + } ); + + if ( matches.length === 0 ) { + return; + } + if ( matches.length > 1 ) { + throw new Error( + `[react-legacy-element] Expected exactly one \`coerceRef\`-like function (first statement \`x = y.props.ref\`) in ${ filename }, found ${ matches.length }.` + ); + } + + const propsRef = matches[ 0 ]; + const object = propsRef.object.object; + const propsRefText = ms.original.slice( propsRef.start, propsRef.end ); + const objectText = ms.original.slice( object.start, object.end ); + ms.overwrite( + propsRef.start, + propsRef.end, + `(${ propsRefText } ?? ${ objectText }.ref)` + ); +} +/** + * Patches every `Symbol.for('react.transitional.element')` variable so that + * checks against it also accept the legacy `Symbol.for('react.element')`. + * + * @param {Object} ast Parsed AST (File node). + * @param {MagicString} ms Magic string for the source being edited. + * @param {string} filename Source name, for error messages. + */ +function patchTransitionalSymbols( ast, ms, filename ) { const declarators = []; traverse( ast, { VariableDeclarator( path ) { @@ -202,6 +311,21 @@ export function patchReactLegacyElement( code, filename = 'input.js' ) { for ( const declPath of declarators ) { patchTransitionalDeclarator( declPath, ms, filename ); } +} + +/** + * Applies the legacy-element codemod to a bundled React script. + * + * @param {string} code Source code to patch. + * @param {string} filename Source name, for error messages. + * @return {string} The patched source code. + */ +export function patchReactLegacyElement( code, filename = 'input.js' ) { + const ast = parse( code, { sourceType: 'unambiguous' } ); + const ms = new MagicString( code ); + + patchTransitionalSymbols( ast, ms, filename ); + patchCoerceRef( ast, ms, filename ); return ms.toString(); } diff --git a/tools/react-19/react-dom.js b/tools/react-19/react-dom.js index 30bc3d0ce81cb0..f8aee02880823b 100644 --- a/tools/react-19/react-dom.js +++ b/tools/react-19/react-dom.js @@ -2,4 +2,5 @@ module.exports = { ...require( 'react-dom' ), ...require( 'react-dom/client' ), ...require( './react-polyfill' ), + __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: {}, }; diff --git a/tools/react-19/react-polyfill.ts b/tools/react-19/react-polyfill.js similarity index 79% rename from tools/react-19/react-polyfill.ts rename to tools/react-19/react-polyfill.js index 0ff6b154dacf99..e4d185a5b19091 100644 --- a/tools/react-19/react-polyfill.ts +++ b/tools/react-19/react-polyfill.js @@ -1,6 +1,5 @@ import { flushSync } from 'react-dom'; import { createRoot, hydrateRoot } from 'react-dom/client'; -import type { Root } from 'react-dom/client'; const internalsKey = '_reactInternals'; @@ -8,7 +7,7 @@ const internalsKey = '_reactInternals'; const HostComponent = 5; const HostText = 6; -function findCurrentFiber( fiber: any ): any { +function findCurrentFiber( fiber ) { if ( ! fiber.alternate ) { // First mount — only one version exists, and it's current. return fiber; @@ -30,7 +29,7 @@ function findCurrentFiber( fiber: any ): any { return fiber.alternate; } -function findHostFiber( fiber: any ): any { +function findHostFiber( fiber ) { const current = findCurrentFiber( fiber ); if ( ! current ) { return null; @@ -38,7 +37,7 @@ function findHostFiber( fiber: any ): any { return findHostFiberImpl( current ); } -function findHostFiberImpl( fiber: any ): any { +function findHostFiberImpl( fiber ) { if ( fiber.tag === HostComponent || fiber.tag === HostText ) { return fiber; } @@ -55,13 +54,13 @@ function findHostFiberImpl( fiber: any ): any { return null; } -export function findDOMNode( instance: any ): Element | Text | null { +export function findDOMNode( instance ) { if ( instance === null || instance === undefined ) { return null; } if ( instance.nodeType !== undefined ) { - return instance as Element | Text; + return instance; } const fiber = instance[ internalsKey ]; @@ -79,13 +78,9 @@ export function findDOMNode( instance: any ): Element | Text | null { return hostFiber?.stateNode ?? null; } -const roots = new WeakMap< Element, Root >(); +const roots = new WeakMap(); -export function render( - element: React.ReactNode, - container: Element, - callback?: () => void -): void { +export function render( element, container, callback ) { let root = roots.get( container ); if ( ! root ) { root = createRoot( container ); @@ -101,11 +96,7 @@ export function render( } } -export function hydrate( - element: React.ReactNode, - container: Element, - callback?: () => void -): void { +export function hydrate( element, container, callback ) { let root = roots.get( container ); if ( ! root ) { root = hydrateRoot( container, element ); @@ -119,7 +110,7 @@ export function hydrate( } } -export function unmountComponentAtNode( container: Element ): boolean { +export function unmountComponentAtNode( container ) { const root = roots.get( container ); if ( ! root ) { return false; diff --git a/tools/react-19/react.js b/tools/react-19/react.js index 4fb3922241a923..6f463bc6af3d97 100644 --- a/tools/react-19/react.js +++ b/tools/react-19/react.js @@ -4,5 +4,9 @@ module.exports = { __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: { ReactCurrentOwner: { current: null }, ReactCurrentDispatcher: { current: null }, + ReactDebugCurrentFrame: { + setExtraStackFrame: () => {}, + getStackAddendum: () => '', + }, }, };