[Draft] React Fast Refresh Transform Technical Plan
This document is a work-in-progress tech plan for implementing a react-refresh
transform. The transform has many details that are tricky to implement in Sucrase, but the hope is that by breaking down the requirements and implementation options more thoroughly, the problem will become more manageable, or at the very least there will be a more confident understanding that the transform is too complicated for Sucrase or comes at too high a performance cost.
React Fast Refresh is a system that allows updating React component implementations in-place without losing component state. Its implementation is contained within React itself, a react-refresh
runtime library, bundler-specific integration details, and a transpile step. Since Sucrase only operates at transpile time, this document is only concerned with the transpile step.
The transpile step assumes that global functions $RefreshSig$
and $RefreshReg$
have been provided by the bundler, and uses those functions to register all React components an hook usage information in the file. The transform implements many heuristics to determine what looks like a component, what looks like a hook, and what changes likely require a re-mount or full reload.
This is an attempt at fully describing the behavior of the Babel plugin. That transform can be decomposed into three independent tasks:
function MyComponent() {
return <div>Hello world!</div>;
}
becomes:
function MyComponent() {
return <div>Hello world!</div>;
}
_c = MyComponent;
var _c;
$RefreshReg$(_c, "MyComponent");
const ConnectedFoo = connect(otherConnect(Foo));
becomes:
const ConnectedFoo = connect(_c = otherConnect(Foo));
_c2 = ConnectedFoo;
var _c, _c2;
$RefreshReg$(_c, "ConnectedFoo$connect");
$RefreshReg$(_c2, "ConnectedFoo");
In order to properly update components after a code change, the runtime needs a registry of all components, each with a unique string ID. At import time, when registering a component with the same ID as an old one, the React Refresh system assumes that the new one is an update of the old one and schedules an update operation. The transpiler is responsible for creating a unique ID within the file, and the bundler concatenates that with a unique ID for the file itself, so that the ID sent to the runtime is a fully unique ID.
Because all of this registration is intended to happen at import time, the transform only affects declarations at the top level of the file. However, programmatically-generated components (via HOCs) are still meant to be registered. In example 2 above, the line is treated as possibly an HOC call because Foo
and ConnectedFoo
both start with a capital letter. Note that Foo
itself is not registered here because it is assumed to be registered separately (maybe in another file).
The transform only considers top-level declarations, and can be decomposed into three cases, from simplest to most complex:
- Top-level
function
statements, includingexport function
,export default function
, andexport function
at the top level of a TS namespace.- Each such function must be named and must have a name beginning with a capital letter, which is used as the component ID. As long as this is satisfied, we always register it as a component.
- The one component assignment
_c = ...
is inserted on the next line.
-
export default [CallExpression]
, e.g.export default connect(Foo);
.- All component assignments
_c = ...
are done within the expression. - The root component ID is
%default%
. - Components are traversed via the wrapper traversal algorithm below, and marked as components according to that algorithm if the traversal succeeds.
- All component assignments
- Top-level variable declarations, including exports and declarations at the top level of a TS namespace.
- The declared variable must have a name beginning with a capital letter, and that name is used as the component ID.
- The declaration must only declare one variable, and that declaration must be a plain identifier, no destructuring.
- The outermost component assignment
_c = ...
is done on the next line to avoid affecting name inference. All other component assignments are done within the expression. - We only consider declarations where the right-hand side is one of the following:
- A function expression or arrow function expression.
- A call expression, except ones of the form
import(...)
orrequire(...)
. - A tagged template expression.
- We run the wrapper traversal algorithm below in the right-hand side. If it succeeds, then the discovered components are registered.
- If the traversal fails, then use the JSX detection algorithm to determine if the variable name is used as JSX. If so, then register the variable as a component.
The export default
and variable declaration cases need to traverse the expression to find all wrapped components and test if the innermost component is eligible:
- A call expression (e.g.
a(b)
) is a valid wrapper if all of the following hold:- The argument list has at least one argument.
- The callee (left-hand side) is either:
- A plain identifier; or
- A member expression like
a.b
, including ones where thea
is a complex expression.
- Each time we see a call expression that is a valid wrapper, recursively traverse into the first argument. Either we find a chain of valid wrappers with a valid innermost expression, or the entire traversal fails. If the traversal succeeds, each wrapper is registered as a component.
- The component ID is a
$
-separated list of source code snippets of callees. For example, in the codeconst A = foo(getThing(1 + 1).bar(baz(B)));
, thebaz
component has IDA$foo$getThing(1 + 1).bar
.
- The component ID is a
- A valid innermost expression is one of the following:
- An identifier starting with a capital letter. In this case, the expression is not marked as a component because it is assumed that it's marked elsewhere.
- A function expression. In this case, the expression is marked as a component, and its name, if any, is ignored.
- An arrow function expression, except one like
() => () => {}
where the arrow function returns another arrow function. In this case, the expression is marked as a component.
- If we encounter any expression that isn't a valid wrapper or valid innermost expression, the traversal fails.
Given a variable declaration like const MyComponent = makeMyComponent();
, we consider it to be a component if MyComponent
is used as JSX anywhere in the file.
Using scope-aware reference detection, find all usages of MyComponent
. A usage is considered to be JSX if:
- It's a direct usage of JSX, i.e.
<MyComponent ...
; or - It's a call to
createElement
,jsx
,jsxDEV
,jsxs
, including member expression calls likeReact.createElement(MyComponent, ...
.
import {useMyOtherHook} from './useMyOtherHook';
function useMyHook() {
const [x, setX] = useState(0);
const foo = useMyOtherHook();
return x + foo;
}
becomes:
var _s = $RefreshSig$();
import { useMyOtherHook } from './useMyOtherHook';
function useMyHook() {
_s();
const [x, setX] = useState(0);
const foo = useMyOtherHook();
return x + foo;
}
_s(useMyHook, "useState{[x, setX](0)}\nuseMyOtherHook{foo}", false, function () {
return [useMyOtherHook];
});
Even though $RefreshReg$
provides enough information to update any component, in some situations a component has changed enough that it must be re-mounted, not just re-rendered. The $RefreshSig$
function returns a function that can attach a signature of hook usages to every component and every custom hook. When a component uses a custom hook, the component's signature lazily incorporates the signature of each hook it's using. Any changes to the signature (e.g. reordering hooks) indicate that the component must be re-mounted rather than updating the code in-place, though this is still usually better than a full page refresh.
The transform finds all hook calls, which are defined as call expressions where:
- The callee is named
use
followed by a capital letter, or the callee is a member expression where the property name has that form, e.g.Foo.useBar
; and - The call is contained within an enclosing function that is a function declaration, function expression, or arrow expression. The hook is associated with that function.
For each hook call, generate a hook key that identifies essential information about the hook:
- In most cases, the hook key uses the hook name and the variable declaration code, if any. For example, with
const [x, setX] = useX()
, the key is"useX{[x, setX]}"
. - For
useState
, include the code for the first argument (if any) in the key. - For
useReducer
, include the code for the second argument (if any) in the key.
For every function declaration, function expression, and arrow expression, determine if any hooks are directly associated with the function. If so:
- Insert a
$RefreshSig$
call to create a_s
function. This must be in the same scope as the function declaration; a top-level variable isn't always valid because, e.g. we may be within an HOC implementation and generate a new_s
each time it's called. - Call that
_s
function with no arguments as the new first line of the function.- Note that this may require changing an expression arrow function to a block arrow function, e.g.
() => useEffect()
becomes() => {_s(); return useEffect();}
.
- Note that this may require changing an expression arrow function to a block arrow function, e.g.
- Insert another call to
_s
with args determined by the signature args algorithm. The insertion point is determined as follows:- For function declarations, insert the call to
_s
as a new line after the function declaration. - For function expressions and arrow expressions:
- If the function/arrow is on a line like
const Foo = () => {...}
, insert the call to_s
on the next line, to avoid affecting inferred function names. - Otherwise, wrap the expression in
_s
with extra arguments after the function body, and also use the wrapping HOC discovery algorithm and call that same_s
function around wrapping functions.
- If the function/arrow is on a line like
- For function declarations, insert the call to
The signature function _s
has four parameters:
-
type
: The component or hook being wrapped. -
key
: A string of the Base64 SHA-1 hash of the hook keys, newline separated. For example, the value to hash might be"useState{[a, setA](0)}\nuseB{{b}}"
. TheemitFullSignatures
option skips the hash and just emits the newline-separated keys. -
forceReset
: A boolean that istrue
if a@refresh reset
comment exists anywhere in the file, andfalse
otherwise. Defaults tofalse
, and the transform omits it if possible. -
getCustomHooks
: A function returning an array of hook functions for all custom hooks in the component. The custom hooks are determined as follows:- The hook name must not be one of 20 built-in hook names defined by the babel plugin.
- If a custom hook has no declaration or is not in scope in the position of the
getCustomHooks
arg, it is omitted andforceReset
is set to true. If the hook is a plain identifier likeuseA
, we check for that declaration, and if it is a property access likea.useB
, we check for the declaration of the left-hand side identifier, and any more complex expressions result inforceReset
always true.
Given an function expression or arrow expression with a signature, we also want to apply that signature to all enclosing HOCs. Normally this wouldn't be necessary, but it fixes some HOC-like functions in the wild that call their component rather than rendering it; see https://github.com/facebook/react/pull/21104 for more details. This is done by traversing up the AST in the following way:
- If the expression is an argument in a function call, traverse to that function call and denote this node as needing a signature wrapper.
- If the expression is the right-hand side of an assignment, traverse to that assignment without adding a signature wrapper. (This is done in the Babel plugin because the code may run after component registration has happened.)
- Otherwise, stop.
If any comment in the file contains the exact substring "@refresh reset"
, then the entire file is marked as "has force reset". In this mode, every call in the file to signature function like _s
will have true
as the third argument.
(TODO)
React native docs, with usage and some caveats: https://reactnative.dev/docs/fast-refresh
Explanation of how to integrate Fast Refresh: https://github.com/facebook/react/issues/16604#issuecomment-528663101
Official Babel plugin: https://github.com/facebook/react/blob/main/packages/react-refresh/src/ReactFreshBabelPlugin.js