@samitouri / QOS-React-2 / commits / 047d95e85f

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