Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 131 additions & 7 deletions tools/build-scripts/packages/codemod-react-legacy-element.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<object>.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 `<object>.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
* `<id>.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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we at least log something in the scenario when no matches are found?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the codemod should fail when something even slightly unexpected happens.

}
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)`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't React do !== undefined here? I guess our version is just "safer", but just wanted to be sure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, !== undefined is probably more precise. And also we shouldn't use ?? because older browsers don't support it.

);
}

/**
* 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 ) {
Expand All @@ -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();
}
1 change: 1 addition & 0 deletions tools/react-19/react-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to be sure, are we intentionally putting this declaration at the end? Should we add it above the require() calls so if they declare it, we don't override it with an empty polyfilled version?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point it's certain that the __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED field is forever gone from React 19. It's been replaced with __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE. We don't need to worry about it coming back.

But the field could be moved into the react-polyfill file.

};
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { flushSync } from 'react-dom';
import { createRoot, hydrateRoot } from 'react-dom/client';
import type { Root } from 'react-dom/client';

const internalsKey = '_reactInternals';

// HostComponent fiber tag, represents a DOM element like <div>.
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;
Expand All @@ -30,15 +29,15 @@ function findCurrentFiber( fiber: any ): any {
return fiber.alternate;
}

function findHostFiber( fiber: any ): any {
function findHostFiber( fiber ) {
const current = findCurrentFiber( fiber );
if ( ! current ) {
return null;
}
return findHostFiberImpl( current );
}

function findHostFiberImpl( fiber: any ): any {
function findHostFiberImpl( fiber ) {
if ( fiber.tag === HostComponent || fiber.tag === HostText ) {
return fiber;
}
Expand All @@ -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 ];
Expand All @@ -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 );
Expand All @@ -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 );
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions tools/react-19/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => '',
Comment on lines +8 to +9

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep an eye on the usage here - we may need to "polyfill" more of the methods here. Or should we just stub more, like getCurrentStack and others?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this moment we know quite precisely where these calls come from: it's the legacy react-jsx-dev-runtime.development.js script that some plugins bundle. And this legacy runtime calls only these two methods.

It's possible that we'll discover usages of getCurrentStack and others. But I'd rather let these usages crash first, so that we know where they are coming from. There is a risk that if we polyfill getCurrentStack too early, we'll never learn if it's used. And our polyfill might be slightly wrong, leading to some nightmare issues that are impossible to debug.

},
},
};
Loading