diff --git a/.changeset/forty-books-dance.md b/.changeset/forty-books-dance.md new file mode 100644 index 000000000..36f548650 --- /dev/null +++ b/.changeset/forty-books-dance.md @@ -0,0 +1,5 @@ +--- +"mobx-react-lite": patch +--- + +refactor reaction tracking diff --git a/packages/mobx-react-lite/__tests__/strictAndConcurrentModeUsingFinalizationRegistry.test.tsx b/packages/mobx-react-lite/__tests__/strictAndConcurrentModeUsingFinalizationRegistry.test.tsx index 584685550..cd9a35fcc 100644 --- a/packages/mobx-react-lite/__tests__/strictAndConcurrentModeUsingFinalizationRegistry.test.tsx +++ b/packages/mobx-react-lite/__tests__/strictAndConcurrentModeUsingFinalizationRegistry.test.tsx @@ -1,21 +1,20 @@ import { cleanup, render } from "@testing-library/react" import * as mobx from "mobx" import * as React from "react" - import { useObserver } from "../src/useObserver" import { sleep } from "./utils" -import { FinalizationRegistry } from "../src/utils/FinalizationRegistryWrapper" - -// @ts-ignore import gc from "expose-gc/function" +import { observerFinalizationRegistry } from "../src/utils/observerFinalizationRegistry" + +if (typeof globalThis.FinalizationRegistry !== "function") { + throw new Error("This test must run with node >= 14") +} + +expect(observerFinalizationRegistry).toBeInstanceOf(globalThis.FinalizationRegistry) afterEach(cleanup) test("uncommitted components should not leak observations", async () => { - if (!FinalizationRegistry) { - throw new Error("This test must run with node >= 14") - } - const store = mobx.observable({ count1: 0, count2: 0 }) // Track whether counts are observed diff --git a/packages/mobx-react-lite/__tests__/strictAndConcurrentModeUsingTimers.test.tsx b/packages/mobx-react-lite/__tests__/strictAndConcurrentModeUsingTimers.test.tsx index 945a5d15a..d7250ccb4 100644 --- a/packages/mobx-react-lite/__tests__/strictAndConcurrentModeUsingTimers.test.tsx +++ b/packages/mobx-react-lite/__tests__/strictAndConcurrentModeUsingTimers.test.tsx @@ -2,21 +2,22 @@ import "./utils/killFinalizationRegistry" import { act, cleanup, render } from "@testing-library/react" import * as mobx from "mobx" import * as React from "react" - import { useObserver } from "../src/useObserver" import { - forceCleanupTimerToRunNowForTests, - resetCleanupScheduleForTests -} from "../src/utils/reactionCleanupTracking" -import { - CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS, - CLEANUP_TIMER_LOOP_MILLIS -} from "../src/utils/reactionCleanupTrackingCommon" + REGISTRY_FINALIZE_AFTER, + REGISTRY_SWEEP_INTERVAL +} from "../src/utils/UniversalFinalizationRegistry" +import { observerFinalizationRegistry } from "../src/utils/observerFinalizationRegistry" +import { TimerBasedFinalizationRegistry } from "../src/utils/UniversalFinalizationRegistry" + +expect(observerFinalizationRegistry).toBeInstanceOf(TimerBasedFinalizationRegistry) + +const registry = observerFinalizationRegistry as TimerBasedFinalizationRegistry afterEach(cleanup) test("uncommitted components should not leak observations", async () => { - resetCleanupScheduleForTests() + registry.finalizeAllImmediately() // Unfortunately, Jest fake timers don't mock out Date.now, so we fake // that out in parallel to Jest useFakeTimers @@ -51,7 +52,7 @@ test("uncommitted components should not leak observations", async () => { ) // Allow any reaction-disposal cleanup timers to run - const skip = Math.max(CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS, CLEANUP_TIMER_LOOP_MILLIS) + const skip = Math.max(REGISTRY_FINALIZE_AFTER, REGISTRY_SWEEP_INTERVAL) fakeNow += skip jest.advanceTimersByTime(skip) @@ -72,7 +73,7 @@ test("cleanup timer should not clean up recently-pended reactions", () => { // 5. The commit phase runs for component A, but reaction R2 has already been disposed. Game over. // This unit test attempts to replicate that scenario: - resetCleanupScheduleForTests() + registry.finalizeAllImmediately() // Unfortunately, Jest fake timers don't mock out Date.now, so we fake // that out in parallel to Jest useFakeTimers @@ -106,7 +107,7 @@ test("cleanup timer should not clean up recently-pended reactions", () => { // We force our cleanup loop to run even though enough time hasn't _really_ // elapsed. In theory, it won't do anything because not enough time has // elapsed since the reactions were queued, and so they won't be disposed. - forceCleanupTimerToRunNowForTests() + registry.sweep() // Advance time enough to allow any timer-queued effects to run jest.advanceTimersByTime(500) @@ -137,7 +138,7 @@ test.skip("component should recreate reaction if necessary", () => { // This unit test attempts to replicate that scenario: - resetCleanupScheduleForTests() + registry.finalizeAllImmediately() // Unfortunately, Jest fake timers don't mock out Date.now, so we fake // that out in parallel to Jest useFakeTimers @@ -166,9 +167,9 @@ test.skip("component should recreate reaction if necessary", () => { // and _then_ the component commits. // Force everything to be disposed. - const skip = Math.max(CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS, CLEANUP_TIMER_LOOP_MILLIS) + const skip = Math.max(REGISTRY_FINALIZE_AFTER, REGISTRY_SWEEP_INTERVAL) fakeNow += skip - forceCleanupTimerToRunNowForTests() + registry.sweep() // The reaction should have been cleaned up. expect(countIsObserved).toBeFalsy() diff --git a/packages/mobx-react-lite/src/index.ts b/packages/mobx-react-lite/src/index.ts index b493ee1e5..55eade13f 100644 --- a/packages/mobx-react-lite/src/index.ts +++ b/packages/mobx-react-lite/src/index.ts @@ -5,6 +5,7 @@ import { observerBatching } from "./utils/observerBatching" import { useDeprecated } from "./utils/utils" import { useObserver as useObserverOriginal } from "./useObserver" import { enableStaticRendering } from "./staticRendering" +import { observerFinalizationRegistry } from "./utils/observerFinalizationRegistry" observerBatching(batch) @@ -14,7 +15,8 @@ export { Observer } from "./ObserverComponent" export { useLocalObservable } from "./useLocalObservable" export { useLocalStore } from "./useLocalStore" export { useAsObservableSource } from "./useAsObservableSource" -export { resetCleanupScheduleForTests as clearTimers } from "./utils/reactionCleanupTracking" + +export const clearTimes = observerFinalizationRegistry["finalizeAllImmediately"] ?? (() => {}) export function useObserver(fn: () => T, baseComponentName: string = "observed"): T { if ("production" !== process.env.NODE_ENV) { diff --git a/packages/mobx-react-lite/src/useObserver.ts b/packages/mobx-react-lite/src/useObserver.ts index a5d889f7a..c54939520 100644 --- a/packages/mobx-react-lite/src/useObserver.ts +++ b/packages/mobx-react-lite/src/useObserver.ts @@ -1,17 +1,30 @@ import { Reaction } from "mobx" import React from "react" import { printDebugValue } from "./utils/printDebugValue" -import { - addReactionToTrack, - IReactionTracking, - recordReactionAsCommitted -} from "./utils/reactionCleanupTracking" +import { observerFinalizationRegistry } from "./utils/observerFinalizationRegistry" import { isUsingStaticRendering } from "./staticRendering" function observerComponentNameFor(baseComponentName: string) { return `observer${baseComponentName}` } +type ObserverAdministration = { + /** The Reaction created during first render, which may be leaked */ + reaction: Reaction | null + + /** + * Whether the component has yet completed mounting (for us, whether + * its useEffect has run) + */ + mounted: boolean + + /** + * Whether the observables that the component is tracking changed between + * the first render and the first useEffect. + */ + changedBeforeMount: boolean +} + /** * We use class to make it easier to detect in heap snapshots by name */ @@ -34,50 +47,51 @@ export function useObserver(fn: () => T, baseComponentName: string = "observe // StrictMode/ConcurrentMode/Suspense may mean that our component is // rendered and abandoned multiple times, so we need to track leaked // Reactions. - const reactionTrackingRef = React.useRef(null) + const admRef = React.useRef(null) + + if (!admRef.current) { + // First render + admRef.current = { + reaction: null, + mounted: false, + changedBeforeMount: false + } + } - if (!reactionTrackingRef.current) { - // First render for this component (or first time since a previous - // reaction from an abandoned render was disposed). + const adm = admRef.current! - const newReaction = new Reaction(observerComponentNameFor(baseComponentName), () => { + if (!adm.reaction) { + // First render or component was not committed and reaction was disposed by registry + adm.reaction = new Reaction(observerComponentNameFor(baseComponentName), () => { // Observable has changed, meaning we want to re-render // BUT if we're a component that hasn't yet got to the useEffect() // stage, we might be a component that _started_ to render, but // got dropped, and we don't want to make state changes then. // (It triggers warnings in StrictMode, for a start.) - if (trackingData.mounted) { + if (adm.mounted) { // We have reached useEffect(), so we're mounted, and can trigger an update forceUpdate() } else { // We haven't yet reached useEffect(), so we'll need to trigger a re-render // when (and if) useEffect() arrives. - trackingData.changedBeforeMount = true + adm.changedBeforeMount = true } }) - const trackingData = addReactionToTrack( - reactionTrackingRef, - newReaction, - objectRetainedByReact - ) + observerFinalizationRegistry.register(objectRetainedByReact, adm, adm) } - const { reaction } = reactionTrackingRef.current! - React.useDebugValue(reaction, printDebugValue) + React.useDebugValue(adm.reaction, printDebugValue) React.useEffect(() => { - // Called on first mount only - recordReactionAsCommitted(reactionTrackingRef) - - if (reactionTrackingRef.current) { - // Great. We've already got our reaction from our render; - // all we need to do is to record that it's now mounted, - // to allow future observable changes to trigger re-renders - reactionTrackingRef.current.mounted = true - // Got a change before first mount, force an update - if (reactionTrackingRef.current.changedBeforeMount) { - reactionTrackingRef.current.changedBeforeMount = false + observerFinalizationRegistry.unregister(adm) + + adm.mounted = true + + if (adm.reaction) { + if (adm.changedBeforeMount) { + // Got a change before mount, force an update + adm.changedBeforeMount = false forceUpdate() } } else { @@ -87,21 +101,18 @@ export function useObserver(fn: () => T, baseComponentName: string = "observe // reaction got cleaned up // Re-create the reaction - reactionTrackingRef.current = { - reaction: new Reaction(observerComponentNameFor(baseComponentName), () => { - // We've definitely already been mounted at this point - forceUpdate() - }), - mounted: true, - changedBeforeMount: false, - cleanAt: Infinity - } + adm.reaction = new Reaction(observerComponentNameFor(baseComponentName), () => { + // We've definitely already been mounted at this point + forceUpdate() + }) forceUpdate() } return () => { - reactionTrackingRef.current!.reaction.dispose() - reactionTrackingRef.current = null + adm.reaction!.dispose() + adm.reaction = null + adm.mounted = false + adm.changedBeforeMount = false } }, []) @@ -110,7 +121,7 @@ export function useObserver(fn: () => T, baseComponentName: string = "observe // can be invalidated (see above) once a dependency changes let rendering!: T let exception - reaction.track(() => { + adm.reaction.track(() => { try { rendering = fn() } catch (e) { diff --git a/packages/mobx-react-lite/src/utils/FinalizationRegistryWrapper.ts b/packages/mobx-react-lite/src/utils/FinalizationRegistryWrapper.ts deleted file mode 100644 index d11006303..000000000 --- a/packages/mobx-react-lite/src/utils/FinalizationRegistryWrapper.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare class FinalizationRegistryType { - constructor(cleanup: (cleanupToken: T) => void) - register(object: object, cleanupToken: T, unregisterToken?: object): void - unregister(unregisterToken: object): void -} - -declare const FinalizationRegistry: typeof FinalizationRegistryType | undefined - -const FinalizationRegistryLocal = - typeof FinalizationRegistry === "undefined" ? undefined : FinalizationRegistry - -export { FinalizationRegistryLocal as FinalizationRegistry } diff --git a/packages/mobx-react-lite/src/utils/UniversalFinalizationRegistry.ts b/packages/mobx-react-lite/src/utils/UniversalFinalizationRegistry.ts new file mode 100644 index 000000000..fedb31c5b --- /dev/null +++ b/packages/mobx-react-lite/src/utils/UniversalFinalizationRegistry.ts @@ -0,0 +1,62 @@ +export declare class FinalizationRegistryType { + constructor(finalize: (value: T) => void) + register(target: object, value: T, token?: object): void + unregister(token: object): void +} + +declare const FinalizationRegistry: typeof FinalizationRegistryType | undefined + +export const REGISTRY_FINALIZE_AFTER = 10_000 +export const REGISTRY_SWEEP_INTERVAL = 10_000 + +export class TimerBasedFinalizationRegistry implements FinalizationRegistryType { + private registrations: Map = new Map() + private sweepTimeout: ReturnType | undefined + + constructor(private readonly finalize: (value: T) => void) {} + + // Token is actually required with this impl + register(target: object, value: T, token?: object) { + this.registrations.set(token, { + value, + registeredAt: Date.now() + }) + this.scheduleSweep() + } + + unregister(token: unknown) { + this.registrations.delete(token) + } + + // Bound so it can be used directly as setTimeout callback. + sweep = (maxAge = REGISTRY_FINALIZE_AFTER) => { + // cancel timeout so we can force sweep anytime + clearTimeout(this.sweepTimeout) + this.sweepTimeout = undefined + + const now = Date.now() + this.registrations.forEach((registration, token) => { + if (now - registration.registeredAt >= maxAge) { + this.finalize(registration.value) + this.registrations.delete(token) + } + }) + + if (this.registrations.size > 0) { + this.scheduleSweep() + } + } + + // Bound so it can be exported directly as clearTimers test utility. + finalizeAllImmediately = () => { + this.sweep(0) + } + + private scheduleSweep() { + if (this.sweepTimeout === undefined) { + this.sweepTimeout = setTimeout(this.sweep, REGISTRY_SWEEP_INTERVAL) + } + } +} + +export const UniversalFinalizationRegistry = FinalizationRegistry ?? TimerBasedFinalizationRegistry diff --git a/packages/mobx-react-lite/src/utils/createReactionCleanupTrackingUsingFinalizationRegister.ts b/packages/mobx-react-lite/src/utils/createReactionCleanupTrackingUsingFinalizationRegister.ts deleted file mode 100644 index 44f20382c..000000000 --- a/packages/mobx-react-lite/src/utils/createReactionCleanupTrackingUsingFinalizationRegister.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { FinalizationRegistry as FinalizationRegistryMaybeUndefined } from "./FinalizationRegistryWrapper" -import { Reaction } from "mobx" -import { - ReactionCleanupTracking, - IReactionTracking, - createTrackingData -} from "./reactionCleanupTrackingCommon" - -/** - * FinalizationRegistry-based uncommitted reaction cleanup - */ -export function createReactionCleanupTrackingUsingFinalizationRegister( - FinalizationRegistry: NonNullable -): ReactionCleanupTracking { - const cleanupTokenToReactionTrackingMap = new Map() - let globalCleanupTokensCounter = 1 - - const registry = new FinalizationRegistry(function cleanupFunction(token: number) { - const trackedReaction = cleanupTokenToReactionTrackingMap.get(token) - if (trackedReaction) { - trackedReaction.reaction.dispose() - cleanupTokenToReactionTrackingMap.delete(token) - } - }) - - return { - addReactionToTrack( - reactionTrackingRef: React.MutableRefObject, - reaction: Reaction, - objectRetainedByReact: object - ) { - const token = globalCleanupTokensCounter++ - - registry.register(objectRetainedByReact, token, reactionTrackingRef) - reactionTrackingRef.current = createTrackingData(reaction) - reactionTrackingRef.current.finalizationRegistryCleanupToken = token - cleanupTokenToReactionTrackingMap.set(token, reactionTrackingRef.current) - - return reactionTrackingRef.current - }, - recordReactionAsCommitted(reactionRef: React.MutableRefObject) { - registry.unregister(reactionRef) - - if (reactionRef.current && reactionRef.current.finalizationRegistryCleanupToken) { - cleanupTokenToReactionTrackingMap.delete( - reactionRef.current.finalizationRegistryCleanupToken - ) - } - }, - forceCleanupTimerToRunNowForTests() { - // When FinalizationRegistry in use, this this is no-op - }, - resetCleanupScheduleForTests() { - // When FinalizationRegistry in use, this this is no-op - } - } -} diff --git a/packages/mobx-react-lite/src/utils/createTimerBasedReactionCleanupTracking.ts b/packages/mobx-react-lite/src/utils/createTimerBasedReactionCleanupTracking.ts deleted file mode 100644 index cb00c7b2c..000000000 --- a/packages/mobx-react-lite/src/utils/createTimerBasedReactionCleanupTracking.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Reaction } from "mobx" -import { - ReactionCleanupTracking, - IReactionTracking, - CLEANUP_TIMER_LOOP_MILLIS, - createTrackingData -} from "./reactionCleanupTrackingCommon" - -/** - * timers, gc-style, uncommitted reaction cleanup - */ -export function createTimerBasedReactionCleanupTracking(): ReactionCleanupTracking { - /** - * Reactions created by components that have yet to be fully mounted. - */ - const uncommittedReactionRefs: Set> = new Set() - - /** - * Latest 'uncommitted reactions' cleanup timer handle. - */ - let reactionCleanupHandle: ReturnType | undefined - - /* istanbul ignore next */ - /** - * Only to be used by test functions; do not export outside of mobx-react-lite - */ - function forceCleanupTimerToRunNowForTests() { - // This allows us to control the execution of the cleanup timer - // to force it to run at awkward times in unit tests. - if (reactionCleanupHandle) { - clearTimeout(reactionCleanupHandle) - cleanUncommittedReactions() - } - } - - /* istanbul ignore next */ - function resetCleanupScheduleForTests() { - if (uncommittedReactionRefs.size > 0) { - for (const ref of uncommittedReactionRefs) { - const tracking = ref.current - if (tracking) { - tracking.reaction.dispose() - ref.current = null - } - } - uncommittedReactionRefs.clear() - } - - if (reactionCleanupHandle) { - clearTimeout(reactionCleanupHandle) - reactionCleanupHandle = undefined - } - } - - function ensureCleanupTimerRunning() { - if (reactionCleanupHandle === undefined) { - reactionCleanupHandle = setTimeout(cleanUncommittedReactions, CLEANUP_TIMER_LOOP_MILLIS) - } - } - - function scheduleCleanupOfReactionIfLeaked( - ref: React.MutableRefObject - ) { - uncommittedReactionRefs.add(ref) - - ensureCleanupTimerRunning() - } - - function recordReactionAsCommitted( - reactionRef: React.MutableRefObject - ) { - uncommittedReactionRefs.delete(reactionRef) - } - - /** - * Run by the cleanup timer to dispose any outstanding reactions - */ - function cleanUncommittedReactions() { - reactionCleanupHandle = undefined - - // Loop through all the candidate leaked reactions; those older - // than CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS get tidied. - - const now = Date.now() - uncommittedReactionRefs.forEach(ref => { - const tracking = ref.current - if (tracking) { - if (now >= tracking.cleanAt) { - // It's time to tidy up this leaked reaction. - tracking.reaction.dispose() - ref.current = null - uncommittedReactionRefs.delete(ref) - } - } - }) - - if (uncommittedReactionRefs.size > 0) { - // We've just finished a round of cleanups but there are still - // some leak candidates outstanding. - ensureCleanupTimerRunning() - } - } - - return { - addReactionToTrack( - reactionTrackingRef: React.MutableRefObject, - reaction: Reaction, - /** - * On timer based implementation we don't really need this object, - * but we keep the same api - */ - objectRetainedByReact: unknown - ) { - reactionTrackingRef.current = createTrackingData(reaction) - scheduleCleanupOfReactionIfLeaked(reactionTrackingRef) - return reactionTrackingRef.current - }, - recordReactionAsCommitted, - forceCleanupTimerToRunNowForTests, - resetCleanupScheduleForTests - } -} diff --git a/packages/mobx-react-lite/src/utils/observerFinalizationRegistry.ts b/packages/mobx-react-lite/src/utils/observerFinalizationRegistry.ts new file mode 100644 index 000000000..e139ac210 --- /dev/null +++ b/packages/mobx-react-lite/src/utils/observerFinalizationRegistry.ts @@ -0,0 +1,9 @@ +import { Reaction } from "mobx" +import { UniversalFinalizationRegistry } from "./UniversalFinalizationRegistry" + +export const observerFinalizationRegistry = new UniversalFinalizationRegistry( + (adm: { reaction: Reaction | null }) => { + adm.reaction?.dispose() + adm.reaction = null + } +) diff --git a/packages/mobx-react-lite/src/utils/reactionCleanupTracking.ts b/packages/mobx-react-lite/src/utils/reactionCleanupTracking.ts deleted file mode 100644 index 1aeffbd33..000000000 --- a/packages/mobx-react-lite/src/utils/reactionCleanupTracking.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FinalizationRegistry as FinalizationRegistryMaybeUndefined } from "./FinalizationRegistryWrapper" -import { createReactionCleanupTrackingUsingFinalizationRegister } from "./createReactionCleanupTrackingUsingFinalizationRegister" -import { createTimerBasedReactionCleanupTracking } from "./createTimerBasedReactionCleanupTracking" -export { IReactionTracking } from "./reactionCleanupTrackingCommon" - -const { - addReactionToTrack, - recordReactionAsCommitted, - resetCleanupScheduleForTests, - forceCleanupTimerToRunNowForTests -} = FinalizationRegistryMaybeUndefined - ? createReactionCleanupTrackingUsingFinalizationRegister(FinalizationRegistryMaybeUndefined) - : createTimerBasedReactionCleanupTracking() - -export { - addReactionToTrack, - recordReactionAsCommitted, - resetCleanupScheduleForTests, - forceCleanupTimerToRunNowForTests -} diff --git a/packages/mobx-react-lite/src/utils/reactionCleanupTrackingCommon.ts b/packages/mobx-react-lite/src/utils/reactionCleanupTrackingCommon.ts deleted file mode 100644 index e0aa149ff..000000000 --- a/packages/mobx-react-lite/src/utils/reactionCleanupTrackingCommon.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Reaction } from "mobx" - -export function createTrackingData(reaction: Reaction) { - const trackingData: IReactionTracking = { - reaction, - mounted: false, - changedBeforeMount: false, - cleanAt: Date.now() + CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS - } - return trackingData -} - -/** - * Unified api for timers/Finalization registry cleanups - * This abstraction make useObserver much simpler - */ -export interface ReactionCleanupTracking { - /** - * - * @param reaction The reaction to cleanup - * @param objectRetainedByReact This will be in actual use only when FinalizationRegister is in use - */ - addReactionToTrack( - reactionTrackingRef: React.MutableRefObject, - reaction: Reaction, - objectRetainedByReact: object - ): IReactionTracking - recordReactionAsCommitted(reactionRef: React.MutableRefObject): void - forceCleanupTimerToRunNowForTests(): void - resetCleanupScheduleForTests(): void -} - -export interface IReactionTracking { - /** The Reaction created during first render, which may be leaked */ - reaction: Reaction - /** - * The time (in ticks) at which point we should dispose of the reaction - * if this component hasn't yet been fully mounted. - */ - cleanAt: number - - /** - * Whether the component has yet completed mounting (for us, whether - * its useEffect has run) - */ - mounted: boolean - - /** - * Whether the observables that the component is tracking changed between - * the first render and the first useEffect. - */ - changedBeforeMount: boolean - - /** - * In case we are using finalization registry based cleanup, - * this will hold the cleanup token associated with this reaction - */ - finalizationRegistryCleanupToken?: number -} - -/** - * The minimum time before we'll clean up a Reaction created in a render - * for a component that hasn't managed to run its effects. This needs to - * be big enough to ensure that a component won't turn up and have its - * effects run without being re-rendered. - */ -export const CLEANUP_LEAKED_REACTIONS_AFTER_MILLIS = 10_000 - -/** - * The frequency with which we'll check for leaked reactions. - */ -export const CLEANUP_TIMER_LOOP_MILLIS = 10_000