Skip to content
Closed
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
13 changes: 13 additions & 0 deletions .changeset/react-pack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"uptide": patch
---

A candidate migration pack for React 18 → 19, scored against excalidraw/excalidraw and
tldraw/tldraw at the commit before their own upgrade. Rewrites by rule: `useRef<T>()` →
`useRef<T>(undefined)`, `ref={(el) => (x = el)}` → a block body, and `React.MutableRefObject`
→ `React.RefObject`. `RefObject<T | null>`, the removed global `JSX` namespace, untyped
`element.props`, the removed react-dom APIs and `PropsWithRef` go to the agent with the guide;
what the compiler cannot see (errors no longer re-thrown, removed legacy APIs, `act` moved to
"react", Strict Mode and Suspense changes) is listed for review. It stays a candidate: `check`
does not yet move `@types/react` with `react`, and it reports 477 breaking sites that the
compiler accepts, so `check`, `list` and `fix` treat `react` as generic.
84 changes: 84 additions & 0 deletions packages/core/src/packs/react/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { type ArrowFunction, Node, Project, type SourceFile, SyntaxKind } from 'ts-morph';
import type { Finding } from '../../domain/report.js';
import { onReset } from '../../shared-state.js';
import type { SourceSite } from '../contract.js';
import type { TransformResult } from '../types.js';

/**
* Where an implicit return in a ref callback is: `ref={(el) => (inputRef.current = el)}`.
* React 19 lets a ref callback return a cleanup function, so its types reject any other
* return value, and an arrow function that assigns returns the assigned value. No type diff
* reports it (the prop's type is the same), and Text alone cannot tell a `ref` attribute from
* an object key named `ref`; the syntax tree can.
*/
const parsed = new Map<string, SourceFile>();
onReset(() => parsed.clear());
function parse(text: string): SourceFile {
let source = parsed.get(text);
if (!source) {
source = new Project({ useInMemoryFileSystem: true }).createSourceFile('consumer.tsx', text);
if (parsed.size >= 16) parsed.delete(parsed.keys().next().value as string);
parsed.set(text, source);
}
return source;
}

interface RefAssignment {
attribute: Node;
arrow: ArrowFunction;
/** The assignment the arrow returns, without the parentheses around it. */
assignment: Node;
}

/** `ref={(el) => (x = el)}`: a `ref` attribute whose arrow function returns an assignment. */
function refAssignments(source: SourceFile): RefAssignment[] {
const found: RefAssignment[] = [];
for (const attribute of source.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
if (attribute.getNameNode().getText() !== 'ref') continue;
const initializer = attribute.getInitializer();
if (!initializer || !Node.isJsxExpression(initializer)) continue;
const arrow = initializer.getExpression();
if (!arrow || !Node.isArrowFunction(arrow)) continue;
let body: Node = arrow.getBody();
while (Node.isParenthesizedExpression(body)) body = body.getExpression();
if (
Node.isBinaryExpression(body) &&
body.getOperatorToken().getKind() === SyntaxKind.EqualsToken
)
found.push({ attribute, arrow, assignment: body });
}
return found;
}

export function refCallbackReturnSites(text: string): SourceSite[] {
if (!text.includes('ref')) return [];
const source = parse(text);
return refAssignments(source).map(({ attribute }) => {
const { line, column } = source.getLineAndColumnAtPos(attribute.getStart());
return {
line,
column,
snippet: (text.split('\n')[line - 1] ?? '').trim(),
name: 'ref',
};
});
}

/** `(el) => (x = el)` becomes `(el) => { x = el; }`: the same assignment, nothing returned. */
export function dropRefCallbackReturn(
text: string,
finding: Pick<Finding, 'usage'>,
): TransformResult {
const source = parse(text);
for (const { attribute, arrow, assignment } of refAssignments(source)) {
const at = source.getLineAndColumnAtPos(attribute.getStart());
if (at.line !== finding.usage.line || at.column !== finding.usage.column) continue;
const body = arrow.getBody();
return {
text: `${text.slice(0, body.getStart())}{ ${assignment.getText()}; }${text.slice(body.getEnd())}`,
applied: true,
reason: 'a ref callback returns nothing: the assignment becomes a statement',
};
}
return { text, applied: false, reason: 'the reported site is not a ref callback that assigns' };
}
10 changes: 10 additions & 0 deletions packages/core/src/packs/react/fixtures/mutable-ref-object/after.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as React from 'react';
import type { MutableRefObject } from 'react';

export type Handle = {
timer: React.RefObject<number | undefined>; // @uptide mutable-ref-object at:React.MutableRefObject kind:deprecated path:MutableRefObject
// Imported by name: the import needs RefObject beside it, which is not the reported site.
data: MutableRefObject<string>; // @uptide mutable-ref-object keep at:MutableRefObject kind:deprecated path:MutableRefObject
// Already the new type.
box: React.RefObject<HTMLDivElement | null>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as React from 'react';
import type { MutableRefObject } from 'react';

export type Handle = {
timer: React.MutableRefObject<number | undefined>; // @uptide mutable-ref-object at:React.MutableRefObject kind:deprecated path:MutableRefObject
// Imported by name: the import needs RefObject beside it, which is not the reported site.
data: MutableRefObject<string>; // @uptide mutable-ref-object keep at:MutableRefObject kind:deprecated path:MutableRefObject
// Already the new type.
box: React.RefObject<HTMLDivElement | null>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useRef } from 'react';

export function Field({ onMount }: { onMount: (el: HTMLInputElement | null) => void }) {
const input = useRef<HTMLInputElement | null>(null);
let last: HTMLInputElement | null = null;
return (
<form>
<input
ref={(el) => { input.current = el; }} // @uptide ref-callback-return at:ref
/>
<input
ref={(el) => { last = el; }} // @uptide ref-callback-return keep at:ref
/>
<input
ref={(el) => { last = el; }} // @uptide ref-callback-return at:ref
name="b"
/>
<input ref={input} />
<input ref={(el) => onMount(el)} />
<input ref={(el) => (el ? (last = el) : null)} />
</form>
);
}

// An object key named ref is not a ref prop.
export const config = { ref: (el: unknown) => (el as { x: number }).x };
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useRef } from 'react';

export function Field({ onMount }: { onMount: (el: HTMLInputElement | null) => void }) {
const input = useRef<HTMLInputElement | null>(null);
let last: HTMLInputElement | null = null;
return (
<form>
<input
ref={(el) => (input.current = el)} // @uptide ref-callback-return at:ref
/>
<input
ref={(el) => { last = el; }} // @uptide ref-callback-return keep at:ref
/>
<input
ref={(el) => (last = el)} // @uptide ref-callback-return at:ref
name="b"
/>
<input ref={input} />
<input ref={(el) => onMount(el)} />
<input ref={(el) => (el ? (last = el) : null)} />
</form>
);
}

// An object key named ref is not a ref prop.
export const config = { ref: (el: unknown) => (el as { x: number }).x };
13 changes: 13 additions & 0 deletions packages/core/src/packs/react/fixtures/use-ref-argument/after.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import { useRef } from 'react';

export function Timer() {
const timer = useRef<number>(undefined); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
const handler = React.useRef<(...args: string[]) => void>(undefined); // @uptide use-ref-argument at:React.useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
const anything = useRef(undefined); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
// Already has an argument: nothing to add.
const input = useRef<HTMLInputElement>(null); // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
// Two calls on one line: the reported site is ambiguous, so the rule declines.
const pair = [useRef<number>(), useRef<string>()]; // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
return { timer, handler, anything, input, pair };
}
13 changes: 13 additions & 0 deletions packages/core/src/packs/react/fixtures/use-ref-argument/before.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as React from 'react';
import { useRef } from 'react';

export function Timer() {
const timer = useRef<number>(); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
const handler = React.useRef<(...args: string[]) => void>(); // @uptide use-ref-argument at:React.useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
const anything = useRef(); // @uptide use-ref-argument at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
// Already has an argument: nothing to add.
const input = useRef<HTMLInputElement>(null); // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
// Two calls on one line: the reported site is ambiguous, so the rule declines.
const pair = [useRef<number>(), useRef<string>()]; // @uptide use-ref-argument keep at:useRef kind:signature path:TS2554 message:"Expected 1 arguments, but got 0."
return { timer, handler, anything, input, pair };
}
Loading
Loading