[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
+