@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.
- package/README.md +51 -0
- package/dist/cjs/hooks/useUrlState.cjs +30 -9
- package/dist/cjs/hooks/useUrlState.native.js +32 -9
- package/dist/cjs/hooks/useUrlState.native.js.map +1 -1
- package/dist/cjs/seam/DomNavigationHost.cjs +122 -0
- package/dist/cjs/seam/DomNavigationHost.native.js +186 -0
- package/dist/cjs/seam/DomNavigationHost.native.js.map +1 -0
- package/dist/cjs/seam/index.cjs +47 -0
- package/dist/cjs/seam/index.native.js +50 -0
- package/dist/cjs/seam/index.native.js.map +1 -0
- package/dist/cjs/seam/navigator.cjs +309 -0
- package/dist/cjs/seam/navigator.native.js +479 -0
- package/dist/cjs/seam/navigator.native.js.map +1 -0
- package/dist/cjs/seam/oneAdapter.cjs +128 -0
- package/dist/cjs/seam/oneAdapter.native.js +141 -0
- package/dist/cjs/seam/oneAdapter.native.js.map +1 -0
- package/dist/esm/hooks/useUrlState.mjs +31 -10
- package/dist/esm/hooks/useUrlState.mjs.map +1 -1
- package/dist/esm/hooks/useUrlState.native.js +33 -10
- package/dist/esm/hooks/useUrlState.native.js.map +1 -1
- package/dist/esm/seam/DomNavigationHost.mjs +96 -0
- package/dist/esm/seam/DomNavigationHost.mjs.map +1 -0
- package/dist/esm/seam/DomNavigationHost.native.js +157 -0
- package/dist/esm/seam/DomNavigationHost.native.js.map +1 -0
- package/dist/esm/seam/index.mjs +5 -0
- package/dist/esm/seam/index.mjs.map +1 -0
- package/dist/esm/seam/index.native.js +5 -0
- package/dist/esm/seam/index.native.js.map +1 -0
- package/dist/esm/seam/navigator.mjs +279 -0
- package/dist/esm/seam/navigator.mjs.map +1 -0
- package/dist/esm/seam/navigator.native.js +446 -0
- package/dist/esm/seam/navigator.native.js.map +1 -0
- package/dist/esm/seam/oneAdapter.mjs +90 -0
- package/dist/esm/seam/oneAdapter.mjs.map +1 -0
- package/dist/esm/seam/oneAdapter.native.js +100 -0
- package/dist/esm/seam/oneAdapter.native.js.map +1 -0
- package/dist/jsx/hooks/useUrlState.mjs +31 -10
- package/dist/jsx/hooks/useUrlState.mjs.map +1 -1
- package/dist/jsx/hooks/useUrlState.native.js +32 -9
- package/dist/jsx/hooks/useUrlState.native.js.map +1 -1
- package/dist/jsx/seam/DomNavigationHost.mjs +96 -0
- package/dist/jsx/seam/DomNavigationHost.mjs.map +1 -0
- package/dist/jsx/seam/DomNavigationHost.native.js +186 -0
- package/dist/jsx/seam/DomNavigationHost.native.js.map +1 -0
- package/dist/jsx/seam/index.mjs +5 -0
- package/dist/jsx/seam/index.mjs.map +1 -0
- package/dist/jsx/seam/index.native.js +50 -0
- package/dist/jsx/seam/index.native.js.map +1 -0
- package/dist/jsx/seam/navigator.mjs +279 -0
- package/dist/jsx/seam/navigator.mjs.map +1 -0
- package/dist/jsx/seam/navigator.native.js +479 -0
- package/dist/jsx/seam/navigator.native.js.map +1 -0
- package/dist/jsx/seam/oneAdapter.mjs +90 -0
- package/dist/jsx/seam/oneAdapter.mjs.map +1 -0
- package/dist/jsx/seam/oneAdapter.native.js +141 -0
- package/dist/jsx/seam/oneAdapter.native.js.map +1 -0
- package/package.json +26 -9
- package/src/hooks/urlStateEdges.spec.ts +98 -0
- package/src/hooks/useUrlState.ts +34 -8
- package/src/seam/DomNavigationHost.spec.tsx +78 -0
- package/src/seam/DomNavigationHost.tsx +150 -0
- package/src/seam/index.ts +41 -0
- package/src/seam/navigator.spec.ts +189 -0
- package/src/seam/navigator.ts +405 -0
- package/src/seam/oneAdapter.tsx +166 -0
- package/types/hooks/index.d.ts +2 -0
- package/types/hooks/index.d.ts.map +1 -0
- package/types/hooks/useUrlState.d.ts +36 -0
- package/types/hooks/useUrlState.d.ts.map +1 -0
- package/types/index.d.ts +4 -0
- package/types/index.d.ts.map +1 -0
- package/types/index.storybook.d.ts +4 -0
- package/types/index.storybook.d.ts.map +1 -0
- package/types/router/EmptyRouter.d.ts +7 -0
- package/types/router/EmptyRouter.d.ts.map +1 -0
- package/types/router/ReactRouter.d.ts +6 -0
- package/types/router/ReactRouter.d.ts.map +1 -0
- package/types/router/index.d.ts +2 -0
- package/types/router/index.d.ts.map +1 -0
- package/types/router/index.storybook-expo.d.ts +2 -0
- package/types/router/index.storybook-expo.d.ts.map +1 -0
- package/types/router/index.storybook.d.ts +2 -0
- package/types/router/index.storybook.d.ts.map +1 -0
- package/types/router/index.webext.d.ts +2 -0
- package/types/router/index.webext.d.ts.map +1 -0
- package/types/seam/DomNavigationHost.d.ts +33 -0
- package/types/seam/DomNavigationHost.d.ts.map +1 -0
- package/types/seam/index.d.ts +7 -0
- package/types/seam/index.d.ts.map +1 -0
- package/types/seam/navigator.d.ts +133 -0
- package/types/seam/navigator.d.ts.map +1 -0
- package/types/seam/oneAdapter.d.ts +57 -0
- package/types/seam/oneAdapter.d.ts.map +1 -0
- package/types/types.d.ts +40 -0
- package/types/types.d.ts.map +1 -0
- package/dist/cjs/hooks/useUrlState.spec.cjs +0 -280
- package/dist/cjs/hooks/useUrlState.spec.native.js +0 -315
- package/dist/cjs/hooks/useUrlState.spec.native.js.map +0 -1
- package/dist/esm/hooks/useUrlState.spec.mjs +0 -257
- package/dist/esm/hooks/useUrlState.spec.mjs.map +0 -1
- package/dist/esm/hooks/useUrlState.spec.native.js +0 -289
- package/dist/esm/hooks/useUrlState.spec.native.js.map +0 -1
- package/dist/jsx/hooks/useUrlState.spec.mjs +0 -257
- package/dist/jsx/hooks/useUrlState.spec.mjs.map +0 -1
- package/dist/jsx/hooks/useUrlState.spec.native.js +0 -315
- 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
|
+
}
|