Skip to content

Instantly share code, notes, and snippets.

@gaearon

gaearon/hot.md Secret

Last active March 25, 2022 19:27
Show Gist options
  • Star 59 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gaearon/a4d9fb3e6ea487a9296a8d2d9a6e3bf2 to your computer and use it in GitHub Desktop.
Save gaearon/a4d9fb3e6ea487a9296a8d2d9a6e3bf2 to your computer and use it in GitHub Desktop.

Edit: since people are reposting this without context -- it's not meant for casual reading by React users. This post was written for a narrow audience (~10 people) who are interested in hot reloading implementation details. Feel free to ignore this -- it doesn't affect you in any way. From user point of view, this is what I'm implementing: https://mobile.twitter.com/dan_abramov/status/1125846420949434368


Rethinking Hot Reloading

I've been experimenting with hot reloading again and I have something that seems to work well. I've built a proof of concept. Here's the approach I'm taking.

No Wrapping

My previous attempts at hot reloading wrapped component types in order to proxy to the latest version of any component. We won't do that.

Instead, we only register our components for hot reloading by calling __register__. It is a special function provided by the hot reloading runtime.

function Counter() {
  // ... 
}

function App() {
  // ... 
}

// Generated by a build step (e.g. Babel)
__register__(Counter, 'App.js$Counter')
__register__(App, 'App.js$App')

We don't wrap the Counter or App types and leave them as they are.

What does __register__ do? It associates the component implementation with its "family".

Family

A "family" represents a particular component type for the lifetime of development session, i.e. between hot reloads.

The family points to the latest implementation of that component:

// After first load
let counterFamily = { latest: CounterV1 };

// After edit
counterFamily.latest = CounterV2;

// After another edit
counterFamily.latest = CounterV3;

It's kind of like a ref.

Family Map

We hold a Map from fileName$displayName to the corresponding families. For example, after three edits to Button.js you might see something like:

// Persisted between reloads
const idToFamilyMap = new Map();

console.log(idToFamilyMap);
Map {
  // These two families point to initial component versions:
  "App.js$App" => { latest: AppV1 },
  "App.js$Counter" => { latest: AppV1 },
  // This file was edited three times, so family links to latest vesion:
  "Button.js$Button" => { latest: ButtonV3 }
}

That's what __register__ does. It finds (or creates) a family for a given ID, sets up a reference from component type to the family it belongs to, and points the family to the latest version.

So this:

__register__(Counter, 'App.js$Counter')

Does something like this:

// Persisted between reloads
const idToFamilyMap = new Map();

// ...
const family = getOrCreateInMap(idToFamilyMap, 'App.js$Counter')
Counter.__family__ = family;
family.latest = Counter;

This means that for any component type, we can always find the latest version of its code.

(Note we don't literally have to mutate the function type. Could be a WeakMap.)

Next Edit

When we edit this code, the module re-runs with __register__ calls:

function Counter() {
  // ... v2 ....
}

function App() {
  // ... v2 ...
}

__register__(Counter, 'App.js$Counter')
__register__(App, 'App.js$App')

Now __register__ calls will find already existing families in the map, and point their .latest fields to the latest implementations of Counter and App.

Resolving

So how does React know to call CounterV3 after three edits, and not keep calling CounterV1?

One way you could solve this is by proxying it upfront in the source code so that the code "sees" some CounterProxy with a stable identity instead of different Counter versions. However, this makes the transform pretty invasive.

I was wondering if we could completely punt on this instead by teaching React about families (in DEV only).

This only requires changes in two places:

  • When reconciling in ChildFiber, we would in DEV compare typeA.__family__ to typeB.__family__ before deciding types are incompatible. Types belonging to the same family are considered compatible.

  • Just before rendering a component in renderWithHooks, if it has a __family__, we call type.__family__.latest(props) instead of type(props). Of course, only in DEV.

Only Render Is Hot

The only usage of Counter which would truly hot reload is <Counter />. It would always resolve to the latest implementation before calling it.

If you manually call Counter() or Counter.someAttachedStatic, you'd keep seeing the old version. This is because only React would be "looking up" type.__family__.latest and respecting that.

However, I argue that this is completely fine. Statics on functions are anti-pattern anyway. For example wrapping in lazy() breaks them. So it's good if DX features like hot reloading discourages using them. (As long as the semantics are exactly the same before the first edit.) The same argument applies to other weird things you could do to a function. Even defaultProps is on its way to deprecation (for function components only).

There are patterns that break on subsequent edits, like Menu checking children.type === MenuItem, then you edit MenuItem and render a new version. However, we know those patterns are fragile anyway: you can't write MyMenuItem. Until we have call/return, you can fix it with a static flag like children.type.isMenuItem. And that would work after hot reloads too. So this also nudges you towards a better pattern. Note this is not a problem for things you didn't edit because there is no wrapping. It's also not a problem if we accept update early (which we should in most cases).

No ID

We can also stop worrying about what happens to function identity.

The way changes to it propagate depend a lot on module environment. webpack, www, RN, CodeSandbox all have different levels of support for changes to module identity.

Even with ES Modules, while live-imported Counter reference could be updated to a latest version, a <Counter /> JSX element stored in the state or in a module-level hashmap wouldn't.

However, if we let React resolve the real identity, it doesn't matter whether CounterV2 === CounterV1. It doesn't even matter if the code keeps rendering <CounterV1 />.

React will render the latest thing and use the family for reconciliation.

Assets

We already have a precedent for the "resolving". It's how we treat lazy components. We "resolve" their real type before rendering them.

We only do it once, but I think conceptually it's a similar model. For example, if everything was an "asset reference", we'd have even stronger limitations regarding Foo() or Foo.someStatic.

Scheduling Changes

There would be a DEV-only method provided by React that lets us invalidate Fibers with given families.

// scheduleUpdateForHotReload is given to us by the renderer

scheduleUpdateForHotReload(root, [
  Counter.__family__,
  App.__family__,
])

It could be injected into the DevTools global Hook, like overrideProps and setSuspenseHandler today. Note you don't need to run DevTools for that to work. We just need that code to execute after DevTools-injected Hook (if any) but before ReactDOM finishes. This is easy. We'd need a reference to all roots for traversal. But DevTools Hook already gets those.

Hooks

Preserving state works out of the box with this model. React renders a new implementation, it calls Hooks in the same order, state gets preserved.

What if Hooks order changes? It seems like usually you'd want this to reset the state and force a remount of this component.

However, it can be difficult to detect some kinds of changes. For example, sometimes you reorder one useState variable with another, but their types are incompatible. So naïvely hot reloading would lead to a crash.

Sometimes you'd change the effect dependencies. But we don't support changing their size midflight. And there's no way to catch and recover from that invariant because it happens in React itself.

Sometimes you'd reorder two custom Hooks and their internal types accidentally match up, but semantically they're different. So that would completely mess things up.

We need a reliable way to detect when to reset the state.

Type Signatures

If Hooks were somehow encoded in type information, we could reset state when those Hooks types don't match. That would be cool.

But do we even need types for that? Consider this component:

function Counter() {
  const [count, setCount] = useState(0)
  const [isHovered, setIsHovered] = useState(false)
  return (
    <div style={{ color: isHovered ? 'green' : 'red' }}>
      {count}
    </div>
  )
}

If we swap green and red, conceptually we think "the Hooks haven't changed". It would be nice to preserve state.

But if we swap two useStates, we think "Hooks have changed" and it would be best to reset.

Hook calls always start with use. So we can generate a "signature" at build time:

function CounterV1() {
  const [count, setCount] = useState(0)
  const [isHovered, setIsHovered] = useState(false)
  // ...
}

__signature(
  CounterV1,
  // Our signature:
  'useState{const [count, setCount]} useState{const [isHovered, setIsHovered]}'
)

__register__(CounterV1, 'Counter.js$Counter')

The signature includes all Hooks in the order they were used. For each Hook, we include its name and a "cache breaker" string.

For Hooks with return value like useState, cache breaker string can be the LHS source. This way, we capture its name. If the name or the way it's captured changes, we likely want to reset the state of the component. It's too risky.

For Hooks that involve user code (like useEffect and useMemo), there is no cache breaker — but we always reinstate them. This might be a bit controversial because not all components are resilient to that. However, it's the only approach that makes sense if you want it to be truly reactive. For example, editing a utility that's imported and used in both render and effect should be consistent. It shouldn't just update "in render only". I'd also argue that it's best practice to write components that are resilient to effects over-firing. Because you might want to expand dependency array for one reason or another anyway later.

For custom Hooks, we can also use LHS. Note that the result signature would include custom Hooks too. This lets us recognize the case where you reordered useThing() and useOtherThing() even if their "low-level" Hooks would have matched up at runtime.

Custom Hooks

What if you're editing a Hook though? It would be nice if reordering states or changing its code wouldn't wreak havoc.

Update for editing a custom Hook would propagate to the closest component modules. (This is no different from other functions.)

We wouldn't try to do anything special for a custom Hook like "resolving". Latest component would already point to the latest version of the Hook anyway.

However, we would annotate every custom Hook with a "signature" too. This would ensure that changes to custom Hooks invalidate consuming component's signatures:

function useHover() {
  const [isHovered, setIsHovered] = useState(false)
  // ...
}
// Custom Hook signature:
__signature__('useState{const [isHovered, setIsHovered]}')

function Counter() {
  const [count, setCount] = useState(0)
  const isHovered = useHover()
  // ...
}

__signature__(
  Counter,
  'useState{const [isHovered, setIsHovered]} useHover{const isHovered}', 
  () => [useHover] // Mark dependency on another signature. Lazy to avoid TDZ issues.
)

__register__(Counter, 'Counter.js$Counter')

This would only happen for custom Hooks in your codebase as they're the only ones that are hot reloadable and that could change.

HOCs

I wouldn't put too much effort into supporting HOCs as their usage will be less prominent. We should probably support common cases like Styled Components though. Our model allows to keep state as long as:

  • HOC returns a function component.
  • We annotated __register__ at instantiation site (top-level).
  • We annotate __signature__ at definition site (inside a function) if HOC itself is in user code.

We'll probably also have a heuristic that "registers" hoc(Foo) if Foo got registers. False positives are not a big problem because registering is not invasive.

Resetting State

If the signature changes, __register__ needs to tell React to force remount for all Fibers of this family. (And use their new types.)

scheduleUpdateForHotReload(root, [
  // Families of Fibers to update
  App.__family__,
], [
  // Families of Fibers to remount
  Counter.__family__,
])

Internally React can do this by mutating elementType of existing Fibers to 'DELETED' and scheduling update on their parents. The reconciliation equality check will fail, and they would be remounted.

We'd need a reliable way to force an update on particular Fiber for this. Currently there are some bailouts that are impossible to override (like on a root).

Handling Errors

Runtime errors would propagate to the error boundary. Once you fix the error, we get to a good state by remounting all failed boundaries. So it doesn't pull you too much out of the flow.

Syntax errors should show up as overlay and prevent a reload until fixed.

Visual Indication

Ideally, components that were successfully updated would "flash" to indicate the update was applied. Maybe scheduleUpdateForHotReload could give us back an array of closest host nodes around invalidated Fibers so we can do that.

Context

This approach doesn't preserve identity but gets away with a lot. In practice though, there's a place where identity matters — context. You could work around it by declaring it in another file. But this is annoying when you want to group context with a reducer.

In practice I've found that patching createContext to cache contexts by currentlyInitializingModuleID:callIndexDuringThisModule works sufficiently well. Yay call order!

Classes

Editing a class would always remount it. This is the price for being confident in correctness on first load.

Status

The prototypes look viable. On React side, it doesn't involve many changes. The biggest missing thing is an ability to reliably schedule a "force update" on an arbitrary Fiber. Some of the bailouts are too aggressive. Of course, the "family" change is invasive and I'll need to measure DEV perf impact from resolving types in that super hot path.

My overall goal is to replace the past implementations I and others have written for React Native, Next, Gatsby, with something that is reliable, doesn't alter DEV semantics (at least, on first load), and is officially supported. We can add it to Create React App too then.

If you have any thoughts on ideas, let me know!

@lilactown
Copy link

This seems great! What I like is that it seems pretty language / tool agnostic; I think we could easily build on top of this in ClojureScript with macros.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment