@paramour-js/next 0.1.1 → 0.2.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/dist/app.d.ts CHANGED
@@ -36,6 +36,29 @@ export type { SelectOptions } from "./select.js";
36
36
  * at one of these call sites is a compile error, not a runtime surprise —
37
37
  * these hooks read Next's App-Router navigation hooks, whose pages twin has
38
38
  * different state cardinality (`@paramour-js/next/pages`).
39
+ *
40
+ * Devtools instrumentation (design-12): each hook reports through the shared
41
+ * emitter in observe.ts — `observe` from inside the `useStableResult`
42
+ * compute callback, which runs exactly on a `(route, fingerprint)` cache
43
+ * miss, so the SEL4 fingerprint layer IS the decode-change dedup (DT4;
44
+ * StrictMode's dev double render reuses the ref cache and cannot
45
+ * double-emit), and `refresh` after the stable result returns, re-emitting
46
+ * the CACHED result when the pathname moved under an unchanged decode so
47
+ * the seam's `navigate`/`pathname` never go stale (DT8). Observations carry
48
+ * the full pre-`select` result (DT12), and the `OrThrow` hooks report the
49
+ * error observation BEFORE rethrowing — only render-phase can, since an
50
+ * effect never runs for a throwing render. Every emit sits behind
51
+ * `process.env.NODE_ENV !== "production"`, which bundlers constant-fold and
52
+ * erase along with the seam module (DT6); the spec each hook hands the
53
+ * emitter is built behind the same literal guard, so prod allocates
54
+ * nothing. `useRouter` and `usePathname` are called unconditionally in
55
+ * every hook (rules of hooks — a build-constant-guarded call would make
56
+ * hook order differ between dev and prod bundles); their cost is a
57
+ * referentially-stable context read each, and only the dev-only spec
58
+ * captures them. The `navigate` capability receives the panel's SEARCH
59
+ * STRING only and resolves it against `usePathname()` — basePath-/locale-
60
+ * relative, exactly what `router.replace` expects back (DT8; the live hash
61
+ * is preserved at call time).
39
62
  */
40
63
  /**
41
64
  * Decoded route params as a `SafeResult` (discriminated on `status`, PR12),
package/dist/app.js CHANGED
@@ -1,24 +1,125 @@
1
1
  "use client";
2
- import { useParams, useSearchParams } from "next/navigation";
3
- import { decodeParams, decodeSearch, safeDecodeParams, safeDecodeSearch, } from "paramour";
2
+ import { useParams, usePathname, useRouter, useSearchParams, } from "next/navigation";
3
+ import { decodeParams, decodeSearch, ParamsDecodeError, safeDecodeParams, safeDecodeSearch, SearchDecodeError, } from "paramour";
4
+ import { searchWireSnapshot } from "./devtools-seam.js";
5
+ import { makeAppNavigate, useDevtoolsEmitter, } from "./observe.js";
4
6
  import { paramsFingerprint, searchParamsFingerprint, useSelectedResult, useSelectedValue, useStableResult, } from "./select.js";
5
7
  export function useRouteParams(route, options) {
6
8
  const params = useParams() ?? {};
7
- const result = useStableResult(route, paramsFingerprint(route, params), () => safeDecodeParams(route, params));
9
+ const router = useRouter();
10
+ const pathname = usePathname();
11
+ const emitter = useDevtoolsEmitter();
12
+ const spec = process.env.NODE_ENV === "production"
13
+ ? undefined
14
+ : {
15
+ hook: "app.useRouteParams",
16
+ kind: "params",
17
+ navigate: makeAppNavigate(router, pathname),
18
+ pathname,
19
+ route,
20
+ routerKind: "app",
21
+ wire: () => ({ ...params }),
22
+ };
23
+ const result = useStableResult(route, paramsFingerprint(route, params), () => {
24
+ const decoded = safeDecodeParams(route, params);
25
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
26
+ emitter.observe(spec, decoded);
27
+ }
28
+ return decoded;
29
+ });
30
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
31
+ emitter.refresh(spec);
32
+ }
8
33
  return useSelectedResult(result, options);
9
34
  }
10
35
  export function useRouteParamsOrThrow(route, options) {
11
36
  const params = useParams() ?? {};
12
- const value = useStableResult(route, paramsFingerprint(route, params), () => decodeParams(route, params));
37
+ const router = useRouter();
38
+ const pathname = usePathname();
39
+ const emitter = useDevtoolsEmitter();
40
+ const spec = process.env.NODE_ENV === "production"
41
+ ? undefined
42
+ : {
43
+ hook: "app.useRouteParamsOrThrow",
44
+ kind: "params",
45
+ navigate: makeAppNavigate(router, pathname),
46
+ pathname,
47
+ route,
48
+ routerKind: "app",
49
+ wire: () => ({ ...params }),
50
+ };
51
+ const value = useStableResult(route, paramsFingerprint(route, params), () => {
52
+ // The duplicated decode call across the prod/dev branches is the price
53
+ // of literal-zero prod cost — the bundler keeps exactly one branch (DT6).
54
+ if (process.env.NODE_ENV === "production") {
55
+ return decodeParams(route, params);
56
+ }
57
+ try {
58
+ const data = decodeParams(route, params);
59
+ if (spec !== undefined) {
60
+ emitter.observe(spec, { data, status: "success" });
61
+ }
62
+ return data;
63
+ }
64
+ catch (error) {
65
+ // Report BEFORE the throw reaches the error boundary (DT4). Only the
66
+ // decode-error class is observed — foreign errors are not URL facts
67
+ // the panel explains, matching safeDecode*'s taxonomy.
68
+ if (error instanceof ParamsDecodeError && spec !== undefined) {
69
+ emitter.observe(spec, { error, status: "error" });
70
+ }
71
+ throw error;
72
+ }
73
+ });
74
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
75
+ emitter.refresh(spec);
76
+ }
13
77
  return useSelectedValue(value, options);
14
78
  }
15
79
  export function useSearch(route, options) {
16
80
  const searchParams = useSearchParams();
17
- const result = useStableResult(route, searchParamsFingerprint(route, searchParams), () => safeDecodeSearch(route, searchParams));
81
+ const router = useRouter();
82
+ const pathname = usePathname();
83
+ const emitter = useDevtoolsEmitter();
84
+ const spec = process.env.NODE_ENV === "production"
85
+ ? undefined
86
+ : {
87
+ hook: "app.useSearch",
88
+ kind: "search",
89
+ navigate: makeAppNavigate(router, pathname),
90
+ pathname,
91
+ route,
92
+ routerKind: "app",
93
+ wire: () => searchWireSnapshot(searchParams),
94
+ };
95
+ const result = useStableResult(route, searchParamsFingerprint(route, searchParams), () => {
96
+ const decoded = safeDecodeSearch(route, searchParams);
97
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
98
+ emitter.observe(spec, decoded);
99
+ }
100
+ return decoded;
101
+ });
102
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
103
+ emitter.refresh(spec);
104
+ }
18
105
  return useSelectedResult(result, options);
19
106
  }
20
107
  export function useSearchOrThrow(route, options) {
21
108
  const searchParams = useSearchParams();
109
+ const router = useRouter();
110
+ const pathname = usePathname();
111
+ const emitter = useDevtoolsEmitter();
112
+ const spec = process.env.NODE_ENV === "production"
113
+ ? undefined
114
+ : {
115
+ hook: "app.useSearchOrThrow",
116
+ kind: "search",
117
+ navigate: makeAppNavigate(router, pathname),
118
+ pathname,
119
+ route,
120
+ routerKind: "app",
121
+ wire: () => searchWireSnapshot(searchParams),
122
+ };
22
123
  const value = useStableResult(route, searchParamsFingerprint(route, searchParams),
23
124
  // decodeSearch is keyed on SearchOutputOf (design-04 SS6) — the correct
24
125
  // public type — but AnyAppRoute erases its SC to `any`, so for a still-
@@ -26,7 +127,32 @@ export function useSearchOrThrow(route, options) {
26
127
  // on the value side while staying deferred on the annotation side. The
27
128
  // cast bridges that inference gap to the SAME (correct) type, so a
28
129
  // rawSearch route now infers its schema output here, not a garbage
29
- // {~kind, ~schema} shape.
30
- () => decodeSearch(route["~search"], searchParams));
130
+ // {~kind, ~schema} shape. The cast appears in both branches below — the
131
+ // prod/dev split (and its duplicated decode call) is the price of
132
+ // literal-zero prod cost; the bundler keeps exactly one branch (DT6).
133
+ () => {
134
+ if (process.env.NODE_ENV === "production") {
135
+ return decodeSearch(route["~search"], searchParams);
136
+ }
137
+ try {
138
+ const data = decodeSearch(route["~search"], searchParams);
139
+ if (spec !== undefined) {
140
+ emitter.observe(spec, { data, status: "success" });
141
+ }
142
+ return data;
143
+ }
144
+ catch (error) {
145
+ // Report BEFORE the throw reaches the error boundary (DT4); only
146
+ // the decode-error class is observed, matching safeDecode*'s
147
+ // taxonomy.
148
+ if (error instanceof SearchDecodeError && spec !== undefined) {
149
+ emitter.observe(spec, { error, status: "error" });
150
+ }
151
+ throw error;
152
+ }
153
+ });
154
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
155
+ emitter.refresh(spec);
156
+ }
31
157
  return useSelectedValue(value, options);
32
158
  }
@@ -0,0 +1,147 @@
1
+ import type { AnyRoute, ParamsSource, RouterKind, SafeResult } from "paramour";
2
+ /**
3
+ * The devtools observation seam (design-12 DT5): a dependency-free global
4
+ * slot the hooks push decode observations into and the devtools panel
5
+ * (`@paramour-js/devtools`) reads out of. This module's JSDoc is the
6
+ * CONTRACT OF RECORD for the slot — the panel never imports runtime code
7
+ * from this package (its `./devtools-seam` exports entry is types-only, so
8
+ * a runtime import fails module resolution); it attaches to the same
9
+ * `globalThis` slot by key.
10
+ *
11
+ * - Slot key: `Symbol.for("paramour.devtools.seam")` — the realm-global
12
+ * symbol registry, the same cross-copy identity idiom as core's error
13
+ * brands (RL6): a second physical copy of this module (dual-package
14
+ * hazard, bundler duplication) mints the SAME symbol and lands on the
15
+ * same slot. Either side — hooks or panel — may create the slot; both
16
+ * sides are create-if-absent.
17
+ * - The slot is DATA-ONLY (no function fields): a function property would
18
+ * close over whichever module copy created the slot first, letting a
19
+ * duplicated or version-skewed copy pin stale behavior. With plain data,
20
+ * every copy of the emit/attach code operates on shared state and
21
+ * `version` is the only skew guard needed.
22
+ * - Protocol: subscribe = `listeners.add(fn)`; unsubscribe =
23
+ * `listeners.delete(fn)`; replay = synchronously read `buffer`, then
24
+ * `add` — same JS thread, so nothing can be emitted between the read and
25
+ * the add. Emitters push to `buffer` (FIFO-capped at
26
+ * {@link OBSERVATION_BUFFER_CAP}) and then invoke every listener.
27
+ * - Production (DT6): every emit call site sits behind
28
+ * `process.env.NODE_ENV !== "production"`, which Next's compilers
29
+ * constant-fold; with the package's `sideEffects: false` the then-dead
30
+ * import of this module is dropped entirely. The emitted JS here imports
31
+ * NOTHING (the one `paramour` import is type-only) — load-bearing for
32
+ * that erasure.
33
+ */
34
+ /** The `Symbol.for("paramour.devtools.seam")` slot shape — the DT5 contract. */
35
+ export interface ParamourDevtoolsSeam {
36
+ /** Capped FIFO; oldest dropped past the cap. Replay = read it. */
37
+ readonly buffer: ParamourObservation[];
38
+ /** Subscribe = add; unsubscribe = delete. Invoked synchronously per emit. */
39
+ readonly listeners: Set<(observation: ParamourObservation) => void>;
40
+ /**
41
+ * Bumped only when an EXISTING field's semantics change; additive fields
42
+ * never bump it.
43
+ */
44
+ readonly version: 1;
45
+ }
46
+ /** Discriminant naming which hook reported (design-12 DT4). */
47
+ export type ParamourHookId = "app.useRouteParams" | "app.useRouteParamsOrThrow" | "app.useSearch" | "app.useSearchOrThrow" | "pages.useRouteParams" | "pages.useSearch";
48
+ /**
49
+ * Navigation capability captured from the EMITTING hook's router (design-12
50
+ * DT8): the panel commits URL edits through this, so it never guesses which
51
+ * router is live and never imports Next. The panel passes ONLY the
52
+ * serialized search string (`""` or `"?…"`); the hook resolves it against
53
+ * its OWN current pathname — `usePathname()` (App) / `asPath`'s path part
54
+ * (Pages), both basePath-/locale-relative, which is what `replace()`
55
+ * expects back. The panel reading `window.location.pathname` instead would
56
+ * double a configured basePath through `router.replace`. `replace`
57
+ * semantics — the panel's commit-to-push editing is an experiment loop, and
58
+ * history entries per experiment would make the back button a slog;
59
+ * extending to an options bag later is additive.
60
+ */
61
+ export type ParamourNavigate = (search: string) => void;
62
+ /**
63
+ * One hook decode, reported on decode CHANGE (design-12 DT4) — and
64
+ * re-reported when the hook's resolution base moves under an unchanged
65
+ * decode (a layout surviving `/product/1?q=a` → `/product/2?q=a`), so the
66
+ * captured `navigate`/`pathname` never go stale while the hook is mounted.
67
+ */
68
+ export type ParamourObservation = ParamourParamsObservation | ParamourSearchObservation;
69
+ /**
70
+ * Pre-`select` decode result (design-12 DT12): the hook's full `SafeResult`
71
+ * — the error arm carries the LIVE `ParamsDecodeError`/`SearchDecodeError`
72
+ * with its `issues` — never the user's `select` projection. `pending` is
73
+ * the Pages-only third state (DT11). Generic-erased on purpose: the panel
74
+ * treats `data` structurally.
75
+ */
76
+ export type ParamourObservationResult = SafeResult<unknown> | {
77
+ readonly status: "pending";
78
+ };
79
+ /** Params decode: wire is a decode-time shallow copy of the source record. */
80
+ export interface ParamourParamsObservation extends ParamourObservationBase {
81
+ readonly kind: "params";
82
+ readonly wire: Readonly<ParamsSource>;
83
+ }
84
+ /**
85
+ * Search decode: wire is decode-time `[key, value]` pairs in wire order —
86
+ * order is load-bearing for repeated keys (P5/S5), and pairs round-trip
87
+ * losslessly into the panel's raw-wire editing (DT8).
88
+ */
89
+ export interface ParamourSearchObservation extends ParamourObservationBase {
90
+ readonly kind: "search";
91
+ readonly wire: ParamourSearchWire;
92
+ }
93
+ export type ParamourSearchWire = readonly (readonly [string, string])[];
94
+ interface ParamourObservationBase {
95
+ readonly hook: ParamourHookId;
96
+ readonly navigate: ParamourNavigate;
97
+ /**
98
+ * The emitting hook's OWN resolution base at decode time —
99
+ * `usePathname()` (App) / `asPath`'s path part (Pages), both
100
+ * basePath-/locale-relative like {@link ParamourNavigate}'s. The panel
101
+ * keys "is this session the page on screen?" on it (suffix-matched
102
+ * against `window.location.pathname`, which DOES carry the prefix), so it
103
+ * never has to reverse-engineer a configured basePath. Additive field —
104
+ * no `version` bump.
105
+ */
106
+ readonly pathname: string;
107
+ readonly result: ParamourObservationResult;
108
+ /**
109
+ * The LIVE route object (DT5: same JS context, no serialization) — the
110
+ * panel calls `describeRoute`, the route's own codecs, and
111
+ * `buildSearchString` on it directly.
112
+ */
113
+ readonly route: AnyRoute;
114
+ readonly routerKind: RouterKind;
115
+ }
116
+ /**
117
+ * 128: replay only needs the pre-panel-mount window. One observation per
118
+ * decode CHANGE per hook (DT4) means even a long pre-open session is dozens
119
+ * of entries, not thousands; the panel keys on route, so depth beyond
120
+ * "every route seen recently" adds nothing — the cap mostly bounds how many
121
+ * live route/result references the buffer retains.
122
+ */
123
+ export declare const OBSERVATION_BUFFER_CAP = 128;
124
+ /**
125
+ * Pushes one observation and notifies listeners. The internal production
126
+ * early-return is belt-and-suspenders under DT6 (every call site is ALSO
127
+ * guarded, which is what the bundler erases); it makes the guard directly
128
+ * unit-testable and keeps a future unguarded call site failing safe.
129
+ */
130
+ export declare function emitObservation(observation: ParamourObservation): void;
131
+ /**
132
+ * The slot, created on first touch by whichever side (hooks or panel) runs
133
+ * first.
134
+ */
135
+ export declare function getParamourSeam(): ParamourDevtoolsSeam;
136
+ /**
137
+ * Pages `query` record → wire pairs; `string[]` values expand to repeated
138
+ * keys in array order, `undefined` values are wire absence and are skipped.
139
+ */
140
+ export declare function recordWireSnapshot(source: ParamsSource): ParamourSearchWire;
141
+ /**
142
+ * Decode-time freeze of the (live, mutable) `URLSearchParams` into wire
143
+ * pairs: the observation outlives the render in the ring buffer, so it must
144
+ * capture what the DECODE saw, not a live view.
145
+ */
146
+ export declare function searchWireSnapshot(source: URLSearchParams): ParamourSearchWire;
147
+ export {};
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 128: replay only needs the pre-panel-mount window. One observation per
3
+ * decode CHANGE per hook (DT4) means even a long pre-open session is dozens
4
+ * of entries, not thousands; the panel keys on route, so depth beyond
5
+ * "every route seen recently" adds nothing — the cap mostly bounds how many
6
+ * live route/result references the buffer retains.
7
+ */
8
+ export const OBSERVATION_BUFFER_CAP = 128;
9
+ const SEAM_KEY = Symbol.for("paramour.devtools.seam");
10
+ const globalSlots = globalThis;
11
+ /**
12
+ * Pushes one observation and notifies listeners. The internal production
13
+ * early-return is belt-and-suspenders under DT6 (every call site is ALSO
14
+ * guarded, which is what the bundler erases); it makes the guard directly
15
+ * unit-testable and keeps a future unguarded call site failing safe.
16
+ */
17
+ export function emitObservation(observation) {
18
+ if (process.env.NODE_ENV === "production")
19
+ return;
20
+ const seam = getParamourSeam();
21
+ seam.buffer.push(observation);
22
+ if (seam.buffer.length > OBSERVATION_BUFFER_CAP)
23
+ seam.buffer.shift();
24
+ for (const listener of seam.listeners) {
25
+ try {
26
+ listener(observation);
27
+ }
28
+ catch {
29
+ // A panel bug must never break app render — emit runs render-phase.
30
+ }
31
+ }
32
+ }
33
+ /**
34
+ * The slot, created on first touch by whichever side (hooks or panel) runs
35
+ * first.
36
+ */
37
+ export function getParamourSeam() {
38
+ const existing = globalSlots[SEAM_KEY];
39
+ if (existing !== undefined)
40
+ return existing;
41
+ const created = {
42
+ buffer: [],
43
+ listeners: new Set(),
44
+ version: 1,
45
+ };
46
+ globalSlots[SEAM_KEY] = created;
47
+ return created;
48
+ }
49
+ /**
50
+ * Pages `query` record → wire pairs; `string[]` values expand to repeated
51
+ * keys in array order, `undefined` values are wire absence and are skipped.
52
+ */
53
+ export function recordWireSnapshot(source) {
54
+ const pairs = [];
55
+ for (const [key, value] of Object.entries(source)) {
56
+ if (value === undefined)
57
+ continue;
58
+ if (Array.isArray(value)) {
59
+ for (const element of value)
60
+ pairs.push([key, element]);
61
+ }
62
+ else {
63
+ pairs.push([key, value]);
64
+ }
65
+ }
66
+ return pairs;
67
+ }
68
+ /**
69
+ * Decode-time freeze of the (live, mutable) `URLSearchParams` into wire
70
+ * pairs: the observation outlives the render in the ring buffer, so it must
71
+ * capture what the DECODE saw, not a live view.
72
+ */
73
+ export function searchWireSnapshot(source) {
74
+ const pairs = [];
75
+ for (const [key, value] of source)
76
+ pairs.push([key, value]);
77
+ return pairs;
78
+ }
@@ -27,8 +27,9 @@ export interface ListReport {
27
27
  */
28
28
  export declare function buildListJson(report: ListReport): unknown;
29
29
  /**
30
- * `integer`, `enum(a, b)`, `string[]`, with annotations in fixed order:
31
- * presence, default, catch.
30
+ * `integer`, `enum(a, b)`, `string[]`, `csv<integer>`, with annotations in
31
+ * fixed order: presence, default, catch — core's shared walk in its verbose
32
+ * skin, so the CLI and the devtools panel can never drift on the field set.
32
33
  */
33
34
  export declare function formatCodec(description: CodecDescription): string;
34
35
  /** Human report; one string per output line. */
@@ -1,3 +1,4 @@
1
+ import { formatCodecDescription } from "paramour";
1
2
  /**
2
3
  * `--json` payload. Keys are alphabetical; a route with no definition
3
4
  * carries `definition: null` rather than an absent member — friendlier to
@@ -13,26 +14,12 @@ export function buildListJson(report) {
13
14
  };
14
15
  }
15
16
  /**
16
- * `integer`, `enum(a, b)`, `string[]`, with annotations in fixed order:
17
- * presence, default, catch.
17
+ * `integer`, `enum(a, b)`, `string[]`, `csv<integer>`, with annotations in
18
+ * fixed order: presence, default, catch — core's shared walk in its verbose
19
+ * skin, so the CLI and the devtools panel can never drift on the field set.
18
20
  */
19
21
  export function formatCodec(description) {
20
- let base = description.enumMembers === undefined
21
- ? description.kind
22
- : `enum(${description.enumMembers.join(", ")})`;
23
- if (description.arity === "many")
24
- base += "[]";
25
- const notes = [];
26
- if (description.presence === "optional")
27
- notes.push("(optional)");
28
- if (description.defaultValue !== undefined) {
29
- notes.push(description.defaultValue.kind === "value"
30
- ? `(default: ${description.defaultValue.wire})`
31
- : "(default: factory)");
32
- }
33
- if (description.caught)
34
- notes.push("(catch)");
35
- return [base, ...notes].join(" ");
22
+ return formatCodecDescription(description, "verbose");
36
23
  }
37
24
  /** Human report; one string per output line. */
38
25
  export function renderListReport(report) {
@@ -0,0 +1,71 @@
1
+ import type { AnyRoute, ParamsSource, RouterKind } from "paramour";
2
+ import type { ParamourHookId, ParamourNavigate, ParamourObservationResult, ParamourSearchWire } from "./devtools-seam.js";
3
+ /**
4
+ * Shared devtools seam wiring for the six read hooks (design-12 DT4/DT8):
5
+ * the navigate builders (one per router flavor) and the per-hook emitter
6
+ * that owns WHEN an observation goes out. Deliberately NO `"use client"`
7
+ * directive — app.ts (which carries one) and pages.ts (which must not,
8
+ * PR2) both import from here, like select.ts.
9
+ *
10
+ * Emission policy: the hooks call {@link DevtoolsEmitter.observe} from
11
+ * inside the `useStableResult` compute — the SEL4 fingerprint cache miss IS
12
+ * the decode-change dedup (DT4), and only render-phase can report the
13
+ * OrThrow hooks' error observation before the rethrow. `observe` alone
14
+ * would leave one staleness hole: a component that survives a navigation
15
+ * whose decode is unchanged (`/product/1?q=a` → `/product/2?q=a` in a
16
+ * layout) never recomputes, so its last-emitted `navigate` stays bound to
17
+ * the OLD pathname — committing a panel edit through it would silently
18
+ * navigate back to the old resource. {@link DevtoolsEmitter.refresh},
19
+ * called render-phase after the stable result returns, closes it: when the
20
+ * resolution base (or a HMR-reminted route) moved under a cached decode, it
21
+ * re-emits the CACHED result with the fresh spec — decode stability (SEL4)
22
+ * is untouched, only the seam payload is renewed.
23
+ *
24
+ * Production erasure (DT6): every call site keeps a literal
25
+ * `process.env.NODE_ENV` guard the bundler constant-folds, and both emitter
26
+ * methods early-return behind the same literal guard, so the
27
+ * `emitObservation` import above is dead in a production bundle and drops
28
+ * with `sideEffects: false`. The unconditional `useRef` here is the same
29
+ * bargain the hooks already make for `useRouter`/`usePathname` — hook order
30
+ * must not differ between dev and prod bundles.
31
+ */
32
+ /** Per-hook emitter; see the module doc for the observe/refresh split. */
33
+ export interface DevtoolsEmitter {
34
+ readonly observe: (spec: ObservationSpec, result: ParamourObservationResult) => void;
35
+ readonly refresh: (spec: ObservationSpec) => void;
36
+ }
37
+ /**
38
+ * Everything one emission needs, rebuilt per render so `navigate` and
39
+ * `wire` always close over the CURRENT render's router/pathname/source.
40
+ * Call sites construct it behind their literal dev guard (undefined in
41
+ * prod), keeping prod allocation at zero.
42
+ */
43
+ export interface ObservationSpec {
44
+ readonly hook: ParamourHookId;
45
+ readonly kind: "params" | "search";
46
+ readonly navigate: ParamourNavigate;
47
+ readonly pathname: string;
48
+ readonly route: AnyRoute;
49
+ readonly routerKind: RouterKind;
50
+ /** Decode-time wire snapshot, taken fresh at each emission. */
51
+ readonly wire: () => ParamourSearchWire | Readonly<ParamsSource>;
52
+ }
53
+ /**
54
+ * App-flavor navigate capability (DT8): `next/navigation`'s `replace`
55
+ * returns void and resolves the basePath-/locale-relative join itself.
56
+ */
57
+ export declare function makeAppNavigate(router: {
58
+ replace: (href: string) => void;
59
+ }, pathname: string): ParamourNavigate;
60
+ /**
61
+ * Pages-flavor navigate capability (DT8): `next/router`'s `replace` returns
62
+ * a promise that REJECTS on routine navigation aborts (rapid re-commits
63
+ * from the panel), marked with next's `cancelled` discriminant — those must
64
+ * not surface as unhandled rejections. Anything else is a real failure
65
+ * (render error, route-info error) silently discarding the user's edit, so
66
+ * it is reported to the console instead of swallowed.
67
+ */
68
+ export declare function makePagesNavigate(router: {
69
+ replace: (url: string) => Promise<boolean>;
70
+ }, pathname: string): ParamourNavigate;
71
+ export declare function useDevtoolsEmitter(): DevtoolsEmitter;
@@ -0,0 +1,73 @@
1
+ import { useRef } from "react";
2
+ import { emitObservation } from "./devtools-seam.js";
3
+ /**
4
+ * App-flavor navigate capability (DT8): `next/navigation`'s `replace`
5
+ * returns void and resolves the basePath-/locale-relative join itself.
6
+ */
7
+ export function makeAppNavigate(router, pathname) {
8
+ return (search) => {
9
+ router.replace(`${pathname}${search}${window.location.hash}`);
10
+ };
11
+ }
12
+ /**
13
+ * Pages-flavor navigate capability (DT8): `next/router`'s `replace` returns
14
+ * a promise that REJECTS on routine navigation aborts (rapid re-commits
15
+ * from the panel), marked with next's `cancelled` discriminant — those must
16
+ * not surface as unhandled rejections. Anything else is a real failure
17
+ * (render error, route-info error) silently discarding the user's edit, so
18
+ * it is reported to the console instead of swallowed.
19
+ */
20
+ export function makePagesNavigate(router, pathname) {
21
+ return (search) => {
22
+ void router
23
+ .replace(`${pathname}${search}${window.location.hash}`)
24
+ .catch((error) => {
25
+ if (!isCancelledNavigation(error))
26
+ console.error(error);
27
+ });
28
+ };
29
+ }
30
+ export function useDevtoolsEmitter() {
31
+ const ref = useRef(null);
32
+ ref.current ??= createEmitter();
33
+ return ref.current;
34
+ }
35
+ function createEmitter() {
36
+ let last = null;
37
+ const observe = (spec, result) => {
38
+ if (process.env.NODE_ENV === "production")
39
+ return;
40
+ last = { pathname: spec.pathname, result, route: spec.route };
41
+ // `kind` and `wire` are pairwise-correct by construction at the six
42
+ // call sites; the correlated union is beyond TS narrowing, hence the
43
+ // cast.
44
+ emitObservation({
45
+ hook: spec.hook,
46
+ kind: spec.kind,
47
+ navigate: spec.navigate,
48
+ pathname: spec.pathname,
49
+ result,
50
+ route: spec.route,
51
+ routerKind: spec.routerKind,
52
+ wire: spec.wire(),
53
+ });
54
+ };
55
+ return {
56
+ observe,
57
+ refresh: (spec) => {
58
+ if (process.env.NODE_ENV === "production")
59
+ return;
60
+ if (last === null)
61
+ return;
62
+ if (last.pathname === spec.pathname && last.route === spec.route)
63
+ return;
64
+ observe(spec, last.result);
65
+ },
66
+ };
67
+ }
68
+ /** Next's pages router marks genuine navigation aborts with `cancelled`. */
69
+ function isCancelledNavigation(error) {
70
+ return (typeof error === "object" &&
71
+ error !== null &&
72
+ error.cancelled === true);
73
+ }
package/dist/pages.d.ts CHANGED
@@ -25,6 +25,15 @@ export type { SelectOptions } from "./select.js";
25
25
  * `query` (+ `isReady`), then an optional `{ select }` projection with
26
26
  * result-equality checking — the `pending` arm passes through the selector
27
27
  * untouched (SEL2), and `PENDING` itself is one referentially stable object.
28
+ *
29
+ * Devtools instrumentation (design-12): each hook reports through the
30
+ * shared emitter in observe.ts — `observe` from inside the `useStableResult`
31
+ * compute callback (DT4 — the fingerprint cache miss IS the decode-change
32
+ * dedup; see app.ts's fuller account), `refresh` after it for pathname
33
+ * moves under an unchanged decode (DT8). The `pending` arm emits as a
34
+ * first-class observation (DT11), keyed by `PENDING_FINGERPRINT` so the
35
+ * pre-`isReady` render reports exactly once. Every emit sits behind
36
+ * `process.env.NODE_ENV !== "production"` (DT6).
28
37
  */
29
38
  /**
30
39
  * Three-state result for the pages hooks (PR5): core's `SafeResult` plus a
package/dist/pages.js CHANGED
@@ -1,30 +1,83 @@
1
1
  import { useRouter } from "next/router";
2
2
  import { ParamourError, safeDecodeParams, safeDecodeSearch, } from "paramour";
3
+ import { recordWireSnapshot } from "./devtools-seam.js";
4
+ import { makePagesNavigate, useDevtoolsEmitter, } from "./observe.js";
3
5
  import { paramsFingerprint, PENDING_FINGERPRINT, queryFingerprint, useSelectedResult, useStableResult, } from "./select.js";
4
6
  /** Referentially stable across every pending render. */
5
7
  const PENDING = { status: "pending" };
6
8
  export function useRouteParams(route, options) {
7
- const { isReady, query } = usePagesRouter();
9
+ const router = usePagesRouter();
10
+ const { isReady, query } = router;
11
+ const pathname = asPathPathname(router.asPath);
12
+ const emitter = useDevtoolsEmitter();
13
+ const spec = process.env.NODE_ENV === "production"
14
+ ? undefined
15
+ : {
16
+ hook: "pages.useRouteParams",
17
+ kind: "params",
18
+ navigate: makePagesNavigate(router, pathname),
19
+ pathname,
20
+ route,
21
+ routerKind: "pages",
22
+ wire: () => ({ ...query }),
23
+ };
8
24
  const result = useStableResult(route, isReady ? paramsFingerprint(route, query) : PENDING_FINGERPRINT, () => {
9
- if (!isReady)
10
- return PENDING;
11
25
  // The merged query is a legal params source as-is: decodeParams reads
12
26
  // only the route's own segment names, never unknown keys. R5: next/router
13
27
  // has already percent-decoded `query`, so skip core's decode to avoid a
14
28
  // double-decode (`/product/a%2520b` → `"a%20b"` must survive as-is).
15
- return safeDecodeParams(route, query, { percentDecode: false });
29
+ const decoded = isReady
30
+ ? safeDecodeParams(route, query, { percentDecode: false })
31
+ : PENDING;
32
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
33
+ emitter.observe(spec, decoded);
34
+ }
35
+ return decoded;
16
36
  });
37
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
38
+ emitter.refresh(spec);
39
+ }
17
40
  return useSelectedResult(result, options);
18
41
  }
19
42
  export function useSearch(route, options) {
20
- const { isReady, query } = usePagesRouter();
43
+ const router = usePagesRouter();
44
+ const { isReady, query } = router;
45
+ const pathname = asPathPathname(router.asPath);
46
+ const emitter = useDevtoolsEmitter();
47
+ const spec = process.env.NODE_ENV === "production"
48
+ ? undefined
49
+ : {
50
+ hook: "pages.useSearch",
51
+ kind: "search",
52
+ navigate: makePagesNavigate(router, pathname),
53
+ pathname,
54
+ route,
55
+ routerKind: "pages",
56
+ // Lazy, so the path-param subtraction only runs when an emission
57
+ // actually snapshots the wire.
58
+ wire: () => recordWireSnapshot(omitPathParams(query, route)),
59
+ };
21
60
  const result = useStableResult(route, isReady ? queryFingerprint(route, query) : PENDING_FINGERPRINT, () => {
22
- if (!isReady)
23
- return PENDING;
24
- return safeDecodeSearch(route, omitPathParams(query, route));
61
+ const source = omitPathParams(query, route);
62
+ const decoded = isReady ? safeDecodeSearch(route, source) : PENDING;
63
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
64
+ emitter.observe(spec, decoded);
65
+ }
66
+ return decoded;
25
67
  });
68
+ if (process.env.NODE_ENV !== "production" && spec !== undefined) {
69
+ emitter.refresh(spec);
70
+ }
26
71
  return useSelectedResult(result, options);
27
72
  }
73
+ /**
74
+ * `asPath`'s path part: basePath-/locale-relative — exactly what
75
+ * `replace()` expects back — so the panel's search-only string (DT8)
76
+ * resolves without doubling a configured basePath.
77
+ */
78
+ function asPathPathname(asPath) {
79
+ return asPath.split(/[#?]/)[0] ?? "/";
80
+ }
28
81
  /**
29
82
  * `query` minus the route's own path-param names (PR5) — the client twin of
30
83
  * `parseContext`'s server-side subtraction (core route.ts, PR10). Entries →
package/dist/select.js CHANGED
@@ -116,15 +116,37 @@ export function useStableResult(route, fingerprint, compute) {
116
116
  if (cached !== null &&
117
117
  cached.route === route &&
118
118
  cached.fingerprint === fingerprint) {
119
- return cached.value;
119
+ if (cached.outcome.status === "thrown")
120
+ throw cached.outcome.thrown;
121
+ return cached.outcome.value;
120
122
  }
121
- // Cleared BEFORE computing (SEL8): a throwing decode must not strand the
122
- // previous entry, or the rerender after an error boundary reset would
123
- // serve a stale value under the new fingerprint.
123
+ // Cleared BEFORE computing (SEL8): no half-computed state may survive a
124
+ // throw, and a NEW fingerprint always recomputes — an error boundary
125
+ // reset after the URL is fixed can never be served a stale entry.
124
126
  cache.current = null;
125
- const value = compute();
126
- cache.current = { fingerprint, route, value };
127
- return value;
127
+ try {
128
+ const value = compute();
129
+ cache.current = {
130
+ fingerprint,
131
+ outcome: { status: "value", value },
132
+ route,
133
+ };
134
+ return value;
135
+ }
136
+ catch (error) {
137
+ // Cache the throw under ITS fingerprint (see StableOutcome): update
138
+ // renders share this ref with the committed fiber, so re-render
139
+ // attempts while the URL stays invalid rethrow instead of re-decoding
140
+ // (and re-emitting). A throwing MOUNT discards its work-in-progress
141
+ // hooks, so replayed mounts still recompute — per-instance semantics,
142
+ // same as the success arm's.
143
+ cache.current = {
144
+ fingerprint,
145
+ outcome: { status: "thrown", thrown: error },
146
+ route,
147
+ };
148
+ throw error;
149
+ }
128
150
  }
129
151
  /**
130
152
  * Declared search keys of a route's `~search` slot, or `null` for a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paramour-js/next",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,11 +11,15 @@
11
11
  "types": "./dist/app.d.ts",
12
12
  "default": "./dist/app.js"
13
13
  },
14
+ "./devtools-seam": {
15
+ "types": "./dist/devtools-seam.d.ts"
16
+ },
14
17
  "./pages": {
15
18
  "types": "./dist/pages.d.ts",
16
19
  "default": "./dist/pages.js"
17
20
  }
18
21
  },
22
+ "sideEffects": false,
19
23
  "bin": {
20
24
  "paramour": "./bin/paramour.js"
21
25
  },
@@ -26,7 +30,7 @@
26
30
  "jiti": "^2.7.0",
27
31
  "magicast": "^0.3.5",
28
32
  "tinyglobby": "^0.2.15",
29
- "paramour": "0.2.0"
33
+ "paramour": "0.3.0"
30
34
  },
31
35
  "peerDependencies": {
32
36
  "next": ">=15",
@@ -60,6 +64,10 @@
60
64
  "bin",
61
65
  "dist"
62
66
  ],
67
+ "publishConfig": {
68
+ "access": "public",
69
+ "provenance": true
70
+ },
63
71
  "scripts": {
64
72
  "build": "tsc -p tsconfig.build.json",
65
73
  "typecheck": "tsc --noEmit"