@ai-matrx/kit 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/confirm/opener.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/confirm-opener — the imperative opener, and since 0.9.0 the\n * ONLY half of the confirm system this package ships.\n *\n * Pure-TS imperative API for the global confirm dialog. Zero React, zero\n * dialog markup — this module is statically importable from anywhere\n * (hooks, utils, Redux thunks, async handlers, sync code, anything).\n *\n * The host (`ConfirmDialogHost`, in `@ai-matrx/design-system`) registers a\n * controller on mount and\n * unregisters on unmount. Calls made before the host has hydrated queue\n * up and resolve as soon as the host is alive — so a destructive action\n * triggered in the first ~50ms after page load still gets a real\n * confirmation, never a silent default-yes/no. With no host ever mounted,\n * a `confirm()` promise stays pending forever (the original's behavior —\n * it never resolves to a silent default).\n *\n * One dialog at a time: concurrent calls queue and present sequentially.\n *\n * Ported verbatim from matrx-frontend\n * `components/dialogs/confirm/confirmDialogOpener.ts`, with ONE structural\n * inversion: the host/queue state lives on `globalThis` under a\n * `Symbol.for` slot instead of module-level variables. With the package\n * built `splitting: false` in dual ESM/CJS format, and the host now living in\n * a DIFFERENT PACKAGE, CJS/ESM each instantiate their own module graph — a module-level variable would\n * silently split the host registration from the callers (the same hazard\n * `@ai-matrx/tap-target` documents for its link registry). Behavior is\n * unchanged; never \"clean this up\" into a module local.\n */\n\nimport type { ReactNode } from \"react\";\n\nexport interface ConfirmOptions {\n title: ReactNode;\n description?: ReactNode | undefined;\n confirmLabel?: string | undefined;\n /** `null` hides the cancel button (acknowledge-only dialogs). */\n cancelLabel?: string | null | undefined;\n variant?: \"default\" | \"destructive\" | undefined;\n}\n\ntype Resolver = (confirmed: boolean) => void;\n\ninterface PendingRequest {\n opts: ConfirmOptions;\n resolve: Resolver;\n}\n\ninterface HostController {\n show: (opts: ConfirmOptions, resolve: Resolver) => void;\n}\n\ninterface OpenerState {\n host: HostController | null;\n queue: PendingRequest[];\n}\n\nconst STATE_SLOT = Symbol.for(\"ai-matrx.kit.confirm-opener-state\");\n\nfunction getState(): OpenerState {\n const holder = globalThis as Record<symbol, OpenerState | undefined>;\n let state = holder[STATE_SLOT];\n if (!state) {\n state = { host: null, queue: [] };\n holder[STATE_SLOT] = state;\n }\n return state;\n}\n\n/** @internal Called by `ConfirmDialogHost` on mount. */\nexport function _registerHost(controller: HostController): void {\n const state = getState();\n state.host = controller;\n while (state.queue.length > 0) {\n const next = state.queue.shift()!;\n controller.show(next.opts, next.resolve);\n }\n}\n\n/** @internal Called by `ConfirmDialogHost` on unmount. */\nexport function _unregisterHost(controller: HostController): void {\n const state = getState();\n if (state.host === controller) state.host = null;\n}\n\n/** @internal Test-only: drop any registered host and pending queue. */\nexport function _resetConfirmOpenerState(): void {\n const state = getState();\n state.host = null;\n state.queue.length = 0;\n}\n\n/**\n * Imperative confirm. Returns a Promise that resolves `true` if the user\n * confirms, `false` if they cancel/dismiss. Replaces `window.confirm`.\n *\n * @example\n * const ok = await confirm({\n * title: \"Delete sandbox\",\n * description: \"This cannot be undone.\",\n * variant: \"destructive\",\n * confirmLabel: \"Delete\",\n * });\n * if (!ok) return;\n */\nexport function confirm(opts: ConfirmOptions): Promise<boolean> {\n return new Promise<boolean>((resolve) => {\n const state = getState();\n if (state.host) {\n state.host.show(opts, resolve);\n } else {\n state.queue.push({ opts, resolve });\n }\n });\n}\n"],"mappings":";AAyDA,IAAM,aAAa,uBAAO,IAAI,mCAAmC;AAEjE,SAAS,WAAwB;AAC/B,QAAM,SAAS;AACf,MAAI,QAAQ,OAAO,UAAU;AAC7B,MAAI,CAAC,OAAO;AACV,YAAQ,EAAE,MAAM,MAAM,OAAO,CAAC,EAAE;AAChC,WAAO,UAAU,IAAI;AAAA,EACvB;AACA,SAAO;AACT;AAGO,SAAS,cAAc,YAAkC;AAC9D,QAAM,QAAQ,SAAS;AACvB,QAAM,OAAO;AACb,SAAO,MAAM,MAAM,SAAS,GAAG;AAC7B,UAAM,OAAO,MAAM,MAAM,MAAM;AAC/B,eAAW,KAAK,KAAK,MAAM,KAAK,OAAO;AAAA,EACzC;AACF;AAGO,SAAS,gBAAgB,YAAkC;AAChE,QAAM,QAAQ,SAAS;AACvB,MAAI,MAAM,SAAS,WAAY,OAAM,OAAO;AAC9C;AAGO,SAAS,2BAAiC;AAC/C,QAAM,QAAQ,SAAS;AACvB,QAAM,OAAO;AACb,QAAM,MAAM,SAAS;AACvB;AAeO,SAAS,QAAQ,MAAwC;AAC9D,SAAO,IAAI,QAAiB,CAAC,YAAY;AACvC,UAAM,QAAQ,SAAS;AACvB,QAAI,MAAM,MAAM;AACd,YAAM,KAAK,KAAK,MAAM,OAAO;AAAA,IAC/B,OAAO;AACL,YAAM,MAAM,KAAK,EAAE,MAAM,QAAQ,CAAC;AAAA,IACpC;AAAA,EACF,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../src/opener.ts","../src/confirm/opener.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/opener — THE opener pattern, once.\n *\n * An \"opener\" is the imperative half of a global dialog: a pure-TS function\n * you can `await` from anywhere (a Redux thunk, a util, a sync handler, an\n * async pipeline) that resolves with what the user chose, plus a registry a\n * React host attaches itself to on mount. The whole point is that the CALL\n * SITE never imports the dialog: the opener is zero-React, zero-markup,\n * zero-CSS, so hundreds of static call sites cost nothing and the heavy body\n * stays behind a lazy host.\n *\n * This module is the generic engine. `./confirm-opener` is a three-line\n * specialisation of it, and so is every app-side opener — the sandbox gate,\n * the scope-mismatch gate, the value prompts. Before this existed each one\n * hand-rolled the same `let host`, the same `queue`, the same\n * `_registerHost` drain loop, the same never-resolve-silently promise; four\n * copies of one contract, three of them with module-level state that would\n * have split across loader graphs the moment they left the app.\n *\n * THE CONTRACT — every opener made here behaves identically:\n *\n * • ONE host at a time. It registers on mount and unregisters on unmount;\n * a late unmount of a SUPERSEDED host never tears down the live one\n * (the React double-mount / provider-swap case).\n * • NEVER a silent default. With no host mounted, `open()` does NOT resolve\n * to some assumed yes/no — the promise stays pending and the request\n * queues, so a destructive action fired in the first ~50ms after page load\n * still gets a real dialog once the host hydrates. Requests are served in\n * call order.\n * • NOTHING FAILS SILENTLY (platform law 4). \"Stays pending\" would be an\n * invisible hang if the host is never mounted at all, so an opener that\n * has waited `warnWithoutHostAfterMs` with a queued request SCREAMS on the\n * console, naming the remedy (`hostHint`) — once per opener, cleared the\n * moment a host registers.\n * • DEDUPE is opt-in. With a `dedupeKey`, a second `open()` for the same key\n * while the first is unsettled returns THE SAME promise instead of\n * stacking a second dialog (double-clicked send buttons).\n * • CANCELLATION is opt-in per call. Pass an `AbortSignal` and the pending\n * request is dropped from the queue and settled — resolved with\n * `onAbortResolveWith` when the caller supplies a neutral answer\n * (\"cancel\"), rejected with an `AbortError` otherwise. A host that answers\n * an already-settled request is ignored, never a double-resolve.\n *\n * 🚨 STATE LIVES ON A `globalThis` SLOT, NEVER A MODULE LOCAL. With the\n * package built `splitting: false` in dual ESM/CJS format — and the host\n * routinely living in a DIFFERENT PACKAGE from the caller — each loader graph\n * instantiates its own copy of this module. A module-level `let host` would\n * silently split host registration from the callers: the dialog mounts, the\n * caller queues into a different registry, and the promise hangs forever with\n * no error anywhere. Same hazard `@ai-matrx/tap-target` documents for its link\n * registry. Never \"clean this up\" into a module local; the tarball canary\n * proves the slot spans both graphs.\n */\n\n/** What a mounted host must provide. */\nexport interface OpenerHostController<TRequest, TResponse> {\n show: (request: TRequest, resolve: (response: TResponse) => void) => void;\n}\n\ninterface PendingEntry<TRequest, TResponse> {\n request: TRequest;\n /** Wrapped resolver: settles once, then every later call is a no-op. */\n settle: (response: TResponse) => void;\n /** Present only while the entry is still queued (no host yet). */\n dedupeKey: string | undefined;\n settled: boolean;\n}\n\ninterface OpenerState<TRequest, TResponse> {\n host: OpenerHostController<TRequest, TResponse> | null;\n queue: PendingEntry<TRequest, TResponse>[];\n /** Unsettled entries by dedupe key — queued AND in-flight at the host. */\n inFlight: Map<string, { entry: PendingEntry<TRequest, TResponse>; promise: Promise<TResponse> }>;\n warnTimer: ReturnType<typeof setTimeout> | null;\n warned: boolean;\n}\n\nexport interface OpenerOptions<TRequest> {\n /**\n * Collapse concurrent duplicates. Return a stable string for requests that\n * must not stack (the same conversation's send gate, say), or `undefined`\n * to opt this request out of deduping.\n */\n dedupeKey?: ((request: TRequest) => string | undefined) | undefined;\n /**\n * How long a request may sit queued with no host before the opener screams\n * on the console. `0` disables the scream (tests, deliberately host-less\n * environments). Default 5000ms.\n */\n warnWithoutHostAfterMs?: number | undefined;\n /**\n * The remedy named in that scream — say EXACTLY what to mount and from\n * where, e.g. `\"<ConfirmDialogHost /> from @ai-matrx/design-system\"`.\n */\n hostHint?: string | undefined;\n}\n\nexport interface OpenRequestOptions<TResponse> {\n /** Abort the request: drops it from the queue and settles the promise. */\n signal?: AbortSignal | undefined;\n /**\n * On abort, resolve with this value instead of rejecting. Use it when the\n * response type already HAS a neutral answer (`\"cancel\"`, `null`) — an\n * abort is then indistinguishable from the user dismissing the dialog, and\n * no call site needs a try/catch.\n */\n onAbortResolveWith?: TResponse | undefined;\n}\n\nexport interface Opener<TRequest, TResponse> {\n /** The `globalThis` slot name this opener's state lives under. */\n readonly slot: string;\n /**\n * Open the dialog. Resolves with the host's answer. With no host mounted\n * the request queues and the promise stays pending — never a silent\n * default.\n */\n open: (request: TRequest, options?: OpenRequestOptions<TResponse>) => Promise<TResponse>;\n /** @internal Called by the host component on mount. */\n _registerHost: (controller: OpenerHostController<TRequest, TResponse>) => void;\n /** @internal Called by the host component on unmount. */\n _unregisterHost: (controller: OpenerHostController<TRequest, TResponse>) => void;\n /** @internal Test-only: drop the registered host, the queue and the timers. */\n _reset: () => void;\n /** @internal Diagnostics: is a host currently registered? */\n _hasHost: () => boolean;\n}\n\n/** Thrown to an aborted `open()` that supplied no `onAbortResolveWith`. */\nexport class OpenerAbortError extends Error {\n override readonly name = \"AbortError\";\n constructor(slot: string) {\n super(`The ${slot} request was aborted before the user answered it.`);\n }\n}\n\nfunction getState<TRequest, TResponse>(slot: string): OpenerState<TRequest, TResponse> {\n const holder = globalThis as Record<symbol, unknown>;\n const symbol = Symbol.for(slot);\n let state = holder[symbol] as OpenerState<TRequest, TResponse> | undefined;\n if (!state) {\n state = { host: null, queue: [], inFlight: new Map(), warnTimer: null, warned: false };\n holder[symbol] = state;\n }\n return state;\n}\n\n/**\n * Build an opener.\n *\n * @param slot The `globalThis` slot name — the opener's identity across every\n * module graph in the process, so it must be globally unique and stable.\n * Convention: `\"<owner>.<name>-opener-state\"`, e.g.\n * `\"ai-matrx.kit.confirm-opener-state\"` or\n * `\"matrx-frontend.sandbox-gate-opener-state\"`. Changing it after release\n * splits live hosts from live callers — treat it as a public name.\n *\n * @example\n * // sandboxGateOpener.ts — the whole module.\n * const opener = createOpener<SandboxGateOptions, SandboxGateChoice>(\n * \"matrx-frontend.sandbox-gate-opener-state\",\n * { hostHint: \"<SandboxGateHost /> (mounted once per provider tree)\" },\n * );\n * export const openSandboxGate = opener.open;\n */\nexport function createOpener<TRequest, TResponse>(\n slot: string,\n options: OpenerOptions<TRequest> = {},\n): Opener<TRequest, TResponse> {\n const warnAfterMs = options.warnWithoutHostAfterMs ?? 5000;\n const hostHint = options.hostHint;\n const dedupeKeyOf = options.dedupeKey;\n\n function clearWarnTimer(state: OpenerState<TRequest, TResponse>): void {\n if (state.warnTimer !== null) {\n clearTimeout(state.warnTimer);\n state.warnTimer = null;\n }\n }\n\n function armWarnTimer(state: OpenerState<TRequest, TResponse>): void {\n if (warnAfterMs <= 0 || state.warned || state.warnTimer !== null) return;\n state.warnTimer = setTimeout(() => {\n state.warnTimer = null;\n if (state.host || state.queue.length === 0) return;\n state.warned = true;\n // THE SCREAM. A queued request with no host is a promise that will hang\n // forever — invisible to the user, invisible in the network tab. Say\n // what is stuck and exactly what to mount.\n console.error(\n `[@ai-matrx/kit/opener] ${state.queue.length} request(s) on \"${slot}\" have been waiting ` +\n `${warnAfterMs}ms with no host registered, so the awaiting code is stuck and the user ` +\n `sees nothing. REMEDY: mount ${hostHint ?? `the host for \"${slot}\"`} once, near the root ` +\n `of this provider tree.`,\n );\n }, warnAfterMs);\n // Never hold a Node process open for a diagnostic timer.\n (state.warnTimer as unknown as { unref?: () => void }).unref?.();\n }\n\n function drainTo(\n state: OpenerState<TRequest, TResponse>,\n controller: OpenerHostController<TRequest, TResponse>,\n ): void {\n while (state.queue.length > 0) {\n const next = state.queue.shift()!;\n if (next.settled) continue;\n controller.show(next.request, next.settle);\n }\n }\n\n const opener: Opener<TRequest, TResponse> = {\n slot,\n\n open(request, requestOptions = {}) {\n const state = getState<TRequest, TResponse>(slot);\n const signal = requestOptions.signal;\n const dedupeKey = dedupeKeyOf?.(request);\n\n if (dedupeKey !== undefined) {\n const existing = state.inFlight.get(dedupeKey);\n // A duplicate NEVER stacks a second dialog: the second caller awaits\n // the answer the first one is already asking for.\n if (existing && !existing.entry.settled) return existing.promise;\n }\n\n let entry!: PendingEntry<TRequest, TResponse>;\n const promise = new Promise<TResponse>((resolve, reject) => {\n const finish = (run: () => void): void => {\n if (entry.settled) return;\n entry.settled = true;\n if (entry.dedupeKey !== undefined) state.inFlight.delete(entry.dedupeKey);\n const queuedAt = state.queue.indexOf(entry);\n if (queuedAt >= 0) state.queue.splice(queuedAt, 1);\n if (state.queue.length === 0) clearWarnTimer(state);\n run();\n };\n\n entry = {\n request,\n dedupeKey,\n settled: false,\n settle: (response) => finish(() => resolve(response)),\n };\n\n const abort = (): void =>\n finish(() => {\n if (\"onAbortResolveWith\" in requestOptions && requestOptions.onAbortResolveWith !== undefined) {\n resolve(requestOptions.onAbortResolveWith);\n } else {\n reject(new OpenerAbortError(slot));\n }\n });\n\n if (signal?.aborted) {\n abort();\n return;\n }\n signal?.addEventListener(\"abort\", abort, { once: true });\n });\n\n if (entry.settled) return promise; // aborted before it ever queued\n\n if (dedupeKey !== undefined) state.inFlight.set(dedupeKey, { entry, promise });\n\n if (state.host) {\n state.host.show(entry.request, entry.settle);\n } else {\n state.queue.push(entry);\n armWarnTimer(state);\n }\n return promise;\n },\n\n _registerHost(controller) {\n const state = getState<TRequest, TResponse>(slot);\n state.host = controller;\n state.warned = false;\n clearWarnTimer(state);\n drainTo(state, controller);\n },\n\n _unregisterHost(controller) {\n const state = getState<TRequest, TResponse>(slot);\n // Only the CURRENT host may unregister: a superseded host's late unmount\n // must not tear down the live one.\n if (state.host === controller) state.host = null;\n },\n\n _reset() {\n const state = getState<TRequest, TResponse>(slot);\n clearWarnTimer(state);\n state.host = null;\n state.queue.length = 0;\n state.inFlight.clear();\n state.warned = false;\n },\n\n _hasHost() {\n return getState<TRequest, TResponse>(slot).host !== null;\n },\n };\n\n return opener;\n}\n","/**\n * @ai-matrx/kit/confirm-opener — the imperative confirm opener, and since\n * 0.9.0 the ONLY half of the confirm system this package ships.\n *\n * Since 0.10.0 this file is a THIN SPECIALISATION of `../opener`: the\n * host registry, the request queue, the never-resolve-silently promise, the\n * globalThis slot and the no-host scream are all the generic engine's, and\n * what is left here is the confirm-shaped types plus the names call sites\n * already import. There is exactly ONE registry — `createOpener` reads and\n * writes the same `Symbol.for(\"ai-matrx.kit.confirm-opener-state\")` slot this\n * module always used, so a host built against any earlier version still meets\n * its callers.\n *\n * Pure TS, zero React, zero dialog markup — statically importable from\n * anywhere (hooks, utils, Redux thunks, async handlers, sync code).\n *\n * The host (`ConfirmDialogHost`, in `@ai-matrx/design-system`) registers a\n * controller on mount and unregisters on unmount. Calls made before the host\n * has hydrated queue up and resolve as soon as the host is alive — so a\n * destructive action triggered in the first ~50ms after page load still gets a\n * real confirmation, never a silent default-yes/no. With no host EVER mounted\n * the promise stays pending (it never resolves to a silent default) and the\n * opener screams on the console naming the component to mount.\n *\n * One dialog at a time: concurrent calls queue and present sequentially.\n */\n\nimport type { ReactNode } from \"react\";\n\nimport { createOpener, type OpenerHostController } from \"../opener\";\n\nexport interface ConfirmOptions {\n title: ReactNode;\n description?: ReactNode | undefined;\n confirmLabel?: string | undefined;\n /** `null` hides the cancel button (acknowledge-only dialogs). */\n cancelLabel?: string | null | undefined;\n variant?: \"default\" | \"destructive\" | undefined;\n}\n\n/** @internal The shape `ConfirmDialogHost` registers. */\nexport type ConfirmHostController = OpenerHostController<ConfirmOptions, boolean>;\n\n/**\n * The confirm opener itself. Exported for hosts that want the generic\n * `useOpenerHost` machinery (`@ai-matrx/kit/opener-react`) instead of\n * hand-wiring `_registerHost`.\n */\nexport const confirmOpener = createOpener<ConfirmOptions, boolean>(\n // 🚨 The slot name is a PUBLIC name: it is how callers in one module graph\n // (or one package) find the host in another. It has been this string since\n // the opener existed — never change it.\n \"ai-matrx.kit.confirm-opener-state\",\n { hostHint: \"<ConfirmDialogHost /> from @ai-matrx/design-system\" },\n);\n\n/** @internal Called by `ConfirmDialogHost` on mount. */\nexport const _registerHost = confirmOpener._registerHost;\n\n/** @internal Called by `ConfirmDialogHost` on unmount. */\nexport const _unregisterHost = confirmOpener._unregisterHost;\n\n/** @internal Test-only: drop any registered host and pending queue. */\nexport const _resetConfirmOpenerState = confirmOpener._reset;\n\n/**\n * Imperative confirm. Returns a Promise that resolves `true` if the user\n * confirms, `false` if they cancel/dismiss. Replaces `window.confirm`.\n *\n * @example\n * const ok = await confirm({\n * title: \"Delete sandbox\",\n * description: \"This cannot be undone.\",\n * variant: \"destructive\",\n * confirmLabel: \"Delete\",\n * });\n * if (!ok) return;\n */\nexport function confirm(opts: ConfirmOptions): Promise<boolean> {\n return confirmOpener.open(opts);\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/opener-react.ts
32
+ var opener_react_exports = {};
33
+ __export(opener_react_exports, {
34
+ useOpenerHost: () => useOpenerHost
35
+ });
36
+ module.exports = __toCommonJS(opener_react_exports);
37
+ var React = __toESM(require("react"), 1);
38
+ function useOpenerHost(opener, options = {}) {
39
+ const [active, setActive] = React.useState(null);
40
+ const [tick, setTick] = React.useState(0);
41
+ const queueRef = React.useRef([]);
42
+ const onActivateRef = React.useRef(options.onActivate);
43
+ onActivateRef.current = options.onActivate;
44
+ React.useEffect(() => {
45
+ const controller = {
46
+ show: (request, resolve) => {
47
+ queueRef.current.push({ request, resolve });
48
+ setTick((n) => n + 1);
49
+ }
50
+ };
51
+ opener._registerHost(controller);
52
+ return () => opener._unregisterHost(controller);
53
+ }, [opener]);
54
+ React.useEffect(() => {
55
+ if (active === null && queueRef.current.length > 0) {
56
+ const next = queueRef.current.shift();
57
+ onActivateRef.current?.(next.request);
58
+ setActive(next);
59
+ }
60
+ }, [active, tick]);
61
+ const settle = React.useCallback((response) => {
62
+ setActive((current) => {
63
+ if (current === null) return null;
64
+ current.resolve(response);
65
+ return null;
66
+ });
67
+ }, []);
68
+ return { request: active?.request ?? null, open: active !== null, settle };
69
+ }
70
+ //# sourceMappingURL=opener-react.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/opener-react.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/opener-react — `useOpenerHost`, the other half of `./opener`.\n *\n * Every host of an opener-backed dialog was writing the SAME twenty lines:\n * a ref-backed queue, a `tick` counter to wake a drain effect, a\n * register/unregister effect, and a `settle` callback that resolves the\n * active request exactly once and clears it. Four copies existed across kit's\n * confirm host and three matrx-frontend dialogs, each with its own chance to\n * get the stale-closure bug wrong. This is that machinery, once.\n *\n * It is a pure hook — React and nothing else. No markup, no CSS, no radix, no\n * design-system import, so it stays BELOW design-system in the package DAG\n * and any host in any package or app can use it: the dialog body is entirely\n * the host's business, and hosts differ wildly (an AlertDialog, a form, a\n * whole embedded panel).\n *\n * @example\n * function SandboxGateHostImpl() {\n * const { request, open, settle } = useOpenerHost(sandboxGateOpener);\n * return (\n * <Dialog open={open} onOpenChange={(o) => { if (!o) settle(\"cancel\"); }}>\n * ...\n * </Dialog>\n * );\n * }\n */\n\nimport * as React from \"react\";\n\nimport type { Opener, OpenerHostController } from \"./opener\";\n\ninterface ActiveRequest<TRequest, TResponse> {\n request: TRequest;\n resolve: (response: TResponse) => void;\n}\n\nexport interface UseOpenerHostOptions<TRequest> {\n /**\n * Called synchronously as a request becomes the active one, inside the same\n * React update — the place to seed per-request form state from the request\n * (default answers, for instance) with no intermediate render showing the\n * previous request's values.\n */\n onActivate?: ((request: TRequest) => void) | undefined;\n}\n\nexport interface OpenerHostState<TRequest, TResponse> {\n /** The request being shown, or `null` when the dialog is closed. */\n request: TRequest | null;\n /** Convenience for `request !== null` — feed it straight to `open=`. */\n open: boolean;\n /**\n * Answer the active request and close. Safe to call when nothing is active\n * (a no-op) and safe to call twice (the second is ignored) — a dismiss\n * handler firing alongside a button click is the normal case, not a bug.\n */\n settle: (response: TResponse) => void;\n}\n\n/**\n * Mount-side of an opener: registers this component as THE host, drains\n * queued requests one at a time, and hands you the active request plus the\n * one way to answer it.\n *\n * Render this hook's component exactly ONCE per provider tree. Mounting two\n * hosts for one opener is not an error the opener can detect as such — the\n * second simply becomes the live one — so keep the mount where the other\n * global hosts live.\n */\nexport function useOpenerHost<TRequest, TResponse>(\n opener: Opener<TRequest, TResponse>,\n options: UseOpenerHostOptions<TRequest> = {},\n): OpenerHostState<TRequest, TResponse> {\n const [active, setActive] = React.useState<ActiveRequest<TRequest, TResponse> | null>(null);\n const [tick, setTick] = React.useState(0);\n const queueRef = React.useRef<ActiveRequest<TRequest, TResponse>[]>([]);\n\n // Read `onActivate` through a ref: the caller almost always passes an inline\n // function, and it must not re-run the registration effect.\n const onActivateRef = React.useRef(options.onActivate);\n onActivateRef.current = options.onActivate;\n\n // Register/unregister exactly once. The controller's `show` always pushes\n // onto the queue and bumps `tick`; the drain effect below picks up from\n // there. That indirection is what keeps `active` out of this closure.\n React.useEffect(() => {\n const controller: OpenerHostController<TRequest, TResponse> = {\n show: (request, resolve) => {\n queueRef.current.push({ request, resolve });\n setTick((n) => n + 1);\n },\n };\n opener._registerHost(controller);\n return () => opener._unregisterHost(controller);\n }, [opener]);\n\n // Drain whenever nothing is showing. One dialog at a time, in call order.\n React.useEffect(() => {\n if (active === null && queueRef.current.length > 0) {\n const next = queueRef.current.shift()!;\n onActivateRef.current?.(next.request);\n setActive(next);\n }\n }, [active, tick]);\n\n const settle = React.useCallback((response: TResponse) => {\n setActive((current) => {\n if (current === null) return null;\n current.resolve(response);\n return null;\n });\n }, []);\n\n return { request: active?.request ?? null, open: active !== null, settle };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AA2BA,YAAuB;AA0ChB,SAAS,cACd,QACA,UAA0C,CAAC,GACL;AACtC,QAAM,CAAC,QAAQ,SAAS,IAAU,eAAoD,IAAI;AAC1F,QAAM,CAAC,MAAM,OAAO,IAAU,eAAS,CAAC;AACxC,QAAM,WAAiB,aAA6C,CAAC,CAAC;AAItE,QAAM,gBAAsB,aAAO,QAAQ,UAAU;AACrD,gBAAc,UAAU,QAAQ;AAKhC,EAAM,gBAAU,MAAM;AACpB,UAAM,aAAwD;AAAA,MAC5D,MAAM,CAAC,SAAS,YAAY;AAC1B,iBAAS,QAAQ,KAAK,EAAE,SAAS,QAAQ,CAAC;AAC1C,gBAAQ,CAAC,MAAM,IAAI,CAAC;AAAA,MACtB;AAAA,IACF;AACA,WAAO,cAAc,UAAU;AAC/B,WAAO,MAAM,OAAO,gBAAgB,UAAU;AAAA,EAChD,GAAG,CAAC,MAAM,CAAC;AAGX,EAAM,gBAAU,MAAM;AACpB,QAAI,WAAW,QAAQ,SAAS,QAAQ,SAAS,GAAG;AAClD,YAAM,OAAO,SAAS,QAAQ,MAAM;AACpC,oBAAc,UAAU,KAAK,OAAO;AACpC,gBAAU,IAAI;AAAA,IAChB;AAAA,EACF,GAAG,CAAC,QAAQ,IAAI,CAAC;AAEjB,QAAM,SAAe,kBAAY,CAAC,aAAwB;AACxD,cAAU,CAAC,YAAY;AACrB,UAAI,YAAY,KAAM,QAAO;AAC7B,cAAQ,QAAQ,QAAQ;AACxB,aAAO;AAAA,IACT,CAAC;AAAA,EACH,GAAG,CAAC,CAAC;AAEL,SAAO,EAAE,SAAS,QAAQ,WAAW,MAAM,MAAM,WAAW,MAAM,OAAO;AAC3E;","names":[]}
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @ai-matrx/kit/opener — THE opener pattern, once.
3
+ *
4
+ * An "opener" is the imperative half of a global dialog: a pure-TS function
5
+ * you can `await` from anywhere (a Redux thunk, a util, a sync handler, an
6
+ * async pipeline) that resolves with what the user chose, plus a registry a
7
+ * React host attaches itself to on mount. The whole point is that the CALL
8
+ * SITE never imports the dialog: the opener is zero-React, zero-markup,
9
+ * zero-CSS, so hundreds of static call sites cost nothing and the heavy body
10
+ * stays behind a lazy host.
11
+ *
12
+ * This module is the generic engine. `./confirm-opener` is a three-line
13
+ * specialisation of it, and so is every app-side opener — the sandbox gate,
14
+ * the scope-mismatch gate, the value prompts. Before this existed each one
15
+ * hand-rolled the same `let host`, the same `queue`, the same
16
+ * `_registerHost` drain loop, the same never-resolve-silently promise; four
17
+ * copies of one contract, three of them with module-level state that would
18
+ * have split across loader graphs the moment they left the app.
19
+ *
20
+ * THE CONTRACT — every opener made here behaves identically:
21
+ *
22
+ * • ONE host at a time. It registers on mount and unregisters on unmount;
23
+ * a late unmount of a SUPERSEDED host never tears down the live one
24
+ * (the React double-mount / provider-swap case).
25
+ * • NEVER a silent default. With no host mounted, `open()` does NOT resolve
26
+ * to some assumed yes/no — the promise stays pending and the request
27
+ * queues, so a destructive action fired in the first ~50ms after page load
28
+ * still gets a real dialog once the host hydrates. Requests are served in
29
+ * call order.
30
+ * • NOTHING FAILS SILENTLY (platform law 4). "Stays pending" would be an
31
+ * invisible hang if the host is never mounted at all, so an opener that
32
+ * has waited `warnWithoutHostAfterMs` with a queued request SCREAMS on the
33
+ * console, naming the remedy (`hostHint`) — once per opener, cleared the
34
+ * moment a host registers.
35
+ * • DEDUPE is opt-in. With a `dedupeKey`, a second `open()` for the same key
36
+ * while the first is unsettled returns THE SAME promise instead of
37
+ * stacking a second dialog (double-clicked send buttons).
38
+ * • CANCELLATION is opt-in per call. Pass an `AbortSignal` and the pending
39
+ * request is dropped from the queue and settled — resolved with
40
+ * `onAbortResolveWith` when the caller supplies a neutral answer
41
+ * ("cancel"), rejected with an `AbortError` otherwise. A host that answers
42
+ * an already-settled request is ignored, never a double-resolve.
43
+ *
44
+ * 🚨 STATE LIVES ON A `globalThis` SLOT, NEVER A MODULE LOCAL. With the
45
+ * package built `splitting: false` in dual ESM/CJS format — and the host
46
+ * routinely living in a DIFFERENT PACKAGE from the caller — each loader graph
47
+ * instantiates its own copy of this module. A module-level `let host` would
48
+ * silently split host registration from the callers: the dialog mounts, the
49
+ * caller queues into a different registry, and the promise hangs forever with
50
+ * no error anywhere. Same hazard `@ai-matrx/tap-target` documents for its link
51
+ * registry. Never "clean this up" into a module local; the tarball canary
52
+ * proves the slot spans both graphs.
53
+ */
54
+ /** What a mounted host must provide. */
55
+ interface OpenerHostController<TRequest, TResponse> {
56
+ show: (request: TRequest, resolve: (response: TResponse) => void) => void;
57
+ }
58
+ interface OpenRequestOptions<TResponse> {
59
+ /** Abort the request: drops it from the queue and settles the promise. */
60
+ signal?: AbortSignal | undefined;
61
+ /**
62
+ * On abort, resolve with this value instead of rejecting. Use it when the
63
+ * response type already HAS a neutral answer (`"cancel"`, `null`) — an
64
+ * abort is then indistinguishable from the user dismissing the dialog, and
65
+ * no call site needs a try/catch.
66
+ */
67
+ onAbortResolveWith?: TResponse | undefined;
68
+ }
69
+ interface Opener<TRequest, TResponse> {
70
+ /** The `globalThis` slot name this opener's state lives under. */
71
+ readonly slot: string;
72
+ /**
73
+ * Open the dialog. Resolves with the host's answer. With no host mounted
74
+ * the request queues and the promise stays pending — never a silent
75
+ * default.
76
+ */
77
+ open: (request: TRequest, options?: OpenRequestOptions<TResponse>) => Promise<TResponse>;
78
+ /** @internal Called by the host component on mount. */
79
+ _registerHost: (controller: OpenerHostController<TRequest, TResponse>) => void;
80
+ /** @internal Called by the host component on unmount. */
81
+ _unregisterHost: (controller: OpenerHostController<TRequest, TResponse>) => void;
82
+ /** @internal Test-only: drop the registered host, the queue and the timers. */
83
+ _reset: () => void;
84
+ /** @internal Diagnostics: is a host currently registered? */
85
+ _hasHost: () => boolean;
86
+ }
87
+
88
+ /**
89
+ * @ai-matrx/kit/opener-react — `useOpenerHost`, the other half of `./opener`.
90
+ *
91
+ * Every host of an opener-backed dialog was writing the SAME twenty lines:
92
+ * a ref-backed queue, a `tick` counter to wake a drain effect, a
93
+ * register/unregister effect, and a `settle` callback that resolves the
94
+ * active request exactly once and clears it. Four copies existed across kit's
95
+ * confirm host and three matrx-frontend dialogs, each with its own chance to
96
+ * get the stale-closure bug wrong. This is that machinery, once.
97
+ *
98
+ * It is a pure hook — React and nothing else. No markup, no CSS, no radix, no
99
+ * design-system import, so it stays BELOW design-system in the package DAG
100
+ * and any host in any package or app can use it: the dialog body is entirely
101
+ * the host's business, and hosts differ wildly (an AlertDialog, a form, a
102
+ * whole embedded panel).
103
+ *
104
+ * @example
105
+ * function SandboxGateHostImpl() {
106
+ * const { request, open, settle } = useOpenerHost(sandboxGateOpener);
107
+ * return (
108
+ * <Dialog open={open} onOpenChange={(o) => { if (!o) settle("cancel"); }}>
109
+ * ...
110
+ * </Dialog>
111
+ * );
112
+ * }
113
+ */
114
+
115
+ interface UseOpenerHostOptions<TRequest> {
116
+ /**
117
+ * Called synchronously as a request becomes the active one, inside the same
118
+ * React update — the place to seed per-request form state from the request
119
+ * (default answers, for instance) with no intermediate render showing the
120
+ * previous request's values.
121
+ */
122
+ onActivate?: ((request: TRequest) => void) | undefined;
123
+ }
124
+ interface OpenerHostState<TRequest, TResponse> {
125
+ /** The request being shown, or `null` when the dialog is closed. */
126
+ request: TRequest | null;
127
+ /** Convenience for `request !== null` — feed it straight to `open=`. */
128
+ open: boolean;
129
+ /**
130
+ * Answer the active request and close. Safe to call when nothing is active
131
+ * (a no-op) and safe to call twice (the second is ignored) — a dismiss
132
+ * handler firing alongside a button click is the normal case, not a bug.
133
+ */
134
+ settle: (response: TResponse) => void;
135
+ }
136
+ /**
137
+ * Mount-side of an opener: registers this component as THE host, drains
138
+ * queued requests one at a time, and hands you the active request plus the
139
+ * one way to answer it.
140
+ *
141
+ * Render this hook's component exactly ONCE per provider tree. Mounting two
142
+ * hosts for one opener is not an error the opener can detect as such — the
143
+ * second simply becomes the live one — so keep the mount where the other
144
+ * global hosts live.
145
+ */
146
+ declare function useOpenerHost<TRequest, TResponse>(opener: Opener<TRequest, TResponse>, options?: UseOpenerHostOptions<TRequest>): OpenerHostState<TRequest, TResponse>;
147
+
148
+ export { type OpenerHostState, type UseOpenerHostOptions, useOpenerHost };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @ai-matrx/kit/opener — THE opener pattern, once.
3
+ *
4
+ * An "opener" is the imperative half of a global dialog: a pure-TS function
5
+ * you can `await` from anywhere (a Redux thunk, a util, a sync handler, an
6
+ * async pipeline) that resolves with what the user chose, plus a registry a
7
+ * React host attaches itself to on mount. The whole point is that the CALL
8
+ * SITE never imports the dialog: the opener is zero-React, zero-markup,
9
+ * zero-CSS, so hundreds of static call sites cost nothing and the heavy body
10
+ * stays behind a lazy host.
11
+ *
12
+ * This module is the generic engine. `./confirm-opener` is a three-line
13
+ * specialisation of it, and so is every app-side opener — the sandbox gate,
14
+ * the scope-mismatch gate, the value prompts. Before this existed each one
15
+ * hand-rolled the same `let host`, the same `queue`, the same
16
+ * `_registerHost` drain loop, the same never-resolve-silently promise; four
17
+ * copies of one contract, three of them with module-level state that would
18
+ * have split across loader graphs the moment they left the app.
19
+ *
20
+ * THE CONTRACT — every opener made here behaves identically:
21
+ *
22
+ * • ONE host at a time. It registers on mount and unregisters on unmount;
23
+ * a late unmount of a SUPERSEDED host never tears down the live one
24
+ * (the React double-mount / provider-swap case).
25
+ * • NEVER a silent default. With no host mounted, `open()` does NOT resolve
26
+ * to some assumed yes/no — the promise stays pending and the request
27
+ * queues, so a destructive action fired in the first ~50ms after page load
28
+ * still gets a real dialog once the host hydrates. Requests are served in
29
+ * call order.
30
+ * • NOTHING FAILS SILENTLY (platform law 4). "Stays pending" would be an
31
+ * invisible hang if the host is never mounted at all, so an opener that
32
+ * has waited `warnWithoutHostAfterMs` with a queued request SCREAMS on the
33
+ * console, naming the remedy (`hostHint`) — once per opener, cleared the
34
+ * moment a host registers.
35
+ * • DEDUPE is opt-in. With a `dedupeKey`, a second `open()` for the same key
36
+ * while the first is unsettled returns THE SAME promise instead of
37
+ * stacking a second dialog (double-clicked send buttons).
38
+ * • CANCELLATION is opt-in per call. Pass an `AbortSignal` and the pending
39
+ * request is dropped from the queue and settled — resolved with
40
+ * `onAbortResolveWith` when the caller supplies a neutral answer
41
+ * ("cancel"), rejected with an `AbortError` otherwise. A host that answers
42
+ * an already-settled request is ignored, never a double-resolve.
43
+ *
44
+ * 🚨 STATE LIVES ON A `globalThis` SLOT, NEVER A MODULE LOCAL. With the
45
+ * package built `splitting: false` in dual ESM/CJS format — and the host
46
+ * routinely living in a DIFFERENT PACKAGE from the caller — each loader graph
47
+ * instantiates its own copy of this module. A module-level `let host` would
48
+ * silently split host registration from the callers: the dialog mounts, the
49
+ * caller queues into a different registry, and the promise hangs forever with
50
+ * no error anywhere. Same hazard `@ai-matrx/tap-target` documents for its link
51
+ * registry. Never "clean this up" into a module local; the tarball canary
52
+ * proves the slot spans both graphs.
53
+ */
54
+ /** What a mounted host must provide. */
55
+ interface OpenerHostController<TRequest, TResponse> {
56
+ show: (request: TRequest, resolve: (response: TResponse) => void) => void;
57
+ }
58
+ interface OpenRequestOptions<TResponse> {
59
+ /** Abort the request: drops it from the queue and settles the promise. */
60
+ signal?: AbortSignal | undefined;
61
+ /**
62
+ * On abort, resolve with this value instead of rejecting. Use it when the
63
+ * response type already HAS a neutral answer (`"cancel"`, `null`) — an
64
+ * abort is then indistinguishable from the user dismissing the dialog, and
65
+ * no call site needs a try/catch.
66
+ */
67
+ onAbortResolveWith?: TResponse | undefined;
68
+ }
69
+ interface Opener<TRequest, TResponse> {
70
+ /** The `globalThis` slot name this opener's state lives under. */
71
+ readonly slot: string;
72
+ /**
73
+ * Open the dialog. Resolves with the host's answer. With no host mounted
74
+ * the request queues and the promise stays pending — never a silent
75
+ * default.
76
+ */
77
+ open: (request: TRequest, options?: OpenRequestOptions<TResponse>) => Promise<TResponse>;
78
+ /** @internal Called by the host component on mount. */
79
+ _registerHost: (controller: OpenerHostController<TRequest, TResponse>) => void;
80
+ /** @internal Called by the host component on unmount. */
81
+ _unregisterHost: (controller: OpenerHostController<TRequest, TResponse>) => void;
82
+ /** @internal Test-only: drop the registered host, the queue and the timers. */
83
+ _reset: () => void;
84
+ /** @internal Diagnostics: is a host currently registered? */
85
+ _hasHost: () => boolean;
86
+ }
87
+
88
+ /**
89
+ * @ai-matrx/kit/opener-react — `useOpenerHost`, the other half of `./opener`.
90
+ *
91
+ * Every host of an opener-backed dialog was writing the SAME twenty lines:
92
+ * a ref-backed queue, a `tick` counter to wake a drain effect, a
93
+ * register/unregister effect, and a `settle` callback that resolves the
94
+ * active request exactly once and clears it. Four copies existed across kit's
95
+ * confirm host and three matrx-frontend dialogs, each with its own chance to
96
+ * get the stale-closure bug wrong. This is that machinery, once.
97
+ *
98
+ * It is a pure hook — React and nothing else. No markup, no CSS, no radix, no
99
+ * design-system import, so it stays BELOW design-system in the package DAG
100
+ * and any host in any package or app can use it: the dialog body is entirely
101
+ * the host's business, and hosts differ wildly (an AlertDialog, a form, a
102
+ * whole embedded panel).
103
+ *
104
+ * @example
105
+ * function SandboxGateHostImpl() {
106
+ * const { request, open, settle } = useOpenerHost(sandboxGateOpener);
107
+ * return (
108
+ * <Dialog open={open} onOpenChange={(o) => { if (!o) settle("cancel"); }}>
109
+ * ...
110
+ * </Dialog>
111
+ * );
112
+ * }
113
+ */
114
+
115
+ interface UseOpenerHostOptions<TRequest> {
116
+ /**
117
+ * Called synchronously as a request becomes the active one, inside the same
118
+ * React update — the place to seed per-request form state from the request
119
+ * (default answers, for instance) with no intermediate render showing the
120
+ * previous request's values.
121
+ */
122
+ onActivate?: ((request: TRequest) => void) | undefined;
123
+ }
124
+ interface OpenerHostState<TRequest, TResponse> {
125
+ /** The request being shown, or `null` when the dialog is closed. */
126
+ request: TRequest | null;
127
+ /** Convenience for `request !== null` — feed it straight to `open=`. */
128
+ open: boolean;
129
+ /**
130
+ * Answer the active request and close. Safe to call when nothing is active
131
+ * (a no-op) and safe to call twice (the second is ignored) — a dismiss
132
+ * handler firing alongside a button click is the normal case, not a bug.
133
+ */
134
+ settle: (response: TResponse) => void;
135
+ }
136
+ /**
137
+ * Mount-side of an opener: registers this component as THE host, drains
138
+ * queued requests one at a time, and hands you the active request plus the
139
+ * one way to answer it.
140
+ *
141
+ * Render this hook's component exactly ONCE per provider tree. Mounting two
142
+ * hosts for one opener is not an error the opener can detect as such — the
143
+ * second simply becomes the live one — so keep the mount where the other
144
+ * global hosts live.
145
+ */
146
+ declare function useOpenerHost<TRequest, TResponse>(opener: Opener<TRequest, TResponse>, options?: UseOpenerHostOptions<TRequest>): OpenerHostState<TRequest, TResponse>;
147
+
148
+ export { type OpenerHostState, type UseOpenerHostOptions, useOpenerHost };
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ // src/opener-react.ts
4
+ import * as React from "react";
5
+ function useOpenerHost(opener, options = {}) {
6
+ const [active, setActive] = React.useState(null);
7
+ const [tick, setTick] = React.useState(0);
8
+ const queueRef = React.useRef([]);
9
+ const onActivateRef = React.useRef(options.onActivate);
10
+ onActivateRef.current = options.onActivate;
11
+ React.useEffect(() => {
12
+ const controller = {
13
+ show: (request, resolve) => {
14
+ queueRef.current.push({ request, resolve });
15
+ setTick((n) => n + 1);
16
+ }
17
+ };
18
+ opener._registerHost(controller);
19
+ return () => opener._unregisterHost(controller);
20
+ }, [opener]);
21
+ React.useEffect(() => {
22
+ if (active === null && queueRef.current.length > 0) {
23
+ const next = queueRef.current.shift();
24
+ onActivateRef.current?.(next.request);
25
+ setActive(next);
26
+ }
27
+ }, [active, tick]);
28
+ const settle = React.useCallback((response) => {
29
+ setActive((current) => {
30
+ if (current === null) return null;
31
+ current.resolve(response);
32
+ return null;
33
+ });
34
+ }, []);
35
+ return { request: active?.request ?? null, open: active !== null, settle };
36
+ }
37
+ export {
38
+ useOpenerHost
39
+ };
40
+ //# sourceMappingURL=opener-react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/opener-react.ts"],"sourcesContent":["/**\n * @ai-matrx/kit/opener-react — `useOpenerHost`, the other half of `./opener`.\n *\n * Every host of an opener-backed dialog was writing the SAME twenty lines:\n * a ref-backed queue, a `tick` counter to wake a drain effect, a\n * register/unregister effect, and a `settle` callback that resolves the\n * active request exactly once and clears it. Four copies existed across kit's\n * confirm host and three matrx-frontend dialogs, each with its own chance to\n * get the stale-closure bug wrong. This is that machinery, once.\n *\n * It is a pure hook — React and nothing else. No markup, no CSS, no radix, no\n * design-system import, so it stays BELOW design-system in the package DAG\n * and any host in any package or app can use it: the dialog body is entirely\n * the host's business, and hosts differ wildly (an AlertDialog, a form, a\n * whole embedded panel).\n *\n * @example\n * function SandboxGateHostImpl() {\n * const { request, open, settle } = useOpenerHost(sandboxGateOpener);\n * return (\n * <Dialog open={open} onOpenChange={(o) => { if (!o) settle(\"cancel\"); }}>\n * ...\n * </Dialog>\n * );\n * }\n */\n\nimport * as React from \"react\";\n\nimport type { Opener, OpenerHostController } from \"./opener\";\n\ninterface ActiveRequest<TRequest, TResponse> {\n request: TRequest;\n resolve: (response: TResponse) => void;\n}\n\nexport interface UseOpenerHostOptions<TRequest> {\n /**\n * Called synchronously as a request becomes the active one, inside the same\n * React update — the place to seed per-request form state from the request\n * (default answers, for instance) with no intermediate render showing the\n * previous request's values.\n */\n onActivate?: ((request: TRequest) => void) | undefined;\n}\n\nexport interface OpenerHostState<TRequest, TResponse> {\n /** The request being shown, or `null` when the dialog is closed. */\n request: TRequest | null;\n /** Convenience for `request !== null` — feed it straight to `open=`. */\n open: boolean;\n /**\n * Answer the active request and close. Safe to call when nothing is active\n * (a no-op) and safe to call twice (the second is ignored) — a dismiss\n * handler firing alongside a button click is the normal case, not a bug.\n */\n settle: (response: TResponse) => void;\n}\n\n/**\n * Mount-side of an opener: registers this component as THE host, drains\n * queued requests one at a time, and hands you the active request plus the\n * one way to answer it.\n *\n * Render this hook's component exactly ONCE per provider tree. Mounting two\n * hosts for one opener is not an error the opener can detect as such — the\n * second simply becomes the live one — so keep the mount where the other\n * global hosts live.\n */\nexport function useOpenerHost<TRequest, TResponse>(\n opener: Opener<TRequest, TResponse>,\n options: UseOpenerHostOptions<TRequest> = {},\n): OpenerHostState<TRequest, TResponse> {\n const [active, setActive] = React.useState<ActiveRequest<TRequest, TResponse> | null>(null);\n const [tick, setTick] = React.useState(0);\n const queueRef = React.useRef<ActiveRequest<TRequest, TResponse>[]>([]);\n\n // Read `onActivate` through a ref: the caller almost always passes an inline\n // function, and it must not re-run the registration effect.\n const onActivateRef = React.useRef(options.onActivate);\n onActivateRef.current = options.onActivate;\n\n // Register/unregister exactly once. The controller's `show` always pushes\n // onto the queue and bumps `tick`; the drain effect below picks up from\n // there. That indirection is what keeps `active` out of this closure.\n React.useEffect(() => {\n const controller: OpenerHostController<TRequest, TResponse> = {\n show: (request, resolve) => {\n queueRef.current.push({ request, resolve });\n setTick((n) => n + 1);\n },\n };\n opener._registerHost(controller);\n return () => opener._unregisterHost(controller);\n }, [opener]);\n\n // Drain whenever nothing is showing. One dialog at a time, in call order.\n React.useEffect(() => {\n if (active === null && queueRef.current.length > 0) {\n const next = queueRef.current.shift()!;\n onActivateRef.current?.(next.request);\n setActive(next);\n }\n }, [active, tick]);\n\n const settle = React.useCallback((response: TResponse) => {\n setActive((current) => {\n if (current === null) return null;\n current.resolve(response);\n return null;\n });\n }, []);\n\n return { request: active?.request ?? null, open: active !== null, settle };\n}\n"],"mappings":";;;AA2BA,YAAY,WAAW;AA0ChB,SAAS,cACd,QACA,UAA0C,CAAC,GACL;AACtC,QAAM,CAAC,QAAQ,SAAS,IAAU,eAAoD,IAAI;AAC1F,QAAM,CAAC,MAAM,OAAO,IAAU,eAAS,CAAC;AACxC,QAAM,WAAiB,aAA6C,CAAC,CAAC;AAItE,QAAM,gBAAsB,aAAO,QAAQ,UAAU;AACrD,gBAAc,UAAU,QAAQ;AAKhC,EAAM,gBAAU,MAAM;AACpB,UAAM,aAAwD;AAAA,MAC5D,MAAM,CAAC,SAAS,YAAY;AAC1B,iBAAS,QAAQ,KAAK,EAAE,SAAS,QAAQ,CAAC;AAC1C,gBAAQ,CAAC,MAAM,IAAI,CAAC;AAAA,MACtB;AAAA,IACF;AACA,WAAO,cAAc,UAAU;AAC/B,WAAO,MAAM,OAAO,gBAAgB,UAAU;AAAA,EAChD,GAAG,CAAC,MAAM,CAAC;AAGX,EAAM,gBAAU,MAAM;AACpB,QAAI,WAAW,QAAQ,SAAS,QAAQ,SAAS,GAAG;AAClD,YAAM,OAAO,SAAS,QAAQ,MAAM;AACpC,oBAAc,UAAU,KAAK,OAAO;AACpC,gBAAU,IAAI;AAAA,IAChB;AAAA,EACF,GAAG,CAAC,QAAQ,IAAI,CAAC;AAEjB,QAAM,SAAe,kBAAY,CAAC,aAAwB;AACxD,cAAU,CAAC,YAAY;AACrB,UAAI,YAAY,KAAM,QAAO;AAC7B,cAAQ,QAAQ,QAAQ;AACxB,aAAO;AAAA,IACT,CAAC;AAAA,EACH,GAAG,CAAC,CAAC;AAEL,SAAO,EAAE,SAAS,QAAQ,WAAW,MAAM,MAAM,WAAW,MAAM,OAAO;AAC3E;","names":[]}
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/opener.ts
21
+ var opener_exports = {};
22
+ __export(opener_exports, {
23
+ OpenerAbortError: () => OpenerAbortError,
24
+ createOpener: () => createOpener
25
+ });
26
+ module.exports = __toCommonJS(opener_exports);
27
+ var OpenerAbortError = class extends Error {
28
+ name = "AbortError";
29
+ constructor(slot) {
30
+ super(`The ${slot} request was aborted before the user answered it.`);
31
+ }
32
+ };
33
+ function getState(slot) {
34
+ const holder = globalThis;
35
+ const symbol = Symbol.for(slot);
36
+ let state = holder[symbol];
37
+ if (!state) {
38
+ state = { host: null, queue: [], inFlight: /* @__PURE__ */ new Map(), warnTimer: null, warned: false };
39
+ holder[symbol] = state;
40
+ }
41
+ return state;
42
+ }
43
+ function createOpener(slot, options = {}) {
44
+ const warnAfterMs = options.warnWithoutHostAfterMs ?? 5e3;
45
+ const hostHint = options.hostHint;
46
+ const dedupeKeyOf = options.dedupeKey;
47
+ function clearWarnTimer(state) {
48
+ if (state.warnTimer !== null) {
49
+ clearTimeout(state.warnTimer);
50
+ state.warnTimer = null;
51
+ }
52
+ }
53
+ function armWarnTimer(state) {
54
+ if (warnAfterMs <= 0 || state.warned || state.warnTimer !== null) return;
55
+ state.warnTimer = setTimeout(() => {
56
+ state.warnTimer = null;
57
+ if (state.host || state.queue.length === 0) return;
58
+ state.warned = true;
59
+ console.error(
60
+ `[@ai-matrx/kit/opener] ${state.queue.length} request(s) on "${slot}" have been waiting ${warnAfterMs}ms with no host registered, so the awaiting code is stuck and the user sees nothing. REMEDY: mount ${hostHint ?? `the host for "${slot}"`} once, near the root of this provider tree.`
61
+ );
62
+ }, warnAfterMs);
63
+ state.warnTimer.unref?.();
64
+ }
65
+ function drainTo(state, controller) {
66
+ while (state.queue.length > 0) {
67
+ const next = state.queue.shift();
68
+ if (next.settled) continue;
69
+ controller.show(next.request, next.settle);
70
+ }
71
+ }
72
+ const opener = {
73
+ slot,
74
+ open(request, requestOptions = {}) {
75
+ const state = getState(slot);
76
+ const signal = requestOptions.signal;
77
+ const dedupeKey = dedupeKeyOf?.(request);
78
+ if (dedupeKey !== void 0) {
79
+ const existing = state.inFlight.get(dedupeKey);
80
+ if (existing && !existing.entry.settled) return existing.promise;
81
+ }
82
+ let entry;
83
+ const promise = new Promise((resolve, reject) => {
84
+ const finish = (run) => {
85
+ if (entry.settled) return;
86
+ entry.settled = true;
87
+ if (entry.dedupeKey !== void 0) state.inFlight.delete(entry.dedupeKey);
88
+ const queuedAt = state.queue.indexOf(entry);
89
+ if (queuedAt >= 0) state.queue.splice(queuedAt, 1);
90
+ if (state.queue.length === 0) clearWarnTimer(state);
91
+ run();
92
+ };
93
+ entry = {
94
+ request,
95
+ dedupeKey,
96
+ settled: false,
97
+ settle: (response) => finish(() => resolve(response))
98
+ };
99
+ const abort = () => finish(() => {
100
+ if ("onAbortResolveWith" in requestOptions && requestOptions.onAbortResolveWith !== void 0) {
101
+ resolve(requestOptions.onAbortResolveWith);
102
+ } else {
103
+ reject(new OpenerAbortError(slot));
104
+ }
105
+ });
106
+ if (signal?.aborted) {
107
+ abort();
108
+ return;
109
+ }
110
+ signal?.addEventListener("abort", abort, { once: true });
111
+ });
112
+ if (entry.settled) return promise;
113
+ if (dedupeKey !== void 0) state.inFlight.set(dedupeKey, { entry, promise });
114
+ if (state.host) {
115
+ state.host.show(entry.request, entry.settle);
116
+ } else {
117
+ state.queue.push(entry);
118
+ armWarnTimer(state);
119
+ }
120
+ return promise;
121
+ },
122
+ _registerHost(controller) {
123
+ const state = getState(slot);
124
+ state.host = controller;
125
+ state.warned = false;
126
+ clearWarnTimer(state);
127
+ drainTo(state, controller);
128
+ },
129
+ _unregisterHost(controller) {
130
+ const state = getState(slot);
131
+ if (state.host === controller) state.host = null;
132
+ },
133
+ _reset() {
134
+ const state = getState(slot);
135
+ clearWarnTimer(state);
136
+ state.host = null;
137
+ state.queue.length = 0;
138
+ state.inFlight.clear();
139
+ state.warned = false;
140
+ },
141
+ _hasHost() {
142
+ return getState(slot).host !== null;
143
+ }
144
+ };
145
+ return opener;
146
+ }
147
+ //# sourceMappingURL=opener.cjs.map