@ai-matrx/kit 0.9.1 → 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.
- package/CHANGELOG.md +66 -0
- package/README.md +5 -1
- package/dist/confirm-opener.cjs +129 -30
- package/dist/confirm-opener.cjs.map +1 -1
- package/dist/confirm-opener.d.cts +13 -38
- package/dist/confirm-opener.d.ts +13 -38
- package/dist/confirm-opener.js +129 -30
- package/dist/confirm-opener.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/opener-react.cjs +70 -0
- package/dist/opener-react.cjs.map +1 -0
- package/dist/opener-react.d.cts +148 -0
- package/dist/opener-react.d.ts +148 -0
- package/dist/opener-react.js +40 -0
- package/dist/opener-react.js.map +1 -0
- package/dist/opener.cjs +147 -0
- package/dist/opener.cjs.map +1 -0
- package/dist/opener.d.cts +131 -0
- package/dist/opener.d.ts +131 -0
- package/dist/opener.js +126 -0
- package/dist/opener.js.map +1 -0
- package/dist/url-state.cjs +1 -1
- package/dist/url-state.cjs.map +1 -1
- package/dist/url-state.js +1 -1
- package/dist/url-state.js.map +1 -1
- package/package.json +22 -1
|
@@ -0,0 +1,131 @@
|
|
|
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 OpenerOptions<TRequest> {
|
|
59
|
+
/**
|
|
60
|
+
* Collapse concurrent duplicates. Return a stable string for requests that
|
|
61
|
+
* must not stack (the same conversation's send gate, say), or `undefined`
|
|
62
|
+
* to opt this request out of deduping.
|
|
63
|
+
*/
|
|
64
|
+
dedupeKey?: ((request: TRequest) => string | undefined) | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* How long a request may sit queued with no host before the opener screams
|
|
67
|
+
* on the console. `0` disables the scream (tests, deliberately host-less
|
|
68
|
+
* environments). Default 5000ms.
|
|
69
|
+
*/
|
|
70
|
+
warnWithoutHostAfterMs?: number | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The remedy named in that scream — say EXACTLY what to mount and from
|
|
73
|
+
* where, e.g. `"<ConfirmDialogHost /> from @ai-matrx/design-system"`.
|
|
74
|
+
*/
|
|
75
|
+
hostHint?: string | undefined;
|
|
76
|
+
}
|
|
77
|
+
interface OpenRequestOptions<TResponse> {
|
|
78
|
+
/** Abort the request: drops it from the queue and settles the promise. */
|
|
79
|
+
signal?: AbortSignal | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* On abort, resolve with this value instead of rejecting. Use it when the
|
|
82
|
+
* response type already HAS a neutral answer (`"cancel"`, `null`) — an
|
|
83
|
+
* abort is then indistinguishable from the user dismissing the dialog, and
|
|
84
|
+
* no call site needs a try/catch.
|
|
85
|
+
*/
|
|
86
|
+
onAbortResolveWith?: TResponse | undefined;
|
|
87
|
+
}
|
|
88
|
+
interface Opener<TRequest, TResponse> {
|
|
89
|
+
/** The `globalThis` slot name this opener's state lives under. */
|
|
90
|
+
readonly slot: string;
|
|
91
|
+
/**
|
|
92
|
+
* Open the dialog. Resolves with the host's answer. With no host mounted
|
|
93
|
+
* the request queues and the promise stays pending — never a silent
|
|
94
|
+
* default.
|
|
95
|
+
*/
|
|
96
|
+
open: (request: TRequest, options?: OpenRequestOptions<TResponse>) => Promise<TResponse>;
|
|
97
|
+
/** @internal Called by the host component on mount. */
|
|
98
|
+
_registerHost: (controller: OpenerHostController<TRequest, TResponse>) => void;
|
|
99
|
+
/** @internal Called by the host component on unmount. */
|
|
100
|
+
_unregisterHost: (controller: OpenerHostController<TRequest, TResponse>) => void;
|
|
101
|
+
/** @internal Test-only: drop the registered host, the queue and the timers. */
|
|
102
|
+
_reset: () => void;
|
|
103
|
+
/** @internal Diagnostics: is a host currently registered? */
|
|
104
|
+
_hasHost: () => boolean;
|
|
105
|
+
}
|
|
106
|
+
/** Thrown to an aborted `open()` that supplied no `onAbortResolveWith`. */
|
|
107
|
+
declare class OpenerAbortError extends Error {
|
|
108
|
+
readonly name = "AbortError";
|
|
109
|
+
constructor(slot: string);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build an opener.
|
|
113
|
+
*
|
|
114
|
+
* @param slot The `globalThis` slot name — the opener's identity across every
|
|
115
|
+
* module graph in the process, so it must be globally unique and stable.
|
|
116
|
+
* Convention: `"<owner>.<name>-opener-state"`, e.g.
|
|
117
|
+
* `"ai-matrx.kit.confirm-opener-state"` or
|
|
118
|
+
* `"matrx-frontend.sandbox-gate-opener-state"`. Changing it after release
|
|
119
|
+
* splits live hosts from live callers — treat it as a public name.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* // sandboxGateOpener.ts — the whole module.
|
|
123
|
+
* const opener = createOpener<SandboxGateOptions, SandboxGateChoice>(
|
|
124
|
+
* "matrx-frontend.sandbox-gate-opener-state",
|
|
125
|
+
* { hostHint: "<SandboxGateHost /> (mounted once per provider tree)" },
|
|
126
|
+
* );
|
|
127
|
+
* export const openSandboxGate = opener.open;
|
|
128
|
+
*/
|
|
129
|
+
declare function createOpener<TRequest, TResponse>(slot: string, options?: OpenerOptions<TRequest>): Opener<TRequest, TResponse>;
|
|
130
|
+
|
|
131
|
+
export { type OpenRequestOptions, type Opener, OpenerAbortError, type OpenerHostController, type OpenerOptions, createOpener };
|
package/dist/opener.d.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
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 OpenerOptions<TRequest> {
|
|
59
|
+
/**
|
|
60
|
+
* Collapse concurrent duplicates. Return a stable string for requests that
|
|
61
|
+
* must not stack (the same conversation's send gate, say), or `undefined`
|
|
62
|
+
* to opt this request out of deduping.
|
|
63
|
+
*/
|
|
64
|
+
dedupeKey?: ((request: TRequest) => string | undefined) | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* How long a request may sit queued with no host before the opener screams
|
|
67
|
+
* on the console. `0` disables the scream (tests, deliberately host-less
|
|
68
|
+
* environments). Default 5000ms.
|
|
69
|
+
*/
|
|
70
|
+
warnWithoutHostAfterMs?: number | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The remedy named in that scream — say EXACTLY what to mount and from
|
|
73
|
+
* where, e.g. `"<ConfirmDialogHost /> from @ai-matrx/design-system"`.
|
|
74
|
+
*/
|
|
75
|
+
hostHint?: string | undefined;
|
|
76
|
+
}
|
|
77
|
+
interface OpenRequestOptions<TResponse> {
|
|
78
|
+
/** Abort the request: drops it from the queue and settles the promise. */
|
|
79
|
+
signal?: AbortSignal | undefined;
|
|
80
|
+
/**
|
|
81
|
+
* On abort, resolve with this value instead of rejecting. Use it when the
|
|
82
|
+
* response type already HAS a neutral answer (`"cancel"`, `null`) — an
|
|
83
|
+
* abort is then indistinguishable from the user dismissing the dialog, and
|
|
84
|
+
* no call site needs a try/catch.
|
|
85
|
+
*/
|
|
86
|
+
onAbortResolveWith?: TResponse | undefined;
|
|
87
|
+
}
|
|
88
|
+
interface Opener<TRequest, TResponse> {
|
|
89
|
+
/** The `globalThis` slot name this opener's state lives under. */
|
|
90
|
+
readonly slot: string;
|
|
91
|
+
/**
|
|
92
|
+
* Open the dialog. Resolves with the host's answer. With no host mounted
|
|
93
|
+
* the request queues and the promise stays pending — never a silent
|
|
94
|
+
* default.
|
|
95
|
+
*/
|
|
96
|
+
open: (request: TRequest, options?: OpenRequestOptions<TResponse>) => Promise<TResponse>;
|
|
97
|
+
/** @internal Called by the host component on mount. */
|
|
98
|
+
_registerHost: (controller: OpenerHostController<TRequest, TResponse>) => void;
|
|
99
|
+
/** @internal Called by the host component on unmount. */
|
|
100
|
+
_unregisterHost: (controller: OpenerHostController<TRequest, TResponse>) => void;
|
|
101
|
+
/** @internal Test-only: drop the registered host, the queue and the timers. */
|
|
102
|
+
_reset: () => void;
|
|
103
|
+
/** @internal Diagnostics: is a host currently registered? */
|
|
104
|
+
_hasHost: () => boolean;
|
|
105
|
+
}
|
|
106
|
+
/** Thrown to an aborted `open()` that supplied no `onAbortResolveWith`. */
|
|
107
|
+
declare class OpenerAbortError extends Error {
|
|
108
|
+
readonly name = "AbortError";
|
|
109
|
+
constructor(slot: string);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Build an opener.
|
|
113
|
+
*
|
|
114
|
+
* @param slot The `globalThis` slot name — the opener's identity across every
|
|
115
|
+
* module graph in the process, so it must be globally unique and stable.
|
|
116
|
+
* Convention: `"<owner>.<name>-opener-state"`, e.g.
|
|
117
|
+
* `"ai-matrx.kit.confirm-opener-state"` or
|
|
118
|
+
* `"matrx-frontend.sandbox-gate-opener-state"`. Changing it after release
|
|
119
|
+
* splits live hosts from live callers — treat it as a public name.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* // sandboxGateOpener.ts — the whole module.
|
|
123
|
+
* const opener = createOpener<SandboxGateOptions, SandboxGateChoice>(
|
|
124
|
+
* "matrx-frontend.sandbox-gate-opener-state",
|
|
125
|
+
* { hostHint: "<SandboxGateHost /> (mounted once per provider tree)" },
|
|
126
|
+
* );
|
|
127
|
+
* export const openSandboxGate = opener.open;
|
|
128
|
+
*/
|
|
129
|
+
declare function createOpener<TRequest, TResponse>(slot: string, options?: OpenerOptions<TRequest>): Opener<TRequest, TResponse>;
|
|
130
|
+
|
|
131
|
+
export { type OpenRequestOptions, type Opener, OpenerAbortError, type OpenerHostController, type OpenerOptions, createOpener };
|
package/dist/opener.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/opener.ts
|
|
2
|
+
var OpenerAbortError = class extends Error {
|
|
3
|
+
name = "AbortError";
|
|
4
|
+
constructor(slot) {
|
|
5
|
+
super(`The ${slot} request was aborted before the user answered it.`);
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
function getState(slot) {
|
|
9
|
+
const holder = globalThis;
|
|
10
|
+
const symbol = Symbol.for(slot);
|
|
11
|
+
let state = holder[symbol];
|
|
12
|
+
if (!state) {
|
|
13
|
+
state = { host: null, queue: [], inFlight: /* @__PURE__ */ new Map(), warnTimer: null, warned: false };
|
|
14
|
+
holder[symbol] = state;
|
|
15
|
+
}
|
|
16
|
+
return state;
|
|
17
|
+
}
|
|
18
|
+
function createOpener(slot, options = {}) {
|
|
19
|
+
const warnAfterMs = options.warnWithoutHostAfterMs ?? 5e3;
|
|
20
|
+
const hostHint = options.hostHint;
|
|
21
|
+
const dedupeKeyOf = options.dedupeKey;
|
|
22
|
+
function clearWarnTimer(state) {
|
|
23
|
+
if (state.warnTimer !== null) {
|
|
24
|
+
clearTimeout(state.warnTimer);
|
|
25
|
+
state.warnTimer = null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function armWarnTimer(state) {
|
|
29
|
+
if (warnAfterMs <= 0 || state.warned || state.warnTimer !== null) return;
|
|
30
|
+
state.warnTimer = setTimeout(() => {
|
|
31
|
+
state.warnTimer = null;
|
|
32
|
+
if (state.host || state.queue.length === 0) return;
|
|
33
|
+
state.warned = true;
|
|
34
|
+
console.error(
|
|
35
|
+
`[@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.`
|
|
36
|
+
);
|
|
37
|
+
}, warnAfterMs);
|
|
38
|
+
state.warnTimer.unref?.();
|
|
39
|
+
}
|
|
40
|
+
function drainTo(state, controller) {
|
|
41
|
+
while (state.queue.length > 0) {
|
|
42
|
+
const next = state.queue.shift();
|
|
43
|
+
if (next.settled) continue;
|
|
44
|
+
controller.show(next.request, next.settle);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const opener = {
|
|
48
|
+
slot,
|
|
49
|
+
open(request, requestOptions = {}) {
|
|
50
|
+
const state = getState(slot);
|
|
51
|
+
const signal = requestOptions.signal;
|
|
52
|
+
const dedupeKey = dedupeKeyOf?.(request);
|
|
53
|
+
if (dedupeKey !== void 0) {
|
|
54
|
+
const existing = state.inFlight.get(dedupeKey);
|
|
55
|
+
if (existing && !existing.entry.settled) return existing.promise;
|
|
56
|
+
}
|
|
57
|
+
let entry;
|
|
58
|
+
const promise = new Promise((resolve, reject) => {
|
|
59
|
+
const finish = (run) => {
|
|
60
|
+
if (entry.settled) return;
|
|
61
|
+
entry.settled = true;
|
|
62
|
+
if (entry.dedupeKey !== void 0) state.inFlight.delete(entry.dedupeKey);
|
|
63
|
+
const queuedAt = state.queue.indexOf(entry);
|
|
64
|
+
if (queuedAt >= 0) state.queue.splice(queuedAt, 1);
|
|
65
|
+
if (state.queue.length === 0) clearWarnTimer(state);
|
|
66
|
+
run();
|
|
67
|
+
};
|
|
68
|
+
entry = {
|
|
69
|
+
request,
|
|
70
|
+
dedupeKey,
|
|
71
|
+
settled: false,
|
|
72
|
+
settle: (response) => finish(() => resolve(response))
|
|
73
|
+
};
|
|
74
|
+
const abort = () => finish(() => {
|
|
75
|
+
if ("onAbortResolveWith" in requestOptions && requestOptions.onAbortResolveWith !== void 0) {
|
|
76
|
+
resolve(requestOptions.onAbortResolveWith);
|
|
77
|
+
} else {
|
|
78
|
+
reject(new OpenerAbortError(slot));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
if (signal?.aborted) {
|
|
82
|
+
abort();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
if (entry.settled) return promise;
|
|
88
|
+
if (dedupeKey !== void 0) state.inFlight.set(dedupeKey, { entry, promise });
|
|
89
|
+
if (state.host) {
|
|
90
|
+
state.host.show(entry.request, entry.settle);
|
|
91
|
+
} else {
|
|
92
|
+
state.queue.push(entry);
|
|
93
|
+
armWarnTimer(state);
|
|
94
|
+
}
|
|
95
|
+
return promise;
|
|
96
|
+
},
|
|
97
|
+
_registerHost(controller) {
|
|
98
|
+
const state = getState(slot);
|
|
99
|
+
state.host = controller;
|
|
100
|
+
state.warned = false;
|
|
101
|
+
clearWarnTimer(state);
|
|
102
|
+
drainTo(state, controller);
|
|
103
|
+
},
|
|
104
|
+
_unregisterHost(controller) {
|
|
105
|
+
const state = getState(slot);
|
|
106
|
+
if (state.host === controller) state.host = null;
|
|
107
|
+
},
|
|
108
|
+
_reset() {
|
|
109
|
+
const state = getState(slot);
|
|
110
|
+
clearWarnTimer(state);
|
|
111
|
+
state.host = null;
|
|
112
|
+
state.queue.length = 0;
|
|
113
|
+
state.inFlight.clear();
|
|
114
|
+
state.warned = false;
|
|
115
|
+
},
|
|
116
|
+
_hasHost() {
|
|
117
|
+
return getState(slot).host !== null;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
return opener;
|
|
121
|
+
}
|
|
122
|
+
export {
|
|
123
|
+
OpenerAbortError,
|
|
124
|
+
createOpener
|
|
125
|
+
};
|
|
126
|
+
//# sourceMappingURL=opener.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/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"],"mappings":"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","names":[]}
|
package/dist/url-state.cjs
CHANGED
|
@@ -164,7 +164,7 @@ function jsonUrlCodec(defaultValue, isValid) {
|
|
|
164
164
|
};
|
|
165
165
|
}
|
|
166
166
|
function useMirroredUrlState(options) {
|
|
167
|
-
const { parse,
|
|
167
|
+
const { parse, textKeys = [], resetKey } = options;
|
|
168
168
|
const params = useUrlSearchParams();
|
|
169
169
|
const optionsRef = (0, import_react.useRef)(options);
|
|
170
170
|
optionsRef.current = options;
|
package/dist/url-state.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/url-state.ts"],"sourcesContent":["\"use client\";\n\n/**\n * @ai-matrx/kit/url-state — THE canonical URL-state core. Every surface that\n * puts view state in the address bar goes through here.\n *\n * The URL is the source of truth: refresh, copied links, and browser\n * back/forward all reproduce the same value. Discrete controls push a history\n * entry by default; high-frequency text inputs opt into `replace` so a single\n * search does not create one entry per keystroke.\n *\n * WHY A RAW `history.pushState` IS A BUG, not just a style choice. It fires no\n * event and no popstate, so every OTHER url-backed control on the page keeps\n * rendering stale values until something unrelated re-renders it.\n * `commitUrlParams` dispatches `matrx:url-state`, which is what\n * `useUrlSearchParams` subscribes to. (Measured in the Matrx frontend,\n * 2026-08-25: 33 hand-rolled writes across 21 files, every one of them\n * silent.)\n *\n * PICK THE RIGHT ONE:\n * one control owns one parameter → `useUrlState` + a codec\n * a cluster of values moving together → `useMirroredUrlState`\n *\n * Codecs below cover string / enum / boolean / positive-integer / JSON, and all\n * of them OMIT the default rather than writing it, so a pristine surface has a\n * clean URL and a link carries only what the user actually chose.\n *\n * Ported verbatim from matrx-frontend `lib/url-state/useUrlState.ts`. The\n * original deliberately imports NOTHING from `next/navigation` — it writes\n * through the History API and notifies subscribers itself, which is exactly\n * how it runs in the Next.js App Router host. This port adds ONE optional\n * seam on top of that verbatim behavior: `setUrlStateRouter` lets a host whose\n * router must observe/perform the writes (react-router, a memory router in\n * tests, a native shell) inject `{ push, replace, getLocation? }`. With no\n * router injected — the default, and the Next.js wiring — behavior is\n * byte-identical to the original. The injected router lives on `globalThis`\n * under `Symbol.for(\"ai-matrx.kit.url-state-router\")` because the package\n * builds `splitting: false` dual ESM/CJS: module-level state would silently\n * split the registration across bundle graphs (the confirm-opener hazard).\n */\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\n\nconst URL_STATE_EVENT = \"matrx:url-state\";\n\n/**\n * The optional injected router. `push`/`replace` receive the full relative\n * URL (`pathname?query#hash`) exactly as the History API default would write\n * it. `getLocation` overrides where the current URL is read from (a memory\n * router); absent, `window.location` is read.\n */\nexport interface UrlStateRouter {\n push: (url: string) => void;\n replace: (url: string) => void;\n getLocation?: (() => { pathname: string; search: string; hash: string }) | undefined;\n}\n\nconst ROUTER_SLOT = Symbol.for(\"ai-matrx.kit.url-state-router\");\n\nfunction getRouter(): UrlStateRouter | null {\n return (\n (globalThis as Record<symbol, UrlStateRouter | null | undefined>)[\n ROUTER_SLOT\n ] ?? null\n );\n}\n\n/**\n * Inject (or with `null` remove) the host router. Optional — with none set,\n * writes go through `window.history` push/replaceState, which is the verbatim\n * Matrx-frontend behavior and correct for Next.js App Router hosts.\n */\nexport function setUrlStateRouter(router: UrlStateRouter | null): void {\n (globalThis as Record<symbol, UrlStateRouter | null | undefined>)[\n ROUTER_SLOT\n ] = router;\n}\n\nfunction currentLocation(): { pathname: string; search: string; hash: string } {\n const custom = getRouter()?.getLocation;\n if (custom) return custom();\n return {\n pathname: window.location.pathname,\n search: window.location.search,\n hash: window.location.hash,\n };\n}\n\nfunction subscribeToUrl(listener: () => void) {\n window.addEventListener(\"popstate\", listener);\n window.addEventListener(URL_STATE_EVENT, listener);\n return () => {\n window.removeEventListener(\"popstate\", listener);\n window.removeEventListener(URL_STATE_EVENT, listener);\n };\n}\n\nfunction getUrlSnapshot() {\n return currentLocation().search;\n}\n\nfunction getServerUrlSnapshot() {\n return \"\";\n}\n\n/** Reactive query-string snapshot for URL-backed primitives. */\nexport function useUrlSearchParams(): URLSearchParams {\n const search = useSyncExternalStore(\n subscribeToUrl,\n getUrlSnapshot,\n getServerUrlSnapshot,\n );\n return useMemo(() => new URLSearchParams(search), [search]);\n}\n\nexport type UrlHistoryMode = \"push\" | \"replace\";\n\nexport interface UrlStateCodec<T> {\n defaultValue: T;\n parse: (raw: string | null) => T;\n serialize: (value: T) => string | null;\n}\n\nexport interface SetUrlStateOptions {\n history?: UrlHistoryMode | undefined;\n}\n\nexport function commitUrlParams(\n patch: Readonly<Record<string, string | null>>,\n history: UrlHistoryMode,\n) {\n const location = currentLocation();\n const params = new URLSearchParams(location.search);\n for (const [key, value] of Object.entries(patch)) {\n if (value === null || value === \"\") params.delete(key);\n else params.set(key, value);\n }\n\n const query = params.toString();\n const next = `${location.pathname}${query ? `?${query}` : \"\"}${location.hash}`;\n const current = `${location.pathname}${location.search}${location.hash}`;\n if (next === current) return;\n\n const router = getRouter();\n if (router) {\n if (history === \"replace\") router.replace(next);\n else router.push(next);\n } else if (history === \"replace\") {\n window.history.replaceState(window.history.state, \"\", next);\n } else {\n window.history.pushState(window.history.state, \"\", next);\n }\n window.dispatchEvent(new Event(URL_STATE_EVENT));\n}\n\n/**\n * Classify a URL transition for surfaces that MIRROR state (a store, local\n * state) into the URL from an effect, where there is no single call site to\n * label.\n *\n * THE RULE: a discrete user decision (tab, filter, sort, page, selection)\n * PUSHES, so Back undoes exactly that one step; only high-frequency text\n * (`textKeys` — search boxes, a slider being dragged) REPLACES, so one search\n * is one entry instead of one per keystroke.\n */\nexport function historyModeForParamChange(\n current: URLSearchParams,\n next: URLSearchParams,\n textKeys: readonly string[],\n): UrlHistoryMode {\n const keys = new Set([...current.keys(), ...next.keys()]);\n const changed = [...keys].filter(\n (key) => current.get(key) !== next.get(key),\n );\n if (changed.length === 0) return \"replace\";\n return changed.every((key) => textKeys.includes(key)) ? \"replace\" : \"push\";\n}\n\nexport function useUrlState<T>(\n key: string,\n codec: UrlStateCodec<T>,\n): readonly [T, (value: T, options?: SetUrlStateOptions) => void] {\n const searchParams = useUrlSearchParams();\n const value = codec.parse(searchParams.get(key));\n\n const setValue = (next: T, options?: SetUrlStateOptions) => {\n commitUrlParams(\n { [key]: codec.serialize(next) },\n options?.history ?? \"push\",\n );\n };\n\n return [value, setValue] as const;\n}\n\nexport function stringUrlCodec(defaultValue = \"\"): UrlStateCodec<string> {\n return {\n defaultValue,\n parse: (raw) => raw ?? defaultValue,\n serialize: (value) => (value === defaultValue ? null : value),\n };\n}\n\nexport function enumUrlCodec<const T extends string>(\n values: readonly T[],\n defaultValue: T,\n): UrlStateCodec<T> {\n const allowed = new Set<string>(values);\n return {\n defaultValue,\n parse: (raw) => (raw && allowed.has(raw) ? (raw as T) : defaultValue),\n serialize: (value) => (value === defaultValue ? null : value),\n };\n}\n\nexport function booleanUrlCodec(defaultValue = false): UrlStateCodec<boolean> {\n return {\n defaultValue,\n parse: (raw) => (raw === null ? defaultValue : raw === \"1\"),\n serialize: (value) => (value === defaultValue ? null : value ? \"1\" : \"0\"),\n };\n}\n\nexport function positiveIntegerUrlCodec(\n defaultValue: number,\n): UrlStateCodec<number> {\n return {\n defaultValue,\n parse: (raw) => {\n const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;\n return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;\n },\n serialize: (value) =>\n value === defaultValue || !Number.isFinite(value) || value <= 0\n ? null\n : String(Math.trunc(value)),\n };\n}\n\nexport function jsonUrlCodec<T>(\n defaultValue: T,\n isValid: (value: unknown) => value is T,\n): UrlStateCodec<T> {\n return {\n defaultValue,\n parse: (raw) => {\n if (!raw) return defaultValue;\n try {\n const parsed: unknown = JSON.parse(raw);\n return isValid(parsed) ? parsed : defaultValue;\n } catch {\n return defaultValue;\n }\n },\n serialize: (value) =>\n JSON.stringify(value) === JSON.stringify(defaultValue)\n ? null\n : JSON.stringify(value),\n };\n}\n\n/**\n * Mirror a whole view-state OBJECT into the URL, in both directions.\n *\n * THE PATTERN 18 FILES WERE HAND-ROLLING. `useUrlState` is right when one\n * control owns one parameter. It is the wrong shape when a surface holds a\n * cluster of related values (search + sort + filters + page) that must move\n * together, be seeded from the URL on first render, and follow Back/Forward.\n * Every surface that needed that wrote its own `history.pushState` — and NONE\n * of them dispatched the sync event, so any other URL-backed control on the\n * same page silently kept showing stale values after they wrote.\n *\n * 🚨 THE LOOP IS BROKEN BY VALUE, NEVER BY BOOKKEEPING. The obvious guard —\n * remember the last URL you wrote and ignore anything matching it — looks\n * equivalent and is not: pressing Forward to a view already visited produces a\n * URL you did indeed write before, so the guard swallows it and the address bar\n * moves while the surface does not. Compare the DECODED value to the current\n * one instead; your own write compares equal and stops.\n *\n * Seeding happens in the `useState` initialiser, not an effect, so the first\n * render already shows the requested view rather than flashing the default and\n * fetching the wrong page before correcting itself.\n */\nexport interface MirroredUrlStateOptions<T> {\n /** Decode the whole value from the query string. Must never throw. */\n parse: (params: URLSearchParams) => T;\n /** Encode it; `null` for a key means \"omit\", which keeps defaults out of the URL. */\n toParams: (value: T) => Record<string, string | null>;\n /** Value equality — what stops the two directions fighting. */\n isSame: (a: T, b: T) => boolean;\n /** Keys that REPLACE instead of pushing (search boxes, dragged sliders). */\n textKeys?: readonly string[] | undefined;\n /**\n * Changing this clears the mirrored state and its parameters — for when the\n * surface switches to a different subject and the old view would be a lie.\n */\n resetKey?: string | undefined;\n}\n\nexport function useMirroredUrlState<T>(\n options: MirroredUrlStateOptions<T>,\n): readonly [T, (updater: T | ((prev: T) => T)) => void] {\n const { parse, toParams, isSame, textKeys = [], resetKey } = options;\n const params = useUrlSearchParams();\n\n const optionsRef = useRef(options);\n optionsRef.current = options;\n\n const [value, setValue] = useState<T>(() =>\n parse(\n typeof window === \"undefined\"\n ? new URLSearchParams()\n : new URLSearchParams(currentLocation().search),\n ),\n );\n\n // value → URL\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const current = new URLSearchParams(currentLocation().search);\n const patch = optionsRef.current.toParams(value);\n\n const next = new URLSearchParams(current);\n for (const [key, v] of Object.entries(patch)) {\n if (v === null || v === \"\") next.delete(key);\n else next.set(key, v);\n }\n if (next.toString() === current.toString()) return;\n\n commitUrlParams(patch, historyModeForParamChange(current, next, textKeys));\n // `textKeys` is a literal in every caller; `value` is the real trigger.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value]);\n\n // URL → value (Back/Forward, a pasted link, another control writing)\n useEffect(() => {\n // Read the URL at EFFECT time, not the render snapshot. The value→URL\n // effect runs first. When it commits a local filter/sort decision it\n // synchronously notifies the external store, but `params` in this render\n // still describes the URL from before that write. Re-applying that stale\n // snapshot here ping-pongs state and URL until React raises #185.\n const fromUrl = optionsRef.current.parse(\n new URLSearchParams(currentLocation().search),\n );\n setValue((prev) => (optionsRef.current.isSame(prev, fromUrl) ? prev : fromUrl));\n }, [params]);\n\n // Clearing on a subject change is deliberate; clearing on a subject ARRIVING\n // is destructive. A surface whose id is undefined for its first render (async\n // params, a late store hydration, a suspended boundary) would otherwise wipe\n // the very view the user just loaded — and the symptom is indistinguishable\n // from \"the URL never saved my filters\", because the write lands and is\n // erased a tick later.\n //\n // So: only a transition between two REAL, DIFFERENT keys resets.\n const previousResetKey = useRef(resetKey);\n useEffect(() => {\n const previous = previousResetKey.current;\n previousResetKey.current = resetKey;\n if (previous === resetKey) return;\n if (previous === undefined || previous === \"\") return; // arriving, not changing\n if (resetKey === undefined || resetKey === \"\") return; // leaving, not changing\n\n const cleared = optionsRef.current.parse(new URLSearchParams());\n setValue(cleared);\n commitUrlParams(optionsRef.current.toParams(cleared), \"replace\");\n }, [resetKey]);\n\n const update = useCallback((updater: T | ((prev: T) => T)) => {\n setValue((prev) =>\n typeof updater === \"function\"\n ? (updater as (p: T) => T)(prev)\n : updater,\n );\n }, []);\n\n return [value, update] as const;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/url-state.ts"],"sourcesContent":["\"use client\";\n\n/**\n * @ai-matrx/kit/url-state — THE canonical URL-state core. Every surface that\n * puts view state in the address bar goes through here.\n *\n * The URL is the source of truth: refresh, copied links, and browser\n * back/forward all reproduce the same value. Discrete controls push a history\n * entry by default; high-frequency text inputs opt into `replace` so a single\n * search does not create one entry per keystroke.\n *\n * WHY A RAW `history.pushState` IS A BUG, not just a style choice. It fires no\n * event and no popstate, so every OTHER url-backed control on the page keeps\n * rendering stale values until something unrelated re-renders it.\n * `commitUrlParams` dispatches `matrx:url-state`, which is what\n * `useUrlSearchParams` subscribes to. (Measured in the Matrx frontend,\n * 2026-08-25: 33 hand-rolled writes across 21 files, every one of them\n * silent.)\n *\n * PICK THE RIGHT ONE:\n * one control owns one parameter → `useUrlState` + a codec\n * a cluster of values moving together → `useMirroredUrlState`\n *\n * Codecs below cover string / enum / boolean / positive-integer / JSON, and all\n * of them OMIT the default rather than writing it, so a pristine surface has a\n * clean URL and a link carries only what the user actually chose.\n *\n * Ported verbatim from matrx-frontend `lib/url-state/useUrlState.ts`. The\n * original deliberately imports NOTHING from `next/navigation` — it writes\n * through the History API and notifies subscribers itself, which is exactly\n * how it runs in the Next.js App Router host. This port adds ONE optional\n * seam on top of that verbatim behavior: `setUrlStateRouter` lets a host whose\n * router must observe/perform the writes (react-router, a memory router in\n * tests, a native shell) inject `{ push, replace, getLocation? }`. With no\n * router injected — the default, and the Next.js wiring — behavior is\n * byte-identical to the original. The injected router lives on `globalThis`\n * under `Symbol.for(\"ai-matrx.kit.url-state-router\")` because the package\n * builds `splitting: false` dual ESM/CJS: module-level state would silently\n * split the registration across bundle graphs (the confirm-opener hazard).\n */\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\n\nconst URL_STATE_EVENT = \"matrx:url-state\";\n\n/**\n * The optional injected router. `push`/`replace` receive the full relative\n * URL (`pathname?query#hash`) exactly as the History API default would write\n * it. `getLocation` overrides where the current URL is read from (a memory\n * router); absent, `window.location` is read.\n */\nexport interface UrlStateRouter {\n push: (url: string) => void;\n replace: (url: string) => void;\n getLocation?: (() => { pathname: string; search: string; hash: string }) | undefined;\n}\n\nconst ROUTER_SLOT = Symbol.for(\"ai-matrx.kit.url-state-router\");\n\nfunction getRouter(): UrlStateRouter | null {\n return (\n (globalThis as Record<symbol, UrlStateRouter | null | undefined>)[\n ROUTER_SLOT\n ] ?? null\n );\n}\n\n/**\n * Inject (or with `null` remove) the host router. Optional — with none set,\n * writes go through `window.history` push/replaceState, which is the verbatim\n * Matrx-frontend behavior and correct for Next.js App Router hosts.\n */\nexport function setUrlStateRouter(router: UrlStateRouter | null): void {\n (globalThis as Record<symbol, UrlStateRouter | null | undefined>)[\n ROUTER_SLOT\n ] = router;\n}\n\nfunction currentLocation(): { pathname: string; search: string; hash: string } {\n const custom = getRouter()?.getLocation;\n if (custom) return custom();\n return {\n pathname: window.location.pathname,\n search: window.location.search,\n hash: window.location.hash,\n };\n}\n\nfunction subscribeToUrl(listener: () => void) {\n window.addEventListener(\"popstate\", listener);\n window.addEventListener(URL_STATE_EVENT, listener);\n return () => {\n window.removeEventListener(\"popstate\", listener);\n window.removeEventListener(URL_STATE_EVENT, listener);\n };\n}\n\nfunction getUrlSnapshot() {\n return currentLocation().search;\n}\n\nfunction getServerUrlSnapshot() {\n return \"\";\n}\n\n/** Reactive query-string snapshot for URL-backed primitives. */\nexport function useUrlSearchParams(): URLSearchParams {\n const search = useSyncExternalStore(\n subscribeToUrl,\n getUrlSnapshot,\n getServerUrlSnapshot,\n );\n return useMemo(() => new URLSearchParams(search), [search]);\n}\n\nexport type UrlHistoryMode = \"push\" | \"replace\";\n\nexport interface UrlStateCodec<T> {\n defaultValue: T;\n parse: (raw: string | null) => T;\n serialize: (value: T) => string | null;\n}\n\nexport interface SetUrlStateOptions {\n history?: UrlHistoryMode | undefined;\n}\n\nexport function commitUrlParams(\n patch: Readonly<Record<string, string | null>>,\n history: UrlHistoryMode,\n) {\n const location = currentLocation();\n const params = new URLSearchParams(location.search);\n for (const [key, value] of Object.entries(patch)) {\n if (value === null || value === \"\") params.delete(key);\n else params.set(key, value);\n }\n\n const query = params.toString();\n const next = `${location.pathname}${query ? `?${query}` : \"\"}${location.hash}`;\n const current = `${location.pathname}${location.search}${location.hash}`;\n if (next === current) return;\n\n const router = getRouter();\n if (router) {\n if (history === \"replace\") router.replace(next);\n else router.push(next);\n } else if (history === \"replace\") {\n window.history.replaceState(window.history.state, \"\", next);\n } else {\n window.history.pushState(window.history.state, \"\", next);\n }\n window.dispatchEvent(new Event(URL_STATE_EVENT));\n}\n\n/**\n * Classify a URL transition for surfaces that MIRROR state (a store, local\n * state) into the URL from an effect, where there is no single call site to\n * label.\n *\n * THE RULE: a discrete user decision (tab, filter, sort, page, selection)\n * PUSHES, so Back undoes exactly that one step; only high-frequency text\n * (`textKeys` — search boxes, a slider being dragged) REPLACES, so one search\n * is one entry instead of one per keystroke.\n */\nexport function historyModeForParamChange(\n current: URLSearchParams,\n next: URLSearchParams,\n textKeys: readonly string[],\n): UrlHistoryMode {\n const keys = new Set([...current.keys(), ...next.keys()]);\n const changed = [...keys].filter(\n (key) => current.get(key) !== next.get(key),\n );\n if (changed.length === 0) return \"replace\";\n return changed.every((key) => textKeys.includes(key)) ? \"replace\" : \"push\";\n}\n\nexport function useUrlState<T>(\n key: string,\n codec: UrlStateCodec<T>,\n): readonly [T, (value: T, options?: SetUrlStateOptions) => void] {\n const searchParams = useUrlSearchParams();\n const value = codec.parse(searchParams.get(key));\n\n const setValue = (next: T, options?: SetUrlStateOptions) => {\n commitUrlParams(\n { [key]: codec.serialize(next) },\n options?.history ?? \"push\",\n );\n };\n\n return [value, setValue] as const;\n}\n\nexport function stringUrlCodec(defaultValue = \"\"): UrlStateCodec<string> {\n return {\n defaultValue,\n parse: (raw) => raw ?? defaultValue,\n serialize: (value) => (value === defaultValue ? null : value),\n };\n}\n\nexport function enumUrlCodec<const T extends string>(\n values: readonly T[],\n defaultValue: T,\n): UrlStateCodec<T> {\n const allowed = new Set<string>(values);\n return {\n defaultValue,\n parse: (raw) => (raw && allowed.has(raw) ? (raw as T) : defaultValue),\n serialize: (value) => (value === defaultValue ? null : value),\n };\n}\n\nexport function booleanUrlCodec(defaultValue = false): UrlStateCodec<boolean> {\n return {\n defaultValue,\n parse: (raw) => (raw === null ? defaultValue : raw === \"1\"),\n serialize: (value) => (value === defaultValue ? null : value ? \"1\" : \"0\"),\n };\n}\n\nexport function positiveIntegerUrlCodec(\n defaultValue: number,\n): UrlStateCodec<number> {\n return {\n defaultValue,\n parse: (raw) => {\n const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;\n return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;\n },\n serialize: (value) =>\n value === defaultValue || !Number.isFinite(value) || value <= 0\n ? null\n : String(Math.trunc(value)),\n };\n}\n\nexport function jsonUrlCodec<T>(\n defaultValue: T,\n isValid: (value: unknown) => value is T,\n): UrlStateCodec<T> {\n return {\n defaultValue,\n parse: (raw) => {\n if (!raw) return defaultValue;\n try {\n const parsed: unknown = JSON.parse(raw);\n return isValid(parsed) ? parsed : defaultValue;\n } catch {\n return defaultValue;\n }\n },\n serialize: (value) =>\n JSON.stringify(value) === JSON.stringify(defaultValue)\n ? null\n : JSON.stringify(value),\n };\n}\n\n/**\n * Mirror a whole view-state OBJECT into the URL, in both directions.\n *\n * THE PATTERN 18 FILES WERE HAND-ROLLING. `useUrlState` is right when one\n * control owns one parameter. It is the wrong shape when a surface holds a\n * cluster of related values (search + sort + filters + page) that must move\n * together, be seeded from the URL on first render, and follow Back/Forward.\n * Every surface that needed that wrote its own `history.pushState` — and NONE\n * of them dispatched the sync event, so any other URL-backed control on the\n * same page silently kept showing stale values after they wrote.\n *\n * 🚨 THE LOOP IS BROKEN BY VALUE, NEVER BY BOOKKEEPING. The obvious guard —\n * remember the last URL you wrote and ignore anything matching it — looks\n * equivalent and is not: pressing Forward to a view already visited produces a\n * URL you did indeed write before, so the guard swallows it and the address bar\n * moves while the surface does not. Compare the DECODED value to the current\n * one instead; your own write compares equal and stops.\n *\n * Seeding happens in the `useState` initialiser, not an effect, so the first\n * render already shows the requested view rather than flashing the default and\n * fetching the wrong page before correcting itself.\n */\nexport interface MirroredUrlStateOptions<T> {\n /** Decode the whole value from the query string. Must never throw. */\n parse: (params: URLSearchParams) => T;\n /** Encode it; `null` for a key means \"omit\", which keeps defaults out of the URL. */\n toParams: (value: T) => Record<string, string | null>;\n /** Value equality — what stops the two directions fighting. */\n isSame: (a: T, b: T) => boolean;\n /** Keys that REPLACE instead of pushing (search boxes, dragged sliders). */\n textKeys?: readonly string[] | undefined;\n /**\n * Changing this clears the mirrored state and its parameters — for when the\n * surface switches to a different subject and the old view would be a lie.\n */\n resetKey?: string | undefined;\n}\n\nexport function useMirroredUrlState<T>(\n options: MirroredUrlStateOptions<T>,\n): readonly [T, (updater: T | ((prev: T) => T)) => void] {\n const { parse, textKeys = [], resetKey } = options;\n const params = useUrlSearchParams();\n\n const optionsRef = useRef(options);\n optionsRef.current = options;\n\n const [value, setValue] = useState<T>(() =>\n parse(\n typeof window === \"undefined\"\n ? new URLSearchParams()\n : new URLSearchParams(currentLocation().search),\n ),\n );\n\n // value → URL\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const current = new URLSearchParams(currentLocation().search);\n const patch = optionsRef.current.toParams(value);\n\n const next = new URLSearchParams(current);\n for (const [key, v] of Object.entries(patch)) {\n if (v === null || v === \"\") next.delete(key);\n else next.set(key, v);\n }\n if (next.toString() === current.toString()) return;\n\n commitUrlParams(patch, historyModeForParamChange(current, next, textKeys));\n // `textKeys` is a literal in every caller; `value` is the real trigger.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value]);\n\n // URL → value (Back/Forward, a pasted link, another control writing)\n useEffect(() => {\n // Read the URL at EFFECT time, not the render snapshot. The value→URL\n // effect runs first. When it commits a local filter/sort decision it\n // synchronously notifies the external store, but `params` in this render\n // still describes the URL from before that write. Re-applying that stale\n // snapshot here ping-pongs state and URL until React raises #185.\n const fromUrl = optionsRef.current.parse(\n new URLSearchParams(currentLocation().search),\n );\n setValue((prev) => (optionsRef.current.isSame(prev, fromUrl) ? prev : fromUrl));\n }, [params]);\n\n // Clearing on a subject change is deliberate; clearing on a subject ARRIVING\n // is destructive. A surface whose id is undefined for its first render (async\n // params, a late store hydration, a suspended boundary) would otherwise wipe\n // the very view the user just loaded — and the symptom is indistinguishable\n // from \"the URL never saved my filters\", because the write lands and is\n // erased a tick later.\n //\n // So: only a transition between two REAL, DIFFERENT keys resets.\n const previousResetKey = useRef(resetKey);\n useEffect(() => {\n const previous = previousResetKey.current;\n previousResetKey.current = resetKey;\n if (previous === resetKey) return;\n if (previous === undefined || previous === \"\") return; // arriving, not changing\n if (resetKey === undefined || resetKey === \"\") return; // leaving, not changing\n\n const cleared = optionsRef.current.parse(new URLSearchParams());\n setValue(cleared);\n commitUrlParams(optionsRef.current.toParams(cleared), \"replace\");\n }, [resetKey]);\n\n const update = useCallback((updater: T | ((prev: T) => T)) => {\n setValue((prev) =>\n typeof updater === \"function\"\n ? (updater as (p: T) => T)(prev)\n : updater,\n );\n }, []);\n\n return [value, update] as const;\n}\n"],"mappings":"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","names":[]}
|
package/dist/url-state.js
CHANGED
|
@@ -138,7 +138,7 @@ function jsonUrlCodec(defaultValue, isValid) {
|
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
function useMirroredUrlState(options) {
|
|
141
|
-
const { parse,
|
|
141
|
+
const { parse, textKeys = [], resetKey } = options;
|
|
142
142
|
const params = useUrlSearchParams();
|
|
143
143
|
const optionsRef = useRef(options);
|
|
144
144
|
optionsRef.current = options;
|
package/dist/url-state.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/url-state.ts"],"sourcesContent":["\"use client\";\n\n/**\n * @ai-matrx/kit/url-state — THE canonical URL-state core. Every surface that\n * puts view state in the address bar goes through here.\n *\n * The URL is the source of truth: refresh, copied links, and browser\n * back/forward all reproduce the same value. Discrete controls push a history\n * entry by default; high-frequency text inputs opt into `replace` so a single\n * search does not create one entry per keystroke.\n *\n * WHY A RAW `history.pushState` IS A BUG, not just a style choice. It fires no\n * event and no popstate, so every OTHER url-backed control on the page keeps\n * rendering stale values until something unrelated re-renders it.\n * `commitUrlParams` dispatches `matrx:url-state`, which is what\n * `useUrlSearchParams` subscribes to. (Measured in the Matrx frontend,\n * 2026-08-25: 33 hand-rolled writes across 21 files, every one of them\n * silent.)\n *\n * PICK THE RIGHT ONE:\n * one control owns one parameter → `useUrlState` + a codec\n * a cluster of values moving together → `useMirroredUrlState`\n *\n * Codecs below cover string / enum / boolean / positive-integer / JSON, and all\n * of them OMIT the default rather than writing it, so a pristine surface has a\n * clean URL and a link carries only what the user actually chose.\n *\n * Ported verbatim from matrx-frontend `lib/url-state/useUrlState.ts`. The\n * original deliberately imports NOTHING from `next/navigation` — it writes\n * through the History API and notifies subscribers itself, which is exactly\n * how it runs in the Next.js App Router host. This port adds ONE optional\n * seam on top of that verbatim behavior: `setUrlStateRouter` lets a host whose\n * router must observe/perform the writes (react-router, a memory router in\n * tests, a native shell) inject `{ push, replace, getLocation? }`. With no\n * router injected — the default, and the Next.js wiring — behavior is\n * byte-identical to the original. The injected router lives on `globalThis`\n * under `Symbol.for(\"ai-matrx.kit.url-state-router\")` because the package\n * builds `splitting: false` dual ESM/CJS: module-level state would silently\n * split the registration across bundle graphs (the confirm-opener hazard).\n */\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\n\nconst URL_STATE_EVENT = \"matrx:url-state\";\n\n/**\n * The optional injected router. `push`/`replace` receive the full relative\n * URL (`pathname?query#hash`) exactly as the History API default would write\n * it. `getLocation` overrides where the current URL is read from (a memory\n * router); absent, `window.location` is read.\n */\nexport interface UrlStateRouter {\n push: (url: string) => void;\n replace: (url: string) => void;\n getLocation?: (() => { pathname: string; search: string; hash: string }) | undefined;\n}\n\nconst ROUTER_SLOT = Symbol.for(\"ai-matrx.kit.url-state-router\");\n\nfunction getRouter(): UrlStateRouter | null {\n return (\n (globalThis as Record<symbol, UrlStateRouter | null | undefined>)[\n ROUTER_SLOT\n ] ?? null\n );\n}\n\n/**\n * Inject (or with `null` remove) the host router. Optional — with none set,\n * writes go through `window.history` push/replaceState, which is the verbatim\n * Matrx-frontend behavior and correct for Next.js App Router hosts.\n */\nexport function setUrlStateRouter(router: UrlStateRouter | null): void {\n (globalThis as Record<symbol, UrlStateRouter | null | undefined>)[\n ROUTER_SLOT\n ] = router;\n}\n\nfunction currentLocation(): { pathname: string; search: string; hash: string } {\n const custom = getRouter()?.getLocation;\n if (custom) return custom();\n return {\n pathname: window.location.pathname,\n search: window.location.search,\n hash: window.location.hash,\n };\n}\n\nfunction subscribeToUrl(listener: () => void) {\n window.addEventListener(\"popstate\", listener);\n window.addEventListener(URL_STATE_EVENT, listener);\n return () => {\n window.removeEventListener(\"popstate\", listener);\n window.removeEventListener(URL_STATE_EVENT, listener);\n };\n}\n\nfunction getUrlSnapshot() {\n return currentLocation().search;\n}\n\nfunction getServerUrlSnapshot() {\n return \"\";\n}\n\n/** Reactive query-string snapshot for URL-backed primitives. */\nexport function useUrlSearchParams(): URLSearchParams {\n const search = useSyncExternalStore(\n subscribeToUrl,\n getUrlSnapshot,\n getServerUrlSnapshot,\n );\n return useMemo(() => new URLSearchParams(search), [search]);\n}\n\nexport type UrlHistoryMode = \"push\" | \"replace\";\n\nexport interface UrlStateCodec<T> {\n defaultValue: T;\n parse: (raw: string | null) => T;\n serialize: (value: T) => string | null;\n}\n\nexport interface SetUrlStateOptions {\n history?: UrlHistoryMode | undefined;\n}\n\nexport function commitUrlParams(\n patch: Readonly<Record<string, string | null>>,\n history: UrlHistoryMode,\n) {\n const location = currentLocation();\n const params = new URLSearchParams(location.search);\n for (const [key, value] of Object.entries(patch)) {\n if (value === null || value === \"\") params.delete(key);\n else params.set(key, value);\n }\n\n const query = params.toString();\n const next = `${location.pathname}${query ? `?${query}` : \"\"}${location.hash}`;\n const current = `${location.pathname}${location.search}${location.hash}`;\n if (next === current) return;\n\n const router = getRouter();\n if (router) {\n if (history === \"replace\") router.replace(next);\n else router.push(next);\n } else if (history === \"replace\") {\n window.history.replaceState(window.history.state, \"\", next);\n } else {\n window.history.pushState(window.history.state, \"\", next);\n }\n window.dispatchEvent(new Event(URL_STATE_EVENT));\n}\n\n/**\n * Classify a URL transition for surfaces that MIRROR state (a store, local\n * state) into the URL from an effect, where there is no single call site to\n * label.\n *\n * THE RULE: a discrete user decision (tab, filter, sort, page, selection)\n * PUSHES, so Back undoes exactly that one step; only high-frequency text\n * (`textKeys` — search boxes, a slider being dragged) REPLACES, so one search\n * is one entry instead of one per keystroke.\n */\nexport function historyModeForParamChange(\n current: URLSearchParams,\n next: URLSearchParams,\n textKeys: readonly string[],\n): UrlHistoryMode {\n const keys = new Set([...current.keys(), ...next.keys()]);\n const changed = [...keys].filter(\n (key) => current.get(key) !== next.get(key),\n );\n if (changed.length === 0) return \"replace\";\n return changed.every((key) => textKeys.includes(key)) ? \"replace\" : \"push\";\n}\n\nexport function useUrlState<T>(\n key: string,\n codec: UrlStateCodec<T>,\n): readonly [T, (value: T, options?: SetUrlStateOptions) => void] {\n const searchParams = useUrlSearchParams();\n const value = codec.parse(searchParams.get(key));\n\n const setValue = (next: T, options?: SetUrlStateOptions) => {\n commitUrlParams(\n { [key]: codec.serialize(next) },\n options?.history ?? \"push\",\n );\n };\n\n return [value, setValue] as const;\n}\n\nexport function stringUrlCodec(defaultValue = \"\"): UrlStateCodec<string> {\n return {\n defaultValue,\n parse: (raw) => raw ?? defaultValue,\n serialize: (value) => (value === defaultValue ? null : value),\n };\n}\n\nexport function enumUrlCodec<const T extends string>(\n values: readonly T[],\n defaultValue: T,\n): UrlStateCodec<T> {\n const allowed = new Set<string>(values);\n return {\n defaultValue,\n parse: (raw) => (raw && allowed.has(raw) ? (raw as T) : defaultValue),\n serialize: (value) => (value === defaultValue ? null : value),\n };\n}\n\nexport function booleanUrlCodec(defaultValue = false): UrlStateCodec<boolean> {\n return {\n defaultValue,\n parse: (raw) => (raw === null ? defaultValue : raw === \"1\"),\n serialize: (value) => (value === defaultValue ? null : value ? \"1\" : \"0\"),\n };\n}\n\nexport function positiveIntegerUrlCodec(\n defaultValue: number,\n): UrlStateCodec<number> {\n return {\n defaultValue,\n parse: (raw) => {\n const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;\n return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;\n },\n serialize: (value) =>\n value === defaultValue || !Number.isFinite(value) || value <= 0\n ? null\n : String(Math.trunc(value)),\n };\n}\n\nexport function jsonUrlCodec<T>(\n defaultValue: T,\n isValid: (value: unknown) => value is T,\n): UrlStateCodec<T> {\n return {\n defaultValue,\n parse: (raw) => {\n if (!raw) return defaultValue;\n try {\n const parsed: unknown = JSON.parse(raw);\n return isValid(parsed) ? parsed : defaultValue;\n } catch {\n return defaultValue;\n }\n },\n serialize: (value) =>\n JSON.stringify(value) === JSON.stringify(defaultValue)\n ? null\n : JSON.stringify(value),\n };\n}\n\n/**\n * Mirror a whole view-state OBJECT into the URL, in both directions.\n *\n * THE PATTERN 18 FILES WERE HAND-ROLLING. `useUrlState` is right when one\n * control owns one parameter. It is the wrong shape when a surface holds a\n * cluster of related values (search + sort + filters + page) that must move\n * together, be seeded from the URL on first render, and follow Back/Forward.\n * Every surface that needed that wrote its own `history.pushState` — and NONE\n * of them dispatched the sync event, so any other URL-backed control on the\n * same page silently kept showing stale values after they wrote.\n *\n * 🚨 THE LOOP IS BROKEN BY VALUE, NEVER BY BOOKKEEPING. The obvious guard —\n * remember the last URL you wrote and ignore anything matching it — looks\n * equivalent and is not: pressing Forward to a view already visited produces a\n * URL you did indeed write before, so the guard swallows it and the address bar\n * moves while the surface does not. Compare the DECODED value to the current\n * one instead; your own write compares equal and stops.\n *\n * Seeding happens in the `useState` initialiser, not an effect, so the first\n * render already shows the requested view rather than flashing the default and\n * fetching the wrong page before correcting itself.\n */\nexport interface MirroredUrlStateOptions<T> {\n /** Decode the whole value from the query string. Must never throw. */\n parse: (params: URLSearchParams) => T;\n /** Encode it; `null` for a key means \"omit\", which keeps defaults out of the URL. */\n toParams: (value: T) => Record<string, string | null>;\n /** Value equality — what stops the two directions fighting. */\n isSame: (a: T, b: T) => boolean;\n /** Keys that REPLACE instead of pushing (search boxes, dragged sliders). */\n textKeys?: readonly string[] | undefined;\n /**\n * Changing this clears the mirrored state and its parameters — for when the\n * surface switches to a different subject and the old view would be a lie.\n */\n resetKey?: string | undefined;\n}\n\nexport function useMirroredUrlState<T>(\n options: MirroredUrlStateOptions<T>,\n): readonly [T, (updater: T | ((prev: T) => T)) => void] {\n const { parse, toParams, isSame, textKeys = [], resetKey } = options;\n const params = useUrlSearchParams();\n\n const optionsRef = useRef(options);\n optionsRef.current = options;\n\n const [value, setValue] = useState<T>(() =>\n parse(\n typeof window === \"undefined\"\n ? new URLSearchParams()\n : new URLSearchParams(currentLocation().search),\n ),\n );\n\n // value → URL\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const current = new URLSearchParams(currentLocation().search);\n const patch = optionsRef.current.toParams(value);\n\n const next = new URLSearchParams(current);\n for (const [key, v] of Object.entries(patch)) {\n if (v === null || v === \"\") next.delete(key);\n else next.set(key, v);\n }\n if (next.toString() === current.toString()) return;\n\n commitUrlParams(patch, historyModeForParamChange(current, next, textKeys));\n // `textKeys` is a literal in every caller; `value` is the real trigger.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value]);\n\n // URL → value (Back/Forward, a pasted link, another control writing)\n useEffect(() => {\n // Read the URL at EFFECT time, not the render snapshot. The value→URL\n // effect runs first. When it commits a local filter/sort decision it\n // synchronously notifies the external store, but `params` in this render\n // still describes the URL from before that write. Re-applying that stale\n // snapshot here ping-pongs state and URL until React raises #185.\n const fromUrl = optionsRef.current.parse(\n new URLSearchParams(currentLocation().search),\n );\n setValue((prev) => (optionsRef.current.isSame(prev, fromUrl) ? prev : fromUrl));\n }, [params]);\n\n // Clearing on a subject change is deliberate; clearing on a subject ARRIVING\n // is destructive. A surface whose id is undefined for its first render (async\n // params, a late store hydration, a suspended boundary) would otherwise wipe\n // the very view the user just loaded — and the symptom is indistinguishable\n // from \"the URL never saved my filters\", because the write lands and is\n // erased a tick later.\n //\n // So: only a transition between two REAL, DIFFERENT keys resets.\n const previousResetKey = useRef(resetKey);\n useEffect(() => {\n const previous = previousResetKey.current;\n previousResetKey.current = resetKey;\n if (previous === resetKey) return;\n if (previous === undefined || previous === \"\") return; // arriving, not changing\n if (resetKey === undefined || resetKey === \"\") return; // leaving, not changing\n\n const cleared = optionsRef.current.parse(new URLSearchParams());\n setValue(cleared);\n commitUrlParams(optionsRef.current.toParams(cleared), \"replace\");\n }, [resetKey]);\n\n const update = useCallback((updater: T | ((prev: T) => T)) => {\n setValue((prev) =>\n typeof updater === \"function\"\n ? (updater as (p: T) => T)(prev)\n : updater,\n );\n }, []);\n\n return [value, update] as const;\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/url-state.ts"],"sourcesContent":["\"use client\";\n\n/**\n * @ai-matrx/kit/url-state — THE canonical URL-state core. Every surface that\n * puts view state in the address bar goes through here.\n *\n * The URL is the source of truth: refresh, copied links, and browser\n * back/forward all reproduce the same value. Discrete controls push a history\n * entry by default; high-frequency text inputs opt into `replace` so a single\n * search does not create one entry per keystroke.\n *\n * WHY A RAW `history.pushState` IS A BUG, not just a style choice. It fires no\n * event and no popstate, so every OTHER url-backed control on the page keeps\n * rendering stale values until something unrelated re-renders it.\n * `commitUrlParams` dispatches `matrx:url-state`, which is what\n * `useUrlSearchParams` subscribes to. (Measured in the Matrx frontend,\n * 2026-08-25: 33 hand-rolled writes across 21 files, every one of them\n * silent.)\n *\n * PICK THE RIGHT ONE:\n * one control owns one parameter → `useUrlState` + a codec\n * a cluster of values moving together → `useMirroredUrlState`\n *\n * Codecs below cover string / enum / boolean / positive-integer / JSON, and all\n * of them OMIT the default rather than writing it, so a pristine surface has a\n * clean URL and a link carries only what the user actually chose.\n *\n * Ported verbatim from matrx-frontend `lib/url-state/useUrlState.ts`. The\n * original deliberately imports NOTHING from `next/navigation` — it writes\n * through the History API and notifies subscribers itself, which is exactly\n * how it runs in the Next.js App Router host. This port adds ONE optional\n * seam on top of that verbatim behavior: `setUrlStateRouter` lets a host whose\n * router must observe/perform the writes (react-router, a memory router in\n * tests, a native shell) inject `{ push, replace, getLocation? }`. With no\n * router injected — the default, and the Next.js wiring — behavior is\n * byte-identical to the original. The injected router lives on `globalThis`\n * under `Symbol.for(\"ai-matrx.kit.url-state-router\")` because the package\n * builds `splitting: false` dual ESM/CJS: module-level state would silently\n * split the registration across bundle graphs (the confirm-opener hazard).\n */\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\n\nconst URL_STATE_EVENT = \"matrx:url-state\";\n\n/**\n * The optional injected router. `push`/`replace` receive the full relative\n * URL (`pathname?query#hash`) exactly as the History API default would write\n * it. `getLocation` overrides where the current URL is read from (a memory\n * router); absent, `window.location` is read.\n */\nexport interface UrlStateRouter {\n push: (url: string) => void;\n replace: (url: string) => void;\n getLocation?: (() => { pathname: string; search: string; hash: string }) | undefined;\n}\n\nconst ROUTER_SLOT = Symbol.for(\"ai-matrx.kit.url-state-router\");\n\nfunction getRouter(): UrlStateRouter | null {\n return (\n (globalThis as Record<symbol, UrlStateRouter | null | undefined>)[\n ROUTER_SLOT\n ] ?? null\n );\n}\n\n/**\n * Inject (or with `null` remove) the host router. Optional — with none set,\n * writes go through `window.history` push/replaceState, which is the verbatim\n * Matrx-frontend behavior and correct for Next.js App Router hosts.\n */\nexport function setUrlStateRouter(router: UrlStateRouter | null): void {\n (globalThis as Record<symbol, UrlStateRouter | null | undefined>)[\n ROUTER_SLOT\n ] = router;\n}\n\nfunction currentLocation(): { pathname: string; search: string; hash: string } {\n const custom = getRouter()?.getLocation;\n if (custom) return custom();\n return {\n pathname: window.location.pathname,\n search: window.location.search,\n hash: window.location.hash,\n };\n}\n\nfunction subscribeToUrl(listener: () => void) {\n window.addEventListener(\"popstate\", listener);\n window.addEventListener(URL_STATE_EVENT, listener);\n return () => {\n window.removeEventListener(\"popstate\", listener);\n window.removeEventListener(URL_STATE_EVENT, listener);\n };\n}\n\nfunction getUrlSnapshot() {\n return currentLocation().search;\n}\n\nfunction getServerUrlSnapshot() {\n return \"\";\n}\n\n/** Reactive query-string snapshot for URL-backed primitives. */\nexport function useUrlSearchParams(): URLSearchParams {\n const search = useSyncExternalStore(\n subscribeToUrl,\n getUrlSnapshot,\n getServerUrlSnapshot,\n );\n return useMemo(() => new URLSearchParams(search), [search]);\n}\n\nexport type UrlHistoryMode = \"push\" | \"replace\";\n\nexport interface UrlStateCodec<T> {\n defaultValue: T;\n parse: (raw: string | null) => T;\n serialize: (value: T) => string | null;\n}\n\nexport interface SetUrlStateOptions {\n history?: UrlHistoryMode | undefined;\n}\n\nexport function commitUrlParams(\n patch: Readonly<Record<string, string | null>>,\n history: UrlHistoryMode,\n) {\n const location = currentLocation();\n const params = new URLSearchParams(location.search);\n for (const [key, value] of Object.entries(patch)) {\n if (value === null || value === \"\") params.delete(key);\n else params.set(key, value);\n }\n\n const query = params.toString();\n const next = `${location.pathname}${query ? `?${query}` : \"\"}${location.hash}`;\n const current = `${location.pathname}${location.search}${location.hash}`;\n if (next === current) return;\n\n const router = getRouter();\n if (router) {\n if (history === \"replace\") router.replace(next);\n else router.push(next);\n } else if (history === \"replace\") {\n window.history.replaceState(window.history.state, \"\", next);\n } else {\n window.history.pushState(window.history.state, \"\", next);\n }\n window.dispatchEvent(new Event(URL_STATE_EVENT));\n}\n\n/**\n * Classify a URL transition for surfaces that MIRROR state (a store, local\n * state) into the URL from an effect, where there is no single call site to\n * label.\n *\n * THE RULE: a discrete user decision (tab, filter, sort, page, selection)\n * PUSHES, so Back undoes exactly that one step; only high-frequency text\n * (`textKeys` — search boxes, a slider being dragged) REPLACES, so one search\n * is one entry instead of one per keystroke.\n */\nexport function historyModeForParamChange(\n current: URLSearchParams,\n next: URLSearchParams,\n textKeys: readonly string[],\n): UrlHistoryMode {\n const keys = new Set([...current.keys(), ...next.keys()]);\n const changed = [...keys].filter(\n (key) => current.get(key) !== next.get(key),\n );\n if (changed.length === 0) return \"replace\";\n return changed.every((key) => textKeys.includes(key)) ? \"replace\" : \"push\";\n}\n\nexport function useUrlState<T>(\n key: string,\n codec: UrlStateCodec<T>,\n): readonly [T, (value: T, options?: SetUrlStateOptions) => void] {\n const searchParams = useUrlSearchParams();\n const value = codec.parse(searchParams.get(key));\n\n const setValue = (next: T, options?: SetUrlStateOptions) => {\n commitUrlParams(\n { [key]: codec.serialize(next) },\n options?.history ?? \"push\",\n );\n };\n\n return [value, setValue] as const;\n}\n\nexport function stringUrlCodec(defaultValue = \"\"): UrlStateCodec<string> {\n return {\n defaultValue,\n parse: (raw) => raw ?? defaultValue,\n serialize: (value) => (value === defaultValue ? null : value),\n };\n}\n\nexport function enumUrlCodec<const T extends string>(\n values: readonly T[],\n defaultValue: T,\n): UrlStateCodec<T> {\n const allowed = new Set<string>(values);\n return {\n defaultValue,\n parse: (raw) => (raw && allowed.has(raw) ? (raw as T) : defaultValue),\n serialize: (value) => (value === defaultValue ? null : value),\n };\n}\n\nexport function booleanUrlCodec(defaultValue = false): UrlStateCodec<boolean> {\n return {\n defaultValue,\n parse: (raw) => (raw === null ? defaultValue : raw === \"1\"),\n serialize: (value) => (value === defaultValue ? null : value ? \"1\" : \"0\"),\n };\n}\n\nexport function positiveIntegerUrlCodec(\n defaultValue: number,\n): UrlStateCodec<number> {\n return {\n defaultValue,\n parse: (raw) => {\n const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;\n return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue;\n },\n serialize: (value) =>\n value === defaultValue || !Number.isFinite(value) || value <= 0\n ? null\n : String(Math.trunc(value)),\n };\n}\n\nexport function jsonUrlCodec<T>(\n defaultValue: T,\n isValid: (value: unknown) => value is T,\n): UrlStateCodec<T> {\n return {\n defaultValue,\n parse: (raw) => {\n if (!raw) return defaultValue;\n try {\n const parsed: unknown = JSON.parse(raw);\n return isValid(parsed) ? parsed : defaultValue;\n } catch {\n return defaultValue;\n }\n },\n serialize: (value) =>\n JSON.stringify(value) === JSON.stringify(defaultValue)\n ? null\n : JSON.stringify(value),\n };\n}\n\n/**\n * Mirror a whole view-state OBJECT into the URL, in both directions.\n *\n * THE PATTERN 18 FILES WERE HAND-ROLLING. `useUrlState` is right when one\n * control owns one parameter. It is the wrong shape when a surface holds a\n * cluster of related values (search + sort + filters + page) that must move\n * together, be seeded from the URL on first render, and follow Back/Forward.\n * Every surface that needed that wrote its own `history.pushState` — and NONE\n * of them dispatched the sync event, so any other URL-backed control on the\n * same page silently kept showing stale values after they wrote.\n *\n * 🚨 THE LOOP IS BROKEN BY VALUE, NEVER BY BOOKKEEPING. The obvious guard —\n * remember the last URL you wrote and ignore anything matching it — looks\n * equivalent and is not: pressing Forward to a view already visited produces a\n * URL you did indeed write before, so the guard swallows it and the address bar\n * moves while the surface does not. Compare the DECODED value to the current\n * one instead; your own write compares equal and stops.\n *\n * Seeding happens in the `useState` initialiser, not an effect, so the first\n * render already shows the requested view rather than flashing the default and\n * fetching the wrong page before correcting itself.\n */\nexport interface MirroredUrlStateOptions<T> {\n /** Decode the whole value from the query string. Must never throw. */\n parse: (params: URLSearchParams) => T;\n /** Encode it; `null` for a key means \"omit\", which keeps defaults out of the URL. */\n toParams: (value: T) => Record<string, string | null>;\n /** Value equality — what stops the two directions fighting. */\n isSame: (a: T, b: T) => boolean;\n /** Keys that REPLACE instead of pushing (search boxes, dragged sliders). */\n textKeys?: readonly string[] | undefined;\n /**\n * Changing this clears the mirrored state and its parameters — for when the\n * surface switches to a different subject and the old view would be a lie.\n */\n resetKey?: string | undefined;\n}\n\nexport function useMirroredUrlState<T>(\n options: MirroredUrlStateOptions<T>,\n): readonly [T, (updater: T | ((prev: T) => T)) => void] {\n const { parse, textKeys = [], resetKey } = options;\n const params = useUrlSearchParams();\n\n const optionsRef = useRef(options);\n optionsRef.current = options;\n\n const [value, setValue] = useState<T>(() =>\n parse(\n typeof window === \"undefined\"\n ? new URLSearchParams()\n : new URLSearchParams(currentLocation().search),\n ),\n );\n\n // value → URL\n useEffect(() => {\n if (typeof window === \"undefined\") return;\n const current = new URLSearchParams(currentLocation().search);\n const patch = optionsRef.current.toParams(value);\n\n const next = new URLSearchParams(current);\n for (const [key, v] of Object.entries(patch)) {\n if (v === null || v === \"\") next.delete(key);\n else next.set(key, v);\n }\n if (next.toString() === current.toString()) return;\n\n commitUrlParams(patch, historyModeForParamChange(current, next, textKeys));\n // `textKeys` is a literal in every caller; `value` is the real trigger.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value]);\n\n // URL → value (Back/Forward, a pasted link, another control writing)\n useEffect(() => {\n // Read the URL at EFFECT time, not the render snapshot. The value→URL\n // effect runs first. When it commits a local filter/sort decision it\n // synchronously notifies the external store, but `params` in this render\n // still describes the URL from before that write. Re-applying that stale\n // snapshot here ping-pongs state and URL until React raises #185.\n const fromUrl = optionsRef.current.parse(\n new URLSearchParams(currentLocation().search),\n );\n setValue((prev) => (optionsRef.current.isSame(prev, fromUrl) ? prev : fromUrl));\n }, [params]);\n\n // Clearing on a subject change is deliberate; clearing on a subject ARRIVING\n // is destructive. A surface whose id is undefined for its first render (async\n // params, a late store hydration, a suspended boundary) would otherwise wipe\n // the very view the user just loaded — and the symptom is indistinguishable\n // from \"the URL never saved my filters\", because the write lands and is\n // erased a tick later.\n //\n // So: only a transition between two REAL, DIFFERENT keys resets.\n const previousResetKey = useRef(resetKey);\n useEffect(() => {\n const previous = previousResetKey.current;\n previousResetKey.current = resetKey;\n if (previous === resetKey) return;\n if (previous === undefined || previous === \"\") return; // arriving, not changing\n if (resetKey === undefined || resetKey === \"\") return; // leaving, not changing\n\n const cleared = optionsRef.current.parse(new URLSearchParams());\n setValue(cleared);\n commitUrlParams(optionsRef.current.toParams(cleared), \"replace\");\n }, [resetKey]);\n\n const update = useCallback((updater: T | ((prev: T) => T)) => {\n setValue((prev) =>\n typeof updater === \"function\"\n ? (updater as (p: T) => T)(prev)\n : updater,\n );\n }, []);\n\n return [value, update] as const;\n}\n"],"mappings":"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","names":[]}
|