[Fast Refresh] Derive the fiber tag from the resolved type when mounting (#36963)
If an edit changed the kind of the type (e.g. a plain function got wrapped in memo()), the fiber could get the old kind's tag with the new kind's type, usually throwing an error.
Sophie Alpert committed
Jul 8, 2026 at 09:56 UTC
3b9b59f7cb4ab9f00c84d9b8cdf2e45eb4bb4b88
3 files changed
+67
-20
packages/react-reconciler/src/ReactFiber.js
+9
-13
@@ -561,18 +561,14 @@ export function createFiberFromTypeAndProps(
561
let fiberTag: WorkTag = FunctionComponent;
562
// The resolved type is set if we know what the final type will be. I.e. it's not lazy.
563
let resolvedType = type;
564
- if (typeof type === 'function') {
565
- if (shouldConstruct(type)) {
564
+ if (__DEV__) {
565
+ resolvedType = resolveTypeForHotReloading(type);
566
+ }
567
+ if (typeof resolvedType === 'function') {
568
+ if (shouldConstruct(resolvedType)) {
569
fiberTag = ClassComponent;
567
- if (__DEV__) {
568
- resolvedType = resolveTypeForHotReloading(resolvedType);
569
- }
570
- } else {
571
- if (__DEV__) {
572
- resolvedType = resolveTypeForHotReloading(resolvedType);
573
- }
570
}
575
- } else if (typeof type === 'string') {
571
+ } else if (typeof resolvedType === 'string') {
572
// $FlowFixMe[constant-condition]
573
if (supportsResources && supportsSingletons) {
574
const hostContext = getHostContext();
@@ -594,7 +590,7 @@ export function createFiberFromTypeAndProps(
590
fiberTag = HostComponent;
591
}
592
} else {
597
- getTag: switch (type) {
593
+ getTag: switch (resolvedType) {
594
// $FlowFixMe[invalid-compare]
595
case REACT_ACTIVITY_TYPE:
596
return createFiberFromActivity(pendingProps, mode, lanes, key);
@@ -642,8 +638,8 @@ export function createFiberFromTypeAndProps(
638
// Fall through
639
default: {
640
// $FlowFixMe[invalid-compare]
645
- if (typeof type === 'object' && type !== null) {
646
- switch (type.$$typeof) {
641
+ if (typeof resolvedType === 'object' && resolvedType !== null) {
642
+ switch (resolvedType.$$typeof) {
643
// $FlowFixMe[invalid-compare]
644
case REACT_CONTEXT_TYPE:
645
fiberTag = ContextProvider;
packages/react-reconciler/src/ReactFiberBeginWork.js
+3
-7
@@ -2102,6 +2102,9 @@ function mountLazyComponent(
2102
const props = workInProgress.pendingProps;
2103
const lazyComponent: LazyComponentType<any, any> = elementType;
2104
let Component = resolveLazy(lazyComponent);
2105
+ if (__DEV__) {
2106
+ Component = resolveTypeForHotReloading(Component);
2107
+ }
2108
// Store the unwrapped component in the type.
2109
workInProgress.type = Component;
2110
@@ -2109,9 +2112,6 @@ function mountLazyComponent(
2112
if (isFunctionClassComponent(Component)) {
2113
const resolvedProps = resolveClassComponentProps(Component, props);
2114
workInProgress.tag = ClassComponent;
2112
- if (__DEV__) {
2113
- workInProgress.type = Component = resolveTypeForHotReloading(Component);
2114
- }
2115
return updateClassComponent(
2116
null,
2117
workInProgress,
@@ -2123,7 +2123,6 @@ function mountLazyComponent(
2123
workInProgress.tag = FunctionComponent;
2124
if (__DEV__) {
2125
validateFunctionComponentInDev(workInProgress, Component);
2126
- workInProgress.type = Component = resolveTypeForHotReloading(Component);
2126
}
2127
return updateFunctionComponent(
2128
null,
@@ -2139,9 +2138,6 @@ function mountLazyComponent(
2138
// $FlowFixMe[invalid-compare]
2139
if ($$typeof === REACT_FORWARD_REF_TYPE) {
2140
workInProgress.tag = ForwardRef;
2142
- if (__DEV__) {
2143
- workInProgress.type = Component = resolveTypeForHotReloading(Component);
2144
- }
2141
return updateForwardRef(
2142
null,
2143
workInProgress,
packages/react-refresh/src/__tests__/ReactFresh-test.js
+55
@@ -915,6 +915,61 @@ describe('ReactFresh', () => {
915
}
916
});
917
918
+ it('can mount an element created before its type changed kinds', async () => {
919
+ if (__DEV__) {
920
+ let oldElement;
921
+ let currentChild = null;
922
+
923
+ await act(async () => {
924
+ await render(() => {
925
+ function Test() {
926
+ return <p>hi test</p>;
927
+ }
928
+ $RefreshReg$(Test, 'Test');
929
+ oldElement = <Test />;
930
+
931
+ function App() {
932
+ const [, forceUpdate] = React.useState(0);
933
+ return (
934
+ <div onClick={() => forceUpdate(n => n + 1)}>{currentChild}</div>
935
+ );
936
+ }
937
+ $RefreshReg$(App, 'App');
938
+ return App;
939
+ });
940
+ });
941
+
942
+ // Change the component kind before it has ever mounted.
943
+ await act(async () => {
944
+ await patch(() => {
945
+ function Test2() {
946
+ return <p>hi memo</p>;
947
+ }
948
+ const Test = React.memo(Test2);
949
+ $RefreshReg$(Test2, 'Test$React.memo');
950
+ $RefreshReg$(Test, 'Test');
951
+
952
+ function App() {
953
+ const [, forceUpdate] = React.useState(0);
954
+ return (
955
+ <div onClick={() => forceUpdate(n => n + 1)}>{currentChild}</div>
956
+ );
957
+ }
958
+ $RefreshReg$(App, 'App');
959
+ return App;
960
+ });
961
+ });
962
+
963
+ // Mount the element created before the edit. The fiber must be
964
+ // created from the latest type, with the tag matching its kind.
965
+ currentChild = oldElement;
966
+ await act(async () => {
967
+ container.firstChild.click();
968
+ });
969
+ expect(container.firstChild.textContent).toBe('hi memo');
970
+ }
971
+ });
972
+
973
it('resets state when switching between different component types', async () => {
974
if (__DEV__) {
975
await act(async () => {