@multiplatform.one/router 6.1.0 → 6.3.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.
Files changed (106) hide show
  1. package/README.md +51 -0
  2. package/dist/cjs/hooks/useUrlState.cjs +30 -9
  3. package/dist/cjs/hooks/useUrlState.native.js +32 -9
  4. package/dist/cjs/hooks/useUrlState.native.js.map +1 -1
  5. package/dist/cjs/seam/DomNavigationHost.cjs +122 -0
  6. package/dist/cjs/seam/DomNavigationHost.native.js +186 -0
  7. package/dist/cjs/seam/DomNavigationHost.native.js.map +1 -0
  8. package/dist/cjs/seam/index.cjs +47 -0
  9. package/dist/cjs/seam/index.native.js +50 -0
  10. package/dist/cjs/seam/index.native.js.map +1 -0
  11. package/dist/cjs/seam/navigator.cjs +309 -0
  12. package/dist/cjs/seam/navigator.native.js +479 -0
  13. package/dist/cjs/seam/navigator.native.js.map +1 -0
  14. package/dist/cjs/seam/oneAdapter.cjs +128 -0
  15. package/dist/cjs/seam/oneAdapter.native.js +141 -0
  16. package/dist/cjs/seam/oneAdapter.native.js.map +1 -0
  17. package/dist/esm/hooks/useUrlState.mjs +31 -10
  18. package/dist/esm/hooks/useUrlState.mjs.map +1 -1
  19. package/dist/esm/hooks/useUrlState.native.js +33 -10
  20. package/dist/esm/hooks/useUrlState.native.js.map +1 -1
  21. package/dist/esm/seam/DomNavigationHost.mjs +96 -0
  22. package/dist/esm/seam/DomNavigationHost.mjs.map +1 -0
  23. package/dist/esm/seam/DomNavigationHost.native.js +157 -0
  24. package/dist/esm/seam/DomNavigationHost.native.js.map +1 -0
  25. package/dist/esm/seam/index.mjs +5 -0
  26. package/dist/esm/seam/index.mjs.map +1 -0
  27. package/dist/esm/seam/index.native.js +5 -0
  28. package/dist/esm/seam/index.native.js.map +1 -0
  29. package/dist/esm/seam/navigator.mjs +279 -0
  30. package/dist/esm/seam/navigator.mjs.map +1 -0
  31. package/dist/esm/seam/navigator.native.js +446 -0
  32. package/dist/esm/seam/navigator.native.js.map +1 -0
  33. package/dist/esm/seam/oneAdapter.mjs +90 -0
  34. package/dist/esm/seam/oneAdapter.mjs.map +1 -0
  35. package/dist/esm/seam/oneAdapter.native.js +100 -0
  36. package/dist/esm/seam/oneAdapter.native.js.map +1 -0
  37. package/dist/jsx/hooks/useUrlState.mjs +31 -10
  38. package/dist/jsx/hooks/useUrlState.mjs.map +1 -1
  39. package/dist/jsx/hooks/useUrlState.native.js +32 -9
  40. package/dist/jsx/hooks/useUrlState.native.js.map +1 -1
  41. package/dist/jsx/seam/DomNavigationHost.mjs +96 -0
  42. package/dist/jsx/seam/DomNavigationHost.mjs.map +1 -0
  43. package/dist/jsx/seam/DomNavigationHost.native.js +186 -0
  44. package/dist/jsx/seam/DomNavigationHost.native.js.map +1 -0
  45. package/dist/jsx/seam/index.mjs +5 -0
  46. package/dist/jsx/seam/index.mjs.map +1 -0
  47. package/dist/jsx/seam/index.native.js +50 -0
  48. package/dist/jsx/seam/index.native.js.map +1 -0
  49. package/dist/jsx/seam/navigator.mjs +279 -0
  50. package/dist/jsx/seam/navigator.mjs.map +1 -0
  51. package/dist/jsx/seam/navigator.native.js +479 -0
  52. package/dist/jsx/seam/navigator.native.js.map +1 -0
  53. package/dist/jsx/seam/oneAdapter.mjs +90 -0
  54. package/dist/jsx/seam/oneAdapter.mjs.map +1 -0
  55. package/dist/jsx/seam/oneAdapter.native.js +141 -0
  56. package/dist/jsx/seam/oneAdapter.native.js.map +1 -0
  57. package/package.json +26 -9
  58. package/src/hooks/urlStateEdges.spec.ts +98 -0
  59. package/src/hooks/useUrlState.ts +34 -8
  60. package/src/seam/DomNavigationHost.spec.tsx +78 -0
  61. package/src/seam/DomNavigationHost.tsx +150 -0
  62. package/src/seam/index.ts +41 -0
  63. package/src/seam/navigator.spec.ts +189 -0
  64. package/src/seam/navigator.ts +405 -0
  65. package/src/seam/oneAdapter.tsx +166 -0
  66. package/types/hooks/index.d.ts +2 -0
  67. package/types/hooks/index.d.ts.map +1 -0
  68. package/types/hooks/useUrlState.d.ts +36 -0
  69. package/types/hooks/useUrlState.d.ts.map +1 -0
  70. package/types/index.d.ts +4 -0
  71. package/types/index.d.ts.map +1 -0
  72. package/types/index.storybook.d.ts +4 -0
  73. package/types/index.storybook.d.ts.map +1 -0
  74. package/types/router/EmptyRouter.d.ts +7 -0
  75. package/types/router/EmptyRouter.d.ts.map +1 -0
  76. package/types/router/ReactRouter.d.ts +6 -0
  77. package/types/router/ReactRouter.d.ts.map +1 -0
  78. package/types/router/index.d.ts +2 -0
  79. package/types/router/index.d.ts.map +1 -0
  80. package/types/router/index.storybook-expo.d.ts +2 -0
  81. package/types/router/index.storybook-expo.d.ts.map +1 -0
  82. package/types/router/index.storybook.d.ts +2 -0
  83. package/types/router/index.storybook.d.ts.map +1 -0
  84. package/types/router/index.webext.d.ts +2 -0
  85. package/types/router/index.webext.d.ts.map +1 -0
  86. package/types/seam/DomNavigationHost.d.ts +33 -0
  87. package/types/seam/DomNavigationHost.d.ts.map +1 -0
  88. package/types/seam/index.d.ts +7 -0
  89. package/types/seam/index.d.ts.map +1 -0
  90. package/types/seam/navigator.d.ts +133 -0
  91. package/types/seam/navigator.d.ts.map +1 -0
  92. package/types/seam/oneAdapter.d.ts +57 -0
  93. package/types/seam/oneAdapter.d.ts.map +1 -0
  94. package/types/types.d.ts +40 -0
  95. package/types/types.d.ts.map +1 -0
  96. package/dist/cjs/hooks/useUrlState.spec.cjs +0 -280
  97. package/dist/cjs/hooks/useUrlState.spec.native.js +0 -315
  98. package/dist/cjs/hooks/useUrlState.spec.native.js.map +0 -1
  99. package/dist/esm/hooks/useUrlState.spec.mjs +0 -257
  100. package/dist/esm/hooks/useUrlState.spec.mjs.map +0 -1
  101. package/dist/esm/hooks/useUrlState.spec.native.js +0 -289
  102. package/dist/esm/hooks/useUrlState.spec.native.js.map +0 -1
  103. package/dist/jsx/hooks/useUrlState.spec.mjs +0 -257
  104. package/dist/jsx/hooks/useUrlState.spec.mjs.map +0 -1
  105. package/dist/jsx/hooks/useUrlState.spec.native.js +0 -315
  106. package/dist/jsx/hooks/useUrlState.spec.native.js.map +0 -1
@@ -0,0 +1,41 @@
1
+ // Stack-navigation seam — One router semantics over host-native stacks
2
+ // (Adw.NavigationView on GTK, DOM pages in VS Code webviews / webext
3
+ // views). See navigator.ts for the design notes.
4
+ //
5
+ // Deliberately NOT re-exported from the package root: the root barrel is
6
+ // consumed by One/native builds where the seam is never the router;
7
+ // targets import `@multiplatform.one/router/seam` (or alias `one` to
8
+ // ./oneAdapter) explicitly.
9
+
10
+ export {
11
+ RouteTagContext,
12
+ StackNavigator,
13
+ hrefToString,
14
+ stackNavigator,
15
+ useNavState,
16
+ useRouteParams,
17
+ } from "./navigator";
18
+ export type {
19
+ Href,
20
+ NavParams,
21
+ NavSnapshot,
22
+ NavStackAdapter,
23
+ RouteDef,
24
+ RouteLoader,
25
+ RouteLoaderProps,
26
+ } from "./navigator";
27
+ export { DomNavigationHost, DomStackAdapter } from "./DomNavigationHost";
28
+ export type { DomNavigationHostProps, DomRouteEntry } from "./DomNavigationHost";
29
+ export {
30
+ Link,
31
+ LoadProgressBar,
32
+ Slot,
33
+ useBlocker,
34
+ useLoader,
35
+ useLocalSearchParams,
36
+ useMatches,
37
+ useParams,
38
+ usePathname,
39
+ useRouter,
40
+ } from "./oneAdapter";
41
+ export type { LinkProps, LoaderProps } from "./oneAdapter";
@@ -0,0 +1,189 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { DomStackAdapter } from "./DomNavigationHost";
3
+ import { hrefToString, stackNavigator } from "./navigator";
4
+
5
+ const ROUTES = [
6
+ { pattern: "/", tag: "home", title: "Home" },
7
+ { pattern: "/todos", tag: "todos", title: "Todos" },
8
+ { pattern: "/pokemon/[id]", tag: "pokemon-detail", title: "Pokemon detail" },
9
+ ];
10
+
11
+ function attachDomHost(): DomStackAdapter {
12
+ const adapter = new DomStackAdapter("home", () => stackNavigator.syncFromHost());
13
+ stackNavigator.attach(adapter);
14
+ return adapter;
15
+ }
16
+
17
+ async function flush(): Promise<void> {
18
+ // Loader navigations resolve over microtasks; two ticks cover
19
+ // loader → navSeq check → host action.
20
+ await Promise.resolve();
21
+ await Promise.resolve();
22
+ }
23
+
24
+ describe("hrefToString", () => {
25
+ it("substitutes [param] tokens and appends leftovers as query", () => {
26
+ expect(hrefToString({ pathname: "/pokemon/[id]", params: { id: "mew" } })).toBe("/pokemon/mew");
27
+ expect(hrefToString({ pathname: "/todos", params: { filter: "open" } })).toBe(
28
+ "/todos?filter=open",
29
+ );
30
+ expect(hrefToString("/plain")).toBe("/plain");
31
+ });
32
+ });
33
+
34
+ describe("StackNavigator", () => {
35
+ beforeEach(() => {
36
+ stackNavigator.detach();
37
+ stackNavigator.configure(ROUTES);
38
+ });
39
+
40
+ it("matches literal, param and query segments", () => {
41
+ expect(stackNavigator.match("/")?.route.tag).toBe("home");
42
+ expect(stackNavigator.match("/todos")?.route.tag).toBe("todos");
43
+ const detail = stackNavigator.match("/pokemon/bulbasaur?shiny=1");
44
+ expect(detail?.route.tag).toBe("pokemon-detail");
45
+ expect(detail?.params).toEqual({ shiny: "1", id: "bulbasaur" });
46
+ expect(stackNavigator.match("/nope")).toBeNull();
47
+ });
48
+
49
+ it("starts on the first manifest route", () => {
50
+ const snapshot = stackNavigator.getSnapshot();
51
+ expect(snapshot.pathname).toBe("/");
52
+ expect(snapshot.tag).toBe("home");
53
+ expect(snapshot.depth).toBe(1);
54
+ expect(snapshot.canGoBack).toBe(false);
55
+ expect(stackNavigator.isVisited("home")).toBe(true);
56
+ expect(stackNavigator.isVisited("todos")).toBe(false);
57
+ });
58
+
59
+ it("pushes, pops and re-push pops back to an existing tag", () => {
60
+ attachDomHost();
61
+ stackNavigator.push("/todos");
62
+ expect(stackNavigator.getSnapshot()).toMatchObject({
63
+ tag: "todos",
64
+ pathname: "/todos",
65
+ depth: 2,
66
+ canGoBack: true,
67
+ });
68
+ expect(stackNavigator.isVisited("todos")).toBe(true);
69
+
70
+ stackNavigator.push("/pokemon/mew");
71
+ expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "pokemon-detail", depth: 3 });
72
+ expect(stackNavigator.paramsSnapshot("pokemon-detail")).toEqual({ id: "mew" });
73
+
74
+ // Tag already in the stack → interpreted as "navigate back to it".
75
+ stackNavigator.push("/todos");
76
+ expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "todos", depth: 2 });
77
+
78
+ stackNavigator.back();
79
+ expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "home", depth: 1 });
80
+ });
81
+
82
+ it("replace swaps the top of the stack", () => {
83
+ attachDomHost();
84
+ stackNavigator.replace("/todos");
85
+ expect(stackNavigator.getSnapshot()).toMatchObject({
86
+ tag: "todos",
87
+ depth: 1,
88
+ canGoBack: false,
89
+ });
90
+ });
91
+
92
+ it("ignores navigation without an attached host or matching route", () => {
93
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
94
+ stackNavigator.push("/todos");
95
+ expect(stackNavigator.getSnapshot().tag).toBe("home");
96
+ attachDomHost();
97
+ stackNavigator.push("/not-in-manifest");
98
+ expect(stackNavigator.getSnapshot().tag).toBe("home");
99
+ log.mockRestore();
100
+ });
101
+
102
+ it("resolves loaders before the host action (loader bridge)", async () => {
103
+ let resolveLoader: (value: string) => void = () => {};
104
+ const loader = vi.fn(
105
+ () => new Promise<string>((resolvePromise) => (resolveLoader = resolvePromise)),
106
+ );
107
+ stackNavigator.configure([
108
+ ROUTES[0]!,
109
+ { pattern: "/todos", tag: "todos", title: "Todos", loader },
110
+ ]);
111
+ attachDomHost();
112
+
113
+ stackNavigator.push("/todos");
114
+ expect(loader).toHaveBeenCalledWith({ path: "/todos", params: {} });
115
+ // Loader still pending — navigation must not have landed.
116
+ expect(stackNavigator.getSnapshot().tag).toBe("home");
117
+ expect(stackNavigator.loaderSnapshot("todos")).toBeUndefined();
118
+
119
+ resolveLoader("loaded");
120
+ await flush();
121
+ expect(stackNavigator.getSnapshot().tag).toBe("todos");
122
+ expect(stackNavigator.loaderSnapshot("todos")).toBe("loaded");
123
+ });
124
+
125
+ it("drops a loader navigation superseded by back()", async () => {
126
+ let resolveLoader: (value: string) => void = () => {};
127
+ const loader = () => new Promise<string>((resolvePromise) => (resolveLoader = resolvePromise));
128
+ stackNavigator.configure([
129
+ ROUTES[0]!,
130
+ { pattern: "/todos", tag: "todos", title: "Todos", loader },
131
+ ]);
132
+ attachDomHost();
133
+
134
+ stackNavigator.push("/todos");
135
+ stackNavigator.back(); // supersedes the in-flight loader navigation
136
+ resolveLoader("late");
137
+ await flush();
138
+ expect(stackNavigator.getSnapshot().tag).toBe("home");
139
+ expect(stackNavigator.loaderSnapshot("todos")).toBeUndefined();
140
+ });
141
+
142
+ it("re-runs the loader on param-only navigation to the top route", async () => {
143
+ const loader = vi.fn(({ params }: { params: Record<string, unknown> }) =>
144
+ Promise.resolve(`doc:${params.id}`),
145
+ );
146
+ stackNavigator.configure([
147
+ ROUTES[0]!,
148
+ { pattern: "/pokemon/[id]", tag: "pokemon-detail", title: "Detail", loader },
149
+ ]);
150
+ const adapter = attachDomHost();
151
+
152
+ stackNavigator.push("/pokemon/mew");
153
+ await flush();
154
+ expect(stackNavigator.loaderSnapshot("pokemon-detail")).toBe("doc:mew");
155
+ expect(adapter.get_navigation_stack().get_n_items()).toBe(2);
156
+
157
+ stackNavigator.push("/pokemon/ditto");
158
+ await flush();
159
+ // Host stack unchanged (param-only), loader data + params updated.
160
+ expect(adapter.get_navigation_stack().get_n_items()).toBe(2);
161
+ expect(stackNavigator.loaderSnapshot("pokemon-detail")).toBe("doc:ditto");
162
+ expect(stackNavigator.paramsSnapshot("pokemon-detail")).toEqual({ id: "ditto" });
163
+ });
164
+
165
+ it("preload resolves the loader without navigating", async () => {
166
+ const loader = vi.fn(() => Promise.resolve("preloaded"));
167
+ stackNavigator.configure([{ pattern: "/", tag: "home", title: "Home", loader }, ROUTES[1]!]);
168
+ await stackNavigator.preload("/");
169
+ expect(stackNavigator.loaderSnapshot("home")).toBe("preloaded");
170
+ expect(stackNavigator.getSnapshot().tag).toBe("home");
171
+ });
172
+
173
+ it("mirrors host-initiated stack changes through syncFromHost", () => {
174
+ const adapter = attachDomHost();
175
+ // Host-side push (e.g. GTK header interaction) — not through the seam.
176
+ adapter.push_by_tag("todos");
177
+ expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "todos", depth: 2 });
178
+ expect(stackNavigator.isVisited("todos")).toBe(true);
179
+ adapter.pop();
180
+ expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "home", depth: 1 });
181
+ });
182
+
183
+ it("setParams merges into the top route's params", () => {
184
+ attachDomHost();
185
+ stackNavigator.push("/pokemon/mew");
186
+ stackNavigator.setParams({ tab: "stats" });
187
+ expect(stackNavigator.paramsSnapshot("pokemon-detail")).toEqual({ id: "mew", tab: "stats" });
188
+ });
189
+ });
@@ -0,0 +1,405 @@
1
+ // Stack-navigation seam — One router semantics over a host-native
2
+ // navigation stack.
3
+ //
4
+ // Some delivery targets can't (or shouldn't) mount One's real router
5
+ // runtime: GNOME/GJS (createApp.native → react-navigation →
6
+ // react-native-screens fabric internals), VS Code webviews and web
7
+ // extension pages (no address bar, no One server runtime). This module is
8
+ // the replacement seam those targets share: a tiny route manifest + an
9
+ // external nav store that MIRRORS a host navigation stack. A one-shim
10
+ // (`./oneAdapter` on DOM, `gnome/shims/one.ts` on GTK) backs
11
+ // useRouter/Link/useParams/useLocalSearchParams/usePathname with this
12
+ // store, so REAL feature screens navigate unmodified.
13
+ //
14
+ // Design (proven on GNOME first — see apps/one/gnome/README.md):
15
+ // - Every route in the manifest is one host page, addressed by its
16
+ // `tag`. Push/replace/back map onto the host adapter's
17
+ // push_by_tag / replace_with_tags / pop. Page SHELLS are permanent;
18
+ // page CONTENT mounts on first visit (visitedTags — lazy mounting).
19
+ // - Routes may declare a `loader`; push/replace resolve it BEFORE the
20
+ // host action (the loader bridge), so a page's first paint has its
21
+ // loader data — the same ordering One guarantees on web.
22
+ // - The HOST is the source of truth for the STACK: every mutation lands
23
+ // as a host signal and the handler re-reads the host stack
24
+ // (syncFromHost). Host-initiated pops — GTK header back button, Esc,
25
+ // back swipe — flow through the exact same path, so the JS mirror
26
+ // can't drift.
27
+ // - Params are per-tag external-store snapshots (stable object identity
28
+ // until written), read via useSyncExternalStore under the page's
29
+ // RouteTagContext. A backgrounded page keeps its last params — same
30
+ // behavior as One keeping a stack screen's route params alive.
31
+ //
32
+ // No host imports here: the adapter arrives duck-typed via attach(),
33
+ // keeping this module loadable in any entry (single-screen entries pull
34
+ // the one-shim → this module without ever attaching a stack host).
35
+
36
+ import { createContext, useContext, useSyncExternalStore } from "react";
37
+
38
+ export type NavParams = Record<string, string | string[] | undefined>;
39
+
40
+ export interface RouteLoaderProps {
41
+ /** Resolved pathname, e.g. "/pokemon/bulbasaur". */
42
+ path: string;
43
+ /** Route params (dynamic segments + query). */
44
+ params: NavParams;
45
+ }
46
+
47
+ /** One-style route loader. On web, One resolves a route's loader before the
48
+ * client navigation completes; the seam mirrors that contract — see
49
+ * StackNavigator.navigate(). */
50
+ export type RouteLoader = (props: RouteLoaderProps) => unknown | Promise<unknown>;
51
+
52
+ export interface RouteDef {
53
+ /** One-style pattern: "/", "/pokemon", "/pokemon/[id]". */
54
+ pattern: string;
55
+ /** Host page tag AND stable route id. */
56
+ tag: string;
57
+ /** Page title (host chrome, e.g. the GTK header bar). */
58
+ title: string;
59
+ /** Optional data loader — resolved BEFORE the page mounts/shows (the
60
+ * loader bridge). Its result is what `useLoader` returns on this
61
+ * route's page. */
62
+ loader?: RouteLoader;
63
+ }
64
+
65
+ export type Href = string | { pathname: string; params?: Record<string, string | number> };
66
+
67
+ interface CompiledRoute extends RouteDef {
68
+ segments: { literal?: string; param?: string }[];
69
+ }
70
+
71
+ export interface NavSnapshot {
72
+ pathname: string;
73
+ tag: string;
74
+ depth: number;
75
+ canGoBack: boolean;
76
+ generation: number;
77
+ }
78
+
79
+ /** Structural view of the host navigation stack — everything the seam
80
+ * calls. The method names deliberately mirror Adw.NavigationView so the
81
+ * GTK target passes its widget straight through; DOM hosts implement the
82
+ * same protocol over an array (see DomNavigationHost). */
83
+ export interface NavStackAdapter {
84
+ push_by_tag(tag: string): void;
85
+ pop(): boolean;
86
+ pop_to_tag(tag: string): boolean;
87
+ replace_with_tags(tags: string[]): void;
88
+ get_navigation_stack(): {
89
+ get_n_items(): number;
90
+ get_item(index: number): { get_tag(): string | null } | null;
91
+ };
92
+ }
93
+
94
+ function compileRoute(def: RouteDef): CompiledRoute {
95
+ const segments = def.pattern
96
+ .split("/")
97
+ .filter(Boolean)
98
+ .map((seg) => {
99
+ const m = seg.match(/^\[(\.{3})?(.+)\]$/);
100
+ return m ? { param: m[2] } : { literal: seg };
101
+ });
102
+ return { ...def, segments };
103
+ }
104
+
105
+ /** Minimal query parser — deliberately not URLSearchParams so this module
106
+ * has zero polyfill-order dependency (GJS entries load it before
107
+ * polyfills). Repeated keys become arrays. */
108
+ function parseQuery(query: string | undefined): NavParams {
109
+ const params: NavParams = {};
110
+ if (!query) return params;
111
+ for (const pair of query.split("&")) {
112
+ if (!pair) continue;
113
+ const eq = pair.indexOf("=");
114
+ const key = decodeURIComponent(eq === -1 ? pair : pair.slice(0, eq));
115
+ const value = eq === -1 ? "" : decodeURIComponent(pair.slice(eq + 1));
116
+ const prev = params[key];
117
+ if (prev === undefined) params[key] = value;
118
+ else if (Array.isArray(prev)) prev.push(value);
119
+ else params[key] = [prev, value];
120
+ }
121
+ return params;
122
+ }
123
+
124
+ export function hrefToString(href: Href): string {
125
+ if (typeof href === "string") return href;
126
+ let path = href.pathname;
127
+ const leftover: string[] = [];
128
+ for (const [key, raw] of Object.entries(href.params ?? {})) {
129
+ const value = String(raw);
130
+ const token = `[${key}]`;
131
+ if (path.includes(token)) path = path.replace(token, encodeURIComponent(value));
132
+ else leftover.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
133
+ }
134
+ return leftover.length ? `${path}?${leftover.join("&")}` : path;
135
+ }
136
+
137
+ const EMPTY_PARAMS: NavParams = Object.freeze({});
138
+
139
+ export class StackNavigator {
140
+ private routes: CompiledRoute[] = [];
141
+ private host: NavStackAdapter | null = null;
142
+ private listeners = new Set<() => void>();
143
+ /** Stack mirror rebuilt from the host on every pushed/popped/replaced
144
+ * signal. */
145
+ private stackTags: string[] = [];
146
+ private paramsByTag = new Map<string, NavParams>();
147
+ private pathnameByTag = new Map<string, string>();
148
+ /** Tags whose page content has been navigated to at least once. The host
149
+ * gates each page's React content on this (lazy mounting) — a tag enters
150
+ * the set right before its first host push and never leaves (backgrounded
151
+ * pages stay alive, same as One keeping stack screens mounted). */
152
+ private visitedTags = new Set<string>();
153
+ /** Route-loader results, per tag (the loader bridge's store). */
154
+ private loaderDataByTag = new Map<string, unknown>();
155
+ /** Monotonic navigation sequence — a loader that resolves after a NEWER
156
+ * navigation started must not perform its (stale) host action. */
157
+ private navSeq = 0;
158
+ private generation = 0;
159
+ private snapshot: NavSnapshot = {
160
+ pathname: "/",
161
+ tag: "",
162
+ depth: 0,
163
+ canGoBack: false,
164
+ generation: 0,
165
+ };
166
+
167
+ get isConfigured(): boolean {
168
+ return this.routes.length > 0;
169
+ }
170
+
171
+ /** Register the route manifest. The FIRST route is the root page. */
172
+ configure(defs: RouteDef[]): void {
173
+ this.routes = defs.map(compileRoute);
174
+ this.visitedTags = new Set();
175
+ this.loaderDataByTag = new Map();
176
+ const root = defs[0];
177
+ if (root) {
178
+ this.stackTags = [root.tag];
179
+ this.visitedTags.add(root.tag);
180
+ this.pathnameByTag.set(root.tag, root.pattern);
181
+ this.rebuildSnapshot();
182
+ }
183
+ }
184
+
185
+ /** Has this route's page been navigated to (⇒ its content may mount)? */
186
+ isVisited(tag: string | null): boolean {
187
+ return tag !== null && this.visitedTags.has(tag);
188
+ }
189
+
190
+ /** The route's last loader result (stable identity until the loader runs
191
+ * again). undefined when the route has no loader / hasn't resolved. */
192
+ loaderSnapshot(tag: string | null): unknown {
193
+ if (!tag) return undefined;
194
+ return this.loaderDataByTag.get(tag);
195
+ }
196
+
197
+ /** Resolve a route's loader WITHOUT navigating. For the initial page:
198
+ * entries can `await stackNavigator.preload("/")` during top-level await
199
+ * (where GJS reliably drains promise jobs) so even the root page's first
200
+ * paint has loader data. */
201
+ async preload(href: Href): Promise<void> {
202
+ const hit = this.match(href);
203
+ if (!hit?.route.loader) return;
204
+ try {
205
+ const data = await hit.route.loader({ path: hit.pathname, params: hit.params });
206
+ this.loaderDataByTag.set(hit.route.tag, data);
207
+ this.rebuildSnapshot();
208
+ } catch (err) {
209
+ console.error(`[stack nav] preload loader for ${hit.route.tag} failed: ${String(err)}`);
210
+ }
211
+ }
212
+
213
+ /** Bind the live host stack (an Adw.NavigationView widget on GTK, a
214
+ * DomStackAdapter on DOM targets). */
215
+ attach(host: NavStackAdapter): void {
216
+ this.host = host;
217
+ this.syncFromHost();
218
+ }
219
+
220
+ detach(): void {
221
+ this.host = null;
222
+ }
223
+
224
+ subscribe = (fn: () => void): (() => void) => {
225
+ this.listeners.add(fn);
226
+ return () => this.listeners.delete(fn);
227
+ };
228
+
229
+ getSnapshot = (): NavSnapshot => this.snapshot;
230
+
231
+ paramsSnapshot(tag: string | null): NavParams {
232
+ if (!tag) return EMPTY_PARAMS;
233
+ return this.paramsByTag.get(tag) ?? EMPTY_PARAMS;
234
+ }
235
+
236
+ match(href: Href): { route: CompiledRoute; pathname: string; params: NavParams } | null {
237
+ const str = hrefToString(href);
238
+ const [path = "", query] = str.split("?");
239
+ const parts = path.split("/").filter(Boolean);
240
+ for (const route of this.routes) {
241
+ if (route.segments.length !== parts.length) continue;
242
+ const params = parseQuery(query);
243
+ let ok = true;
244
+ for (let i = 0; i < parts.length; i++) {
245
+ const seg = route.segments[i]!;
246
+ const part = decodeURIComponent(parts[i]!);
247
+ if (seg.literal !== undefined) {
248
+ if (seg.literal !== part) {
249
+ ok = false;
250
+ break;
251
+ }
252
+ } else if (seg.param !== undefined) {
253
+ params[seg.param] = part;
254
+ }
255
+ }
256
+ if (ok) return { route, pathname: path === "" ? "/" : `/${parts.join("/")}`, params };
257
+ }
258
+ return null;
259
+ }
260
+
261
+ push = (href: Href): void => {
262
+ void this.navigate(href, "push");
263
+ };
264
+
265
+ replace = (href: Href): void => {
266
+ void this.navigate(href, "replace");
267
+ };
268
+
269
+ /** Shared push/replace flow, incl. the loader bridge. When the target
270
+ * route has no loader the whole body runs SYNCHRONOUSLY (an async
271
+ * function only yields at its first await), preserving the original
272
+ * timing. With a loader, the host action waits for the loader to resolve
273
+ * — same contract as One on web, where client navigation completes only
274
+ * after the route's loader data arrived — so the page's first paint has
275
+ * its data (and the lazily-mounted content mounts with it). */
276
+ private async navigate(href: Href, mode: "push" | "replace"): Promise<void> {
277
+ const hit = this.match(href);
278
+ if (!hit) {
279
+ console.log(
280
+ `[stack nav] ${mode} ${hrefToString(href)} — no page in the route manifest, ignored`,
281
+ );
282
+ return;
283
+ }
284
+ if (!this.host) {
285
+ console.log(`[stack nav] ${mode} ${hrefToString(href)} — no stack host attached, ignored`);
286
+ return;
287
+ }
288
+ const { route, pathname, params } = hit;
289
+ const seq = ++this.navSeq;
290
+ if (route.loader) {
291
+ try {
292
+ const data = await route.loader({ path: pathname, params });
293
+ if (seq !== this.navSeq) return; // superseded by a newer navigation
294
+ this.loaderDataByTag.set(route.tag, data);
295
+ } catch (err) {
296
+ if (seq !== this.navSeq) return;
297
+ console.error(`[stack nav] loader for ${route.tag} failed: ${String(err)}`);
298
+ }
299
+ }
300
+ // Params/pathname/visited land BEFORE the host action so the target page
301
+ // (content mounts on first visit) renders with them by the time the host
302
+ // shows it.
303
+ this.visitedTags.add(route.tag);
304
+ this.setRouteState(route.tag, pathname, params);
305
+ if (mode === "replace") {
306
+ const top = this.stackTags[this.stackTags.length - 1];
307
+ if (top === route.tag) return;
308
+ const tags = [...this.stackTags.slice(0, -1), route.tag];
309
+ this.host.replace_with_tags(tags);
310
+ return;
311
+ }
312
+ const idx = this.stackTags.indexOf(route.tag);
313
+ if (idx === -1) {
314
+ this.host.push_by_tag(route.tag);
315
+ } else if (idx < this.stackTags.length - 1) {
316
+ // Tag already in the nav stack — Adw treats re-push as a programming
317
+ // error. Interpret as "navigate back to it" (params above still apply).
318
+ this.host.pop_to_tag(route.tag);
319
+ }
320
+ // Already on top: param-only navigation, nothing to do on the host side.
321
+ }
322
+
323
+ back = (): void => {
324
+ if (!this.host) {
325
+ console.log("[stack nav] back — no stack host attached, ignored");
326
+ return;
327
+ }
328
+ // Supersede any in-flight loader navigation — its host action must not
329
+ // land after this pop (navigate() re-checks navSeq after its await).
330
+ this.navSeq++;
331
+ // The popped signal (same path as the GTK header back button / Esc /
332
+ // back swipe) syncs the mirror.
333
+ this.host.pop();
334
+ };
335
+
336
+ setParams = (params: Record<string, string>): void => {
337
+ const top = this.stackTags[this.stackTags.length - 1];
338
+ if (!top) return;
339
+ const prev = this.paramsByTag.get(top) ?? {};
340
+ this.paramsByTag.set(top, { ...prev, ...params });
341
+ this.rebuildSnapshot();
342
+ };
343
+
344
+ /** Re-read the host navigation stack (called from pushed/popped/replaced
345
+ * signal handlers and attach). The host is the source of truth. */
346
+ syncFromHost = (): void => {
347
+ if (!this.host) return;
348
+ // Any landed stack mutation — seam- or host-initiated (GTK header back
349
+ // button / Esc / swipe) — supersedes in-flight loader navigations.
350
+ // Safe for the navigation that CAUSED this signal: its navSeq check
351
+ // sits before its host action, never after.
352
+ this.navSeq++;
353
+ const model = this.host.get_navigation_stack();
354
+ const tags: string[] = [];
355
+ const count = model.get_n_items();
356
+ for (let i = 0; i < count; i++) {
357
+ const tag = model.get_item(i)?.get_tag();
358
+ if (tag) tags.push(tag);
359
+ }
360
+ if (tags.length > 0) this.stackTags = tags;
361
+ // Anything in the host stack is by definition visited — covers pushes
362
+ // that originate on the host side rather than through the seam.
363
+ for (const tag of this.stackTags) this.visitedTags.add(tag);
364
+ this.rebuildSnapshot();
365
+ };
366
+
367
+ private setRouteState(tag: string, pathname: string, params: NavParams): void {
368
+ this.paramsByTag.set(tag, params);
369
+ this.pathnameByTag.set(tag, pathname);
370
+ this.rebuildSnapshot();
371
+ }
372
+
373
+ private rebuildSnapshot(): void {
374
+ const tag = this.stackTags[this.stackTags.length - 1] ?? "";
375
+ const route = this.routes.find((r) => r.tag === tag);
376
+ this.generation++;
377
+ this.snapshot = {
378
+ pathname: this.pathnameByTag.get(tag) ?? route?.pattern ?? "/",
379
+ tag,
380
+ depth: this.stackTags.length,
381
+ canGoBack: this.stackTags.length > 1,
382
+ generation: this.generation,
383
+ };
384
+ for (const fn of this.listeners) fn();
385
+ }
386
+ }
387
+
388
+ /** The per-bundle navigator singleton. Every target build (GNOME entry,
389
+ * VS Code webview, webext view) is its own bundle, so module state is
390
+ * naturally per-target. */
391
+ export const stackNavigator = new StackNavigator();
392
+
393
+ /** Set per page by the navigation host — useParams/useLocalSearchParams
394
+ * read the params of the page they render INSIDE, not the top of the
395
+ * stack. */
396
+ export const RouteTagContext = createContext<string | null>(null);
397
+
398
+ export function useNavState(): NavSnapshot {
399
+ return useSyncExternalStore(stackNavigator.subscribe, stackNavigator.getSnapshot);
400
+ }
401
+
402
+ export function useRouteParams(): NavParams {
403
+ const tag = useContext(RouteTagContext);
404
+ return useSyncExternalStore(stackNavigator.subscribe, () => stackNavigator.paramsSnapshot(tag));
405
+ }