Skip to content

transform.react.refresh includes a custom hook's own name in getCustomHooks #11832

@oblador

Description

@oblador

Describe the bug

When transform.react.refresh is enabled and a function recognised as a React custom hook
(useFoo) recursively calls itself in its own body, SWC's react-refresh transform emits a
registration call whose getCustomHooks callback returns an array that includes useFoo itself:

_s(useFoo, "<sigKey>", false, function () {
  return [useFoo];
});

useFoo should not appear in its own dependency list — those entries are meant to be the
other custom hooks that useFoo depends on, used by Fast Refresh's family-graph signature
algorithm. Babel's react-refresh/babel plugin filters self-references; SWC does not.

The runtime consequence is fatal. react-refresh-runtime's computeFullKey(family) walks family.getCustomHooks() and recursively computes computeFullKey(child.family)
for each entry. With a self-reference the walk hits the same family twice, but family.fullKey
is only memoised at the end of the outer call — so the inner call sees fullKey === null and
recurses again. Every component that imports the offending hook stack-overflows during module
load with Maximum call stack size exceeded.

Input code

import { useState, useEffect } from 'react';

export function useFoo(deps) {
  const [x, setX] = useState();
  useEffect(() => { setX(1); }, []);
  const y = deps ? useFoo(deps) : null;
  return y || x;
}

Config

{
  "jsc": {
    "parser": { "syntax": "ecmascript", "jsx": true },
    "transform": {
      "react": {
        "runtime": "automatic",
        "development": true,
        "refresh": true
      }
    }
  },
  "isModule": true
}

Link to the code that reproduces this issue

https://play.swc.rs/?version=1.15.32&code=H4sIAAAAAAAAA1XNsQqDMBDG8T1P8W1NwKVrg%2B3UvkCXgjhIegFBE0kuYFHfvYkFodvB%2Fe5%2F%2FTj5wFiQIj25Y6rKdLeWDGODDX7EKVBn%2BKSFoHnXNjnDvXeFPryXb5qiwiIA411kNHOFSPxqUR9dqXTeH20pFepr%2FlucPCuNrULT7ugX%2BeTjEsbt%2F80FLg1DcYE4BZfhumLWYhNfqP3D080AAAA%3D&config=H4sIAAAAAAAAA02OQQ7CMAwE731F5HMOqEf%2BwCOs1IWgJqlsB4Gq%2Fr1JCaUne2e1ay%2BdMfAUB1ezlLWIGVmIqzYgn6j4LjuQCyiO%2Faxga6BC5Uxmtd%2BYMkYZE4ejqUAmdHoCFeWoPlDtxKwpoHoH9u8P9KIpzYGithMnk2lkkkczGl%2F3efyBfCfdX5b%2B0vfQNQ%2B83NKQJ%2Fql1w2wMxLK%2FAAAAA%3D%3D

SWC Info output

Expected behavior

The emitted getCustomHooks array must not contain useFoo (the function being registered).
Babel's react-refresh/babel plugin produces:

var _s = $RefreshSig$();

function useFoo(deps) {
  _s();
  var _useState = _slicedToArray(useState(), 2),
      x = _useState[0],
      setX = _useState[1];
  useEffect(function () { setX(1); }, []);
  var y = deps ? useFoo(deps) : null;
  return y || x;
}

_s(useFoo, "evHy7IFA8+DTDHwDqNbBVPj7xeE=", false, function () {
  return [];
});

Note the empty array — Babel correctly excludes the self-reference. (useState and useEffect
are built-in React hooks and are also correctly excluded.)

Actual behavior

SWC (1.15.32) emits the self-reference:

var _s = $RefreshSig$();

export function useFoo(deps) {
  _s();
  var _useState = _sliced_to_array(useState(), 2), x = _useState[0], setX = _useState[1];
  useEffect(function() { setX(1); }, []);
  var y = deps ? useFoo(deps) : null;
  return y || x;
}

_s(useFoo, "evHy7IFA8+DTDHwDqNbBVPj7xeE=", false, function() {
  return [
    useFoo                         // <-- self-reference; should not be here
  ];
});

When this output is loaded under react-refresh-runtime.development.js the very first call to
_s() (from inside useFoo's body) triggers computeFullKey(useFoo.family), which walks
getCustomHooks() and recurses into itself. Result: RangeError: Maximum call stack size exceeded
in react-refresh-runtime's y function at module load.

Version

@swc/core 1.15.32

Additional context

  • The Babel reference plugin that filters self-references is
    react-refresh/babel.
    The relevant filter happens in getHookCallsForCustomHook (or similar — name varies by version);
    it walks the function body, collects call sites that look like custom hooks, and explicitly skips
    any reference whose binding resolves to the function being analysed.
  • We hit this in @legendapp/state v3 (useObservable(initialValue, deps) recurses on deps),
    but it reproduces with any user-defined useX whose body conditionally calls itself, but seems to only happen when minified.
  • Filtering should match Babel's: identifiers in the callback's returned array literal whose
    binding resolves to the function passed as _s's first argument get dropped. Member-expression
    or call-expression entries (_state.useFoo, useFoo()) are not affected because Babel doesn't
    emit them and SWC's bug is also limited to the bare-identifier case.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions