From d8afd1c82e671662c6d1e998bad7fe524071bd56 Mon Sep 17 00:00:00 2001 From: lauren Date: Fri, 15 Nov 2024 17:49:31 -0500 Subject: [PATCH 1/4] [crud] Scaffold initial types (#31555) Scaffolds the initial `useResourceEffect` dispatcher type. This will eventually be folded into `useEffect` et al as an overload. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/31555). * #31523 * #31557 * #31556 * __->__ #31555 --- packages/react-reconciler/src/ReactInternalTypes.js | 8 ++++++++ packages/react/src/ReactHooks.js | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index d91727525c..bd40c74785 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/src/ReactHooks.js b/packages/react/src/ReactHooks.js index dba0d4be0f..4d9f7ce937 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -226,6 +226,16 @@ export function useEffectEvent) => mixed>( return dispatcher.useEffectEvent(callback); } +export function useResourceEffect( + create: () => mixed, + createDeps: Array | void | null, + update: ((resource: mixed) => void) | void, + updateDeps: Array | void | null, + destroy: ((resource: mixed) => void) | void, +): void { + throw new Error('Not implemented.'); +} + export function useOptimistic( passthrough: S, reducer: ?(S, A) => S, From 7dd6b9e68abed4f37a2dafb649294dafdfd26b63 Mon Sep 17 00:00:00 2001 From: lauren Date: Fri, 15 Nov 2024 17:49:42 -0500 Subject: [PATCH 2/4] [crud] Add enableUseResourceEffectHook flag (#31556) Adds a new feature flag for `enableUseResourceEffectHook`. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/31556). * #31523 * #31557 * __->__ #31556 * #31555 --- packages/shared/ReactFeatureFlags.js | 5 +++++ packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js | 1 + packages/shared/forks/ReactFeatureFlags.native-fb.js | 1 + packages/shared/forks/ReactFeatureFlags.native-oss.js | 1 + packages/shared/forks/ReactFeatureFlags.test-renderer.js | 2 ++ .../forks/ReactFeatureFlags.test-renderer.native-fb.js | 1 + packages/shared/forks/ReactFeatureFlags.test-renderer.www.js | 2 ++ packages/shared/forks/ReactFeatureFlags.www-dynamic.js | 2 ++ packages/shared/forks/ReactFeatureFlags.www.js | 1 + 9 files changed, 16 insertions(+) diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 02e2e796a3..997e946105 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -161,6 +161,11 @@ export const transitionLaneExpirationMs = 5000; */ export const enableInfiniteRenderLoopDetection = false; +/** + * Experimental new hook for better managing resources in effects. + */ +export const enableUseResourceEffectHook = false; + // ----------------------------------------------------------------------------- // Ready for next major. // diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index 457ec37b2f..8328383130 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -25,3 +25,4 @@ export const enableShallowPropDiffing = __VARIANT__; export const passChildrenWhenCloningPersistedNodes = __VARIANT__; export const enableFabricCompleteRootInCommitPhase = __VARIANT__; export const enableSiblingPrerendering = __VARIANT__; +export const enableUseResourceEffectHook = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index a553c275fe..748aa03a9d 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -25,6 +25,7 @@ export const { enableObjectFiber, enablePersistedModeClonedFlag, enableShallowPropDiffing, + enableUseResourceEffectHook, passChildrenWhenCloningPersistedNodes, enableSiblingPrerendering, } = dynamicFlags; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 89c6413ac3..9f48f3877f 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -84,6 +84,7 @@ export const syncLaneExpirationMs = 250; export const transitionLaneExpirationMs = 5000; export const useModernStrictMode = true; export const enableSiblingPrerendering = true; +export const enableUseResourceEffectHook = false; // Profiling Only export const enableProfilerTimer = __PROFILE__; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 04246165a8..fbb95e0829 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -83,6 +83,8 @@ export const renameElementSymbol = true; export const enableShallowPropDiffing = false; export const enableSiblingPrerendering = true; +export const enableUseResourceEffectHook = false; + // TODO: This must be in sync with the main ReactFeatureFlags file because // the Test Renderer's value must be the same as the one used by the // react package. diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 0198b7419a..62b8343122 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -80,6 +80,7 @@ export const transitionLaneExpirationMs = 5000; export const useModernStrictMode = true; export const enableFabricCompleteRootInCommitPhase = false; export const enableSiblingPrerendering = true; +export const enableUseResourceEffectHook = false; // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index ebd7d03431..e28627760a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -94,5 +94,7 @@ export const enableOwnerStacks = false; export const enableShallowPropDiffing = false; export const enableSiblingPrerendering = true; +export const enableUseResourceEffectHook = false; + // Flow magic to verify the exports of this file match the original version. ((((null: any): ExportsType): FeatureFlagsType): ExportsType); diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 482ebb03c6..db38e0e1fd 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -42,6 +42,8 @@ export const enableSchedulingProfiler = __VARIANT__; export const enableInfiniteRenderLoopDetection = __VARIANT__; export const enableSiblingPrerendering = __VARIANT__; +export const enableUseResourceEffectHook = __VARIANT__; + // TODO: These flags are hard-coded to the default values used in open source. // Update the tests so that they pass in either mode, then set these // to __VARIANT__. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 013092c1c4..67d1ad74c0 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -31,6 +31,7 @@ export const { enableSiblingPrerendering, enableTransitionTracing, enableTrustedTypesIntegration, + enableUseResourceEffectHook, favorSafetyOverHydrationPerf, renameElementSymbol, retryLaneExpirationMs, From 053b3cb0503e26da6d1dfa02b74fa52e30936bd6 Mon Sep 17 00:00:00 2001 From: lauren Date: Fri, 15 Nov 2024 17:50:50 -0500 Subject: [PATCH 3/4] [crud] Rename Effect type (#31557) Adds a new `Effect` type which for now just points to the `SimpleEffect` type, in prepartion for later in the stack where we add more. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/facebook/react/pull/31557). * #31523 * __->__ #31557 * #31556 * #31555 --- packages/react-reconciler/src/ReactFiberHooks.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 174235fc76..2e4f23dd7b 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -218,7 +218,8 @@ type EffectInstance = { destroy: void | (() => void), }; -export type Effect = { +export type Effect = SimpleEffect; +export type SimpleEffect = { tag: HookFlags, create: () => (() => void) | void, inst: EffectInstance, @@ -2522,6 +2523,10 @@ function pushEffect( // Circular next: (null: any), }; + return pushEffectImpl(effect); +} + +function pushEffectImpl(effect: Effect): Effect { let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any); if (componentUpdateQueue === null) { From 4f21a87cb7da92570b2245760a5e1b6d5b62b639 Mon Sep 17 00:00:00 2001 From: lauren Date: Fri, 15 Nov 2024 17:51:19 -0500 Subject: [PATCH 4/4] [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 | 59 +- .../src/ReactFiberCommitEffects.js | 161 ++++- .../react-reconciler/src/ReactFiberHooks.js | 426 ++++++++++- .../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 +- 9 files changed, 1311 insertions(+), 35 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCallUserSpace.js b/packages/react-reconciler/src/ReactFiberCallUserSpace.js index ada092438a..a1b86c7560 100644 --- a/packages/react-reconciler/src/ReactFiberCallUserSpace.js +++ b/packages/react-reconciler/src/ReactFiberCallUserSpace.js @@ -14,6 +14,11 @@ import type {CapturedValue} from './ReactCapturedValue'; import {isRendering, setIsRendering} from './ReactCurrentFiber'; import {captureCommitPhaseError} from './ReactFiberWorkLoop'; +import { + ResourceEffectIdentityKind, + ResourceEffectUpdateKind, +} from './ReactFiberHooks'; +import {enableUseResourceEffectHook} from 'shared/ReactFeatureFlags'; // 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. @@ -176,12 +181,54 @@ export const callComponentWillUnmountInDEV: ( : (null: any); 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; + 'react-stack-bottom-frame': function ( + effect: Effect, + ): (() => void) | mixed | void { + if (!enableUseResourceEffectHook) { + if (effect.resourceKind != null) { + if (__DEV__) { + console.error( + 'Expected only SimpleEffects when enableUseResourceEffectHook is disabled, ' + + 'got %s', + effect.resourceKind, + ); + } + } + const create = effect.create; + const inst = effect.inst; + // $FlowFixMe[not-a-function] (@poteto) + const destroy = create(); + // $FlowFixMe[incompatible-type] (@poteto) + inst.destroy = destroy; + return destroy; + } else { + if (effect.resourceKind == null) { + const create = effect.create; + const inst = effect.inst; + const destroy = create(); + inst.destroy = destroy; + return destroy; + } + switch (effect.resourceKind) { + case ResourceEffectIdentityKind: { + return effect.create(); + } + case ResourceEffectUpdateKind: { + if (typeof effect.update === 'function') { + effect.update(effect.inst.resource); + } + break; + } + default: { + if (__DEV__) { + console.error( + 'Unhandled Effect kind %s. This is a bug in React.', + effect.kind, + ); + } + } + } + } }, }; diff --git a/packages/react-reconciler/src/ReactFiberCommitEffects.js b/packages/react-reconciler/src/ReactFiberCommitEffects.js index 70c49bc62f..0847f26034 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,10 @@ import { } from './ReactFiberCallUserSpace'; import {runWithFiberInDEV} from './ReactCurrentFiber'; +import { + ResourceEffectIdentityKind, + ResourceEffectUpdateKind, +} from './ReactFiberHooks'; function shouldProfile(current: Fiber): boolean { return ( @@ -146,19 +152,90 @@ export function commitHookEffectListMount( // Mount let destroy; + if (enableUseResourceEffectHook) { + if (effect.resourceKind === ResourceEffectIdentityKind) { + if (__DEV__) { + effect.inst.resource = runWithFiberInDEV( + finishedWork, + callCreateInDEV, + effect, + ); + if (effect.inst.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.inst.resource, + ); + } + } else { + effect.inst.resource = effect.create(); + } + destroy = effect.inst.destroy; + } + if (effect.resourceKind === ResourceEffectUpdateKind) { + if ( + // We don't want to fire updates on remount during Activity + (flags & HookHasEffect) > 0 && + typeof effect.update === 'function' && + effect.inst.resource != null + ) { + // TODO(@poteto) what about multiple updates? + if (__DEV__) { + runWithFiberInDEV(finishedWork, callCreateInDEV, effect); + } else { + effect.update(effect.inst.resource); + } + } + } + } if (__DEV__) { if ((flags & HookInsertion) !== NoHookEffect) { setIsRunningInsertionEffect(true); } - destroy = runWithFiberInDEV(finishedWork, callCreateInDEV, effect); + if (enableUseResourceEffectHook) { + if (effect.resourceKind == null) { + destroy = runWithFiberInDEV( + finishedWork, + callCreateInDEV, + effect, + ); + } + } else { + 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 (enableUseResourceEffectHook) { + if (effect.resourceKind == null) { + const create = effect.create; + const inst = effect.inst; + destroy = create(); + inst.destroy = destroy; + } + } else { + if (effect.resourceKind != null) { + if (__DEV__) { + console.error( + 'Expected only SimpleEffects when enableUseResourceEffectHook is disabled, ' + + 'got %s', + effect.resourceKind, + ); + } + } + const create = effect.create; + const inst = effect.inst; + // $FlowFixMe[incompatible-type] (@poteto) + // $FlowFixMe[not-a-function] (@poteto) + destroy = create(); + // $FlowFixMe[incompatible-type] (@poteto) + inst.destroy = destroy; + } } if (enableSchedulingProfiler) { @@ -176,6 +253,11 @@ export function commitHookEffectListMount( hookName = 'useLayoutEffect'; } else if ((effect.tag & HookInsertion) !== NoFlags) { hookName = 'useInsertionEffect'; + } else if ( + enableUseResourceEffectHook && + effect.resourceKind != null + ) { + hookName = 'useResourceEffect'; } else { hookName = 'useEffect'; } @@ -202,6 +284,7 @@ export function commitHookEffectListMount( `}, [someId]); // Or [] if effect doesn't need props or state\n\n` + 'Learn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching'; } else { + // $FlowFixMe[unsafe-addition] (@poteto) addendum = ' You returned: ' + destroy; } runWithFiberInDEV( @@ -246,7 +329,13 @@ export function commitHookEffectListUnmount( const inst = effect.inst; const destroy = inst.destroy; if (destroy !== undefined) { - inst.destroy = undefined; + if (enableUseResourceEffectHook) { + if (effect.resourceKind == null) { + inst.destroy = undefined; + } + } else { + inst.destroy = undefined; + } if (enableSchedulingProfiler) { if ((flags & HookPassive) !== NoHookEffect) { markComponentPassiveEffectUnmountStarted(finishedWork); @@ -260,7 +349,41 @@ export function commitHookEffectListUnmount( setIsRunningInsertionEffect(true); } } - safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy); + if (enableUseResourceEffectHook) { + if ( + effect.resourceKind === ResourceEffectIdentityKind && + effect.inst.resource != null + ) { + safelyCallDestroyWithResource( + finishedWork, + nearestMountedAncestor, + destroy, + effect.inst.resource, + ); + if (effect.next.resourceKind === ResourceEffectUpdateKind) { + // $FlowFixMe[prop-missing] (@poteto) + effect.next.update = undefined; + } else { + if (__DEV__) { + console.error( + 'Expected a ResourceEffectUpdateKind to follow ResourceEffectIdentityKind, ' + + 'got %s. This is a bug in React.', + effect.next.resourceKind, + ); + } + } + effect.inst.resource = null; + } + if (effect.resourceKind == null) { + safelyCallDestroy( + finishedWork, + nearestMountedAncestor, + destroy, + ); + } + } else { + safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy); + } if (__DEV__) { if ((flags & HookInsertion) !== NoHookEffect) { setIsRunningInsertionEffect(false); @@ -895,6 +1018,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 2e4f23dd7b..93f2aa41c3 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, @@ -215,15 +216,40 @@ export type Hook = { // the additional memory and we can follow up with performance // optimizations later. type EffectInstance = { - destroy: void | (() => void), + resource: mixed, + destroy: void | (() => void) | ((resource: mixed) => void), }; -export type Effect = SimpleEffect; +export const ResourceEffectIdentityKind: 0 = 0; +export const ResourceEffectUpdateKind: 1 = 1; +export type EffectKind = + | typeof ResourceEffectIdentityKind + | typeof ResourceEffectUpdateKind; +export type Effect = + | SimpleEffect + | ResourceEffectIdentity + | ResourceEffectUpdate; export type SimpleEffect = { tag: HookFlags, - create: () => (() => void) | void, inst: EffectInstance, - deps: Array | null, + create: () => (() => void) | void, + deps: Array | void | null, + next: Effect, +}; +export type ResourceEffectIdentity = { + resourceKind: typeof ResourceEffectIdentityKind, + tag: HookFlags, + inst: EffectInstance, + create: () => mixed, + deps: Array | void | null, + next: Effect, +}; +export type ResourceEffectUpdate = { + resourceKind: typeof ResourceEffectUpdateKind, + tag: HookFlags, + inst: EffectInstance, + update: ((resource: mixed) => void) | void, + deps: Array | void | null, next: Effect, }; @@ -348,6 +374,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); @@ -1719,10 +1762,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, ); @@ -1789,10 +1832,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, ); @@ -2449,10 +2492,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, ); } @@ -2509,17 +2552,53 @@ function rerenderActionState( return [state, dispatch, false]; } -function pushEffect( +function pushSimpleEffect( tag: HookFlags, - create: () => (() => void) | void, inst: EffectInstance, - deps: Array | null, + create: () => (() => void) | void, + deps: Array | void | null, ): Effect { const effect: Effect = { tag, create, - inst, deps, + inst, + // Circular + next: (null: any), + }; + return pushEffectImpl(effect); +} + +function pushResourceEffectIdentity( + tag: HookFlags, + inst: EffectInstance, + create: () => mixed, + deps: Array | void | null, +): Effect { + const effect: ResourceEffectIdentity = { + resourceKind: ResourceEffectIdentityKind, + tag, + create, + deps, + inst, + // Circular + next: (null: any), + }; + return pushEffectImpl(effect); +} + +function pushResourceEffectUpdate( + tag: HookFlags, + inst: EffectInstance, + update: ((resource: mixed) => void) | void, + deps: Array | void | null, +): Effect { + const effect: ResourceEffectUpdate = { + resourceKind: ResourceEffectUpdateKind, + tag, + update, + deps, + inst, // Circular next: (null: any), }; @@ -2546,7 +2625,7 @@ function pushEffectImpl(effect: Effect): Effect { } function createEffectInstance(): EffectInstance { - return {destroy: undefined}; + return {destroy: undefined, resource: undefined}; } function mountRef(initialValue: T): {current: T} { @@ -2570,10 +2649,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, ); } @@ -2595,8 +2674,14 @@ function updateEffectImpl( if (nextDeps !== null) { const prevEffect: Effect = currentHook.memoizedState; const prevDeps = prevEffect.deps; + // $FlowFixMe[incompatible-call] (@poteto) if (areHookInputsEqual(nextDeps, prevDeps)) { - hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps); + hook.memoizedState = pushSimpleEffect( + hookFlags, + inst, + create, + nextDeps, + ); return; } } @@ -2604,10 +2689,10 @@ function updateEffectImpl( currentlyRenderingFiber.flags |= fiberFlags; - hook.memoizedState = pushEffect( + hook.memoizedState = pushSimpleEffect( HookHasEffect | hookFlags, - create, inst, + create, nextDeps, ); } @@ -2644,6 +2729,149 @@ 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; + const inst = createEffectInstance(); + inst.destroy = destroy; + hook.memoizedState = pushResourceEffectIdentity( + HookHasEffect | hookFlags, + inst, + create, + createDeps, + ); + hook.memoizedState = pushResourceEffectUpdate( + hookFlags, + inst, + 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; + inst.destroy = destroy; + + 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; + // Seems sketchy but in practice we always push an Identity and an Update together. For safety + // we error in DEV if this does not hold true. + if (prevEffect.resourceKind === ResourceEffectUpdateKind) { + prevCreateDeps = + prevEffect.next.deps != null ? prevEffect.next.deps : null; + } else { + if (__DEV__) { + console.error( + 'Expected a ResourceEffectUpdateKind to be pushed together with ' + + 'ResourceEffectIdentityKind, got %s. This is a bug in React.', + prevEffect.resourceKind, + ); + } + prevCreateDeps = prevEffect.deps != null ? prevEffect.deps : null; + } + isCreateDepsSame = areHookInputsEqual(nextCreateDeps, prevCreateDeps); + } + if (nextUpdateDeps !== null) { + const prevUpdateDeps = prevEffect.deps != null ? prevEffect.deps : null; + isUpdateDepsSame = areHookInputsEqual(nextUpdateDeps, prevUpdateDeps); + } + } + + if (!(isCreateDepsSame && isUpdateDepsSame)) { + currentlyRenderingFiber.flags |= fiberFlags; + } + + hook.memoizedState = pushResourceEffectIdentity( + isCreateDepsSame ? hookFlags : HookHasEffect | hookFlags, + inst, + create, + nextCreateDeps, + ); + hook.memoizedState = pushResourceEffectUpdate( + isUpdateDepsSame ? hookFlags : HookHasEffect | hookFlags, + inst, + update, + nextUpdateDeps, + ); +} + function useEffectEventImpl) => Return>( payload: EventFunctionPayload, ) { @@ -3794,6 +4022,9 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (ContextOnlyDispatcher: Dispatcher).useEffectEvent = throwInvalidHookError; } +if (enableUseResourceEffectHook) { + (ContextOnlyDispatcher: Dispatcher).useResourceEffect = throwInvalidHookError; +} if (enableAsyncActions) { (ContextOnlyDispatcher: Dispatcher).useHostTransitionStatus = throwInvalidHookError; @@ -3837,6 +4068,9 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent; } +if (enableUseResourceEffectHook) { + (HooksDispatcherOnMount: Dispatcher).useResourceEffect = mountResourceEffect; +} if (enableAsyncActions) { (HooksDispatcherOnMount: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -3880,6 +4114,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent; } +if (enableUseResourceEffectHook) { + (HooksDispatcherOnUpdate: Dispatcher).useResourceEffect = + updateResourceEffect; +} if (enableAsyncActions) { (HooksDispatcherOnUpdate: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -3923,6 +4161,10 @@ if (enableUseMemoCacheHook) { if (enableUseEffectEventHook) { (HooksDispatcherOnRerender: Dispatcher).useEffectEvent = updateEvent; } +if (enableUseResourceEffectHook) { + (HooksDispatcherOnRerender: Dispatcher).useResourceEffect = + updateResourceEffect; +} if (enableAsyncActions) { (HooksDispatcherOnRerender: Dispatcher).useHostTransitionStatus = useHostTransitionStatus; @@ -4113,6 +4355,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; @@ -4305,6 +4568,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; @@ -4496,6 +4779,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; @@ -4687,6 +4990,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; @@ -4902,6 +5225,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; @@ -5120,6 +5464,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; @@ -5338,6 +5703,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/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index bc87e47083..f9b382647d 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', 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( + <> + +