diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-fc-next/src/Component.ts index 6023781..adcafd1 100644 --- a/packages/effect-fc-next/src/Component.ts +++ b/packages/effect-fc-next/src/Component.ts @@ -153,12 +153,20 @@ export interface ComponentOptions { * @default "100 millis" */ readonly finalizerExecutionDebounce: Duration.Input + + /** + * Maximum duration an uncommitted scope may remain registered before it is considered abandoned. + * + * @default "30 seconds" + */ + readonly scopeCommitTimeout: Duration.Input } export const defaultOptions: ComponentOptions = { nonReactiveTags: [Tracer.ParentSpan, Scheduler.Scheduler, Layer.CurrentMemoMap], finalizerExecutionStrategy: "sequential", finalizerExecutionDebounce: "100 millis", + scopeCommitTimeout: "1 minute", } @@ -641,6 +649,7 @@ export declare namespace useScope { export interface Options { readonly finalizerExecutionStrategy?: "sequential" | "parallel" readonly finalizerExecutionDebounce?: Duration.Input + readonly scopeCommitTimeout?: Duration.Input } } @@ -675,6 +684,7 @@ export const useScope = Effect.fnUntraced(function*( const entry = yield* registry.register(key, { finalizerExecutionStrategy: options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy, finalizerExecutionDebounce: options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce, + scopeCommitTimeout: options?.scopeCommitTimeout ?? defaultOptions.scopeCommitTimeout, }) return [key, entry.scope] diff --git a/packages/effect-fc-next/src/ScopeRegistry.ts b/packages/effect-fc-next/src/ScopeRegistry.ts index 1506987..e5307b3 100644 --- a/packages/effect-fc-next/src/ScopeRegistry.ts +++ b/packages/effect-fc-next/src/ScopeRegistry.ts @@ -20,6 +20,7 @@ export declare namespace ScopeRegistryService { export interface RegisterOptions { readonly finalizerExecutionStrategy: "sequential" | "parallel" readonly finalizerExecutionDebounce: Duration.Input + readonly scopeCommitTimeout: Duration.Input } export interface Entry { @@ -45,7 +46,7 @@ export class ScopeRegistryServiceImpl implements ScopeRegistryService { return Effect.gen({ self: this }, function*() { const entry = Equal.byReference({ scope: yield* Scope.make(options.finalizerExecutionStrategy), - expiresAt: Option.some(DateTime.addDuration(yield* DateTime.now, options.finalizerExecutionDebounce)), + expiresAt: Option.some(DateTime.addDuration(yield* DateTime.now, options.scopeCommitTimeout)), finalizerExecutionDebounce: options.finalizerExecutionDebounce, }) diff --git a/packages/effect-fc-next/test/Component.test.tsx b/packages/effect-fc-next/test/Component.test.tsx index e015212..dffcc0b 100644 --- a/packages/effect-fc-next/test/Component.test.tsx +++ b/packages/effect-fc-next/test/Component.test.tsx @@ -374,6 +374,7 @@ describe("Component", () => { React.use(pending) return
committed
}).pipe( + Component.withOptions({ scopeCommitTimeout: "10 millis" }), Component.withContext(runtime.context) ) @@ -414,7 +415,10 @@ describe("Component", () => { return
committed
}).pipe( - Component.withOptions({ finalizerExecutionDebounce: "10 millis" }), + Component.withOptions({ + finalizerExecutionDebounce: "10 millis", + scopeCommitTimeout: "10 millis", + }), Component.withContext(runtime.context), ) @@ -437,6 +441,42 @@ describe("Component", () => { await runtime.runtime.dispose() }) + it("does not expire a scope while a descendant is suspended past the finalizer debounce", async () => { + const runtime = ReactRuntime.make(Layer.empty) + let resolve!: () => void + const pending = new Promise(complete => { + resolve = complete + }) + + const Suspended = () => { + React.use(pending) + return
committed
+ } + + let view!: ReturnType + await act(async () => { + view = render( + fallback}> + + + ) + }) + + try { + await screen.findByText("fallback") + await new Promise(resolve => setTimeout(resolve, 150)) + await act(async () => { + resolve() + await pending + }) + await screen.findByText("committed") + } + finally { + view.unmount() + await runtime.runtime.dispose() + } + }) + it("clears ScopeRegistry after a suspended render retries, commits, and unmounts", async () => { const lifecycle = vi.fn<(message: string) => void>() const runtime = ReactRuntime.make(Layer.empty) @@ -455,6 +495,7 @@ describe("Component", () => { }), []) return
committed
}).pipe( + Component.withOptions({ scopeCommitTimeout: "10 millis" }), Component.withContext(runtime.context) )