@samitouri / QOS-React / commits / 3b9b59f7cb

[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 () => {