@samitouri / QOS-React / commits / c11c9510fa

[crud] Fix deps comparison bug (#31599)

Fixes a bug with the experimental `useResourceEffect` hook where we would compare the wrong deps when there happened to be another kind of effect preceding the ResourceEffect. To do this correctly we need to add a pointer to the ResourceEffect's identity on the update. I also unified the previously separate push effect impls for resource effects since they are always pushed together as a unit.

lauren committed Nov 20, 2024 at 16:54 UTC c11c9510fa14bbd87053685c19bfdfec2f427f49
3 files changed +115 -42
packages/react-reconciler/src/ReactFiberHooks.js
+39 -41
@@ -253,6 +253,7 @@ export type ResourceEffectUpdate = {
253 update: ((resource: mixed) => void) | void,
254 deps: Array<mixed> | void | null,
255 next: Effect,
256 + identity: ResourceEffectIdentity,
257 };
258
259 type StoreInstance<T> = {
@@ -2585,40 +2586,37 @@ function pushSimpleEffect(
2586 return pushEffectImpl(effect);
2587 }
2588
2588 -function pushResourceEffectIdentity(
2589 - tag: HookFlags,
2589 +function pushResourceEffect(
2590 + identityTag: HookFlags,
2591 + updateTag: HookFlags,
2592 inst: EffectInstance,
2593 create: () => mixed,
2592 - deps: Array<mixed> | void | null,
2594 + createDeps: Array<mixed> | void | null,
2595 + update: ((resource: mixed) => void) | void,
2596 + updateDeps: Array<mixed> | void | null,
2597 ): Effect {
2594 - const effect: ResourceEffectIdentity = {
2598 + const effectIdentity: ResourceEffectIdentity = {
2599 resourceKind: ResourceEffectIdentityKind,
2596 - tag,
2600 + tag: identityTag,
2601 create,
2598 - deps,
2602 + deps: createDeps,
2603 inst,
2604 // Circular
2605 next: (null: any),
2606 };
2603 - return pushEffectImpl(effect);
2604 -}
2607 + pushEffectImpl(effectIdentity);
2608
2606 -function pushResourceEffectUpdate(
2607 - tag: HookFlags,
2608 - inst: EffectInstance,
2609 - update: ((resource: mixed) => void) | void,
2610 - deps: Array<mixed> | void | null,
2611 -): Effect {
2612 - const effect: ResourceEffectUpdate = {
2609 + const effectUpdate: ResourceEffectUpdate = {
2610 resourceKind: ResourceEffectUpdateKind,
2614 - tag,
2611 + tag: updateTag,
2612 update,
2616 - deps,
2613 + deps: updateDeps,
2614 inst,
2615 + identity: effectIdentity,
2616 // Circular
2617 next: (null: any),
2618 };
2621 - return pushEffectImpl(effect);
2619 + return pushEffectImpl(effectUpdate);
2620 }
2621
2622 function pushEffectImpl(effect: Effect): Effect {
@@ -2792,15 +2790,12 @@ function mountResourceEffectImpl(
2790 currentlyRenderingFiber.flags |= fiberFlags;
2791 const inst = createEffectInstance();
2792 inst.destroy = destroy;
2795 - hook.memoizedState = pushResourceEffectIdentity(
2793 + hook.memoizedState = pushResourceEffect(
2794 HookHasEffect | hookFlags,
2795 + hookFlags,
2796 inst,
2797 create,
2798 createDeps,
2800 - );
2801 - hook.memoizedState = pushResourceEffectUpdate(
2802 - hookFlags,
2803 - inst,
2799 update,
2800 updateDeps,
2801 );
@@ -2847,25 +2842,31 @@ function updateResourceEffectImpl(
2842 const prevEffect: Effect = currentHook.memoizedState;
2843 if (nextCreateDeps !== null) {
2844 let prevCreateDeps;
2850 - // Seems sketchy but in practice we always push an Identity and an Update together. For safety
2851 - // we error in DEV if this does not hold true.
2852 - if (prevEffect.resourceKind === ResourceEffectUpdateKind) {
2845 + if (
2846 + prevEffect.resourceKind != null &&
2847 + prevEffect.resourceKind === ResourceEffectUpdateKind
2848 + ) {
2849 prevCreateDeps =
2854 - prevEffect.next.deps != null ? prevEffect.next.deps : null;
2850 + prevEffect.identity.deps != null ? prevEffect.identity.deps : null;
2851 } else {
2856 - if (__DEV__) {
2857 - console.error(
2858 - 'Expected a ResourceEffectUpdateKind to be pushed together with ' +
2859 - 'ResourceEffectIdentityKind, got %s. This is a bug in React.',
2860 - prevEffect.resourceKind,
2861 - );
2862 - }
2863 - prevCreateDeps = prevEffect.deps != null ? prevEffect.deps : null;
2852 + throw new Error(
2853 + `Expected a ResourceEffectUpdate to be pushed together with ResourceEffectIdentity. This is a bug in React.`,
2854 + );
2855 }
2856 isCreateDepsSame = areHookInputsEqual(nextCreateDeps, prevCreateDeps);
2857 }
2858 if (nextUpdateDeps !== null) {
2868 - const prevUpdateDeps = prevEffect.deps != null ? prevEffect.deps : null;
2859 + let prevUpdateDeps;
2860 + if (
2861 + prevEffect.resourceKind != null &&
2862 + prevEffect.resourceKind === ResourceEffectUpdateKind
2863 + ) {
2864 + prevUpdateDeps = prevEffect.deps != null ? prevEffect.deps : null;
2865 + } else {
2866 + throw new Error(
2867 + `Expected a ResourceEffectUpdate to be pushed together with ResourceEffectIdentity. This is a bug in React.`,
2868 + );
2869 + }
2870 isUpdateDepsSame = areHookInputsEqual(nextUpdateDeps, prevUpdateDeps);
2871 }
2872 }
@@ -2874,15 +2875,12 @@ function updateResourceEffectImpl(
2875 currentlyRenderingFiber.flags |= fiberFlags;
2876 }
2877
2877 - hook.memoizedState = pushResourceEffectIdentity(
2878 + hook.memoizedState = pushResourceEffect(
2879 isCreateDepsSame ? hookFlags : HookHasEffect | hookFlags,
2880 + isUpdateDepsSame ? hookFlags : HookHasEffect | hookFlags,
2881 inst,
2882 create,
2883 nextCreateDeps,
2882 - );
2883 - hook.memoizedState = pushResourceEffectUpdate(
2884 - isUpdateDepsSame ? hookFlags : HookHasEffect | hookFlags,
2885 - inst,
2884 update,
2885 nextUpdateDeps,
2886 );
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
+73
@@ -3927,6 +3927,79 @@ describe('ReactHooksWithNoopRenderer', () => {
3927 </>,
3928 );
3929 });
3930 +
3931 + // @gate enableUseResourceEffectHook
3932 + it('composes with other kinds of effects', async () => {
3933 + let rerender;
3934 + function App({id, username}) {
3935 + const [count, rerender_] = useState(0);
3936 + rerender = rerender_;
3937 + const opts = useMemo(() => {
3938 + return {username};
3939 + }, [username]);
3940 + useEffect(() => {
3941 + Scheduler.log(`useEffect(${count})`);
3942 + }, [count]);
3943 + useResourceEffect(
3944 + () => {
3945 + const resource = new Resource(id, opts);
3946 + Scheduler.log(`create(${resource.id}, ${resource.opts.username})`);
3947 + return resource;
3948 + },
3949 + [id],
3950 + resource => {
3951 + resource.update(opts);
3952 + Scheduler.log(`update(${resource.id}, ${resource.opts.username})`);
3953 + },
3954 + [opts],
3955 + resource => {
3956 + resource.destroy();
3957 + Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`);
3958 + },
3959 + );
3960 + return null;
3961 + }
3962 +
3963 + await act(() => {
3964 + ReactNoop.render(<App id={1} username="Jack" />);
3965 + });
3966 + assertLog(['useEffect(0)', 'create(1, Jack)']);
3967 +
3968 + await act(() => {
3969 + ReactNoop.render(<App id={1} username="Lauren" />);
3970 + });
3971 + assertLog(['update(1, Lauren)']);
3972 +
3973 + await act(() => {
3974 + ReactNoop.render(<App id={1} username="Lauren" />);
3975 + });
3976 + assertLog([]);
3977 +
3978 + await act(() => {
3979 + ReactNoop.render(<App id={1} username="Jordan" />);
3980 + });
3981 + assertLog(['update(1, Jordan)']);
3982 +
3983 + await act(() => {
3984 + rerender(n => n + 1);
3985 + });
3986 + assertLog(['useEffect(1)']);
3987 +
3988 + await act(() => {
3989 + ReactNoop.render(<App id={1} username="Mofei" />);
3990 + });
3991 + assertLog(['update(1, Mofei)']);
3992 +
3993 + await act(() => {
3994 + ReactNoop.render(<App id={2} username="Jack" />);
3995 + });
3996 + assertLog(['destroy(1, Mofei)', 'create(2, Jack)']);
3997 +
3998 + await act(() => {
3999 + ReactNoop.render(null);
4000 + });
4001 + assertLog(['destroy(2, Jack)']);
4002 + });
4003 });
4004
4005 describe('useCallback', () => {
scripts/error-codes/codes.json
+3 -1
@@ -527,5 +527,7 @@
527 "539": "Binary RSC chunks cannot be encoded as strings. This is a bug in the wiring of the React streams.",
528 "540": "String chunks need to be passed in their original shape. Not split into smaller string chunks. This is a bug in the wiring of the React streams.",
529 "541": "Compared context values must be arrays",
530 - "542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary."
530 + "542": "Suspense Exception: This is not a real error! It's an implementation detail of `useActionState` to interrupt the current render. You must either rethrow it immediately, or move the `useActionState` call outside of the `try/catch` block. Capturing without rethrowing will lead to unexpected behavior.\n\nTo handle async errors, wrap your component in an error boundary.",
531 + "543": "Expected a ResourceEffectUpdate to be pushed together with ResourceEffectIdentity. This is a bug in React."
532 }
533 +