Skip to content

[Draft] React Fast Refresh Transform Technical Plan

Alan Pierce edited this page Jul 29, 2023 · 2 revisions

Background

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.

High-level overview

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.

Overview of expected transform behavior

This is an attempt at fully describing the behavior of the Babel plugin. That transform can be decomposed into three independent tasks:

Use $RefreshReg$ to mark all React components

Examples

Example 1:

function MyComponent() {
  return <div>Hello world!</div>;
}

becomes:

function MyComponent() {
  return <div>Hello world!</div>;
}
_c = MyComponent;
var _c;
$RefreshReg$(_c, "MyComponent");

Example 2:

const ConnectedFoo = connect(otherConnect(Foo));

becomes:

const ConnectedFoo = connect(_c = otherConnect(Foo));
_c2 = ConnectedFoo;
var _c, _c2;
$RefreshReg$(_c, "ConnectedFoo$connect");
$RefreshReg$(_c2, "ConnectedFoo");

Rationale

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).

Transform behavior

The transform only considers top-level declarations, and can be decomposed into three cases, from simplest to most complex:

  • Top-level function statements, including export function, export default function, and export 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.
  • 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(...) or require(...).
      • 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.

Wrapper traversal algorithm (findInnerComponents in the Babel plugin)

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 the a 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 code const A = foo(getThing(1 + 1).bar(baz(B)));, the baz component has ID A$foo$getThing(1 + 1).bar.
  • 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.

JSX detection algorithm

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 like React.createElement(MyComponent, ....

Use $RefreshSig$ to mark all hook-calling functions and their wrappers

Example

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];
});

Rationale

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.

Transform behavior

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();}.
  • 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.

Signature args algorithm

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}}". The emitFullSignatures option skips the hash and just emits the newline-separated keys.
  • forceReset: A boolean that is true if a @refresh reset comment exists anywhere in the file, and false otherwise. Defaults to false, 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 and forceReset is set to true. If the hook is a plain identifier like useA, we check for that declaration, and if it is a property access like a.useB, we check for the declaration of the left-hand side identifier, and any more complex expressions result in forceReset always true.

Wrapping HOC discovery algorithm (findHOCCallPathsAbove in the Babel plugin)

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.

Detect if @refresh reset appears in any comment

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.

Sucrase implementation plan

(TODO)

References

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