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
29 changes: 29 additions & 0 deletions docs/decisions/703-dialog-session-scope-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Type dialog session helpers and retain the runtime default (#703)

**Status:** accepted — owner chose typed helpers plus a decision record on 2026-09-08.

**No incident for this residual risk.** Earlier PRs in [#703](https://github.com/mforce/cluckwork/issues/703) fixed abandoned-request successes affecting replacement dialogs. This final slice adds compile-time protection against future scope mismatches.

## Decision

In [useDialogAction](../../web/src/components/useDialogAction.ts), `openDialog`, `dismissDialog`, and `startLoad` accept the generic scope type `S` inferred from the screen's `dialogScopes`. A caller preserving a literal union gets a compiler error for an undeclared name. `RunOptions.dialog` already has the same type constraint.

`run` and `isPending` keep their `string` scope parameters so dynamic row and panel scopes, such as `assign:<user>:<flock>`, remain usable. No runtime branch or session behavior changes. [useDialogSession](../../web/src/components/useDialogSession.ts) still treats an unbegun scope as generation zero.

## Accepted limits

- A mistyped `run` scope without an explicit `dialog` option can fall outside `dialogScopes`. Its errors then route to the page and its `current()` predicate always returns true. If that action was meant to belong to a dialog, the abandoned-success bug can return for it.
- A declared dialog scope whose session has never begun claims generation zero and remains current until that scope is begun. Typing a name does not ensure the screen calls its session-edge helpers.
- Membership checking cannot detect choosing the wrong valid scope or inconsistent semantic use of a consistently declared name. Widening the scope type to `string`, bypassing it with type assertions, or calling from untyped JavaScript can also bypass this protection.

These limits are accepted. The compiler guard does not make abandoned successes impossible; screens still own correct session-edge calls and the per-statement `current()` checks that protect replacement-dialog state.

## Why no development assertion

A development-only check for a declared scope used before its first session could catch forgotten setup, but adds runtime code and a development/production difference. As described, it would not catch unknown `run` names: those take the page-action path and bypass session claiming. The owner chose the type-only change and recorded these remaining risks instead.

## Verification

The `restricts session edges to declared dialog scopes at compile time` case in [useDialogAction.test.ts](../../web/src/components/useDialogAction.test.ts) pins valid and invalid names for all three helpers, and keeps `run`'s scope unrestricted.

`npm run typecheck` checks its `expectTypeOf` and `@ts-expect-error` assertions. CI's `npm run build` also typechecks through `tsc -b`; the web pre-commit hook runs the typecheck script. Vitest executes the test body but does not validate TypeScript types, so a passing Vitest run alone does not prove this guard works.
30 changes: 25 additions & 5 deletions web/src/components/useDialogAction.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { describe, expect, expectTypeOf, it } from "vitest";
import { useDialogAction } from "./useDialogAction";

// #703 — the composed wrapper, away from any screen. SalesPage's tests prove the
Expand Down Expand Up @@ -88,7 +88,10 @@ describe("useDialogAction", () => {
it("treats a non-dialog scope as always current, whatever begins mid-flight", async () => {
// A panel action has no session to be superseded by. Gating it would be
// #703's PR 5 question, answered there — here it must behave exactly as it
// did before the hook existed.
// did before the hook existed. The mid-flight begin now has to name a
// declared dialog scope ("create"), since `openDialog`/`dismissDialog` are
// typed to `S`; it opens a dialog wholly unrelated to the "confirm" run,
// which is the point being pinned.
const { result } = renderHook(() => useDialogAction(DIALOGS));
const gate = deferred<void>();
let seen: boolean | undefined;
Expand All @@ -99,8 +102,8 @@ describe("useDialogAction", () => {
seen = current();
});
});
act(() => result.current.openDialog("confirm"));
act(() => result.current.dismissDialog("confirm"));
act(() => result.current.openDialog("create"));
act(() => result.current.dismissDialog("create"));
await act(async () => {
gate.resolve();
await flight;
Expand Down Expand Up @@ -264,7 +267,7 @@ describe("useDialogAction", () => {
// observable exactly one way — an onAttempt that itself ends the session
// (a screen resetting the dialog it is about) must leave THIS attempt
// superseded, rather than letting the attempt claim the session it created.
let api: ReturnType<typeof useDialogAction> | undefined;
let api: ReturnType<typeof useDialogAction<(typeof DIALOGS)[number]>> | undefined;
const { result } = renderHook(() =>
useDialogAction(DIALOGS, { onAttempt: () => api?.openDialog("create") }));
api = result.current;
Expand Down Expand Up @@ -423,4 +426,21 @@ describe("useDialogAction — `dialog` option and `startLoad` (#703 PR 4)", () =
act(() => result.current.openDialog("create"));
expect(result.current.errors.forDialog("create")).toBeUndefined();
});

it("restricts session edges to declared dialog scopes at compile time", () => {
const { result } = renderHook(() => useDialogAction(["create", "edit"] as const));
expectTypeOf(result.current.openDialog).toBeCallableWith("create");
expectTypeOf(result.current.openDialog).toBeCallableWith("edit");
// @ts-expect-error Unknown dialog scopes must be rejected by the typechecker.
expectTypeOf(result.current.openDialog).toBeCallableWith("not-a-scope");
expectTypeOf(result.current.dismissDialog).toBeCallableWith("create");
expectTypeOf(result.current.dismissDialog).toBeCallableWith("edit");
// @ts-expect-error Unknown dialog scopes must be rejected by the typechecker.
expectTypeOf(result.current.dismissDialog).toBeCallableWith("not-a-scope");
expectTypeOf(result.current.startLoad).toBeCallableWith("create");
expectTypeOf(result.current.startLoad).toBeCallableWith("edit");
// @ts-expect-error Unknown dialog scopes must be rejected by the typechecker.
expectTypeOf(result.current.startLoad).toBeCallableWith("not-a-scope");
expectTypeOf(result.current.run).parameter(0).toEqualTypeOf<string>();
});
});
23 changes: 13 additions & 10 deletions web/src/components/useDialogAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ export interface DialogAction<S extends string = string> {
* that started it is still the one on screen; a scope that owns no dialog is
* always current. Resolves the action's value, or `undefined` when the guard
* skipped it or the action threw — never read `undefined` as success.
* A method signature, not a function-typed property: a screen's
* `DialogAction<"create" | …>` must stay assignable to a plain
* `DialogAction`, which a property's parameter would forbid.
* `scope` stays plain `string`, unlike the session edges below: row/panel
* scopes (`assign:<user>:<flock>`) are dynamic and never declared in
* `dialogScopes`, so `run` cannot narrow to `S` without rejecting them.
*/
run<T>(scope: string, action: (current: () => boolean) => Promise<T>, options?: RunOptions<S>): Promise<T | undefined>;
/**
Expand All @@ -56,20 +56,20 @@ export interface DialogAction<S extends string = string> {
* the screen calls `openDialog` (which ends the session again and clears
* it). A load that fails leaves the slot as it found it.
*/
startLoad: (scope: string) => () => boolean;
startLoad: (scope: S) => () => boolean;
/**
* Called when a dialog OPENS. Mutes the attempt still out, so its failure
* lands nowhere, and ends the session, so its success cannot touch the
* dialog now on screen. The same operation as `dismissDialog` — one name per
* edge so a call site reads as what the screen is doing.
*/
openDialog: (scope: string) => void;
openDialog: (scope: S) => void;
/**
* Called when a dialog is DISMISSED, or closed out from under the user by
* the screen itself (a record swap, a role change): mutes the attempt still
* out and ends the session. Stable across renders, so an effect may list it.
*/
dismissDialog: (scope: string) => void;
dismissDialog: (scope: S) => void;
}

/**
Expand All @@ -96,8 +96,11 @@ export interface DialogAction<S extends string = string> {
* `dialogScopes` names the scopes that own a dialog. A scope outside it — a
* panel action, a row verb — routes its failure to the page and is never
* superseded, exactly as before this hook existed, unless the run names its
* dialog through `RunOptions.dialog`. A dialog scope the screen never
* `openDialog`s behaves the same way (#703 finding 3, deliberately open).
* dialog through `RunOptions.dialog`. A declared dialog scope the screen
* never `openDialog`s claims session 0 and so is never superseded either,
* until its first `begin` — deliberately open, kept and explained rather
* than closed by the type narrowing in
* docs/decisions/703-dialog-session-scope-types.md (#703 finding 3).
*/
export function useDialogAction<S extends string = string>(
dialogScopes: readonly S[],
Expand Down Expand Up @@ -145,15 +148,15 @@ export function useDialogAction<S extends string = string>(
// re-running every render — `abandon` and `begin` are themselves stable.
const { abandon } = errors;
const { begin, claim, isCurrent } = session;
const endSession = useCallback((scope: string) => {
const endSession = useCallback((scope: S) => {
abandon(scope);
begin(scope);
}, [abandon, begin]);

// The latest click wins: a new session, claimed for this load, with the
// slot left alone (see the interface). Pinned by the hook's own
// `startLoad` tests (`startLoad neither mutes nor clears…`).
const startLoad = useCallback((scope: string) => {
const startLoad = useCallback((scope: S) => {
begin(scope);
const claimed = claim(scope);
return () => isCurrent(scope, claimed);
Expand Down
Loading