Skip to content

[Fast Refresh] Remount correctly when an edit changes the component kind - #36950

Merged
sophiebits merged 1 commit into
react:mainfrom
sophiebits:refresh-hoc-kind-change
Jul 7, 2026
Merged

[Fast Refresh] Remount correctly when an edit changes the component kind#36950
sophiebits merged 1 commit into
react:mainfrom
sophiebits:refresh-hoc-kind-change

Conversation

@sophiebits

Copy link
Copy Markdown
Contributor

Fixes #30659. I'm not confident in this yet but here's what Fable said based on #32214 (comment):

Editing a component from a plain function to a memo or forwardRef wrapper (or between wrapper kinds) crashed with "Component is not a function", because canPreserveStateBetween only compared hook signatures. The edit was classified as state-preserving, so createWorkInProgress swapped the wrapper object in as the type of a FunctionComponent fiber and renderWithHooks tried to call it.

Two fixes, both DEV-only:

  • canPreserveStateBetween returns false when typeof or $$typeof differ. A fiber's tag is derived from the kind of its type, so state can never be preserved across a kind change; these edits must go to staleFamilies (remount). Nested wrappers need no special handling because register() creates a family per nesting level ($type/$render ids), and each level's kind is compared against its own family.

  • The reconciler now finds and remounts wrapper fibers whose outer kind changed. scheduleFibersWithFamiliesRecursively previously resolved families only through the inner implementation (type/type.render), but a kind-changing edit is recorded only on the outer type's family, so edits like memo -> function never reached the scanner and were silently dropped. The scanner now also checks the elementType's family, and the _debugNeedsRemount branch in beginWork rebuilds the fiber from family.current (the latest registered type for the fiber's identity) rather than the fiber's possibly-stale inner type. This also fixes stale simple memo remounts recreating as plain functions, dropping the memo wrapper.

Unlike the reverted #30660 (see #32214), this does not fabricate families or guess type shapes: families are still created in exactly one place, and the same ID always resolves to the same family.

Restores the tests from #30660 and adds coverage for changing the inner type of a memo between function and forwardRef.

@sophiebits
sophiebits requested a review from gaearon July 7, 2026 06:05
@meta-cla meta-cla Bot added the CLA Signed label Jul 7, 2026
Fixes react#30659. I'm not confident in this yet but here's what Fable said based on react#32214 (comment):

Editing a component from a plain function to a memo or forwardRef
wrapper (or between wrapper kinds) crashed with "Component is not a
function", because canPreserveStateBetween only compared hook
signatures. The edit was classified as state-preserving, so
createWorkInProgress swapped the wrapper object in as the type of a
FunctionComponent fiber and renderWithHooks tried to call it.

Two fixes, both DEV-only:

- canPreserveStateBetween returns false when typeof or $$typeof
  differ. A fiber's tag is derived from the kind of its type, so state
  can never be preserved across a kind change; these edits must go to
  staleFamilies (remount). Nested wrappers need no special handling
  because register() creates a family per nesting level ($type/$render
  ids), and each level's kind is compared against its own family.

- The reconciler now finds and remounts wrapper fibers whose outer
  kind changed. scheduleFibersWithFamiliesRecursively previously
  resolved families only through the inner implementation
  (type/type.render), but a kind-changing edit is recorded only on the
  outer type's family, so edits like memo -> function never reached
  the scanner and were silently dropped. The scanner now also checks
  the elementType's family, and the _debugNeedsRemount branch in
  beginWork rebuilds the fiber from family.current (the latest
  registered type for the fiber's identity) rather than the fiber's
  possibly-stale inner type. This also fixes stale simple memo
  remounts recreating as plain functions, dropping the memo wrapper.

Unlike the reverted react#30660 (see react#32214), this does not fabricate
families or guess type shapes: families are still created in exactly
one place, and the same ID always resolves to the same family.

Restores the tests from react#30660 and adds coverage for changing the
inner type of a memo between function and forwardRef.

Co-authored-by: BIKI DAS <bikid475@gmail.com>
Co-authored-by: dan <dan.abramov@me.com>
@sophiebits
sophiebits force-pushed the refresh-hoc-kind-change branch from b92afbf to a686d36 Compare July 7, 2026 06:05
@github-actions github-actions Bot added the React Core Team Opened by a member of the React Core Team label Jul 7, 2026
@react-sizebot

Copy link
Copy Markdown

Comparing: fc08d76...a686d36

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 7.19 kB 7.19 kB = 1.91 kB 1.91 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 614.43 kB 614.43 kB = 108.59 kB 108.59 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 7.19 kB 7.19 kB +0.05% 1.91 kB 1.91 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 685.61 kB 685.61 kB = 120.08 kB 120.09 kB
facebook-www/ReactDOM-prod.classic.js = 705.96 kB 705.96 kB = 123.67 kB 123.67 kB
facebook-www/ReactDOM-prod.modern.js = 696.28 kB 696.28 kB = 122.05 kB 122.06 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
facebook-www/ReactFreshRuntime-dev.classic.js +1.90% 12.34 kB 12.57 kB +1.41% 2.97 kB 3.02 kB
facebook-www/ReactFreshRuntime-dev.modern.js +1.90% 12.34 kB 12.57 kB +1.41% 2.97 kB 3.02 kB
oss-experimental/react-refresh/cjs/react-refresh-runtime.development.js +1.89% 12.36 kB 12.59 kB +1.44% 2.98 kB 3.03 kB
oss-stable-semver/react-refresh/cjs/react-refresh-runtime.development.js +1.89% 12.36 kB 12.59 kB +1.44% 2.98 kB 3.03 kB
oss-stable/react-refresh/cjs/react-refresh-runtime.development.js +1.89% 12.36 kB 12.59 kB +1.44% 2.98 kB 3.03 kB

Generated by 🚫 dangerJS against a686d36

daltino

This comment was marked as low quality.

@gaearon gaearon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes sense to me!

@sophiebits
sophiebits merged commit eb343c7 into react:main Jul 7, 2026
238 checks passed
github-actions Bot pushed a commit that referenced this pull request Jul 7, 2026
…ind (#36950)

Fixes #30659. I'm not confident in this yet but here's what Fable said
based on
#32214 (comment):

Editing a component from a plain function to a memo or forwardRef
wrapper (or between wrapper kinds) crashed with "Component is not a
function", because canPreserveStateBetween only compared hook
signatures. The edit was classified as state-preserving, so
createWorkInProgress swapped the wrapper object in as the type of a
FunctionComponent fiber and renderWithHooks tried to call it.

Two fixes, both DEV-only:

- canPreserveStateBetween returns false when typeof or $$typeof differ.
A fiber's tag is derived from the kind of its type, so state can never
be preserved across a kind change; these edits must go to staleFamilies
(remount). Nested wrappers need no special handling because register()
creates a family per nesting level ($type/$render ids), and each level's
kind is compared against its own family.

- The reconciler now finds and remounts wrapper fibers whose outer kind
changed. scheduleFibersWithFamiliesRecursively previously resolved
families only through the inner implementation (type/type.render), but a
kind-changing edit is recorded only on the outer type's family, so edits
like memo -> function never reached the scanner and were silently
dropped. The scanner now also checks the elementType's family, and the
_debugNeedsRemount branch in beginWork rebuilds the fiber from
family.current (the latest registered type for the fiber's identity)
rather than the fiber's possibly-stale inner type. This also fixes stale
simple memo remounts recreating as plain functions, dropping the memo
wrapper.

Unlike the reverted #30660 (see #32214), this does not fabricate
families or guess type shapes: families are still created in exactly one
place, and the same ID always resolves to the same family.

Restores the tests from #30660 and adds coverage for changing the inner
type of a memo between function and forwardRef.

Co-authored-by: BIKI DAS <bikid475@gmail.com>
Co-authored-by: dan <dan.abramov@me.com>

DiffTrain build for [eb343c7](eb343c7)
github-actions Bot pushed a commit that referenced this pull request Jul 7, 2026
…ind (#36950)

Fixes #30659. I'm not confident in this yet but here's what Fable said
based on
#32214 (comment):

Editing a component from a plain function to a memo or forwardRef
wrapper (or between wrapper kinds) crashed with "Component is not a
function", because canPreserveStateBetween only compared hook
signatures. The edit was classified as state-preserving, so
createWorkInProgress swapped the wrapper object in as the type of a
FunctionComponent fiber and renderWithHooks tried to call it.

Two fixes, both DEV-only:

- canPreserveStateBetween returns false when typeof or $$typeof differ.
A fiber's tag is derived from the kind of its type, so state can never
be preserved across a kind change; these edits must go to staleFamilies
(remount). Nested wrappers need no special handling because register()
creates a family per nesting level ($type/$render ids), and each level's
kind is compared against its own family.

- The reconciler now finds and remounts wrapper fibers whose outer kind
changed. scheduleFibersWithFamiliesRecursively previously resolved
families only through the inner implementation (type/type.render), but a
kind-changing edit is recorded only on the outer type's family, so edits
like memo -> function never reached the scanner and were silently
dropped. The scanner now also checks the elementType's family, and the
_debugNeedsRemount branch in beginWork rebuilds the fiber from
family.current (the latest registered type for the fiber's identity)
rather than the fiber's possibly-stale inner type. This also fixes stale
simple memo remounts recreating as plain functions, dropping the memo
wrapper.

Unlike the reverted #30660 (see #32214), this does not fabricate
families or guess type shapes: families are still created in exactly one
place, and the same ID always resolves to the same family.

Restores the tests from #30660 and adds coverage for changing the inner
type of a memo between function and forwardRef.

Co-authored-by: BIKI DAS <bikid475@gmail.com>
Co-authored-by: dan <dan.abramov@me.com>

DiffTrain build for [eb343c7](eb343c7)
case ClassComponent:
candidateType = type;
break;
case SimpleMemoComponent:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what happens if you switch between Memo and SimpleMemo (e.g. by adding areEquals). Maybe worth hardening the test suite to test combination of cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. Multiple bugs. #36961

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

github-actions Bot pushed a commit to code/lib-react that referenced this pull request Jul 9, 2026
…ind (react#36950)

Fixes react#30659. I'm not confident in this yet but here's what Fable said
based on
react#32214 (comment):

Editing a component from a plain function to a memo or forwardRef
wrapper (or between wrapper kinds) crashed with "Component is not a
function", because canPreserveStateBetween only compared hook
signatures. The edit was classified as state-preserving, so
createWorkInProgress swapped the wrapper object in as the type of a
FunctionComponent fiber and renderWithHooks tried to call it.

Two fixes, both DEV-only:

- canPreserveStateBetween returns false when typeof or $$typeof differ.
A fiber's tag is derived from the kind of its type, so state can never
be preserved across a kind change; these edits must go to staleFamilies
(remount). Nested wrappers need no special handling because register()
creates a family per nesting level ($type/$render ids), and each level's
kind is compared against its own family.

- The reconciler now finds and remounts wrapper fibers whose outer kind
changed. scheduleFibersWithFamiliesRecursively previously resolved
families only through the inner implementation (type/type.render), but a
kind-changing edit is recorded only on the outer type's family, so edits
like memo -> function never reached the scanner and were silently
dropped. The scanner now also checks the elementType's family, and the
_debugNeedsRemount branch in beginWork rebuilds the fiber from
family.current (the latest registered type for the fiber's identity)
rather than the fiber's possibly-stale inner type. This also fixes stale
simple memo remounts recreating as plain functions, dropping the memo
wrapper.

Unlike the reverted react#30660 (see react#32214), this does not fabricate
families or guess type shapes: families are still created in exactly one
place, and the same ID always resolves to the same family.

Restores the tests from react#30660 and adds coverage for changing the inner
type of a memo between function and forwardRef.

Co-authored-by: BIKI DAS <bikid475@gmail.com>
Co-authored-by: dan <dan.abramov@me.com>

DiffTrain build for [eb343c7](react@eb343c7)
github-actions Bot pushed a commit to code/lib-react that referenced this pull request Jul 9, 2026
…ind (react#36950)

Fixes react#30659. I'm not confident in this yet but here's what Fable said
based on
react#32214 (comment):

Editing a component from a plain function to a memo or forwardRef
wrapper (or between wrapper kinds) crashed with "Component is not a
function", because canPreserveStateBetween only compared hook
signatures. The edit was classified as state-preserving, so
createWorkInProgress swapped the wrapper object in as the type of a
FunctionComponent fiber and renderWithHooks tried to call it.

Two fixes, both DEV-only:

- canPreserveStateBetween returns false when typeof or $$typeof differ.
A fiber's tag is derived from the kind of its type, so state can never
be preserved across a kind change; these edits must go to staleFamilies
(remount). Nested wrappers need no special handling because register()
creates a family per nesting level ($type/$render ids), and each level's
kind is compared against its own family.

- The reconciler now finds and remounts wrapper fibers whose outer kind
changed. scheduleFibersWithFamiliesRecursively previously resolved
families only through the inner implementation (type/type.render), but a
kind-changing edit is recorded only on the outer type's family, so edits
like memo -> function never reached the scanner and were silently
dropped. The scanner now also checks the elementType's family, and the
_debugNeedsRemount branch in beginWork rebuilds the fiber from
family.current (the latest registered type for the fiber's identity)
rather than the fiber's possibly-stale inner type. This also fixes stale
simple memo remounts recreating as plain functions, dropping the memo
wrapper.

Unlike the reverted react#30660 (see react#32214), this does not fabricate
families or guess type shapes: families are still created in exactly one
place, and the same ID always resolves to the same family.

Restores the tests from react#30660 and adds coverage for changing the inner
type of a memo between function and forwardRef.

Co-authored-by: BIKI DAS <bikid475@gmail.com>
Co-authored-by: dan <dan.abramov@me.com>

DiffTrain build for [eb343c7](react@eb343c7)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: React refresh fails when component type is changed to memo or forward ref and vice versa

4 participants