[crud] Basic implementation (#31523)
This PR introduces a new experimental hook `useResourceEffect`, which is something that we're doing some very early initial tests on. This may likely not pan out and will be removed or modified if so. Please do not rely on it as it will break.
lauren committed
Nov 18, 2024 at 10:16 UTC
047d95e85f0f0cfa6085b2e355e052a3c34ae24d
9 files changed
+1309
-33
packages/react-reconciler/src/ReactFiberCallUserSpace.js
+53
-6
@@ -14,6 +14,11 @@ import type {CapturedValue} from './ReactCapturedValue';
14
15
import {isRendering, setIsRendering} from './ReactCurrentFiber';
16
import {captureCommitPhaseError} from './ReactFiberWorkLoop';
17
+import {
18
+ ResourceEffectIdentityKind,
19
+ ResourceEffectUpdateKind,
20
+} from './ReactFiberHooks';
21
+import {enableUseResourceEffectHook} from 'shared/ReactFeatureFlags';
22
23
// These indirections exists so we can exclude its stack frame in DEV (and anything below it).
24
// TODO: Consider marking the whole bundle instead of these boundaries.
@@ -176,12 +181,54 @@ export const callComponentWillUnmountInDEV: (
181
: (null: any);
182
183
const callCreate = {
179
- 'react-stack-bottom-frame': function (effect: Effect): (() => void) | void {
180
- const create = effect.create;
181
- const inst = effect.inst;
182
- const destroy = create();
183
- inst.destroy = destroy;
184
- return destroy;
184
+ 'react-stack-bottom-frame': function (
185
+ effect: Effect,
186
+ ): (() => void) | mixed | void {
187
+ if (!enableUseResourceEffectHook) {
188
+ if (effect.resourceKind != null) {
189
+ if (__DEV__) {
190
+ console.error(
191
+ 'Expected only SimpleEffects when enableUseResourceEffectHook is disabled, ' +
192
+ 'got %s',
193
+ effect.resourceKind,
194
+ );
195
+ }
196
+ }
197
+ const create = effect.create;
198
+ const inst = effect.inst;
199
+ // $FlowFixMe[not-a-function] (@poteto)
200
+ const destroy = create();
201
+ // $FlowFixMe[incompatible-type] (@poteto)
202
+ inst.destroy = destroy;
203
+ return destroy;
204
+ } else {
205
+ if (effect.resourceKind == null) {
206
+ const create = effect.create;
207
+ const inst = effect.inst;
208
+ const destroy = create();
209
+ inst.destroy = destroy;
210
+ return destroy;
211
+ }
212
+ switch (effect.resourceKind) {
213
+ case ResourceEffectIdentityKind: {
214
+ return effect.create();
215
+ }
216
+ case ResourceEffectUpdateKind: {
217
+ if (typeof effect.update === 'function') {
218
+ effect.update(effect.inst.resource);
219
+ }
220
+ break;
221
+ }
222
+ default: {
223
+ if (__DEV__) {
224
+ console.error(
225
+ 'Unhandled Effect kind %s. This is a bug in React.',
226
+ effect.kind,
227
+ );
228
+ }
229
+ }
230
+ }
231
+ }
232
},
233
};
234
packages/react-reconciler/src/ReactFiberCommitEffects.js
+154
-7
@@ -18,6 +18,7 @@ import {
18
enableProfilerNestedUpdatePhase,
19
enableSchedulingProfiler,
20
enableScopeAPI,
21
+ enableUseResourceEffectHook,
22
} from 'shared/ReactFeatureFlags';
23
import {
24
ClassComponent,
@@ -49,6 +50,7 @@ import {
50
Layout as HookLayout,
51
Insertion as HookInsertion,
52
Passive as HookPassive,
53
+ HasEffect as HookHasEffect,
54
} from './ReactHookEffectTags';
55
import {didWarnAboutReassigningProps} from './ReactFiberBeginWork';
56
import {
@@ -70,6 +72,10 @@ import {
72
} from './ReactFiberCallUserSpace';
73
74
import {runWithFiberInDEV} from './ReactCurrentFiber';
75
+import {
76
+ ResourceEffectIdentityKind,
77
+ ResourceEffectUpdateKind,
78
+} from './ReactFiberHooks';
79
80
function shouldProfile(current: Fiber): boolean {
81
return (
@@ -146,19 +152,90 @@ export function commitHookEffectListMount(
152
153
// Mount
154
let destroy;
155
+ if (enableUseResourceEffectHook) {
156
+ if (effect.resourceKind === ResourceEffectIdentityKind) {
157
+ if (__DEV__) {
158
+ effect.inst.resource = runWithFiberInDEV(
159
+ finishedWork,
160
+ callCreateInDEV,
161
+ effect,
162
+ );
163
+ if (effect.inst.resource == null) {
164
+ console.error(
165
+ 'useResourceEffect must provide a callback which returns a resource. ' +
166
+ 'If a managed resource is not needed here, use useEffect. Received %s',
167
+ effect.inst.resource,
168
+ );
169
+ }
170
+ } else {
171
+ effect.inst.resource = effect.create();
172
+ }
173
+ destroy = effect.inst.destroy;
174
+ }
175
+ if (effect.resourceKind === ResourceEffectUpdateKind) {
176
+ if (
177
+ // We don't want to fire updates on remount during Activity
178
+ (flags & HookHasEffect) > 0 &&
179
+ typeof effect.update === 'function' &&
180
+ effect.inst.resource != null
181
+ ) {
182
+ // TODO(@poteto) what about multiple updates?
183
+ if (__DEV__) {
184
+ runWithFiberInDEV(finishedWork, callCreateInDEV, effect);
185
+ } else {
186
+ effect.update(effect.inst.resource);
187
+ }
188
+ }
189
+ }
190
+ }
191
if (__DEV__) {
192
if ((flags & HookInsertion) !== NoHookEffect) {
193
setIsRunningInsertionEffect(true);
194
}
153
- destroy = runWithFiberInDEV(finishedWork, callCreateInDEV, effect);
195
+ if (enableUseResourceEffectHook) {
196
+ if (effect.resourceKind == null) {
197
+ destroy = runWithFiberInDEV(
198
+ finishedWork,
199
+ callCreateInDEV,
200
+ effect,
201
+ );
202
+ }
203
+ } else {
204
+ destroy = runWithFiberInDEV(
205
+ finishedWork,
206
+ callCreateInDEV,
207
+ effect,
208
+ );
209
+ }
210
if ((flags & HookInsertion) !== NoHookEffect) {
211
setIsRunningInsertionEffect(false);
212
}
213
} else {
158
- const create = effect.create;
159
- const inst = effect.inst;
160
- destroy = create();
161
- inst.destroy = destroy;
214
+ if (enableUseResourceEffectHook) {
215
+ if (effect.resourceKind == null) {
216
+ const create = effect.create;
217
+ const inst = effect.inst;
218
+ destroy = create();
219
+ inst.destroy = destroy;
220
+ }
221
+ } else {
222
+ if (effect.resourceKind != null) {
223
+ if (__DEV__) {
224
+ console.error(
225
+ 'Expected only SimpleEffects when enableUseResourceEffectHook is disabled, ' +
226
+ 'got %s',
227
+ effect.resourceKind,
228
+ );
229
+ }
230
+ }
231
+ const create = effect.create;
232
+ const inst = effect.inst;
233
+ // $FlowFixMe[incompatible-type] (@poteto)
234
+ // $FlowFixMe[not-a-function] (@poteto)
235
+ destroy = create();
236
+ // $FlowFixMe[incompatible-type] (@poteto)
237
+ inst.destroy = destroy;
238
+ }
239
}
240
241
if (enableSchedulingProfiler) {
@@ -176,6 +253,11 @@ export function commitHookEffectListMount(
253
hookName = 'useLayoutEffect';
254
} else if ((effect.tag & HookInsertion) !== NoFlags) {
255
hookName = 'useInsertionEffect';
256
+ } else if (
257
+ enableUseResourceEffectHook &&
258
+ effect.resourceKind != null
259
+ ) {
260
+ hookName = 'useResourceEffect';
261
} else {
262
hookName = 'useEffect';
263
}
@@ -202,6 +284,7 @@ export function commitHookEffectListMount(
284
`}, [someId]); // Or [] if effect doesn't need props or state\n\n` +
285
'Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching';
286
} else {
287
+ // $FlowFixMe[unsafe-addition] (@poteto)
288
addendum = ' You returned: ' + destroy;
289
}
290
runWithFiberInDEV(
@@ -246,7 +329,13 @@ export function commitHookEffectListUnmount(
329
const inst = effect.inst;
330
const destroy = inst.destroy;
331
if (destroy !== undefined) {
249
- inst.destroy = undefined;
332
+ if (enableUseResourceEffectHook) {
333
+ if (effect.resourceKind == null) {
334
+ inst.destroy = undefined;
335
+ }
336
+ } else {
337
+ inst.destroy = undefined;
338
+ }
339
if (enableSchedulingProfiler) {
340
if ((flags & HookPassive) !== NoHookEffect) {
341
markComponentPassiveEffectUnmountStarted(finishedWork);
@@ -260,7 +349,41 @@ export function commitHookEffectListUnmount(
349
setIsRunningInsertionEffect(true);
350
}
351
}
263
- safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
352
+ if (enableUseResourceEffectHook) {
353
+ if (
354
+ effect.resourceKind === ResourceEffectIdentityKind &&
355
+ effect.inst.resource != null
356
+ ) {
357
+ safelyCallDestroyWithResource(
358
+ finishedWork,
359
+ nearestMountedAncestor,
360
+ destroy,
361
+ effect.inst.resource,
362
+ );
363
+ if (effect.next.resourceKind === ResourceEffectUpdateKind) {
364
+ // $FlowFixMe[prop-missing] (@poteto)
365
+ effect.next.update = undefined;
366
+ } else {
367
+ if (__DEV__) {
368
+ console.error(
369
+ 'Expected a ResourceEffectUpdateKind to follow ResourceEffectIdentityKind, ' +
370
+ 'got %s. This is a bug in React.',
371
+ effect.next.resourceKind,
372
+ );
373
+ }
374
+ }
375
+ effect.inst.resource = null;
376
+ }
377
+ if (effect.resourceKind == null) {
378
+ safelyCallDestroy(
379
+ finishedWork,
380
+ nearestMountedAncestor,
381
+ destroy,
382
+ );
383
+ }
384
+ } else {
385
+ safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
386
+ }
387
if (__DEV__) {
388
if ((flags & HookInsertion) !== NoHookEffect) {
389
setIsRunningInsertionEffect(false);
@@ -895,6 +1018,30 @@ function safelyCallDestroy(
1018
}
1019
}
1020
1021
+function safelyCallDestroyWithResource(
1022
+ current: Fiber,
1023
+ nearestMountedAncestor: Fiber | null,
1024
+ destroy: mixed => void,
1025
+ resource: mixed,
1026
+) {
1027
+ const destroy_ = resource == null ? destroy : destroy.bind(null, resource);
1028
+ if (__DEV__) {
1029
+ runWithFiberInDEV(
1030
+ current,
1031
+ callDestroyInDEV,
1032
+ current,
1033
+ nearestMountedAncestor,
1034
+ destroy_,
1035
+ );
1036
+ } else {
1037
+ try {
1038
+ destroy_();
1039
+ } catch (error) {
1040
+ captureCommitPhaseError(current, nearestMountedAncestor, error);
1041
+ }
1042
+ }
1043
+}
1044
+
1045
function commitProfiler(
1046
finishedWork: Fiber,
1047
current: Fiber | null,
packages/react-reconciler/src/ReactFiberHooks.js
+404
-18
@@ -48,6 +48,7 @@ import {
48
disableLegacyMode,
49
enableNoCloningMemoCache,
50
enableContextProfiling,
51
+ enableUseResourceEffectHook,
52
} from 'shared/ReactFeatureFlags';
53
import {
54
REACT_CONTEXT_TYPE,
@@ -217,15 +218,40 @@ export type Hook = {
218
// the additional memory and we can follow up with performance
219
// optimizations later.
220
type EffectInstance = {
220
- destroy: void | (() => void),
221
+ resource: mixed,
222
+ destroy: void | (() => void) | ((resource: mixed) => void),
223
};
224
223
-export type Effect = SimpleEffect;
225
+export const ResourceEffectIdentityKind: 0 = 0;
226
+export const ResourceEffectUpdateKind: 1 = 1;
227
+export type EffectKind =
228
+ | typeof ResourceEffectIdentityKind
229
+ | typeof ResourceEffectUpdateKind;
230
+export type Effect =
231
+ | SimpleEffect
232
+ | ResourceEffectIdentity
233
+ | ResourceEffectUpdate;
234
export type SimpleEffect = {
235
tag: HookFlags,
236
+ inst: EffectInstance,
237
create: () => (() => void) | void,
238
+ deps: Array<mixed> | void | null,
239
+ next: Effect,
240
+};
241
+export type ResourceEffectIdentity = {
242
+ resourceKind: typeof ResourceEffectIdentityKind,
243
+ tag: HookFlags,
244
inst: EffectInstance,
228
- deps: Array<mixed> | null,
245
+ create: () => mixed,
246
+ deps: Array<mixed> | void | null,
247
+ next: Effect,
248
+};
249
+export type ResourceEffectUpdate = {
250
+ resourceKind: typeof ResourceEffectUpdateKind,
251
+ tag: HookFlags,
252
+ inst: EffectInstance,
253
+ update: ((resource: mixed) => void) | void,
254
+ deps: Array<mixed> | void | null,
255
next: Effect,
256
};
257
@@ -350,6 +376,23 @@ function checkDepsAreArrayDev(deps: mixed): void {
376
}
377
}
378
379
+function checkDepsAreNonEmptyArrayDev(deps: mixed): void {
380
+ if (__DEV__) {
381
+ if (
382
+ deps !== undefined &&
383
+ deps !== null &&
384
+ isArray(deps) &&
385
+ deps.length === 0
386
+ ) {
387
+ console.error(
388
+ '%s received a dependency array with no dependencies. When ' +
389
+ 'specified, the dependency array must have at least one dependency.',
390
+ currentHookNameInDev,
391
+ );
392
+ }
393
+ }
394
+}
395
+
396
function warnOnHookMismatchInDev(currentHookName: HookType): void {
397
if (__DEV__) {
398
const componentName = getComponentNameFromFiber(currentlyRenderingFiber);
@@ -1721,10 +1764,10 @@ function mountSyncExternalStore<T>(
1764
// directly, without storing any additional state. For the same reason, we
1765
// don't need to set a static flag, either.
1766
fiber.flags |= PassiveEffect;
1724
- pushEffect(
1767
+ pushSimpleEffect(
1768
HookHasEffect | HookPassive,
1726
- updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1769
createEffectInstance(),
1770
+ updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1771
null,
1772
);
1773
@@ -1791,10 +1834,10 @@ function updateSyncExternalStore<T>(
1834
workInProgressHook.memoizedState.tag & HookHasEffect)
1835
) {
1836
fiber.flags |= PassiveEffect;
1794
- pushEffect(
1837
+ pushSimpleEffect(
1838
HookHasEffect | HookPassive,
1796
- updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1839
createEffectInstance(),
1840
+ updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1841
null,
1842
);
1843
@@ -2465,10 +2508,10 @@ function updateActionStateImpl<S, P>(
2508
const prevAction = actionQueueHook.memoizedState;
2509
if (action !== prevAction) {
2510
currentlyRenderingFiber.flags |= PassiveEffect;
2468
- pushEffect(
2511
+ pushSimpleEffect(
2512
HookHasEffect | HookPassive,
2470
- actionStateActionEffect.bind(null, actionQueue, action),
2513
createEffectInstance(),
2514
+ actionStateActionEffect.bind(null, actionQueue, action),
2515
null,
2516
);
2517
}
@@ -2525,17 +2568,53 @@ function rerenderActionState<S, P>(
2568
return [state, dispatch, false];
2569
}
2570
2528
-function pushEffect(
2571
+function pushSimpleEffect(
2572
tag: HookFlags,
2530
- create: () => (() => void) | void,
2573
inst: EffectInstance,
2532
- deps: Array<mixed> | null,
2574
+ create: () => (() => void) | void,
2575
+ deps: Array<mixed> | void | null,
2576
): Effect {
2577
const effect: Effect = {
2578
tag,
2579
create,
2580
+ deps,
2581
inst,
2582
+ // Circular
2583
+ next: (null: any),
2584
+ };
2585
+ return pushEffectImpl(effect);
2586
+}
2587
+
2588
+function pushResourceEffectIdentity(
2589
+ tag: HookFlags,
2590
+ inst: EffectInstance,
2591
+ create: () => mixed,
2592
+ deps: Array<mixed> | void | null,
2593
+): Effect {
2594
+ const effect: ResourceEffectIdentity = {
2595
+ resourceKind: ResourceEffectIdentityKind,
2596
+ tag,
2597
+ create,
2598
deps,
2599
+ inst,
2600
+ // Circular
2601
+ next: (null: any),
2602
+ };
2603
+ return pushEffectImpl(effect);
2604
+}
2605
+
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 = {
2613
+ resourceKind: ResourceEffectUpdateKind,
2614
+ tag,
2615
+ update,
2616
+ deps,
2617
+ inst,
2618
// Circular
2619
next: (null: any),
2620
};
@@ -2562,7 +2641,7 @@ function pushEffectImpl(effect: Effect): Effect {
2641
}
2642
2643
function createEffectInstance(): EffectInstance {
2565
- return {destroy: undefined};
2644
+ return {destroy: undefined, resource: undefined};
2645
}
2646
2647
function mountRef<T>(initialValue: T): {current: T} {
@@ -2586,10 +2665,10 @@ function mountEffectImpl(
2665
const hook = mountWorkInProgressHook();
2666
const nextDeps = deps === undefined ? null : deps;
2667
currentlyRenderingFiber.flags |= fiberFlags;
2589
- hook.memoizedState = pushEffect(
2668
+ hook.memoizedState = pushSimpleEffect(
2669
HookHasEffect | hookFlags,
2591
- create,
2670
createEffectInstance(),
2671
+ create,
2672
nextDeps,
2673
);
2674
}
@@ -2611,8 +2690,14 @@ function updateEffectImpl(
2690
if (nextDeps !== null) {
2691
const prevEffect: Effect = currentHook.memoizedState;
2692
const prevDeps = prevEffect.deps;
2693
+ // $FlowFixMe[incompatible-call] (@poteto)
2694
if (areHookInputsEqual(nextDeps, prevDeps)) {
2615
- hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps);
2695
+ hook.memoizedState = pushSimpleEffect(
2696
+ hookFlags,
2697
+ inst,
2698
+ create,
2699
+ nextDeps,
2700
+ );
2701
return;
2702
}
2703
}
@@ -2620,10 +2705,10 @@ function updateEffectImpl(
2705
2706
currentlyRenderingFiber.flags |= fiberFlags;
2707
2623
- hook.memoizedState = pushEffect(
2708
+ hook.memoizedState = pushSimpleEffect(
2709
HookHasEffect | hookFlags,
2625
- create,
2710
inst,
2711
+ create,
2712
nextDeps,
2713
);
2714
}
@@ -2660,6 +2745,149 @@ function updateEffect(
2745
updateEffectImpl(PassiveEffect, HookPassive, create, deps);
2746
}
2747
2748
+function mountResourceEffect(
2749
+ create: () => mixed,
2750
+ createDeps: Array<mixed> | void | null,
2751
+ update: ((resource: mixed) => void) | void,
2752
+ updateDeps: Array<mixed> | void | null,
2753
+ destroy: ((resource: mixed) => void) | void,
2754
+) {
2755
+ if (
2756
+ __DEV__ &&
2757
+ (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode &&
2758
+ (currentlyRenderingFiber.mode & NoStrictPassiveEffectsMode) === NoMode
2759
+ ) {
2760
+ mountResourceEffectImpl(
2761
+ MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect,
2762
+ HookPassive,
2763
+ create,
2764
+ createDeps,
2765
+ update,
2766
+ updateDeps,
2767
+ destroy,
2768
+ );
2769
+ } else {
2770
+ mountResourceEffectImpl(
2771
+ PassiveEffect | PassiveStaticEffect,
2772
+ HookPassive,
2773
+ create,
2774
+ createDeps,
2775
+ update,
2776
+ updateDeps,
2777
+ destroy,
2778
+ );
2779
+ }
2780
+}
2781
+
2782
+function mountResourceEffectImpl(
2783
+ fiberFlags: Flags,
2784
+ hookFlags: HookFlags,
2785
+ create: () => mixed,
2786
+ createDeps: Array<mixed> | void | null,
2787
+ update: ((resource: mixed) => void) | void,
2788
+ updateDeps: Array<mixed> | void | null,
2789
+ destroy: ((resource: mixed) => void) | void,
2790
+) {
2791
+ const hook = mountWorkInProgressHook();
2792
+ currentlyRenderingFiber.flags |= fiberFlags;
2793
+ const inst = createEffectInstance();
2794
+ inst.destroy = destroy;
2795
+ hook.memoizedState = pushResourceEffectIdentity(
2796
+ HookHasEffect | hookFlags,
2797
+ inst,
2798
+ create,
2799
+ createDeps,
2800
+ );
2801
+ hook.memoizedState = pushResourceEffectUpdate(
2802
+ hookFlags,
2803
+ inst,
2804
+ update,
2805
+ updateDeps,
2806
+ );
2807
+}
2808
+
2809
+function updateResourceEffect(
2810
+ create: () => mixed,
2811
+ createDeps: Array<mixed> | void | null,
2812
+ update: ((resource: mixed) => void) | void,
2813
+ updateDeps: Array<mixed> | void | null,
2814
+ destroy: ((resource: mixed) => void) | void,
2815
+) {
2816
+ updateResourceEffectImpl(
2817
+ PassiveEffect,
2818
+ HookPassive,
2819
+ create,
2820
+ createDeps,
2821
+ update,
2822
+ updateDeps,
2823
+ destroy,
2824
+ );
2825
+}
2826
+
2827
+function updateResourceEffectImpl(
2828
+ fiberFlags: Flags,
2829
+ hookFlags: HookFlags,
2830
+ create: () => mixed,
2831
+ createDeps: Array<mixed> | void | null,
2832
+ update: ((resource: mixed) => void) | void,
2833
+ updateDeps: Array<mixed> | void | null,
2834
+ destroy: ((resource: mixed) => void) | void,
2835
+) {
2836
+ const hook = updateWorkInProgressHook();
2837
+ const effect: Effect = hook.memoizedState;
2838
+ const inst = effect.inst;
2839
+ inst.destroy = destroy;
2840
+
2841
+ const nextCreateDeps = createDeps === undefined ? null : createDeps;
2842
+ const nextUpdateDeps = updateDeps === undefined ? null : updateDeps;
2843
+ let isCreateDepsSame: boolean;
2844
+ let isUpdateDepsSame: boolean;
2845
+
2846
+ if (currentHook !== null) {
2847
+ const prevEffect: Effect = currentHook.memoizedState;
2848
+ if (nextCreateDeps !== null) {
2849
+ 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) {
2853
+ prevCreateDeps =
2854
+ prevEffect.next.deps != null ? prevEffect.next.deps : null;
2855
+ } 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;
2864
+ }
2865
+ isCreateDepsSame = areHookInputsEqual(nextCreateDeps, prevCreateDeps);
2866
+ }
2867
+ if (nextUpdateDeps !== null) {
2868
+ const prevUpdateDeps = prevEffect.deps != null ? prevEffect.deps : null;
2869
+ isUpdateDepsSame = areHookInputsEqual(nextUpdateDeps, prevUpdateDeps);
2870
+ }
2871
+ }
2872
+
2873
+ if (!(isCreateDepsSame && isUpdateDepsSame)) {
2874
+ currentlyRenderingFiber.flags |= fiberFlags;
2875
+ }
2876
+
2877
+ hook.memoizedState = pushResourceEffectIdentity(
2878
+ isCreateDepsSame ? hookFlags : HookHasEffect | hookFlags,
2879
+ inst,
2880
+ create,
2881
+ nextCreateDeps,
2882
+ );
2883
+ hook.memoizedState = pushResourceEffectUpdate(
2884
+ isUpdateDepsSame ? hookFlags : HookHasEffect | hookFlags,
2885
+ inst,
2886
+ update,
2887
+ nextUpdateDeps,
2888
+ );
2889
+}
2890
+
2891
function useEffectEventImpl<Args, Return, F: (...Array<Args>) => Return>(
2892
payload: EventFunctionPayload<Args, Return, F>,
2893
) {
@@ -3810,6 +4038,9 @@ if (enableUseMemoCacheHook) {
4038
if (enableUseEffectEventHook) {
4039
(ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError;
4040
}
4041
+if (enableUseResourceEffectHook) {
4042
+ (ContextOnlyDispatcher: Dispatcher).useResourceEffect = throwInvalidHookError;
4043
+}
4044
if (enableAsyncActions) {
4045
(ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus =
4046
throwInvalidHookError;
@@ -3853,6 +4084,9 @@ if (enableUseMemoCacheHook) {
4084
if (enableUseEffectEventHook) {
4085
(HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent;
4086
}
4087
+if (enableUseResourceEffectHook) {
4088
+ (HooksDispatcherOnMount: Dispatcher).useResourceEffect = mountResourceEffect;
4089
+}
4090
if (enableAsyncActions) {
4091
(HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus =
4092
useHostTransitionStatus;
@@ -3896,6 +4130,10 @@ if (enableUseMemoCacheHook) {
4130
if (enableUseEffectEventHook) {
4131
(HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent;
4132
}
4133
+if (enableUseResourceEffectHook) {
4134
+ (HooksDispatcherOnUpdate: Dispatcher).useResourceEffect =
4135
+ updateResourceEffect;
4136
+}
4137
if (enableAsyncActions) {
4138
(HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus =
4139
useHostTransitionStatus;
@@ -3939,6 +4177,10 @@ if (enableUseMemoCacheHook) {
4177
if (enableUseEffectEventHook) {
4178
(HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent;
4179
}
4180
+if (enableUseResourceEffectHook) {
4181
+ (HooksDispatcherOnRerender: Dispatcher).useResourceEffect =
4182
+ updateResourceEffect;
4183
+}
4184
if (enableAsyncActions) {
4185
(HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus =
4186
useHostTransitionStatus;
@@ -4129,6 +4371,27 @@ if (__DEV__) {
4371
return mountEvent(callback);
4372
};
4373
}
4374
+ if (enableUseResourceEffectHook) {
4375
+ (HooksDispatcherOnMountInDEV: Dispatcher).useResourceEffect =
4376
+ function useResourceEffect(
4377
+ create: () => mixed,
4378
+ createDeps: Array<mixed> | void | null,
4379
+ update: ((resource: mixed) => void) | void,
4380
+ updateDeps: Array<mixed> | void | null,
4381
+ destroy: ((resource: mixed) => void) | void,
4382
+ ): void {
4383
+ currentHookNameInDev = 'useResourceEffect';
4384
+ mountHookTypesDev();
4385
+ checkDepsAreNonEmptyArrayDev(updateDeps);
4386
+ return mountResourceEffect(
4387
+ create,
4388
+ createDeps,
4389
+ update,
4390
+ updateDeps,
4391
+ destroy,
4392
+ );
4393
+ };
4394
+ }
4395
if (enableAsyncActions) {
4396
(HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
4397
useHostTransitionStatus;
@@ -4321,6 +4584,26 @@ if (__DEV__) {
4584
return mountEvent(callback);
4585
};
4586
}
4587
+ if (enableUseResourceEffectHook) {
4588
+ (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useResourceEffect =
4589
+ function useResourceEffect(
4590
+ create: () => mixed,
4591
+ createDeps: Array<mixed> | void | null,
4592
+ update: ((resource: mixed) => void) | void,
4593
+ updateDeps: Array<mixed> | void | null,
4594
+ destroy: ((resource: mixed) => void) | void,
4595
+ ): void {
4596
+ currentHookNameInDev = 'useResourceEffect';
4597
+ updateHookTypesDev();
4598
+ return mountResourceEffect(
4599
+ create,
4600
+ createDeps,
4601
+ update,
4602
+ updateDeps,
4603
+ destroy,
4604
+ );
4605
+ };
4606
+ }
4607
if (enableAsyncActions) {
4608
(HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus =
4609
useHostTransitionStatus;
@@ -4512,6 +4795,26 @@ if (__DEV__) {
4795
return updateEvent(callback);
4796
};
4797
}
4798
+ if (enableUseResourceEffectHook) {
4799
+ (HooksDispatcherOnUpdateInDEV: Dispatcher).useResourceEffect =
4800
+ function useResourceEffect(
4801
+ create: () => mixed,
4802
+ createDeps: Array<mixed> | void | null,
4803
+ update: ((resource: mixed) => void) | void,
4804
+ updateDeps: Array<mixed> | void | null,
4805
+ destroy: ((resource: mixed) => void) | void,
4806
+ ) {
4807
+ currentHookNameInDev = 'useResourceEffect';
4808
+ updateHookTypesDev();
4809
+ return updateResourceEffect(
4810
+ create,
4811
+ createDeps,
4812
+ update,
4813
+ updateDeps,
4814
+ destroy,
4815
+ );
4816
+ };
4817
+ }
4818
if (enableAsyncActions) {
4819
(HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
4820
useHostTransitionStatus;
@@ -4703,6 +5006,26 @@ if (__DEV__) {
5006
return updateEvent(callback);
5007
};
5008
}
5009
+ if (enableUseResourceEffectHook) {
5010
+ (HooksDispatcherOnRerenderInDEV: Dispatcher).useResourceEffect =
5011
+ function useResourceEffect(
5012
+ create: () => mixed,
5013
+ createDeps: Array<mixed> | void | null,
5014
+ update: ((resource: mixed) => void) | void,
5015
+ updateDeps: Array<mixed> | void | null,
5016
+ destroy: ((resource: mixed) => void) | void,
5017
+ ) {
5018
+ currentHookNameInDev = 'useResourceEffect';
5019
+ updateHookTypesDev();
5020
+ return updateResourceEffect(
5021
+ create,
5022
+ createDeps,
5023
+ update,
5024
+ updateDeps,
5025
+ destroy,
5026
+ );
5027
+ };
5028
+ }
5029
if (enableAsyncActions) {
5030
(HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
5031
useHostTransitionStatus;
@@ -4918,6 +5241,27 @@ if (__DEV__) {
5241
return mountEvent(callback);
5242
};
5243
}
5244
+ if (InvalidNestedHooksDispatcherOnMountInDEV) {
5245
+ (HooksDispatcherOnRerenderInDEV: Dispatcher).useResourceEffect =
5246
+ function useResourceEffect(
5247
+ create: () => mixed,
5248
+ createDeps: Array<mixed> | void | null,
5249
+ update: ((resource: mixed) => void) | void,
5250
+ updateDeps: Array<mixed> | void | null,
5251
+ destroy: ((resource: mixed) => void) | void,
5252
+ ): void {
5253
+ currentHookNameInDev = 'useResourceEffect';
5254
+ warnInvalidHookAccess();
5255
+ mountHookTypesDev();
5256
+ return mountResourceEffect(
5257
+ create,
5258
+ createDeps,
5259
+ update,
5260
+ updateDeps,
5261
+ destroy,
5262
+ );
5263
+ };
5264
+ }
5265
if (enableAsyncActions) {
5266
(InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus =
5267
useHostTransitionStatus;
@@ -5136,6 +5480,27 @@ if (__DEV__) {
5480
return updateEvent(callback);
5481
};
5482
}
5483
+ if (enableUseResourceEffectHook) {
5484
+ (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useResourceEffect =
5485
+ function useResourceEffect(
5486
+ create: () => mixed,
5487
+ createDeps: Array<mixed> | void | null,
5488
+ update: ((resource: mixed) => void) | void,
5489
+ updateDeps: Array<mixed> | void | null,
5490
+ destroy: ((resource: mixed) => void) | void,
5491
+ ) {
5492
+ currentHookNameInDev = 'useResourceEffect';
5493
+ warnInvalidHookAccess();
5494
+ updateHookTypesDev();
5495
+ return updateResourceEffect(
5496
+ create,
5497
+ createDeps,
5498
+ update,
5499
+ updateDeps,
5500
+ destroy,
5501
+ );
5502
+ };
5503
+ }
5504
if (enableAsyncActions) {
5505
(InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus =
5506
useHostTransitionStatus;
@@ -5354,6 +5719,27 @@ if (__DEV__) {
5719
return updateEvent(callback);
5720
};
5721
}
5722
+ if (enableUseResourceEffectHook) {
5723
+ (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useResourceEffect =
5724
+ function useResourceEffect(
5725
+ create: () => mixed,
5726
+ createDeps: Array<mixed> | void | null,
5727
+ update: ((resource: mixed) => void) | void,
5728
+ updateDeps: Array<mixed> | void | null,
5729
+ destroy: ((resource: mixed) => void) | void,
5730
+ ) {
5731
+ currentHookNameInDev = 'useResourceEffect';
5732
+ warnInvalidHookAccess();
5733
+ updateHookTypesDev();
5734
+ return updateResourceEffect(
5735
+ create,
5736
+ createDeps,
5737
+ update,
5738
+ updateDeps,
5739
+ destroy,
5740
+ );
5741
+ };
5742
+ }
5743
if (enableAsyncActions) {
5744
(InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus =
5745
useHostTransitionStatus;
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
+677
@@ -41,6 +41,7 @@ let waitFor;
41
let waitForThrow;
42
let waitForPaint;
43
let assertLog;
44
+let useResourceEffect;
45
46
describe('ReactHooksWithNoopRenderer', () => {
47
beforeEach(() => {
@@ -66,6 +67,7 @@ describe('ReactHooksWithNoopRenderer', () => {
67
useDeferredValue = React.useDeferredValue;
68
Suspense = React.Suspense;
69
Activity = React.unstable_Activity;
70
+ useResourceEffect = React.experimental_useResourceEffect;
71
ContinuousEventPriority =
72
require('react-reconciler/constants').ContinuousEventPriority;
73
if (gate(flags => flags.enableSuspenseList)) {
@@ -3252,6 +3254,681 @@ describe('ReactHooksWithNoopRenderer', () => {
3254
});
3255
});
3256
3257
+ // @gate enableUseResourceEffectHook
3258
+ describe('useResourceEffect', () => {
3259
+ class Resource {
3260
+ isDeleted: false;
3261
+ id: string;
3262
+ opts: mixed;
3263
+ constructor(id, opts) {
3264
+ this.id = id;
3265
+ this.opts = opts;
3266
+ }
3267
+ update(opts) {
3268
+ if (this.isDeleted) {
3269
+ console.error('Cannot update deleted resource');
3270
+ return;
3271
+ }
3272
+ this.opts = opts;
3273
+ }
3274
+ destroy() {
3275
+ this.isDeleted = true;
3276
+ }
3277
+ }
3278
+
3279
+ // @gate enableUseResourceEffectHook
3280
+ it('validates create return value', async () => {
3281
+ function App({id}) {
3282
+ useResourceEffect(() => {
3283
+ Scheduler.log(`create(${id})`);
3284
+ }, [id]);
3285
+ return null;
3286
+ }
3287
+
3288
+ await expect(async () => {
3289
+ await act(() => {
3290
+ ReactNoop.render(<App id={1} />);
3291
+ });
3292
+ }).toErrorDev(
3293
+ 'useResourceEffect must provide a callback which returns a resource. ' +
3294
+ 'If a managed resource is not needed here, use useEffect. Received undefined',
3295
+ {withoutStack: true},
3296
+ );
3297
+ });
3298
+
3299
+ // @gate enableUseResourceEffectHook
3300
+ it('validates non-empty update deps', async () => {
3301
+ function App({id}) {
3302
+ useResourceEffect(
3303
+ () => {
3304
+ Scheduler.log(`create(${id})`);
3305
+ return {};
3306
+ },
3307
+ [id],
3308
+ () => {
3309
+ Scheduler.log('update');
3310
+ },
3311
+ [],
3312
+ );
3313
+ return null;
3314
+ }
3315
+
3316
+ await expect(async () => {
3317
+ await act(() => {
3318
+ ReactNoop.render(<App id={1} />);
3319
+ });
3320
+ }).toErrorDev(
3321
+ 'useResourceEffect received a dependency array with no dependencies. ' +
3322
+ 'When specified, the dependency array must have at least one dependency.',
3323
+ );
3324
+ });
3325
+
3326
+ // @gate enableUseResourceEffectHook
3327
+ it('simple mount and update', async () => {
3328
+ function App({id, username}) {
3329
+ const opts = useMemo(() => {
3330
+ return {username};
3331
+ }, [username]);
3332
+ useResourceEffect(
3333
+ () => {
3334
+ const resource = new Resource(id, opts);
3335
+ Scheduler.log(`create(${resource.id}, ${resource.opts.username})`);
3336
+ return resource;
3337
+ },
3338
+ [id],
3339
+ resource => {
3340
+ resource.update(opts);
3341
+ Scheduler.log(`update(${resource.id}, ${resource.opts.username})`);
3342
+ },
3343
+ [opts],
3344
+ resource => {
3345
+ resource.destroy();
3346
+ Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`);
3347
+ },
3348
+ );
3349
+ return null;
3350
+ }
3351
+
3352
+ await act(() => {
3353
+ ReactNoop.render(<App id={1} username="Jack" />);
3354
+ });
3355
+ assertLog(['create(1, Jack)']);
3356
+
3357
+ await act(() => {
3358
+ ReactNoop.render(<App id={1} username="Lauren" />);
3359
+ });
3360
+ assertLog(['update(1, Lauren)']);
3361
+
3362
+ await act(() => {
3363
+ ReactNoop.render(<App id={1} username="Lauren" />);
3364
+ });
3365
+ assertLog([]);
3366
+
3367
+ await act(() => {
3368
+ ReactNoop.render(<App id={1} username="Jordan" />);
3369
+ });
3370
+ assertLog(['update(1, Jordan)']);
3371
+
3372
+ await act(() => {
3373
+ ReactNoop.render(<App id={2} username="Jack" />);
3374
+ });
3375
+ assertLog(['destroy(1, Jordan)', 'create(2, Jack)']);
3376
+
3377
+ await act(() => {
3378
+ ReactNoop.render(null);
3379
+ });
3380
+ assertLog(['destroy(2, Jack)']);
3381
+ });
3382
+
3383
+ // @gate enableUseResourceEffectHook
3384
+ it('simple mount with no update', async () => {
3385
+ function App({id, username}) {
3386
+ const opts = useMemo(() => {
3387
+ return {username};
3388
+ }, [username]);
3389
+ useResourceEffect(
3390
+ () => {
3391
+ const resource = new Resource(id, opts);
3392
+ Scheduler.log(`create(${resource.id}, ${resource.opts.username})`);
3393
+ return resource;
3394
+ },
3395
+ [id],
3396
+ resource => {
3397
+ resource.update(opts);
3398
+ Scheduler.log(`update(${resource.id}, ${resource.opts.username})`);
3399
+ },
3400
+ [opts],
3401
+ resource => {
3402
+ resource.destroy();
3403
+ Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`);
3404
+ },
3405
+ );
3406
+ return null;
3407
+ }
3408
+
3409
+ await act(() => {
3410
+ ReactNoop.render(<App id={1} username="Jack" />);
3411
+ });
3412
+ assertLog(['create(1, Jack)']);
3413
+
3414
+ await act(() => {
3415
+ ReactNoop.render(null);
3416
+ });
3417
+ assertLog(['destroy(1, Jack)']);
3418
+ });
3419
+
3420
+ // @gate enableUseResourceEffectHook
3421
+ it('calls update on every render if no deps are specified', async () => {
3422
+ function App({id, username}) {
3423
+ const opts = useMemo(() => {
3424
+ return {username};
3425
+ }, [username]);
3426
+ useResourceEffect(
3427
+ () => {
3428
+ const resource = new Resource(id, opts);
3429
+ Scheduler.log(`create(${resource.id}, ${resource.opts.username})`);
3430
+ return resource;
3431
+ },
3432
+ [id],
3433
+ resource => {
3434
+ resource.update(opts);
3435
+ Scheduler.log(`update(${resource.id}, ${resource.opts.username})`);
3436
+ },
3437
+ );
3438
+ return null;
3439
+ }
3440
+
3441
+ await act(() => {
3442
+ ReactNoop.render(<App id={1} username="Jack" />);
3443
+ });
3444
+ assertLog(['create(1, Jack)']);
3445
+
3446
+ await act(() => {
3447
+ ReactNoop.render(<App id={1} username="Jack" />);
3448
+ });
3449
+ assertLog(['update(1, Jack)']);
3450
+
3451
+ await act(() => {
3452
+ ReactNoop.render(<App id={2} username="Jack" />);
3453
+ });
3454
+ assertLog(['create(2, Jack)', 'update(2, Jack)']);
3455
+
3456
+ await act(() => {
3457
+ ReactNoop.render(<App id={2} username="Lauren" />);
3458
+ });
3459
+
3460
+ assertLog(['update(2, Lauren)']);
3461
+ });
3462
+
3463
+ // @gate enableUseResourceEffectHook
3464
+ it('does not unmount previous useResourceEffect between updates', async () => {
3465
+ function App({id}) {
3466
+ useResourceEffect(
3467
+ () => {
3468
+ const resource = new Resource(id);
3469
+ Scheduler.log(`create(${resource.id})`);
3470
+ return resource;
3471
+ },
3472
+ [],
3473
+ resource => {
3474
+ Scheduler.log(`update(${resource.id})`);
3475
+ },
3476
+ undefined,
3477
+ resource => {
3478
+ Scheduler.log(`destroy(${resource.id})`);
3479
+ resource.destroy();
3480
+ },
3481
+ );
3482
+ return <Text text={'Id: ' + id} />;
3483
+ }
3484
+
3485
+ await act(async () => {
3486
+ ReactNoop.render(<App id={0} />, () => Scheduler.log('Sync effect'));
3487
+ await waitFor(['Id: 0', 'Sync effect']);
3488
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 0" />);
3489
+ });
3490
+
3491
+ assertLog(['create(0)']);
3492
+
3493
+ await act(async () => {
3494
+ ReactNoop.render(<App id={1} />, () => Scheduler.log('Sync effect'));
3495
+ await waitFor(['Id: 1', 'Sync effect']);
3496
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 1" />);
3497
+ });
3498
+
3499
+ assertLog(['update(0)']);
3500
+ });
3501
+
3502
+ // @gate enableUseResourceEffectHook
3503
+ it('unmounts only on deletion', async () => {
3504
+ function App({id}) {
3505
+ useResourceEffect(
3506
+ () => {
3507
+ const resource = new Resource(id);
3508
+ Scheduler.log(`create(${resource.id})`);
3509
+ return resource;
3510
+ },
3511
+ undefined,
3512
+ resource => {
3513
+ Scheduler.log(`update(${resource.id})`);
3514
+ },
3515
+ undefined,
3516
+ resource => {
3517
+ Scheduler.log(`destroy(${resource.id})`);
3518
+ resource.destroy();
3519
+ },
3520
+ );
3521
+ return <Text text={'Id: ' + id} />;
3522
+ }
3523
+ await act(async () => {
3524
+ ReactNoop.render(<App id={0} />, () => Scheduler.log('Sync effect'));
3525
+ await waitFor(['Id: 0', 'Sync effect']);
3526
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 0" />);
3527
+ });
3528
+
3529
+ assertLog(['create(0)']);
3530
+
3531
+ ReactNoop.render(null);
3532
+ await waitForAll(['destroy(0)']);
3533
+ expect(ReactNoop).toMatchRenderedOutput(null);
3534
+ });
3535
+
3536
+ // @gate enableUseResourceEffectHook
3537
+ it('unmounts on deletion', async () => {
3538
+ function Wrapper(props) {
3539
+ return <App {...props} />;
3540
+ }
3541
+ function App({id, username}) {
3542
+ const opts = useMemo(() => {
3543
+ return {username};
3544
+ }, [username]);
3545
+ useResourceEffect(
3546
+ () => {
3547
+ const resource = new Resource(id, opts);
3548
+ Scheduler.log(`create(${resource.id}, ${resource.opts.username})`);
3549
+ return resource;
3550
+ },
3551
+ [id],
3552
+ resource => {
3553
+ resource.update(opts);
3554
+ Scheduler.log(`update(${resource.id}, ${resource.opts.username})`);
3555
+ },
3556
+ [opts],
3557
+ resource => {
3558
+ resource.destroy();
3559
+ Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`);
3560
+ },
3561
+ );
3562
+ return <Text text={'Id: ' + id} />;
3563
+ }
3564
+
3565
+ await act(async () => {
3566
+ ReactNoop.render(<Wrapper id={0} username="Sathya" />, () =>
3567
+ Scheduler.log('Sync effect'),
3568
+ );
3569
+ await waitFor(['Id: 0', 'Sync effect']);
3570
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 0" />);
3571
+ });
3572
+
3573
+ assertLog(['create(0, Sathya)']);
3574
+
3575
+ await act(async () => {
3576
+ ReactNoop.render(<Wrapper id={0} username="Lauren" />, () =>
3577
+ Scheduler.log('Sync effect'),
3578
+ );
3579
+ await waitFor(['Id: 0', 'Sync effect']);
3580
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 0" />);
3581
+ });
3582
+
3583
+ assertLog(['update(0, Lauren)']);
3584
+
3585
+ ReactNoop.render(null);
3586
+ await waitForAll(['destroy(0, Lauren)']);
3587
+ expect(ReactNoop).toMatchRenderedOutput(null);
3588
+ });
3589
+
3590
+ // @gate enableUseResourceEffectHook
3591
+ it('handles errors in create on mount', async () => {
3592
+ function App({id}) {
3593
+ useResourceEffect(
3594
+ () => {
3595
+ Scheduler.log(`Mount A [${id}]`);
3596
+ return {};
3597
+ },
3598
+ undefined,
3599
+ undefined,
3600
+ undefined,
3601
+ resource => {
3602
+ Scheduler.log(`Unmount A [${id}]`);
3603
+ },
3604
+ );
3605
+ useResourceEffect(
3606
+ () => {
3607
+ Scheduler.log('Oops!');
3608
+ throw new Error('Oops!');
3609
+ // eslint-disable-next-line no-unreachable
3610
+ Scheduler.log(`Mount B [${id}]`);
3611
+ return {};
3612
+ },
3613
+ undefined,
3614
+ undefined,
3615
+ undefined,
3616
+ resource => {
3617
+ Scheduler.log(`Unmount B [${id}]`);
3618
+ },
3619
+ );
3620
+ return <Text text={'Id: ' + id} />;
3621
+ }
3622
+ await expect(async () => {
3623
+ await act(async () => {
3624
+ ReactNoop.render(<App id={0} />, () => Scheduler.log('Sync effect'));
3625
+ await waitFor(['Id: 0', 'Sync effect']);
3626
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 0" />);
3627
+ });
3628
+ }).rejects.toThrow('Oops');
3629
+
3630
+ assertLog([
3631
+ 'Mount A [0]',
3632
+ 'Oops!',
3633
+ // Clean up effect A. There's no effect B to clean-up, because it
3634
+ // never mounted.
3635
+ 'Unmount A [0]',
3636
+ ]);
3637
+ expect(ReactNoop).toMatchRenderedOutput(null);
3638
+ });
3639
+
3640
+ // @gate enableUseResourceEffectHook
3641
+ it('handles errors in create on update', async () => {
3642
+ function App({id}) {
3643
+ useResourceEffect(
3644
+ () => {
3645
+ Scheduler.log(`Mount A [${id}]`);
3646
+ return {};
3647
+ },
3648
+ [],
3649
+ () => {
3650
+ if (id === 1) {
3651
+ Scheduler.log('Oops!');
3652
+ throw new Error('Oops error!');
3653
+ }
3654
+ Scheduler.log(`Update A [${id}]`);
3655
+ },
3656
+ [id],
3657
+ () => {
3658
+ Scheduler.log(`Unmount A [${id}]`);
3659
+ },
3660
+ );
3661
+ return <Text text={'Id: ' + id} />;
3662
+ }
3663
+ await act(async () => {
3664
+ ReactNoop.render(<App id={0} />, () => Scheduler.log('Sync effect'));
3665
+ await waitFor(['Id: 0', 'Sync effect']);
3666
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 0" />);
3667
+ ReactNoop.flushPassiveEffects();
3668
+ assertLog(['Mount A [0]']);
3669
+ });
3670
+
3671
+ await expect(async () => {
3672
+ await act(async () => {
3673
+ // This update will trigger an error
3674
+ ReactNoop.render(<App id={1} />, () => Scheduler.log('Sync effect'));
3675
+ await waitFor(['Id: 1', 'Sync effect']);
3676
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 1" />);
3677
+ ReactNoop.flushPassiveEffects();
3678
+ assertLog(['Oops!', 'Unmount A [1]']);
3679
+ expect(ReactNoop).toMatchRenderedOutput(null);
3680
+ });
3681
+ }).rejects.toThrow('Oops error!');
3682
+ });
3683
+
3684
+ // @gate enableUseResourceEffectHook
3685
+ it('handles errors in destroy on update', async () => {
3686
+ function App({id, username}) {
3687
+ const opts = useMemo(() => {
3688
+ return {username};
3689
+ }, [username]);
3690
+ useResourceEffect(
3691
+ () => {
3692
+ const resource = new Resource(id, opts);
3693
+ Scheduler.log(`Mount A [${id}, ${resource.opts.username}]`);
3694
+ return resource;
3695
+ },
3696
+ [id],
3697
+ resource => {
3698
+ resource.update(opts);
3699
+ Scheduler.log(`Update A [${id}, ${resource.opts.username}]`);
3700
+ },
3701
+ [opts],
3702
+ resource => {
3703
+ Scheduler.log(`Oops, ${resource.opts.username}!`);
3704
+ if (id === 1) {
3705
+ throw new Error(`Oops ${resource.opts.username} error!`);
3706
+ }
3707
+ Scheduler.log(`Unmount A [${id}, ${resource.opts.username}]`);
3708
+ },
3709
+ );
3710
+ return <Text text={'Id: ' + id} />;
3711
+ }
3712
+ await act(async () => {
3713
+ ReactNoop.render(<App id={0} username="Lauren" />, () =>
3714
+ Scheduler.log('Sync effect'),
3715
+ );
3716
+ await waitFor(['Id: 0', 'Sync effect']);
3717
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 0" />);
3718
+ ReactNoop.flushPassiveEffects();
3719
+ assertLog(['Mount A [0, Lauren]']);
3720
+ });
3721
+
3722
+ await expect(async () => {
3723
+ await act(async () => {
3724
+ // This update will trigger an error during passive effect unmount
3725
+ ReactNoop.render(<App id={1} username="Sathya" />, () =>
3726
+ Scheduler.log('Sync effect'),
3727
+ );
3728
+ await waitFor(['Id: 1', 'Sync effect']);
3729
+ expect(ReactNoop).toMatchRenderedOutput(<span prop="Id: 1" />);
3730
+ ReactNoop.flushPassiveEffects();
3731
+ assertLog(['Oops, Lauren!', 'Mount A [1, Sathya]', 'Oops, Sathya!']);
3732
+ });
3733
+ // TODO(lauren) more explicit assertions. this is weird because we
3734
+ // destroy both the first and second resource
3735
+ }).rejects.toThrow();
3736
+
3737
+ expect(ReactNoop).toMatchRenderedOutput(null);
3738
+ });
3739
+
3740
+ // @gate enableUseResourceEffectHook && enableActivity
3741
+ it('composes with activity', async () => {
3742
+ function App({id, username}) {
3743
+ const opts = useMemo(() => {
3744
+ return {username};
3745
+ }, [username]);
3746
+ useResourceEffect(
3747
+ () => {
3748
+ const resource = new Resource(id, opts);
3749
+ Scheduler.log(`create(${resource.id}, ${resource.opts.username})`);
3750
+ return resource;
3751
+ },
3752
+ [id],
3753
+ resource => {
3754
+ resource.update(opts);
3755
+ Scheduler.log(`update(${resource.id}, ${resource.opts.username})`);
3756
+ },
3757
+ [opts],
3758
+ resource => {
3759
+ resource.destroy();
3760
+ Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`);
3761
+ },
3762
+ );
3763
+ return null;
3764
+ }
3765
+
3766
+ const root = ReactNoop.createRoot();
3767
+ await act(() => {
3768
+ root.render(
3769
+ <Activity mode="hidden">
3770
+ <App id={0} username="Rick" />
3771
+ </Activity>,
3772
+ );
3773
+ });
3774
+ assertLog([]);
3775
+
3776
+ await act(() => {
3777
+ root.render(
3778
+ <Activity mode="hidden">
3779
+ <App id={0} username="Lauren" />
3780
+ </Activity>,
3781
+ );
3782
+ });
3783
+ assertLog([]);
3784
+
3785
+ await act(() => {
3786
+ root.render(
3787
+ <Activity mode="visible">
3788
+ <App id={0} username="Rick" />
3789
+ </Activity>,
3790
+ );
3791
+ });
3792
+ assertLog(['create(0, Rick)']);
3793
+
3794
+ await act(() => {
3795
+ root.render(
3796
+ <Activity mode="visible">
3797
+ <App id={0} username="Lauren" />
3798
+ </Activity>,
3799
+ );
3800
+ });
3801
+ assertLog(['update(0, Lauren)']);
3802
+
3803
+ await act(() => {
3804
+ root.render(
3805
+ <Activity mode="hidden">
3806
+ <App id={0} username="Lauren" />
3807
+ </Activity>,
3808
+ );
3809
+ });
3810
+ assertLog(['destroy(0, Lauren)']);
3811
+ });
3812
+
3813
+ // @gate enableUseResourceEffectHook
3814
+ it('composes with suspense', async () => {
3815
+ function TextBox({text}) {
3816
+ return <AsyncText text={text} ms={0} />;
3817
+ }
3818
+ let setUsername_;
3819
+ function App({id}) {
3820
+ const [username, setUsername] = useState('Mofei');
3821
+ setUsername_ = setUsername;
3822
+ const opts = useMemo(() => {
3823
+ return {username};
3824
+ }, [username]);
3825
+ useResourceEffect(
3826
+ () => {
3827
+ const resource = new Resource(id, opts);
3828
+ Scheduler.log(`create(${resource.id}, ${resource.opts.username})`);
3829
+ return resource;
3830
+ },
3831
+ [id],
3832
+ resource => {
3833
+ resource.update(opts);
3834
+ Scheduler.log(`update(${resource.id}, ${resource.opts.username})`);
3835
+ },
3836
+ [opts],
3837
+ resource => {
3838
+ resource.destroy();
3839
+ Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`);
3840
+ },
3841
+ );
3842
+ return (
3843
+ <>
3844
+ <Text text={'Sync: ' + username} />
3845
+ <Suspense fallback={<Text text={'Loading'} />}>
3846
+ <TextBox text={username} />
3847
+ </Suspense>
3848
+ </>
3849
+ );
3850
+ }
3851
+
3852
+ await act(async () => {
3853
+ ReactNoop.render(<App id={0} />);
3854
+ await waitFor([
3855
+ 'Sync: Mofei',
3856
+ 'Suspend! [Mofei]',
3857
+ 'Loading',
3858
+ 'create(0, Mofei)',
3859
+ ]);
3860
+ expect(ReactNoop).toMatchRenderedOutput(
3861
+ <>
3862
+ <span prop="Sync: Mofei" />
3863
+ <span prop="Loading" />
3864
+ </>,
3865
+ );
3866
+ ReactNoop.flushPassiveEffects();
3867
+ assertLog([]);
3868
+
3869
+ Scheduler.unstable_advanceTime(10);
3870
+ await advanceTimers(10);
3871
+ assertLog(['Promise resolved [Mofei]']);
3872
+ });
3873
+ assertLog(['Mofei']);
3874
+ expect(ReactNoop).toMatchRenderedOutput(
3875
+ <>
3876
+ <span prop="Sync: Mofei" />
3877
+ <span prop="Mofei" />
3878
+ </>,
3879
+ );
3880
+
3881
+ await act(async () => {
3882
+ ReactNoop.render(<App id={1} />, () => Scheduler.log('Sync effect'));
3883
+ await waitFor([
3884
+ 'Sync: Mofei',
3885
+ 'Mofei',
3886
+ 'Sync effect',
3887
+ 'destroy(0, Mofei)',
3888
+ 'create(1, Mofei)',
3889
+ ]);
3890
+ expect(ReactNoop).toMatchRenderedOutput(
3891
+ <>
3892
+ <span prop="Sync: Mofei" />
3893
+ <span prop="Mofei" />
3894
+ </>,
3895
+ );
3896
+ ReactNoop.flushPassiveEffects();
3897
+ assertLog([]);
3898
+ });
3899
+
3900
+ await act(async () => {
3901
+ setUsername_('Lauren');
3902
+ await waitFor([
3903
+ 'Sync: Lauren',
3904
+ 'Suspend! [Lauren]',
3905
+ 'Loading',
3906
+ 'update(1, Lauren)',
3907
+ ]);
3908
+ expect(ReactNoop).toMatchRenderedOutput(
3909
+ <>
3910
+ <span prop="Sync: Lauren" />
3911
+ <span hidden={true} prop="Mofei" />
3912
+ <span prop="Loading" />
3913
+ </>,
3914
+ );
3915
+ ReactNoop.flushPassiveEffects();
3916
+ assertLog([]);
3917
+
3918
+ Scheduler.unstable_advanceTime(10);
3919
+ await advanceTimers(10);
3920
+ assertLog(['Promise resolved [Lauren]']);
3921
+ });
3922
+ assertLog(['Lauren']);
3923
+ expect(ReactNoop).toMatchRenderedOutput(
3924
+ <>
3925
+ <span prop="Sync: Lauren" />
3926
+ <span prop="Lauren" />
3927
+ </>,
3928
+ );
3929
+ });
3930
+ });
3931
+
3932
describe('useCallback', () => {
3933
it('memoizes callback by comparing inputs', async () => {
3934
class IncrementButton extends React.PureComponent {
packages/react/index.development.js
+1
@@ -60,6 +60,7 @@ export {
60
useDeferredValue,
61
useEffect,
62
experimental_useEffectEvent,
63
+ experimental_useResourceEffect,
64
useImperativeHandle,
65
useInsertionEffect,
66
useLayoutEffect,
packages/react/index.experimental.development.js
+1
@@ -41,6 +41,7 @@ export {
41
useDeferredValue,
42
useEffect,
43
experimental_useEffectEvent,
44
+ experimental_useResourceEffect,
45
useImperativeHandle,
46
useInsertionEffect,
47
useLayoutEffect,
packages/react/index.fb.js
+1
@@ -19,6 +19,7 @@ export {
19
createElement,
20
createRef,
21
experimental_useEffectEvent,
22
+ experimental_useResourceEffect,
23
forwardRef,
24
Fragment,
25
isValidElement,
packages/react/src/ReactClient.js
+2
@@ -42,6 +42,7 @@ import {
42
useContext,
43
useEffect,
44
useEffectEvent,
45
+ useResourceEffect,
46
useImperativeHandle,
47
useDebugValue,
48
useInsertionEffect,
@@ -89,6 +90,7 @@ export {
90
useContext,
91
useEffect,
92
useEffectEvent as experimental_useEffectEvent,
93
+ useResourceEffect as experimental_useResourceEffect,
94
useImperativeHandle,
95
useDebugValue,
96
useInsertionEffect,
packages/react/src/ReactHooks.js
+16
-2
@@ -18,7 +18,10 @@ import {REACT_CONSUMER_TYPE} from 'shared/ReactSymbols';
18
19
import ReactSharedInternals from 'shared/ReactSharedInternals';
20
21
-import {enableAsyncActions} from 'shared/ReactFeatureFlags';
21
+import {
22
+ enableAsyncActions,
23
+ enableUseResourceEffectHook,
24
+} from 'shared/ReactFeatureFlags';
25
import {
26
enableContextProfiling,
27
enableLazyContextPropagation,
@@ -233,7 +236,18 @@ export function useResourceEffect(
236
updateDeps: Array<mixed> | void | null,
237
destroy: ((resource: mixed) => void) | void,
238
): void {
236
- throw new Error('Not implemented.');
239
+ if (!enableUseResourceEffectHook) {
240
+ throw new Error('Not implemented.');
241
+ }
242
+ const dispatcher = resolveDispatcher();
243
+ // $FlowFixMe[not-a-function] This is unstable, thus optional
244
+ return dispatcher.useResourceEffect(
245
+ create,
246
+ createDeps,
247
+ update,
248
+ updateDeps,
249
+ destroy,
250
+ );
251
}
252
253
export function useOptimistic<S, A>(