Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/suspend-waiting.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions packages/effect-app/src/waitingGate.ts
Original file line number Diff line number Diff line change
@@ -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<void>

export interface WaitingGateShape {
/** `false` for the default gate. Suspension then changes nothing. */
readonly active: boolean
readonly depth: number
readonly add: (delta: number) => Effect.Effect<void>
/** 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<WaitingGateShape>("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 = <A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function*() {
const gate = yield* WaitingGate
yield* gate.add(1)
return yield* Effect.ensuring(restore(self), gate.add(-1))
})
)
56 changes: 51 additions & 5 deletions packages/effect-app/src/withToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<A, E, Args extends ReadonlyArray<unknown>, WaiR, SucR, ErrR> {
stableToastId?: undefined | string | ((...args: Args) => string | undefined)
Expand Down Expand Up @@ -66,12 +67,57 @@ export class WithToast extends Context.Service<WithToast>()("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<void> | 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) {
Expand Down
64 changes: 64 additions & 0 deletions packages/effect-app/test/waitingGate.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <A, E>(self: Effect.Effect<A, E, never>) => Effect.runPromise(self)

describe("suspendWaiting", () => {
test("brackets depth and tells the controller", async () => {
const depths: Array<number> = []
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<void>()
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)
}))
})
})
59 changes: 43 additions & 16 deletions packages/vue/src/commander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -2114,6 +2115,18 @@ export const CommanderStatic = {
updateProgress: (progress: Progress | undefined): Effect.Effect<void> =>
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
Expand Down Expand Up @@ -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 = <A, E, R>(self: Effect.Effect<A, E, R>) =>
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,
Expand Down Expand Up @@ -2658,6 +2695,7 @@ export class CommanderImpl<RT, RTHooks> {
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(
Expand All @@ -2666,7 +2704,7 @@ export class CommanderImpl<RT, RTHooks> {
if (blockId !== undefined && blockId !== waitId) {
registerWait(blockId)
}
return yield* exec_(...args)
return yield* chrome.provide(exec_(...args))
},
Effect.onExit(() =>
Effect.sync(() => {
Expand All @@ -2678,13 +2716,7 @@ export class CommanderImpl<RT, RTHooks> {
)
)

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
Expand Down Expand Up @@ -2992,6 +3024,7 @@ export class CommanderImpl<RT, RTHooks> {
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(
Expand All @@ -3000,7 +3033,7 @@ export class CommanderImpl<RT, RTHooks> {
if (blockId !== undefined && blockId !== waitId) {
registerWait(blockId)
}
return yield* exec_(...args)
return yield* chrome.provide(exec_(...args))
},
Effect.onExit(() =>
Effect.sync(() => {
Expand All @@ -3012,13 +3045,7 @@ export class CommanderImpl<RT, RTHooks> {
)
)

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
Expand Down
Loading
Loading