Skip to content

[Compiler Bug]: Closure dependency cache keys eagerly evaluate property accesses, causing TypeError on nullable objects #35762

@MatiasCiccone

Description

@MatiasCiccone

What kind of issue is this?

  • React Compiler core (the JS output is incorrect, or your app works incorrectly after optimization)
  • babel-plugin-react-compiler (build issue installing or using the Babel plugin)
  • eslint-plugin-react-hooks (build issue installing or using the eslint plugin)
  • react-compiler-healthcheck (build issue installing or using the healthcheck script)

Link to repro

https://playground.react.dev/#N4Igzg9grgTgxgUxALhHCA7MAXABAWQE8BhCAWwAdMEM8BeXACmFyjARlwF8BKXOgHy5gAHQy5c6LHgAWAQwwATADYJiygJZwA1vyZ9BwsRIlTIqgHTKIAc0ZsOFjHLIIeAbmPdPGLxoBmTACEDjB8MAjYsOIYUMrKPl4RUTDiADwARlDY2Ji4mOpa2nTA8kqqhTpcApXaaQD0WTmYAj5c7iAANGiY-ho2KCAalBAweNiEFAjCuAAKylA2GhgA8hTYGphg3Lj+MOS4AOQZchkIygC0FAtLGBcRcnDYF+iUGqow9YoaOIeJGMwvPV6q8KO85BtMPgIIoEMhcCIQHJ4oixFxcGAIT8+ghtvNFss1pCsB4uuAZBAAO4ASVoHGcyjAKH8yPYXCAA

Repro steps

  1. Create a component that receives a nullable prop
  2. Access that prop's properties inside a closure (event handler)
  3. Add an early return guard: if (!prop) return null
  4. Pass null/undefined as the prop
  5. Component throws TypeError during render

React Compiler hoists closure property accesses into cache keys, causing TypeError

Summary

The React Compiler generates memoization cache keys by eagerly evaluating property accesses that were originally deferred inside closures (event handlers). This causes TypeError at render time when the object is null/undefined, even though the original code would never access the property during render.

Early returns (if (!x) return null) do not help — the compiler hoists the cache key check above the guard.

JSX expressions are not affected — the compiler correctly extracts them to intermediate variables.

Reproduction

React Compiler Playground

Minimal case

const MyComponent = ({ user }) => {
  const handleClick = () => {
    console.log(user.name);
  };

  if (!user) return null;

  return <button onClick={handleClick}>Click</button>;
};

Compiled output:

const MyComponent = (t0) => {
  const $ = _c(4);
  const { user } = t0;

  // Cache key runs BEFORE the null guard — throws when user is null
  if ($[0] !== user.name) {
    t1 = () => {
      console.log(user.name);
    };
    $[0] = user.name;
    $[1] = t1;
  }

  // The null guard comes AFTER — too late
  if (!user) {
    return null;
  }

  // ...
};

The source code is safe: user.name is only inside a click handler, and the early return prevents the button from rendering when user is null. But the compiler hoists user.name into a cache key that runs on every render, before the guard.

With nested access and JSX

const MyComponent = ({ user }) => {
  const [visible, setVisible] = useState(false);
  const [link, setLink] = useState(null);

  const handleClick = async () => {
    console.log(user.company.id);
    console.log(user.email);
  };

  return (
    <div onClick={() => setVisible(false)}>
      <span>{user?.email}</span>
      <button onClick={handleClick}>Click</button>
    </div>
  );
};

Compiled output (simplified):

// The compiler hoists user.company.id and user.email from the closure
// into cache key checks that run on EVERY render:
if ($[0] !== user.company.id || $[1] !== user.email) {
    t1 = async () => {
      console.log(user.company.id);
      console.log(user.email);
    };
    $[0] = user.company.id;
    $[1] = user.email;
    $[2] = t1;
}

// JSX — correctly extracted to variable (safe)
const t4 = user?.email;
if ($[7] !== t4) {
    t5 = <span>{t4}</span>;
}

Expected behavior

The compiler should not introduce runtime errors that the source code would not produce. Property accesses hoisted from closures into cache keys should be guarded, e.g. by extracting to intermediate variables:

const t0 = user?.name;
if ($[0] !== t0) {
    t1 = () => {
      console.log(user.name);
    };
    $[0] = t0;
    $[1] = t1;
}

Workarounds

1. Extract to variables

Extract property accesses into variables before using them in closures. The compiler then caches on the primitive values:

const companyId = user?.company?.id;
const email = user?.email;

const handleClick = async () => {
  console.log(companyId);
  console.log(email);
};

2. Add optional chaining to every closure access

Adding ?. to every property access inside closures makes the compiler preserve it in cache keys. Note that every access must have ?. — missing it on even one will produce an unsafe cache key for that property path:

const handleClick = async () => {
  console.log(user?.company.id);
  console.log(user?.email);
};

3. Use nullish coalescing on destructuring

Using ?? {} when destructuring the nullable object anywhere in the component signals to the compiler that the value can be nullish. This makes the compiler generate ?. in all cache keys for that object:

const handleClick = () => {
  const { name, email } = user ?? {};
  console.log(name, email);
};

This produces $[x] !== user?.name instead of $[x] !== user.name in cache keys across the component.

4. Add a null guard inside the closure

Adding a null check before the property access inside the closure makes the compiler use the object reference as the cache key instead of the property path:

const handleClick = () => {
  if (!user) return;
  console.log(user.company.id);
  console.log(user.email);
};

This produces $[x] !== user (the reference) instead of $[x] !== user.company.id (the property access) in the cache key. Comparing a reference to null/undefined never throws.

Environment

  • React Compiler (babel plugin via react-compiler-runtime)
  • Webpack 5 with HMR
  • Observed in both HMR hot-update bundles and regular builds

How often does this bug happen?

Every time

What version of React are you using?

18.3.1

What version of React Compiler are you using?

1.0.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bugType: Bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions