@samitouri / QOS-React-1 / commits / eb343c7ccb

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

Fixes #30659. I'm not confident in this yet but here's what Fable said based on https://github.com/react/react/pull/32214#issuecomment-2734436781: 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>

Sophie Alpert committed Jul 7, 2026 at 16:30 UTC eb343c7ccb2d475cbdf0231b4af3d1df7d4135d6
4 files changed +362 -7
packages/react-reconciler/src/ReactFiberBeginWork.js
+5 -1
@@ -136,6 +136,7 @@ import {
136 resolveFunctionForHotReloading,
137 resolveForwardRefForHotReloading,
138 resolveClassForHotReloading,
139 + resolveRemountTypeForHotReloading,
140 } from './ReactFiberHotReloading';
141
142 import {
@@ -4195,7 +4196,10 @@ function beginWork(
4196 if (workInProgress._debugNeedsRemount && current !== null) {
4197 // This will restart the begin phase with a new fiber.
4198 const copiedFiber = createFiberFromTypeAndProps(
4198 - workInProgress.type,
4199 + resolveRemountTypeForHotReloading(
4200 + workInProgress.elementType,
4201 + workInProgress.type,
4202 + ),
4203 workInProgress.key,
4204 workInProgress.pendingProps,
4205 workInProgress._debugOwner || null,
packages/react-reconciler/src/ReactFiberHotReloading.js
+49 -6
@@ -121,6 +121,29 @@ export function resolveForwardRefForHotReloading(type: any): any {
121 }
122 }
123
124 +export function resolveRemountTypeForHotReloading(
125 + elementType: any,
126 + type: any,
127 +): any {
128 + if (__DEV__) {
129 + if (resolveFamily === null) {
130 + // Hot reloading is disabled.
131 + return type;
132 + }
133 + // The elementType is the fiber's public identity, so its family tracks
134 + // the latest implementation even when an edit changed the kind of the
135 + // type (e.g. memo to a plain function) and `type` still points at the
136 + // old inner implementation.
137 + const family = resolveFamily(elementType);
138 + if (family === undefined) {
139 + return type;
140 + }
141 + return family.current;
142 + } else {
143 + return type;
144 + }
145 +}
146 +
147 export function isCompatibleFamilyForHotReloading(
148 fiber: Fiber,
149 element: ReactElement,
@@ -130,6 +153,7 @@ export function isCompatibleFamilyForHotReloading(
153 // Hot reloading is disabled.
154 return false;
155 }
156 + const resolve = resolveFamily;
157
158 const prevType = fiber.elementType;
159 const nextType = element.type;
@@ -191,9 +215,8 @@ export function isCompatibleFamilyForHotReloading(
215 // If we unwrapped and compared the inner types for wrappers instead,
216 // then we would risk falsely saying two separate memo(Foo)
217 // calls are equivalent because they wrap the same Foo function.
194 - const prevFamily = resolveFamily(prevType);
195 - // $FlowFixMe[not-a-function] found when upgrading Flow
196 - if (prevFamily !== undefined && prevFamily === resolveFamily(nextType)) {
218 + const prevFamily = resolve(prevType);
219 + if (prevFamily !== undefined && prevFamily === resolve(nextType)) {
220 return true;
221 }
222 }
@@ -262,17 +285,30 @@ function scheduleFibersWithFamiliesRecursively(
285 ): void {
286 if (__DEV__) {
287 do {
265 - const {alternate, child, sibling, tag, type} = fiber;
288 + const {alternate, child, sibling, tag, type, elementType} = fiber;
289
290 let candidateType = null;
291 + // Wrapper fibers (memo, forwardRef) resolve their family through the
292 + // inner implementation, but an edit that changes the kind of the type
293 + // (e.g. memo to a plain function) is only recorded on the family of the
294 + // outer type. Check the outer type too so such edits trigger a remount.
295 + let outerCandidateType = null;
296 switch (tag) {
297 case FunctionComponent:
270 - case SimpleMemoComponent:
298 case ClassComponent:
299 candidateType = type;
300 break;
301 + case SimpleMemoComponent:
302 + candidateType = type;
303 + outerCandidateType = elementType;
304 + break;
305 + case MemoComponent:
306 + // Edits to the inner implementation are handled by the inner fiber.
307 + outerCandidateType = elementType;
308 + break;
309 case ForwardRef:
310 candidateType = type.render;
311 + outerCandidateType = elementType;
312 break;
313 default:
314 break;
@@ -281,11 +317,12 @@ function scheduleFibersWithFamiliesRecursively(
317 if (resolveFamily === null) {
318 throw new Error('Expected resolveFamily to be set during hot reload.');
319 }
320 + const resolve = resolveFamily;
321
322 let needsRender = false;
323 let needsRemount = false;
324 if (candidateType !== null) {
288 - const family = resolveFamily(candidateType);
325 + const family = resolve(candidateType);
326 if (family !== undefined) {
327 if (staleFamilies.has(family)) {
328 needsRemount = true;
@@ -298,6 +335,12 @@ function scheduleFibersWithFamiliesRecursively(
335 }
336 }
337 }
338 + if (!needsRemount && outerCandidateType !== null) {
339 + const outerFamily = resolve(outerCandidateType);
340 + if (outerFamily !== undefined && staleFamilies.has(outerFamily)) {
341 + needsRemount = true;
342 + }
343 + }
344 if (failedBoundaries !== null) {
345 if (
346 failedBoundaries.has(fiber) ||
packages/react-refresh/src/ReactFreshRuntime.js
+13
@@ -146,6 +146,19 @@ function canPreserveStateBetween(prevType: any, nextType: any) {
146 if (isReactClass(prevType) || isReactClass(nextType)) {
147 return false;
148 }
149 + // A fiber's tag is derived from the kind of its type (a plain function
150 + // vs memo vs forwardRef), and the reconciler can only swap implementations
151 + // in place within the same tag. If the kind changed, the tree must remount.
152 + if (typeof prevType !== typeof nextType) {
153 + return false;
154 + }
155 + if (typeof prevType === 'object' && prevType !== null && nextType !== null) {
156 + if (
157 + getProperty(prevType, '$$typeof') !== getProperty(nextType, '$$typeof')
158 + ) {
159 + return false;
160 + }
161 + }
162 if (haveEqualSignatures(prevType, nextType)) {
163 return true;
164 }
packages/react-refresh/src/__tests__/ReactFresh-test.js
+295
@@ -699,6 +699,301 @@ describe('ReactFresh', () => {
699 }
700 });
701
702 + it('can remount when change function to memo', async () => {
703 + if (__DEV__) {
704 + await act(async () => {
705 + await render(() => {
706 + function Test() {
707 + return <p>hi test</p>;
708 + }
709 + $RefreshReg$(Test, 'Test');
710 + return Test;
711 + });
712 + });
713 +
714 + // Check the initial render
715 + const el = container.firstChild;
716 + expect(el.textContent).toBe('hi test');
717 +
718 + // Patch to change function to memo
719 + await act(async () => {
720 + await patch(() => {
721 + function Test2() {
722 + return <p>hi memo</p>;
723 + }
724 + const Test = React.memo(Test2);
725 + $RefreshReg$(Test2, 'Test2');
726 + $RefreshReg$(Test, 'Test');
727 + return Test;
728 + });
729 + });
730 +
731 + // Check remount
732 + expect(container.firstChild).not.toBe(el);
733 + const nextEl = container.firstChild;
734 + expect(nextEl.textContent).toBe('hi memo');
735 +
736 + // Patch back to original function
737 + await act(async () => {
738 + await patch(() => {
739 + function Test() {
740 + return <p>hi test</p>;
741 + }
742 + $RefreshReg$(Test, 'Test');
743 + return Test;
744 + });
745 + });
746 +
747 + // Check final remount
748 + expect(container.firstChild).not.toBe(nextEl);
749 + const newEl = container.firstChild;
750 + expect(newEl.textContent).toBe('hi test');
751 + }
752 + });
753 +
754 + it('can remount when change memo to forwardRef', async () => {
755 + if (__DEV__) {
756 + await act(async () => {
757 + await render(() => {
758 + function Test2() {
759 + return <p>hi memo</p>;
760 + }
761 + const Test = React.memo(Test2);
762 + $RefreshReg$(Test2, 'Test2');
763 + $RefreshReg$(Test, 'Test');
764 + return Test;
765 + });
766 + });
767 + // Check the initial render
768 + const el = container.firstChild;
769 + expect(el.textContent).toBe('hi memo');
770 +
771 + // Patch to change memo to forwardRef
772 + await act(async () => {
773 + await patch(() => {
774 + function Test2() {
775 + return <p>hi forwardRef</p>;
776 + }
777 + const Test = React.forwardRef(Test2);
778 + $RefreshReg$(Test2, 'Test2');
779 + $RefreshReg$(Test, 'Test');
780 + return Test;
781 + });
782 + });
783 + // Check remount
784 + expect(container.firstChild).not.toBe(el);
785 + const nextEl = container.firstChild;
786 + expect(nextEl.textContent).toBe('hi forwardRef');
787 +
788 + // Patch back to memo
789 + await act(async () => {
790 + await patch(() => {
791 + function Test2() {
792 + return <p>hi memo</p>;
793 + }
794 + const Test = React.memo(Test2);
795 + $RefreshReg$(Test2, 'Test2');
796 + $RefreshReg$(Test, 'Test');
797 + return Test;
798 + });
799 + });
800 + // Check final remount
801 + expect(container.firstChild).not.toBe(nextEl);
802 + const newEl = container.firstChild;
803 + expect(newEl.textContent).toBe('hi memo');
804 + }
805 + });
806 +
807 + it('can remount when change function to forwardRef', async () => {
808 + if (__DEV__) {
809 + await act(async () => {
810 + await render(() => {
811 + function Test() {
812 + return <p>hi test</p>;
813 + }
814 + $RefreshReg$(Test, 'Test');
815 + return Test;
816 + });
817 + });
818 +
819 + // Check the initial render
820 + const el = container.firstChild;
821 + expect(el.textContent).toBe('hi test');
822 +
823 + // Patch to change function to forwardRef
824 + await act(async () => {
825 + await patch(() => {
826 + function Test2() {
827 + return <p>hi forwardRef</p>;
828 + }
829 + const Test = React.forwardRef(Test2);
830 + $RefreshReg$(Test2, 'Test2');
831 + $RefreshReg$(Test, 'Test');
832 + return Test;
833 + });
834 + });
835 +
836 + // Check remount
837 + expect(container.firstChild).not.toBe(el);
838 + const nextEl = container.firstChild;
839 + expect(nextEl.textContent).toBe('hi forwardRef');
840 +
841 + // Patch back to a new function
842 + await act(async () => {
843 + await patch(() => {
844 + function Test() {
845 + return <p>hi test1</p>;
846 + }
847 + $RefreshReg$(Test, 'Test');
848 + return Test;
849 + });
850 + });
851 +
852 + // Check final remount
853 + expect(container.firstChild).not.toBe(nextEl);
854 + const newEl = container.firstChild;
855 + expect(newEl.textContent).toBe('hi test1');
856 + }
857 + });
858 +
859 + it('can remount when change memo inner type from function to forwardRef', async () => {
860 + if (__DEV__) {
861 + await act(async () => {
862 + await render(() => {
863 + function Test2() {
864 + return <p>hi memo</p>;
865 + }
866 + const Test = React.memo(Test2);
867 + $RefreshReg$(Test2, 'Test$React.memo');
868 + $RefreshReg$(Test, 'Test');
869 + return Test;
870 + });
871 + });
872 +
873 + // Check the initial render
874 + const el = container.firstChild;
875 + expect(el.textContent).toBe('hi memo');
876 +
877 + // Patch to wrap the inner function in forwardRef.
878 + // The outer type is still a memo, so only the inner family changes.
879 + await act(async () => {
880 + await patch(() => {
881 + function Test2(props, ref) {
882 + return <p>hi memo forwardRef</p>;
883 + }
884 + const Test2Ref = React.forwardRef(Test2);
885 + const Test = React.memo(Test2Ref);
886 + $RefreshReg$(Test2, 'Test$React.memo$React.forwardRef');
887 + $RefreshReg$(Test2Ref, 'Test$React.memo');
888 + $RefreshReg$(Test, 'Test');
889 + return Test;
890 + });
891 + });
892 +
893 + // Check remount
894 + expect(container.firstChild).not.toBe(el);
895 + const nextEl = container.firstChild;
896 + expect(nextEl.textContent).toBe('hi memo forwardRef');
897 +
898 + // Patch back to a plain function inside memo
899 + await act(async () => {
900 + await patch(() => {
901 + function Test2() {
902 + return <p>hi memo</p>;
903 + }
904 + const Test = React.memo(Test2);
905 + $RefreshReg$(Test2, 'Test$React.memo');
906 + $RefreshReg$(Test, 'Test');
907 + return Test;
908 + });
909 + });
910 +
911 + // Check final remount
912 + expect(container.firstChild).not.toBe(nextEl);
913 + const newEl = container.firstChild;
914 + expect(newEl.textContent).toBe('hi memo');
915 + }
916 + });
917 +
918 + it('resets state when switching between different component types', async () => {
919 + if (__DEV__) {
920 + await act(async () => {
921 + await render(() => {
922 + function Test() {
923 + const [count, setCount] = React.useState(0);
924 + return (
925 + <div onClick={() => setCount(c => c + 1)}>count: {count}</div>
926 + );
927 + }
928 + $RefreshReg$(Test, 'Test');
929 + return Test;
930 + });
931 + });
932 +
933 + expect(container.firstChild.textContent).toBe('count: 0');
934 + await act(async () => {
935 + container.firstChild.click();
936 + });
937 + expect(container.firstChild.textContent).toBe('count: 1');
938 +
939 + await act(async () => {
940 + await patch(() => {
941 + function Test2() {
942 + const [count, setCount] = React.useState(0);
943 + return (
944 + <div onClick={() => setCount(c => c + 1)}>count: {count}</div>
945 + );
946 + }
947 + const Test = React.memo(Test2);
948 + $RefreshReg$(Test2, 'Test2');
949 + $RefreshReg$(Test, 'Test');
950 + return Test;
951 + });
952 + });
953 +
954 + expect(container.firstChild.textContent).toBe('count: 0');
955 + await act(async () => {
956 + container.firstChild.click();
957 + });
958 + expect(container.firstChild.textContent).toBe('count: 1');
959 +
960 + await act(async () => {
961 + await patch(() => {
962 + const Test = React.forwardRef((props, ref) => {
963 + const [count, setCount] = React.useState(0);
964 + const handleClick = () => setCount(c => c + 1);
965 +
966 + // Ensure ref is extensible
967 + const divRef = React.useRef(null);
968 + React.useEffect(() => {
969 + if (ref) {
970 + if (typeof ref === 'function') {
971 + ref(divRef.current);
972 + } else if (Object.isExtensible(ref)) {
973 + ref.current = divRef.current;
974 + }
975 + }
976 + }, [ref]);
977 +
978 + return (
979 + <div ref={divRef} onClick={handleClick}>
980 + count: {count}
981 + </div>
982 + );
983 + });
984 + $RefreshReg$(Test, 'Test');
985 + return Test;
986 + });
987 + });
988 +
989 + expect(container.firstChild.textContent).toBe('count: 0');
990 + await act(async () => {
991 + container.firstChild.click();
992 + });
993 + expect(container.firstChild.textContent).toBe('count: 1');
994 + }
995 + });
996 +
997 it('can update simple memo function in isolation', async () => {
998 if (__DEV__) {
999 await render(() => {