diff --git a/.changeset/suspend-waiting.md b/.changeset/suspend-waiting.md new file mode 100644 index 000000000..5c76379ba --- /dev/null +++ b/.changeset/suspend-waiting.md @@ -0,0 +1,6 @@ +--- +"effect-app": patch +"@effect-app/vue": patch +--- + +Add `suspendWaiting` so a command can park on a dialog without the spinner or the waiting toast. The button stays disabled. Both come back when the effect finishes and the rest of the command runs. diff --git a/packages/effect-app/src/waitingGate.ts b/packages/effect-app/src/waitingGate.ts new file mode 100644 index 000000000..a5bf0096d --- /dev/null +++ b/packages/effect-app/src/waitingGate.ts @@ -0,0 +1,82 @@ +import * as Context from "./Context.ts" +import * as Effect from "./Effect.ts" + +/** + * Notified with the suspension depth after each change. `0` means the command + * is doing work. A positive depth means it is parked on a caller. + */ +export type WaitingGateController = (depth: number) => Effect.Effect + +export interface WaitingGateShape { + /** `false` for the default gate. Suspension then changes nothing. */ + readonly active: boolean + readonly depth: number + readonly add: (delta: number) => Effect.Effect + /** Stack a controller. The returned function restores the previous one. */ + readonly bind: (controller: WaitingGateController) => () => void +} + +const inactiveWaitingGate: WaitingGateShape = { + active: false, + depth: 0, + add: () => Effect.void, + bind: () => () => {} +} + +/** + * Per-command gate for {@link suspendWaiting}. Commands provide an active gate. + * The default is inactive, so the helper is a no-op outside a command. + */ +export const WaitingGate = Context.Reference("effect-app/WaitingGate", { + defaultValue: () => inactiveWaitingGate +}) + +/** Build the gate one command owns. `onDepth` runs synchronously inside `add`. */ +export const makeWaitingGate = (onDepth?: (depth: number) => void): WaitingGateShape => { + let depth = 0 + let controller: WaitingGateController | undefined + return { + active: true, + get depth() { + return depth + }, + add: (delta) => + Effect.uninterruptible(Effect.gen(function*() { + depth += delta + if (depth < 0) depth = 0 + const next = depth + onDepth?.(next) + if (controller) yield* controller(next) + })), + bind: (next) => { + const previous = controller + controller = next + return () => { + if (controller === next) controller = previous + } + } + } +} + +/** + * Run `self` while command chrome is quiet. + * + * The spinner and the waiting toast pause for `self` and resume when it + * finishes, including when it is interrupted. The command stays blocked for + * the whole run, so the button cannot start again. + * + * Outside a command the default gate is inactive and `self` runs unchanged. + * + * ```ts + * const decision = yield* suspendWaiting(ask(pickupDate)) + * yield* close(orderId) + * ``` + */ +export const suspendWaiting = (self: Effect.Effect): Effect.Effect => + Effect.uninterruptibleMask((restore) => + Effect.gen(function*() { + const gate = yield* WaitingGate + yield* gate.add(1) + return yield* Effect.ensuring(restore(self), gate.add(-1)) + }) + ) diff --git a/packages/effect-app/src/withToast.ts b/packages/effect-app/src/withToast.ts index 9b0b6bb73..95d4ac406 100644 --- a/packages/effect-app/src/withToast.ts +++ b/packages/effect-app/src/withToast.ts @@ -7,6 +7,7 @@ import type * as Option from "./Option.ts" import * as S from "./Schema.ts" import { CurrentToastId, Toast, type ToastId } from "./toast.ts" import { wrapEffect } from "./utils.ts" +import { WaitingGate } from "./waitingGate.ts" export interface ToastOptions, WaiR, SucR, ErrR> { stableToastId?: undefined | string | ((...args: Args) => string | undefined) @@ -66,12 +67,57 @@ export class WithToast extends Context.Service()("WithToast", { ? stableToastId : stableToastId ?? `wait-${Math.random().toString(36).slice(2)}` - const waitingFiber = t === null ? undefined : yield* Effect.forkChild( - Effect.sleep("1 seconds").pipe( - Effect.andThen(toast.info(t, { id: toastId!, timeout: Infinity, ...meta })) + const gate = yield* WaitingGate + let waitingFiber: Fiber.Fiber | undefined + let toastShown = false + let unbind = () => {} + + const armWaiting = () => + Effect.gen(function*() { + if (waitingFiber !== undefined || t === null || toastId === undefined) return + waitingFiber = yield* Effect.forkChild( + Effect.sleep("1 seconds").pipe( + Effect.andThen(Effect.uninterruptible(Effect.gen(function*() { + toastShown = true + yield* toast.info(t, { id: toastId, timeout: Infinity, ...meta }) + }))), + Effect.asVoid + ) + ) + }) + + const disarmWaiting = () => + Effect.gen(function*() { + const fiber = waitingFiber + waitingFiber = undefined + if (fiber) yield* Fiber.interrupt(fiber) + if (toastShown && toastId !== undefined) { + toastShown = false + yield* toast.dismiss(toastId) + } + }) + + // An active gate pauses the toast while `suspendWaiting` is parked and + // starts a fresh one-second delay when work resumes. The inactive + // default keeps the original fork. + if (t !== null && gate.active) { + unbind = gate.bind((depth) => (depth === 0 ? armWaiting() : disarmWaiting())) + if (gate.depth === 0) yield* armWaiting() + } else if (t !== null) { + waitingFiber = yield* Effect.forkChild( + Effect.sleep("1 seconds").pipe( + Effect.andThen(toast.info(t, { id: toastId!, timeout: Infinity, ...meta })), + Effect.asVoid + ) ) - ) - const interruptWaiting = waitingFiber ? Fiber.interrupt(waitingFiber) : Effect.void + } + + const interruptWaiting = Effect.uninterruptible(Effect.gen(function*() { + unbind() + const fiber = waitingFiber + waitingFiber = undefined + if (fiber) yield* Fiber.interrupt(fiber) + })) return yield* self.pipe( Effect.tap(Effect.fnUntraced(function*(a) { diff --git a/packages/effect-app/test/waitingGate.test.ts b/packages/effect-app/test/waitingGate.test.ts new file mode 100644 index 000000000..ad640aad3 --- /dev/null +++ b/packages/effect-app/test/waitingGate.test.ts @@ -0,0 +1,64 @@ +import * as Deferred from "effect/Deferred" +import * as Effect from "effect/Effect" +import * as Fiber from "effect/Fiber" +import { describe, expect, test } from "vitest" +import { makeWaitingGate, suspendWaiting, WaitingGate } from "../src/waitingGate.js" + +const run = (self: Effect.Effect) => Effect.runPromise(self) + +describe("suspendWaiting", () => { + test("brackets depth and tells the controller", async () => { + const depths: Array = [] + const gate = makeWaitingGate((depth) => depths.push(depth)) + gate.bind((depth) => Effect.sync(() => depths.push(depth))) + + await run( + suspendWaiting(Effect.sync(() => { + expect(gate.depth).toBe(1) + })) + .pipe(Effect.provideService(WaitingGate, gate)) + ) + + expect(gate.depth).toBe(0) + expect(depths).toEqual([1, 1, 0, 0]) + }) + + test("nests", async () => { + const gate = makeWaitingGate() + await run( + suspendWaiting(suspendWaiting(Effect.sync(() => { + expect(gate.depth).toBe(2) + }))) + .pipe(Effect.provideService(WaitingGate, gate)) + ) + expect(gate.depth).toBe(0) + }) + + test("restores depth when the effect is interrupted", async () => { + const gate = makeWaitingGate() + await run(Effect.gen(function*() { + const entered = yield* Deferred.make() + const fiber = yield* suspendWaiting(Effect.gen(function*() { + yield* Deferred.succeed(entered, undefined) + return yield* Effect.never + })) + .pipe( + Effect.provideService(WaitingGate, gate), + Effect.forkChild + ) + yield* Deferred.await(entered) + expect(gate.depth).toBe(1) + yield* Fiber.interrupt(fiber) + expect(gate.depth).toBe(0) + })) + }) + + test("does nothing on the default gate", async () => { + await run(Effect.gen(function*() { + const gate = yield* WaitingGate + expect(gate.active).toBe(false) + yield* suspendWaiting(Effect.void) + expect(gate.depth).toBe(0) + })) + }) +}) diff --git a/packages/vue/src/commander.ts b/packages/vue/src/commander.ts index eec037824..9161e80f9 100644 --- a/packages/vue/src/commander.ts +++ b/packages/vue/src/commander.ts @@ -10,6 +10,7 @@ import * as Layer from "effect-app/Layer" import * as Option from "effect-app/Option" import * as S from "effect-app/Schema" import { isGeneratorFunction, wrapEffect } from "effect-app/utils" +import { makeWaitingGate, suspendWaiting, WaitingGate } from "effect-app/waitingGate" import * as Cause from "effect/Cause" import type * as Exit from "effect/Exit" import type * as Fiber from "effect/Fiber" @@ -2114,6 +2115,18 @@ export const CommanderStatic = { updateProgress: (progress: Progress | undefined): Effect.Effect => CommandProgress.use((s) => s.update(progress)), + /** + * Park on an effect without the spinner or the waiting toast. + * The button stays disabled. Chrome returns when the effect finishes. + * Stream commands pause the spinner only. `withDefaultToastStream` does not. + * + * ```ts + * const decision = yield* Command.suspendWaiting(ask(pickupDate)) + * yield* close(orderId) + * ``` + */ + suspendWaiting, + /** Version of @see confirmOrInterrupt that automatically includes the action name in the default messages */ confirmOrInterrupt: Effect.fnUntraced(function*( message?: string @@ -2509,6 +2522,30 @@ const unregisterWait = (id: string) => { } } +/** + * `waiting` is the spinner. It drops while `suspendWaiting` is parked. + * `blocked` stays on for the whole run, including that pause. + */ +const commandChrome = ( + ownRunning: () => boolean, + waitId: string | undefined, + blockId: string | undefined +) => { + const quiet = ref(0) + const gate = makeWaitingGate((depth) => { + quiet.value = depth + }) + const running = computed(() => ownRunning() || (waitId !== undefined && (waitState.value[waitId] ?? 0) > 0)) + const waiting = computed(() => running.value && quiet.value === 0) + const blocked = computed(() => running.value || (blockId !== undefined && (waitState.value[blockId] ?? 0) > 0)) + const provide = (self: Effect.Effect) => + Effect.gen(function*() { + if (gate.depth !== 0) yield* gate.add(-gate.depth) + return yield* self.pipe(Effect.provideService(WaitingGate, gate)) + }) + return { waiting, blocked, provide } +} + const getStateValues = < const Id extends string, const I18nKey extends string, @@ -2658,6 +2695,7 @@ export class CommanderImpl { const blockId = options?.blockKey ? options.blockKey(id) : undefined const [result, exec_] = asResult(theHandler) + const chrome = commandChrome(() => result.value.waiting, waitId, blockId) const exec = Effect .fnUntraced( @@ -2666,7 +2704,7 @@ export class CommanderImpl { if (blockId !== undefined && blockId !== waitId) { registerWait(blockId) } - return yield* exec_(...args) + return yield* chrome.provide(exec_(...args)) }, Effect.onExit(() => Effect.sync(() => { @@ -2678,13 +2716,7 @@ export class CommanderImpl { ) ) - const waiting = waitId !== undefined - ? computed(() => result.value.waiting || (waitState.value[waitId] ?? 0) > 0) - : computed(() => result.value.waiting) - - const blocked = blockId !== undefined - ? computed(() => waiting.value || (waitState.value[blockId] ?? 0) > 0) - : computed(() => waiting.value) + const { blocked, waiting } = chrome const computeAllowed = options?.allowed const allowed = computeAllowed ? computed(() => computeAllowed(id, state)) : true @@ -2992,6 +3024,7 @@ export class CommanderImpl { const blockId = options?.blockKey ? options.blockKey(id) : undefined const [result, exec_] = asStreamResult(theStreamHandler) + const chrome = commandChrome(() => result.value.waiting, waitId, blockId) const exec = Effect .fnUntraced( @@ -3000,7 +3033,7 @@ export class CommanderImpl { if (blockId !== undefined && blockId !== waitId) { registerWait(blockId) } - return yield* exec_(...args) + return yield* chrome.provide(exec_(...args)) }, Effect.onExit(() => Effect.sync(() => { @@ -3012,13 +3045,7 @@ export class CommanderImpl { ) ) - const waiting = waitId !== undefined - ? computed(() => result.value.waiting || (waitState.value[waitId] ?? 0) > 0) - : computed(() => result.value.waiting) - - const blocked = blockId !== undefined - ? computed(() => waiting.value || (waitState.value[blockId] ?? 0) > 0) - : computed(() => waiting.value) + const { blocked, waiting } = chrome const computeAllowed = options?.allowed const allowed = computeAllowed ? computed(() => computeAllowed(id, state)) : true diff --git a/packages/vue/test/suspendWaiting.test.ts b/packages/vue/test/suspendWaiting.test.ts new file mode 100644 index 000000000..a5fff6214 --- /dev/null +++ b/packages/vue/test/suspendWaiting.test.ts @@ -0,0 +1,142 @@ +import { it } from "@effect/vitest" +import * as Effect from "effect-app/Effect" +import * as Deferred from "effect/Deferred" +import * as Exit from "effect/Exit" +import * as Fiber from "effect/Fiber" +import { TestClock } from "effect/testing" +import { DefaultIntl } from "../src/commander.js" +import { useExperimentalE } from "./stubs.js" + +const unwrap = (r: Fiber.Fiber, never>) => Fiber.join(r).pipe(Effect.flatten) + +const infoToasts = (toasts: ReadonlyArray<{ type: string }>) => toasts.filter((toast) => toast.type === "info") + +describe("Command.suspendWaiting", () => { + it.effect("hides the spinner and the waiting toast until work resumes", () => + Effect.gen(function*() { + const toasts: Array<{ type: string; message: string }> = [] + const Command = yield* useExperimentalE({ toasts, messages: DefaultIntl.en }) + const toastVisible = yield* Deferred.make() + const continueToDialog = yield* Deferred.make() + const suspended = yield* Deferred.make() + const release = yield* Deferred.make() + const resumed = yield* Deferred.make() + + const command = Command.fn("Test Action")( + function*() { + yield* Effect.sleep("1100 millis") + yield* Deferred.succeed(toastVisible, undefined) + yield* Deferred.await(continueToDialog) + yield* Command.suspendWaiting(Effect.gen(function*() { + yield* Deferred.succeed(suspended, undefined) + return yield* Deferred.await(release) + })) + yield* Deferred.succeed(resumed, undefined) + expect(infoToasts(toasts)).toHaveLength(0) + yield* Effect.sleep("1100 millis") + expect(infoToasts(toasts)).toHaveLength(1) + return "done" + }, + Command.withDefaultToast() + ) + + const fiber = command.handle(undefined) + yield* TestClock.adjust("1100 millis") + yield* Deferred.await(toastVisible) + expect(command.waiting).toBe(true) + expect(command.blocked).toBe(true) + expect(infoToasts(toasts)).toHaveLength(1) + + yield* Deferred.succeed(continueToDialog, undefined) + yield* Deferred.await(suspended) + expect(command.waiting).toBe(false) + expect(command.blocked).toBe(true) + expect(infoToasts(toasts)).toHaveLength(0) + + yield* TestClock.adjust("2 seconds") + expect(infoToasts(toasts)).toHaveLength(0) + expect(command.waiting).toBe(false) + + yield* Deferred.succeed(release, undefined) + yield* Deferred.await(resumed) + expect(command.waiting).toBe(true) + expect(command.blocked).toBe(true) + + yield* TestClock.adjust("1100 millis") + expect(yield* unwrap(fiber)).toBe("done") + expect(toasts.map((toast) => toast.type)).toEqual(["success"]) + })) + + it.effect("stays quiet for a suspension that starts the command", () => + Effect.gen(function*() { + const toasts: Array<{ type: string; message: string }> = [] + const Command = yield* useExperimentalE({ toasts, messages: DefaultIntl.en }) + const suspended = yield* Deferred.make() + const release = yield* Deferred.make() + const resumed = yield* Deferred.make() + + const command = Command.fn("Test Action")( + function*() { + const answer = yield* Command.suspendWaiting(Effect.gen(function*() { + yield* Deferred.succeed(suspended, undefined) + return yield* Deferred.await(release) + })) + yield* Deferred.succeed(resumed, undefined) + expect(infoToasts(toasts)).toHaveLength(0) + yield* Effect.sleep("1100 millis") + expect(infoToasts(toasts)).toHaveLength(1) + return answer + }, + Command.withDefaultToast() + ) + + const fiber = command.handle(undefined) + yield* Deferred.await(suspended) + expect(command.waiting).toBe(false) + expect(command.blocked).toBe(true) + + yield* TestClock.adjust("2 seconds") + expect(infoToasts(toasts)).toHaveLength(0) + + yield* Deferred.succeed(release, "yes") + yield* Deferred.await(resumed) + expect(command.waiting).toBe(true) + + yield* TestClock.adjust("1100 millis") + expect(yield* unwrap(fiber)).toBe("yes") + expect(toasts.map((toast) => toast.type)).toEqual(["success"]) + })) + + it.effect("drops the block when the suspension is interrupted", () => + Effect.gen(function*() { + const toasts: Array<{ type: string }> = [] + const Command = yield* useExperimentalE({ toasts, messages: DefaultIntl.en }) + const suspended = yield* Deferred.make() + + const release = yield* Deferred.make() + + const command = Command.fn("Test Action")( + function*() { + return yield* Command.suspendWaiting(Effect.gen(function*() { + yield* Deferred.succeed(suspended, undefined) + yield* Deferred.await(release) + return yield* Effect.interrupt + })) + }, + Command.withDefaultToast() + ) + + const fiber = command.handle(undefined) + yield* Deferred.await(suspended) + expect(command.waiting).toBe(false) + expect(command.blocked).toBe(true) + expect(toasts).toHaveLength(0) + + yield* Deferred.succeed(release, undefined) + const exit = yield* Fiber.join(fiber) + expect(Exit.hasInterrupts(exit)).toBe(true) + expect(command.waiting).toBe(false) + expect(command.blocked).toBe(false) + expect(toasts).toHaveLength(0) + })) +})