From 55c5497265955840a3f9ef1562a797549e690e66 Mon Sep 17 00:00:00 2001 From: lauren Date: Tue, 12 Nov 2024 16:29:30 -0500 Subject: [PATCH] [crud] Basic implementation 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. --- .../src/ReactFiberCallUserSpace.js | 13 +- .../src/ReactFiberCommitEffects.js | 113 ++- .../react-reconciler/src/ReactFiberHooks.js | 440 +++++++++++- .../src/ReactInternalTypes.js | 8 + .../ReactHooksWithNoopRenderer-test.js | 677 ++++++++++++++++++ packages/react/index.development.js | 1 + .../react/index.experimental.development.js | 1 + packages/react/index.fb.js | 1 + packages/react/src/ReactClient.js | 2 + packages/react/src/ReactHooks.js | 18 + packages/shared/ReactFeatureFlags.js | 5 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 2 + ...actFeatureFlags.test-renderer.native-fb.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 2 + .../forks/ReactFeatureFlags.www-dynamic.js | 2 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 18 files changed, 1260 insertions(+), 29 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCallUserSpace.js b/packages/react-reconciler/src/ReactFiberCallUserSpace.js index ada092438a5f0..e4e0e4b81ebbe 100644 --- a/packages/react-reconciler/src/ReactFiberCallUserSpace.js +++ b/packages/react-reconciler/src/ReactFiberCallUserSpace.js @@ -14,6 +14,7 @@ import type {CapturedValue} from './ReactCapturedValue'; import {isRendering, setIsRendering} from './ReactCurrentFiber'; import {captureCommitPhaseError} from './ReactFiberWorkLoop'; +import {SimpleEffectKind} from './ReactFiberHooks'; // These indirections exists so we can exclude its stack frame in DEV (and anything below it). // TODO: Consider marking the whole bundle instead of these boundaries. @@ -177,11 +178,13 @@ export const callComponentWillUnmountInDEV: ( const callCreate = { 'react-stack-bottom-frame': function (effect: Effect): (() => void) | void { - const create = effect.create; - const inst = effect.inst; - const destroy = create(); - inst.destroy = destroy; - return destroy; + if (effect.kind === SimpleEffectKind) { + const create = effect.create; + const inst = effect.inst; + const destroy = create(); + inst.destroy = destroy; + return destroy; + } }, }; diff --git a/packages/react-reconciler/src/ReactFiberCommitEffects.js b/packages/react-reconciler/src/ReactFiberCommitEffects.js index 70c49bc62fae5..eadefaadcbe86 100644 --- a/packages/react-reconciler/src/ReactFiberCommitEffects.js +++ b/packages/react-reconciler/src/ReactFiberCommitEffects.js @@ -18,6 +18,7 @@ import { enableProfilerNestedUpdatePhase, enableSchedulingProfiler, enableScopeAPI, + enableUseResourceEffectHook, } from 'shared/ReactFeatureFlags'; import { ClassComponent, @@ -49,6 +50,7 @@ import { Layout as HookLayout, Insertion as HookInsertion, Passive as HookPassive, + HasEffect as HookHasEffect, } from './ReactHookEffectTags'; import {didWarnAboutReassigningProps} from './ReactFiberBeginWork'; import { @@ -70,6 +72,11 @@ import { } from './ReactFiberCallUserSpace'; import {runWithFiberInDEV} from './ReactCurrentFiber'; +import { + ResourceEffectIdentityKind, + ResourceEffectUpdateKind, + SimpleEffectKind, +} from './ReactFiberHooks'; function shouldProfile(current: Fiber): boolean { return ( @@ -146,19 +153,56 @@ export function commitHookEffectListMount( // Mount let destroy; + if (enableUseResourceEffectHook) { + if (effect.kind === ResourceEffectIdentityKind) { + effect.resource = effect.create(); + if (__DEV__) { + if (effect.resource == null) { + console.error( + 'useResourceEffect must provide a callback which returns a resource. ' + + 'If a managed resource is not needed here, use useEffect. Received %s', + effect.resource, + ); + } + } + if (effect.next.kind === ResourceEffectUpdateKind) { + effect.next.resource = effect.resource; + } + destroy = effect.destroy; + } + if (effect.kind === ResourceEffectUpdateKind) { + if ( + // We don't want to fire updates on remount during Activity + (flags & HookHasEffect) > 0 && + typeof effect.update === 'function' && + effect.resource != null + ) { + // TODO(@poteto) what about multiple updates? + effect.update(effect.resource); + } + } + } if (__DEV__) { if ((flags & HookInsertion) !== NoHookEffect) { setIsRunningInsertionEffect(true); } - destroy = runWithFiberInDEV(finishedWork, callCreateInDEV, effect); + if (effect.kind === SimpleEffectKind) { + destroy = runWithFiberInDEV( + finishedWork, + callCreateInDEV, + effect, + ); + } if ((flags & HookInsertion) !== NoHookEffect) { setIsRunningInsertionEffect(false); } } else { - const create = effect.create; - const inst = effect.inst; - destroy = create(); - inst.destroy = destroy; + if (effect.kind === SimpleEffectKind) { + const create = effect.create; + const inst = effect.inst; + destroy = create(); + inst.destroy = destroy; + } } if (enableSchedulingProfiler) { @@ -176,6 +220,11 @@ export function commitHookEffectListMount( hookName = 'useLayoutEffect'; } else if ((effect.tag & HookInsertion) !== NoFlags) { hookName = 'useInsertionEffect'; + } else if ( + enableUseResourceEffectHook && + effect.kind === ResourceEffectIdentityKind + ) { + hookName = 'useResourceEffect'; } else { hookName = 'useEffect'; } @@ -244,9 +293,28 @@ export function commitHookEffectListUnmount( if ((effect.tag & flags) === flags) { // Unmount const inst = effect.inst; + if ( + enableUseResourceEffectHook && + effect.kind === ResourceEffectIdentityKind && + effect.resource != null + ) { + inst.destroy = effect.destroy; + } const destroy = inst.destroy; if (destroy !== undefined) { inst.destroy = undefined; + let resource; + if (enableUseResourceEffectHook) { + if (effect.kind === ResourceEffectIdentityKind) { + resource = effect.resource; + effect.resource = null; + // TODO(@poteto) very sketchy + if (effect.next.kind === ResourceEffectUpdateKind) { + effect.next.resource = null; + effect.next.update = undefined; + } + } + } if (enableSchedulingProfiler) { if ((flags & HookPassive) !== NoHookEffect) { markComponentPassiveEffectUnmountStarted(finishedWork); @@ -260,7 +328,16 @@ export function commitHookEffectListUnmount( setIsRunningInsertionEffect(true); } } - safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy); + if (enableUseResourceEffectHook) { + safelyCallDestroyWithResource( + finishedWork, + nearestMountedAncestor, + destroy, + resource, + ); + } else { + safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy); + } if (__DEV__) { if ((flags & HookInsertion) !== NoHookEffect) { setIsRunningInsertionEffect(false); @@ -895,6 +972,30 @@ function safelyCallDestroy( } } +function safelyCallDestroyWithResource( + current: Fiber, + nearestMountedAncestor: Fiber | null, + destroy: mixed => void, + resource: mixed, +) { + const destroy_ = resource == null ? destroy : destroy.bind(null, resource); + if (__DEV__) { + runWithFiberInDEV( + current, + callDestroyInDEV, + current, + nearestMountedAncestor, + destroy_, + ); + } else { + try { + destroy_(); + } catch (error) { + captureCommitPhaseError(current, nearestMountedAncestor, error); + } + } +} + function commitProfiler( finishedWork: Fiber, current: Fiber | null, diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 01f3b400adf70..52240c1f69cef 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -48,6 +48,7 @@ import { disableLegacyMode, enableNoCloningMemoCache, enableContextProfiling, + enableUseResourceEffectHook, } from 'shared/ReactFeatureFlags'; import { REACT_CONTEXT_TYPE, @@ -218,12 +219,43 @@ type EffectInstance = { destroy: void | (() => void), }; -export type Effect = { +export const SimpleEffectKind: 0 = 0; +export const ResourceEffectIdentityKind: 1 = 1; +export const ResourceEffectUpdateKind: 2 = 2; +export type EffectKind = + | typeof SimpleEffectKind + | typeof ResourceEffectIdentityKind + | typeof ResourceEffectUpdateKind; +export type Effect = + | SimpleEffect + | ResourceEffectIdentity + | ResourceEffectUpdate; +export type SimpleEffect = { + kind: typeof SimpleEffectKind, tag: HookFlags, + inst: EffectInstance, create: () => (() => void) | void, + createDeps: Array | void | null, + next: Effect, +}; +export type ResourceEffectIdentity = { + kind: typeof ResourceEffectIdentityKind, + tag: HookFlags, + inst: EffectInstance, + create: () => mixed, + createDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + next: Effect, + resource: mixed, +}; +export type ResourceEffectUpdate = { + kind: typeof ResourceEffectUpdateKind, + tag: HookFlags, inst: EffectInstance, - deps: Array | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, next: Effect, + resource: mixed, }; type StoreInstance = { @@ -347,6 +379,23 @@ function checkDepsAreArrayDev(deps: mixed): void { } } +function checkDepsAreNonEmptyArrayDev(deps: mixed): void { + if (__DEV__) { + if ( + deps !== undefined && + deps !== null && + isArray(deps) && + deps.length === 0 + ) { + console.error( + '%s received a dependency array with no dependencies. When ' + + 'specified, the dependency array must have at least one dependency.', + currentHookNameInDev, + ); + } + } +} + function warnOnHookMismatchInDev(currentHookName: HookType): void { if (__DEV__) { const componentName = getComponentNameFromFiber(currentlyRenderingFiber); @@ -1718,10 +1767,10 @@ function mountSyncExternalStore( // directly, without storing any additional state. For the same reason, we // don't need to set a static flag, either. fiber.flags |= PassiveEffect; - pushEffect( + pushSimpleEffect( HookHasEffect | HookPassive, - updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), createEffectInstance(), + updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), null, ); @@ -1788,10 +1837,10 @@ function updateSyncExternalStore( workInProgressHook.memoizedState.tag & HookHasEffect) ) { fiber.flags |= PassiveEffect; - pushEffect( + pushSimpleEffect( HookHasEffect | HookPassive, - updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), createEffectInstance(), + updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot), null, ); @@ -2448,10 +2497,10 @@ function updateActionStateImpl( const prevAction = actionQueueHook.memoizedState; if (action !== prevAction) { currentlyRenderingFiber.flags |= PassiveEffect; - pushEffect( + pushSimpleEffect( HookHasEffect | HookPassive, - actionStateActionEffect.bind(null, actionQueue, action), createEffectInstance(), + actionStateActionEffect.bind(null, actionQueue, action), null, ); } @@ -2508,20 +2557,67 @@ function rerenderActionState( return [state, dispatch, false]; } -function pushEffect( +function pushSimpleEffect( tag: HookFlags, + inst: EffectInstance, create: () => (() => void) | void, + createDeps: Array | void | null, +): Effect { + const effect: SimpleEffect = { + kind: SimpleEffectKind, + tag, + create, + createDeps, + inst, + // Circular + next: (null: any), + }; + return pushEffectImpl(effect); +} + +function pushResourceEffectIdentity( + tag: HookFlags, inst: EffectInstance, - deps: Array | null, + create: () => mixed, + createDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + resource: mixed, ): Effect { - const effect: Effect = { + const effect: ResourceEffectIdentity = { + kind: ResourceEffectIdentityKind, tag, create, + createDeps, + inst, + destroy, + resource, + // Circular + next: (null: any), + }; + return pushEffectImpl(effect); +} + +function pushResourceEffectUpdate( + tag: HookFlags, + inst: EffectInstance, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + resource: mixed, +): Effect { + const effect: ResourceEffectUpdate = { + kind: ResourceEffectUpdateKind, + tag, + update, + updateDeps, inst, - deps, + resource, // Circular next: (null: any), }; + return pushEffectImpl(effect); +} + +function pushEffectImpl(effect: Effect): Effect { let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any); if (componentUpdateQueue === null) { @@ -2565,10 +2661,10 @@ function mountEffectImpl( const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; currentlyRenderingFiber.flags |= fiberFlags; - hook.memoizedState = pushEffect( + hook.memoizedState = pushSimpleEffect( HookHasEffect | hookFlags, - create, createEffectInstance(), + create, nextDeps, ); } @@ -2589,9 +2685,15 @@ function updateEffectImpl( if (currentHook !== null) { if (nextDeps !== null) { const prevEffect: Effect = currentHook.memoizedState; - const prevDeps = prevEffect.deps; + const prevDeps = prevEffect.createDeps; + // $FlowFixMe[incompatible-call] (@poteto) if (areHookInputsEqual(nextDeps, prevDeps)) { - hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps); + hook.memoizedState = pushSimpleEffect( + hookFlags, + inst, + create, + nextDeps, + ); return; } } @@ -2599,10 +2701,10 @@ function updateEffectImpl( currentlyRenderingFiber.flags |= fiberFlags; - hook.memoizedState = pushEffect( + hook.memoizedState = pushSimpleEffect( HookHasEffect | hookFlags, - create, inst, + create, nextDeps, ); } @@ -2639,6 +2741,150 @@ function updateEffect( updateEffectImpl(PassiveEffect, HookPassive, create, deps); } +function mountResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, +) { + if ( + __DEV__ && + (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode && + (currentlyRenderingFiber.mode & NoStrictPassiveEffectsMode) === NoMode + ) { + mountResourceEffectImpl( + MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect, + HookPassive, + create, + createDeps, + update, + updateDeps, + destroy, + ); + } else { + mountResourceEffectImpl( + PassiveEffect | PassiveStaticEffect, + HookPassive, + create, + createDeps, + update, + updateDeps, + destroy, + ); + } +} + +function mountResourceEffectImpl( + fiberFlags: Flags, + hookFlags: HookFlags, + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, +) { + const hook = mountWorkInProgressHook(); + currentlyRenderingFiber.flags |= fiberFlags; + hook.memoizedState = pushResourceEffectIdentity( + HookHasEffect | hookFlags, + createEffectInstance(), + create, + createDeps, + destroy, + ); + hook.memoizedState = pushResourceEffectUpdate( + hookFlags, + createEffectInstance(), + update, + updateDeps, + ); +} + +function updateResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, +) { + updateResourceEffectImpl( + PassiveEffect, + HookPassive, + create, + createDeps, + update, + updateDeps, + destroy, + ); +} + +function updateResourceEffectImpl( + fiberFlags: Flags, + hookFlags: HookFlags, + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, +) { + const hook = updateWorkInProgressHook(); + const effect: Effect = hook.memoizedState; + const inst = effect.inst; + + const nextCreateDeps = createDeps === undefined ? null : createDeps; + const nextUpdateDeps = updateDeps === undefined ? null : updateDeps; + let isCreateDepsSame: boolean; + let isUpdateDepsSame: boolean; + + if (currentHook !== null) { + const prevEffect: Effect = currentHook.memoizedState; + if (nextCreateDeps !== null) { + let prevCreateDeps; + // TODO(@poteto) seems sketchy + if (prevEffect.kind === ResourceEffectIdentityKind) { + prevCreateDeps = + prevEffect.createDeps != null ? prevEffect.createDeps : null; + } else { + prevCreateDeps = + prevEffect.next.createDeps != null + ? prevEffect.next.createDeps + : null; + } + isCreateDepsSame = areHookInputsEqual(nextCreateDeps, prevCreateDeps); + } + if (nextUpdateDeps !== null) { + const prevUpdateDeps = + prevEffect.updateDeps != null ? prevEffect.updateDeps : null; + isUpdateDepsSame = areHookInputsEqual(nextUpdateDeps, prevUpdateDeps); + } + } + + if (!(isCreateDepsSame && isUpdateDepsSame)) { + currentlyRenderingFiber.flags |= fiberFlags; + } + + const resource = + currentHook !== null + ? (currentHook.memoizedState as Effect).resource + : undefined; + hook.memoizedState = pushResourceEffectIdentity( + isCreateDepsSame ? hookFlags : HookHasEffect | hookFlags, + inst, + create, + nextCreateDeps, + destroy, + resource, + ); + hook.memoizedState = pushResourceEffectUpdate( + isUpdateDepsSame ? hookFlags : HookHasEffect | hookFlags, + inst, + update, + nextUpdateDeps, + resource, + ); +} + function useEffectEventImpl) => Return>( payload: EventFunctionPayload, ) { @@ -3789,6 +4035,9 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError; } +if (enableUseResourceEffectHook) { + (ContextOnlyDispatcher: Dispatcher).useResourceEffect = throwInvalidHookError; +} if (enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = throwInvalidHookError; @@ -3832,6 +4081,9 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent; } +if (enableUseResourceEffectHook) { + (HooksDispatcherOnMount: Dispatcher).useResourceEffect = mountResourceEffect; +} if (enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -3875,6 +4127,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } +if (enableUseResourceEffectHook) { + (HooksDispatcherOnUpdate: Dispatcher).useResourceEffect = + updateResourceEffect; +} if (enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -3918,6 +4174,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent; } +if (enableUseResourceEffectHook) { + (HooksDispatcherOnRerender: Dispatcher).useResourceEffect = + updateResourceEffect; +} if (enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4108,6 +4368,27 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableUseResourceEffectHook) { + (HooksDispatcherOnMountInDEV: Dispatcher).useResourceEffect = + function useResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + ): void { + currentHookNameInDev = 'useResourceEffect'; + mountHookTypesDev(); + checkDepsAreNonEmptyArrayDev(updateDeps); + return mountResourceEffect( + create, + createDeps, + update, + updateDeps, + destroy, + ); + }; + } if (enableAsyncActions) { (HooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4300,6 +4581,26 @@ if (__DEV__) { return mountEvent(callback); }; } + if (enableUseResourceEffectHook) { + (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useResourceEffect = + function useResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + ): void { + currentHookNameInDev = 'useResourceEffect'; + updateHookTypesDev(); + return mountResourceEffect( + create, + createDeps, + update, + updateDeps, + destroy, + ); + }; + } if (enableAsyncActions) { (HooksDispatcherOnMountWithHookTypesInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4491,6 +4792,26 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableUseResourceEffectHook) { + (HooksDispatcherOnUpdateInDEV: Dispatcher).useResourceEffect = + function useResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + ) { + currentHookNameInDev = 'useResourceEffect'; + updateHookTypesDev(); + return updateResourceEffect( + create, + createDeps, + update, + updateDeps, + destroy, + ); + }; + } if (enableAsyncActions) { (HooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4682,6 +5003,26 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableUseResourceEffectHook) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useResourceEffect = + function useResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + ) { + currentHookNameInDev = 'useResourceEffect'; + updateHookTypesDev(); + return updateResourceEffect( + create, + createDeps, + update, + updateDeps, + destroy, + ); + }; + } if (enableAsyncActions) { (HooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4897,6 +5238,27 @@ if (__DEV__) { return mountEvent(callback); }; } + if (InvalidNestedHooksDispatcherOnMountInDEV) { + (HooksDispatcherOnRerenderInDEV: Dispatcher).useResourceEffect = + function useResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + ): void { + currentHookNameInDev = 'useResourceEffect'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountResourceEffect( + create, + createDeps, + update, + updateDeps, + destroy, + ); + }; + } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -5115,6 +5477,27 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableUseResourceEffectHook) { + (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useResourceEffect = + function useResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + ) { + currentHookNameInDev = 'useResourceEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateResourceEffect( + create, + createDeps, + update, + updateDeps, + destroy, + ); + }; + } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -5333,6 +5716,27 @@ if (__DEV__) { return updateEvent(callback); }; } + if (enableUseResourceEffectHook) { + (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useResourceEffect = + function useResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + ) { + currentHookNameInDev = 'useResourceEffect'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateResourceEffect( + create, + createDeps, + update, + updateDeps, + destroy, + ); + }; + } if (enableAsyncActions) { (InvalidNestedHooksDispatcherOnRerenderInDEV: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index d91727525c96a..bd40c7478551a 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -47,6 +47,7 @@ export type HookType = | 'useRef' | 'useEffect' | 'useEffectEvent' + | 'useResourceEffect' | 'useInsertionEffect' | 'useLayoutEffect' | 'useCallback' @@ -412,6 +413,13 @@ export type Dispatcher = { deps: Array | void | null, ): void, useEffectEvent?: ) => mixed>(callback: F) => F, + useResourceEffect?: ( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, + ) => void, useInsertionEffect( create: () => (() => void) | void, deps: Array | void | null, diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index bc87e47083e2a..fea2cfb2968f3 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -41,6 +41,7 @@ let waitFor; let waitForThrow; let waitForPaint; let assertLog; +let useResourceEffect; describe('ReactHooksWithNoopRenderer', () => { beforeEach(() => { @@ -66,6 +67,7 @@ describe('ReactHooksWithNoopRenderer', () => { useDeferredValue = React.useDeferredValue; Suspense = React.Suspense; Activity = React.unstable_Activity; + useResourceEffect = React.experimental_useResourceEffect; ContinuousEventPriority = require('react-reconciler/constants').ContinuousEventPriority; if (gate(flags => flags.enableSuspenseList)) { @@ -3252,6 +3254,681 @@ describe('ReactHooksWithNoopRenderer', () => { }); }); + // @gate enableUseResourceEffectHook + describe('useResourceEffect', () => { + class Resource { + isDeleted: false; + id: string; + opts: mixed; + constructor(id, opts) { + this.id = id; + this.opts = opts; + } + update(opts) { + if (this.isDeleted) { + console.error('Cannot update deleted resource'); + return; + } + this.opts = opts; + } + destroy() { + this.isDeleted = true; + } + } + + // @gate enableUseResourceEffectHook + it('validates create return value', async () => { + function App({id}) { + useResourceEffect(() => { + Scheduler.log(`create(${id})`); + }, [id]); + return null; + } + + await expect(async () => { + await act(() => { + ReactNoop.render(); + }); + }).toErrorDev( + 'useResourceEffect must provide a callback which returns a resource. ' + + 'If a managed resource is not needed here, use useEffect. Received undefined', + {withoutStack: true}, + ); + }); + + // @gate enableUseResourceEffectHook + it('validates non-empty update deps', async () => { + function App({id}) { + useResourceEffect( + () => { + Scheduler.log(`create(${id})`); + return {}; + }, + [id], + () => { + Scheduler.log('update'); + }, + [], + ); + return null; + } + + await expect(async () => { + await act(() => { + ReactNoop.render(); + }); + }).toErrorDev( + 'useResourceEffect received a dependency array with no dependencies. ' + + 'When specified, the dependency array must have at least one dependency.', + ); + }); + + // @gate enableUseResourceEffectHook + it('simple mount and update', async () => { + function App({id, username}) { + const opts = useMemo(() => { + return {username}; + }, [username]); + useResourceEffect( + () => { + const resource = new Resource(id, opts); + Scheduler.log(`create(${resource.id}, ${resource.opts.username})`); + return resource; + }, + [id], + resource => { + resource.update(opts); + Scheduler.log(`update(${resource.id}, ${resource.opts.username})`); + }, + [opts], + resource => { + resource.destroy(); + Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`); + }, + ); + return null; + } + + await act(() => { + ReactNoop.render(); + }); + assertLog(['create(1, Jack)']); + + await act(() => { + ReactNoop.render(); + }); + assertLog(['update(1, Lauren)']); + + await act(() => { + ReactNoop.render(); + }); + assertLog([]); + + await act(() => { + ReactNoop.render(); + }); + assertLog(['update(1, Jordan)']); + + await act(() => { + ReactNoop.render(); + }); + assertLog(['destroy(1, Jordan)', 'create(2, Jack)']); + + await act(() => { + ReactNoop.render(null); + }); + assertLog(['destroy(2, Jack)']); + }); + + // @gate enableUseResourceEffectHook + it('simple mount with no update', async () => { + function App({id, username}) { + const opts = useMemo(() => { + return {username}; + }, [username]); + useResourceEffect( + () => { + const resource = new Resource(id, opts); + Scheduler.log(`create(${resource.id}, ${resource.opts.username})`); + return resource; + }, + [id], + resource => { + resource.update(opts); + Scheduler.log(`update(${resource.id}, ${resource.opts.username})`); + }, + [opts], + resource => { + resource.destroy(); + Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`); + }, + ); + return null; + } + + await act(() => { + ReactNoop.render(); + }); + assertLog(['create(1, Jack)']); + + await act(() => { + ReactNoop.render(null); + }); + assertLog(['destroy(1, Jack)']); + }); + + // @gate enableUseResourceEffectHook + it('calls update on every render if no deps are specified', async () => { + function App({id, username}) { + const opts = useMemo(() => { + return {username}; + }, [username]); + useResourceEffect( + () => { + const resource = new Resource(id, opts); + Scheduler.log(`create(${resource.id}, ${resource.opts.username})`); + return resource; + }, + [id], + resource => { + resource.update(opts); + Scheduler.log(`update(${resource.id}, ${resource.opts.username})`); + }, + ); + return null; + } + + await act(() => { + ReactNoop.render(); + }); + assertLog(['create(1, Jack)']); + + await act(() => { + ReactNoop.render(); + }); + assertLog(['update(1, Jack)']); + + await act(() => { + ReactNoop.render(); + }); + assertLog(['create(2, Jack)', 'update(2, Jack)']); + + await act(() => { + ReactNoop.render(); + }); + + assertLog(['update(2, Lauren)']); + }); + + // @gate enableUseResourceEffectHook + it('does not unmount previous useResourceEffect between updates', async () => { + function App({id}) { + useResourceEffect( + () => { + const resource = new Resource(id); + Scheduler.log(`create(${resource.id})`); + return resource; + }, + [], + resource => { + Scheduler.log(`update(${resource.id})`); + }, + undefined, + resource => { + Scheduler.log(`destroy(${resource.id})`); + resource.destroy(); + }, + ); + return ; + } + + await act(async () => { + ReactNoop.render(, () => Scheduler.log('Sync effect')); + await waitFor(['Id: 0', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + }); + + assertLog(['create(0)']); + + await act(async () => { + ReactNoop.render(, () => Scheduler.log('Sync effect')); + await waitFor(['Id: 1', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + }); + + assertLog(['update(0)']); + }); + + // @gate enableUseResourceEffectHook + it('unmounts only on deletion', async () => { + function App({id}) { + useResourceEffect( + () => { + const resource = new Resource(id); + Scheduler.log(`create(${resource.id})`); + return resource; + }, + undefined, + resource => { + Scheduler.log(`update(${resource.id})`); + }, + undefined, + resource => { + Scheduler.log(`destroy(${resource.id})`); + resource.destroy(); + }, + ); + return ; + } + await act(async () => { + ReactNoop.render(, () => Scheduler.log('Sync effect')); + await waitFor(['Id: 0', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + }); + + assertLog(['create(0)']); + + ReactNoop.render(null); + await waitForAll(['destroy(0)']); + expect(ReactNoop).toMatchRenderedOutput(null); + }); + + // @gate enableUseResourceEffectHook + it('unmounts on deletion after skipped effect', async () => { + function Wrapper(props) { + return ; + } + function App({id, username}) { + const opts = useMemo(() => { + return {username}; + }, [username]); + useResourceEffect( + () => { + const resource = new Resource(id, opts); + Scheduler.log(`create(${resource.id}, ${resource.opts.username})`); + return resource; + }, + [id], + resource => { + resource.update(opts); + Scheduler.log(`update(${resource.id}, ${resource.opts.username})`); + }, + [opts], + resource => { + resource.destroy(); + Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`); + }, + ); + return ; + } + + await act(async () => { + ReactNoop.render(, () => + Scheduler.log('Sync effect'), + ); + await waitFor(['Id: 0', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + }); + + assertLog(['create(0, Sathya)']); + + await act(async () => { + ReactNoop.render(, () => + Scheduler.log('Sync effect'), + ); + await waitFor(['Id: 0', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + }); + + assertLog(['update(0, Lauren)']); + + ReactNoop.render(null); + await waitForAll(['destroy(0, Lauren)']); + expect(ReactNoop).toMatchRenderedOutput(null); + }); + + // @gate enableUseResourceEffectHook + it('handles errors in create on mount', async () => { + function App({id}) { + useResourceEffect( + () => { + Scheduler.log(`Mount A [${id}]`); + return {}; + }, + undefined, + undefined, + undefined, + resource => { + Scheduler.log(`Unmount A [${id}]`); + }, + ); + useResourceEffect( + () => { + Scheduler.log('Oops!'); + throw new Error('Oops!'); + // eslint-disable-next-line no-unreachable + Scheduler.log(`Mount B [${id}]`); + return {}; + }, + undefined, + undefined, + undefined, + resource => { + Scheduler.log(`Unmount B [${id}]`); + }, + ); + return ; + } + await expect(async () => { + await act(async () => { + ReactNoop.render(, () => Scheduler.log('Sync effect')); + await waitFor(['Id: 0', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + }); + }).rejects.toThrow('Oops'); + + assertLog([ + 'Mount A [0]', + 'Oops!', + // Clean up effect A. There's no effect B to clean-up, because it + // never mounted. + 'Unmount A [0]', + ]); + expect(ReactNoop).toMatchRenderedOutput(null); + }); + + // @gate enableUseResourceEffectHook + it('handles errors in create on update', async () => { + function App({id}) { + useResourceEffect( + () => { + Scheduler.log(`Mount A [${id}]`); + return {}; + }, + [], + () => { + if (id === 1) { + Scheduler.log('Oops!'); + throw new Error('Oops error!'); + } + Scheduler.log(`Update A [${id}]`); + }, + [id], + () => { + Scheduler.log(`Unmount A [${id}]`); + }, + ); + return ; + } + await act(async () => { + ReactNoop.render(, () => Scheduler.log('Sync effect')); + await waitFor(['Id: 0', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + ReactNoop.flushPassiveEffects(); + assertLog(['Mount A [0]']); + }); + + await expect(async () => { + await act(async () => { + // This update will trigger an error + ReactNoop.render(, () => Scheduler.log('Sync effect')); + await waitFor(['Id: 1', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + ReactNoop.flushPassiveEffects(); + assertLog(['Oops!', 'Unmount A [1]']); + expect(ReactNoop).toMatchRenderedOutput(null); + }); + }).rejects.toThrow('Oops error!'); + }); + + // @gate enableUseResourceEffectHook + it('handles errors in destroy on update', async () => { + function App({id, username}) { + const opts = useMemo(() => { + return {username}; + }, [username]); + useResourceEffect( + () => { + const resource = new Resource(id, opts); + Scheduler.log(`Mount A [${id}, ${resource.opts.username}]`); + return resource; + }, + [id], + resource => { + resource.update(opts); + Scheduler.log(`Update A [${id}, ${resource.opts.username}]`); + }, + [opts], + resource => { + Scheduler.log(`Oops, ${resource.opts.username}!`); + if (id === 1) { + throw new Error(`Oops ${resource.opts.username} error!`); + } + Scheduler.log(`Unmount A [${id}, ${resource.opts.username}]`); + }, + ); + return ; + } + await act(async () => { + ReactNoop.render(, () => + Scheduler.log('Sync effect'), + ); + await waitFor(['Id: 0', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + ReactNoop.flushPassiveEffects(); + assertLog(['Mount A [0, Lauren]']); + }); + + await expect(async () => { + await act(async () => { + // This update will trigger an error during passive effect unmount + ReactNoop.render(, () => + Scheduler.log('Sync effect'), + ); + await waitFor(['Id: 1', 'Sync effect']); + expect(ReactNoop).toMatchRenderedOutput(); + ReactNoop.flushPassiveEffects(); + assertLog(['Oops, Lauren!', 'Mount A [1, Sathya]', 'Oops, Sathya!']); + }); + // TODO(lauren) more explicit assertions. this is weird because we + // destroy both the first and second resource + }).rejects.toThrow(); + + expect(ReactNoop).toMatchRenderedOutput(null); + }); + + // @gate enableUseResourceEffectHook && enableActivity + it('composes with activity', async () => { + function App({id, username}) { + const opts = useMemo(() => { + return {username}; + }, [username]); + useResourceEffect( + () => { + const resource = new Resource(id, opts); + Scheduler.log(`create(${resource.id}, ${resource.opts.username})`); + return resource; + }, + [id], + resource => { + resource.update(opts); + Scheduler.log(`update(${resource.id}, ${resource.opts.username})`); + }, + [opts], + resource => { + resource.destroy(); + Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`); + }, + ); + return null; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render( + + + , + ); + }); + assertLog([]); + + await act(() => { + root.render( + + + , + ); + }); + assertLog([]); + + await act(() => { + root.render( + + + , + ); + }); + assertLog(['create(0, Rick)']); + + await act(() => { + root.render( + + + , + ); + }); + assertLog(['update(0, Lauren)']); + + await act(() => { + root.render( + + + , + ); + }); + assertLog(['destroy(0, Lauren)']); + }); + + // @gate enableUseResourceEffectHook + it('composes with suspense', async () => { + function TextBox({text}) { + return ; + } + let setUsername_; + function App({id}) { + const [username, setUsername] = useState('Mofei'); + setUsername_ = setUsername; + const opts = useMemo(() => { + return {username}; + }, [username]); + useResourceEffect( + () => { + const resource = new Resource(id, opts); + Scheduler.log(`create(${resource.id}, ${resource.opts.username})`); + return resource; + }, + [id], + resource => { + resource.update(opts); + Scheduler.log(`update(${resource.id}, ${resource.opts.username})`); + }, + [opts], + resource => { + resource.destroy(); + Scheduler.log(`destroy(${resource.id}, ${resource.opts.username})`); + }, + ); + return ( + <> + + }> + + + + ); + } + + await act(async () => { + ReactNoop.render(); + await waitFor([ + 'Sync: Mofei', + 'Suspend! [Mofei]', + 'Loading', + 'create(0, Mofei)', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + ReactNoop.flushPassiveEffects(); + assertLog([]); + + Scheduler.unstable_advanceTime(10); + await advanceTimers(10); + assertLog(['Promise resolved [Mofei]']); + }); + assertLog(['Mofei']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + await act(async () => { + ReactNoop.render(, () => Scheduler.log('Sync effect')); + await waitFor([ + 'Sync: Mofei', + 'Mofei', + 'Sync effect', + 'destroy(0, Mofei)', + 'create(1, Mofei)', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + ReactNoop.flushPassiveEffects(); + assertLog([]); + }); + + await act(async () => { + setUsername_('Lauren'); + await waitFor([ + 'Sync: Lauren', + 'Suspend! [Lauren]', + 'Loading', + 'update(1, Lauren)', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> + +