From 47b41cd280429578c8ebc8e9a6100e431d2c986d Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:16:59 +0000 Subject: [PATCH] feat(vue): pause command chrome while a dialog is open Command.suspendWaiting parks on an effect without the spinner or the waiting toast. The button stays disabled until the effect finishes, then both come back for the rest of the command. --- .changeset/suspend-waiting.md | 6 + packages/effect-app/src/waitingGate.ts | 82 +++++++++++ packages/effect-app/src/withToast.ts | 56 +++++++- packages/effect-app/test/waitingGate.test.ts | 64 +++++++++ packages/vue/src/commander.ts | 59 +++++--- packages/vue/test/suspendWaiting.test.ts | 142 +++++++++++++++++++ 6 files changed, 388 insertions(+), 21 deletions(-) create mode 100644 .changeset/suspend-waiting.md create mode 100644 packages/effect-app/src/waitingGate.ts create mode 100644 packages/effect-app/test/waitingGate.test.ts create mode 100644 packages/vue/test/suspendWaiting.test.ts 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) + })) +})