@multiplatform.one/router 6.7.0 → 7.1.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/cjs/seam/DomNavigationHost.cjs +7 -4
- package/dist/cjs/seam/DomNavigationHost.native.js +11 -8
- package/dist/cjs/seam/DomNavigationHost.native.js.map +1 -1
- package/dist/cjs/seam/navigator.cjs +40 -5
- package/dist/cjs/seam/navigator.native.js +61 -5
- package/dist/cjs/seam/navigator.native.js.map +1 -1
- package/dist/esm/seam/DomNavigationHost.mjs +7 -4
- package/dist/esm/seam/DomNavigationHost.mjs.map +1 -1
- package/dist/esm/seam/DomNavigationHost.native.js +11 -8
- package/dist/esm/seam/DomNavigationHost.native.js.map +1 -1
- package/dist/esm/seam/navigator.mjs +40 -5
- package/dist/esm/seam/navigator.mjs.map +1 -1
- package/dist/esm/seam/navigator.native.js +61 -5
- package/dist/esm/seam/navigator.native.js.map +1 -1
- package/dist/jsx/seam/DomNavigationHost.mjs +7 -4
- package/dist/jsx/seam/DomNavigationHost.mjs.map +1 -1
- package/dist/jsx/seam/DomNavigationHost.native.js +11 -8
- package/dist/jsx/seam/DomNavigationHost.native.js.map +1 -1
- package/dist/jsx/seam/navigator.mjs +40 -5
- package/dist/jsx/seam/navigator.mjs.map +1 -1
- package/dist/jsx/seam/navigator.native.js +61 -5
- package/dist/jsx/seam/navigator.native.js.map +1 -1
- package/package.json +5 -5
- package/src/seam/DomNavigationHost.spec.tsx +95 -4
- package/src/seam/DomNavigationHost.tsx +17 -8
- package/src/seam/navigator.spec.ts +140 -8
- package/src/seam/navigator.ts +64 -6
- package/types/seam/DomNavigationHost.d.ts +3 -2
- package/types/seam/DomNavigationHost.d.ts.map +1 -1
- package/types/seam/navigator.d.ts +18 -2
- package/types/seam/navigator.d.ts.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/router",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.1.0",
|
|
4
4
|
"description": "multiplatform.one router with support for react-router-dom and react-navigation",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -62,11 +62,11 @@
|
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@vxrn/color-scheme": "~1.16.5",
|
|
65
|
-
"@multiplatform.one/platform": "
|
|
65
|
+
"@multiplatform.one/platform": "7.1.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@react-navigation/native": "^7.2.2",
|
|
69
|
-
"@tamagui/build": "2.
|
|
69
|
+
"@tamagui/build": "2.7.6",
|
|
70
70
|
"@testing-library/react": "^16.3.2",
|
|
71
71
|
"@vitejs/plugin-react": "^6.0.1",
|
|
72
72
|
"one": "^1.16.5",
|
|
@@ -77,8 +77,8 @@
|
|
|
77
77
|
"typescript": "~5.9.3",
|
|
78
78
|
"vite": "^8.0.10",
|
|
79
79
|
"vitest": "^4.1.5",
|
|
80
|
-
"@multiplatform.one/
|
|
81
|
-
"@multiplatform.one/
|
|
80
|
+
"@multiplatform.one/config": "7.1.0",
|
|
81
|
+
"@multiplatform.one/test-utils": "7.1.0"
|
|
82
82
|
},
|
|
83
83
|
"peerDependencies": {
|
|
84
84
|
"@react-navigation/native": "~7.2.2",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { act, render, screen } from "@testing-library/react";
|
|
2
|
-
import {
|
|
2
|
+
import { StrictMode, useEffect, useRef } from "react";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
4
|
import { DomNavigationHost } from "./DomNavigationHost";
|
|
4
5
|
import { stackNavigator } from "./navigator";
|
|
5
6
|
import { useParams } from "./oneAdapter";
|
|
@@ -108,13 +109,103 @@ describe("DomNavigationHost", () => {
|
|
|
108
109
|
expect((container.firstElementChild as HTMLElement).style.minHeight).toBe("480px");
|
|
109
110
|
});
|
|
110
111
|
|
|
111
|
-
it("restores an initial route
|
|
112
|
+
it("restores an initial route above the root so back affordances survive", () => {
|
|
112
113
|
const { container } = renderHost("/todos");
|
|
113
114
|
expect(pageByTag(container, "todos").dataset.seamActive).toBe("true");
|
|
115
|
+
// The manifest root stays beneath the restored page (documented replace
|
|
116
|
+
// semantics) — a restored detail page must not dead-end its back button.
|
|
114
117
|
expect(stackNavigator.getSnapshot()).toMatchObject({
|
|
115
118
|
tag: "todos",
|
|
116
|
-
depth:
|
|
117
|
-
canGoBack:
|
|
119
|
+
depth: 2,
|
|
120
|
+
canGoBack: true,
|
|
118
121
|
});
|
|
122
|
+
act(() => stackNavigator.back());
|
|
123
|
+
expect(pageByTag(container, "home").dataset.seamActive).toBe("true");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// The root page's content renders BEFORE the attach effect applies
|
|
127
|
+
// initialHref — a restored route's query params must already be seeded by
|
|
128
|
+
// then, or mount-time state seeding (list filters from
|
|
129
|
+
// useLocalSearchParams) reads an empty object and the URL round-trip
|
|
130
|
+
// silently drops the restored filters.
|
|
131
|
+
it("seeds a restored route's params before the first content render", () => {
|
|
132
|
+
const firstRenderParams: Array<Record<string, unknown>> = [];
|
|
133
|
+
function SeedProbe() {
|
|
134
|
+
const params = useParams();
|
|
135
|
+
const first = useRef(true);
|
|
136
|
+
if (first.current) {
|
|
137
|
+
first.current = false;
|
|
138
|
+
firstRenderParams.push({ ...params });
|
|
139
|
+
}
|
|
140
|
+
return <span data-testid="seed-params">{String(params.q ?? "")}</span>;
|
|
141
|
+
}
|
|
142
|
+
stackNavigator.detach();
|
|
143
|
+
stackNavigator.configure(ROUTES);
|
|
144
|
+
render(
|
|
145
|
+
<DomNavigationHost
|
|
146
|
+
initialHref="/?q=fire"
|
|
147
|
+
routes={[
|
|
148
|
+
{ ...ROUTES[0]!, element: <SeedProbe /> },
|
|
149
|
+
{ ...ROUTES[1]!, element: <div data-testid="todos-screen" /> },
|
|
150
|
+
{ ...ROUTES[2]!, element: <DetailProbe /> },
|
|
151
|
+
]}
|
|
152
|
+
/>,
|
|
153
|
+
);
|
|
154
|
+
expect(firstRenderParams[0]).toMatchObject({ q: "fire" });
|
|
155
|
+
expect(screen.getByTestId("seed-params").textContent).toBe("fire");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// The warm-deep-link drop: a navigation dispatched from a mounted screen's
|
|
159
|
+
// own effect fires BEFORE the host's attach effect (child effects run
|
|
160
|
+
// first). It must queue and apply, not vanish based on timing.
|
|
161
|
+
it("applies a deep link dispatched from a screen effect before the host attached", () => {
|
|
162
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
163
|
+
function RedirectingHome() {
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
stackNavigator.push("/pokemon/pikachu");
|
|
166
|
+
}, []);
|
|
167
|
+
return <div data-testid="home-screen" />;
|
|
168
|
+
}
|
|
169
|
+
stackNavigator.detach();
|
|
170
|
+
stackNavigator.configure(ROUTES);
|
|
171
|
+
const { container } = render(
|
|
172
|
+
<DomNavigationHost
|
|
173
|
+
routes={[
|
|
174
|
+
{ ...ROUTES[0]!, element: <RedirectingHome /> },
|
|
175
|
+
{ ...ROUTES[1]!, element: <div data-testid="todos-screen" /> },
|
|
176
|
+
{ ...ROUTES[2]!, element: <DetailProbe /> },
|
|
177
|
+
]}
|
|
178
|
+
/>,
|
|
179
|
+
);
|
|
180
|
+
expect(pageByTag(container, "pokemon-detail").dataset.seamActive).toBe("true");
|
|
181
|
+
expect(screen.getByTestId("detail-params").textContent).toBe("pikachu");
|
|
182
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ depth: 2, canGoBack: true });
|
|
183
|
+
log.mockRestore();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// StrictMode's mount→unmount→remount opens a detach window; the host must
|
|
187
|
+
// come back attached and both queued and post-mount navigations must land.
|
|
188
|
+
it("survives a StrictMode remount cycle and still navigates", () => {
|
|
189
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
190
|
+
stackNavigator.detach();
|
|
191
|
+
stackNavigator.configure(ROUTES);
|
|
192
|
+
const { container } = render(
|
|
193
|
+
<StrictMode>
|
|
194
|
+
<DomNavigationHost
|
|
195
|
+
initialHref="/todos"
|
|
196
|
+
routes={[
|
|
197
|
+
{ ...ROUTES[0]!, element: <div data-testid="home-screen" /> },
|
|
198
|
+
{ ...ROUTES[1]!, element: <div data-testid="todos-screen" /> },
|
|
199
|
+
{ ...ROUTES[2]!, element: <DetailProbe /> },
|
|
200
|
+
]}
|
|
201
|
+
/>
|
|
202
|
+
</StrictMode>,
|
|
203
|
+
);
|
|
204
|
+
expect(pageByTag(container, "todos").dataset.seamActive).toBe("true");
|
|
205
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "todos", depth: 2 });
|
|
206
|
+
act(() => stackNavigator.push("/pokemon/mew"));
|
|
207
|
+
expect(pageByTag(container, "pokemon-detail").dataset.seamActive).toBe("true");
|
|
208
|
+
expect(screen.getByTestId("detail-params").textContent).toBe("mew");
|
|
209
|
+
log.mockRestore();
|
|
119
210
|
});
|
|
120
211
|
});
|
|
@@ -93,8 +93,9 @@ export interface DomRouteEntry extends RouteDef {
|
|
|
93
93
|
export interface DomNavigationHostProps {
|
|
94
94
|
routes: DomRouteEntry[];
|
|
95
95
|
/** Restore a previous route on mount (e.g. from vscode.getState()) —
|
|
96
|
-
* applied as a root
|
|
97
|
-
* semantics
|
|
96
|
+
* applied as a replace ABOVE the manifest root (see the replace
|
|
97
|
+
* semantics in navigator.ts), so a restored detail page keeps a working
|
|
98
|
+
* back affordance to the root instead of dead-ending. */
|
|
98
99
|
initialHref?: Href;
|
|
99
100
|
style?: CSSProperties;
|
|
100
101
|
}
|
|
@@ -155,9 +156,16 @@ export function DomNavigationHost({
|
|
|
155
156
|
}: DomNavigationHostProps): ReactNode {
|
|
156
157
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
157
158
|
const adapterRef = useRef<DomStackAdapter | null>(null);
|
|
158
|
-
adapterRef.current
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
if (adapterRef.current === null) {
|
|
160
|
+
// First-render lazy init. Seeding the initial route's params here (not
|
|
161
|
+
// in the attach effect, which runs AFTER page content rendered once)
|
|
162
|
+
// lets a restored route's query params reach mount-time state seeding —
|
|
163
|
+
// e.g. a root list initializing its filters from useLocalSearchParams.
|
|
164
|
+
if (initialHref !== undefined) stackNavigator.seedInitialRoute(initialHref);
|
|
165
|
+
adapterRef.current = new DomStackAdapter(routes[0]?.tag ?? "", () =>
|
|
166
|
+
stackNavigator.syncFromHost(),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
161
169
|
// Mount-time value only — reattaching on href identity changes would
|
|
162
170
|
// reset the live stack.
|
|
163
171
|
const initialHrefRef = useRef(initialHref);
|
|
@@ -165,9 +173,10 @@ export function DomNavigationHost({
|
|
|
165
173
|
useEffect(() => {
|
|
166
174
|
const adapter = adapterRef.current;
|
|
167
175
|
if (!adapter) return;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
176
|
+
// initialHref rides attach() so the restore lands BEFORE queued deep
|
|
177
|
+
// links flush — a navigation that arrived while unattached (child
|
|
178
|
+
// effects, remount gaps) is fresher intent and must end up on top.
|
|
179
|
+
stackNavigator.attach(adapter, { initialHref: initialHrefRef.current });
|
|
171
180
|
return () => stackNavigator.detach();
|
|
172
181
|
}, []);
|
|
173
182
|
|
|
@@ -79,26 +79,158 @@ describe("StackNavigator", () => {
|
|
|
79
79
|
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "home", depth: 1 });
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// Replace semantics (documented in navigator.ts): with no navigation entry
|
|
83
|
+
// above the manifest root there is nothing to discard, so replace lands the
|
|
84
|
+
// target ABOVE the root like a push — cold deep links / initialHref restores
|
|
85
|
+
// keep a working back affordance (react-navigation/expo-router synthesize
|
|
86
|
+
// the anchor route for deep links the same way; Adw.NavigationView's
|
|
87
|
+
// replace on an empty view is a push).
|
|
88
|
+
it("replace on a fresh stack layers above the root so back still works", () => {
|
|
83
89
|
attachDomHost();
|
|
84
|
-
stackNavigator.replace("/
|
|
90
|
+
stackNavigator.replace("/pokemon/mew");
|
|
85
91
|
expect(stackNavigator.getSnapshot()).toMatchObject({
|
|
86
|
-
tag: "
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
tag: "pokemon-detail",
|
|
93
|
+
pathname: "/pokemon/mew",
|
|
94
|
+
depth: 2,
|
|
95
|
+
canGoBack: true,
|
|
89
96
|
});
|
|
97
|
+
stackNavigator.back();
|
|
98
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "home", depth: 1 });
|
|
90
99
|
});
|
|
91
100
|
|
|
92
|
-
it("
|
|
93
|
-
|
|
101
|
+
it("replace lays the root down even when the host stack is empty (GTK cold start)", () => {
|
|
102
|
+
// An Adw.NavigationView is empty until the first push; the mirror still
|
|
103
|
+
// carries the configure()-seeded root. Replace must materialize BOTH the
|
|
104
|
+
// root and the target on the host, not strand the target alone.
|
|
105
|
+
const adapter = new DomStackAdapter("", () => stackNavigator.syncFromHost());
|
|
106
|
+
stackNavigator.attach(adapter);
|
|
107
|
+
expect(adapter.get_navigation_stack().get_n_items()).toBe(0);
|
|
108
|
+
stackNavigator.replace("/pokemon/mew");
|
|
109
|
+
const model = adapter.get_navigation_stack();
|
|
110
|
+
expect(model.get_n_items()).toBe(2);
|
|
111
|
+
expect(model.get_item(0)?.get_tag()).toBe("home");
|
|
112
|
+
expect(model.get_item(1)?.get_tag()).toBe("pokemon-detail");
|
|
113
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "pokemon-detail", canGoBack: true });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("replace swaps the top once navigation has landed", () => {
|
|
117
|
+
attachDomHost();
|
|
94
118
|
stackNavigator.push("/todos");
|
|
95
|
-
|
|
119
|
+
stackNavigator.replace("/pokemon/mew");
|
|
120
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({
|
|
121
|
+
tag: "pokemon-detail",
|
|
122
|
+
depth: 2,
|
|
123
|
+
canGoBack: true,
|
|
124
|
+
});
|
|
125
|
+
// The replaced entry (todos) is gone: back lands on home.
|
|
126
|
+
stackNavigator.back();
|
|
127
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "home", depth: 1 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("replace to the showing route is a param-only refresh", () => {
|
|
131
|
+
attachDomHost();
|
|
132
|
+
stackNavigator.push("/pokemon/mew");
|
|
133
|
+
stackNavigator.replace("/pokemon/ditto");
|
|
134
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "pokemon-detail", depth: 2 });
|
|
135
|
+
expect(stackNavigator.paramsSnapshot("pokemon-detail")).toEqual({ id: "ditto" });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("replace dedupes a target that already sits in the back stack", () => {
|
|
139
|
+
// Every tag appears at most once in the host stack (Adw treats a
|
|
140
|
+
// duplicate page as a programming error) — replacing to a page that's
|
|
141
|
+
// already beneath pops the stack to a single occurrence of it.
|
|
142
|
+
const adapter = attachDomHost();
|
|
143
|
+
stackNavigator.push("/todos");
|
|
144
|
+
stackNavigator.push("/pokemon/mew");
|
|
145
|
+
stackNavigator.replace("/todos");
|
|
146
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "todos", depth: 2 });
|
|
147
|
+
const model = adapter.get_navigation_stack();
|
|
148
|
+
expect(model.get_n_items()).toBe(2);
|
|
149
|
+
expect(model.get_item(0)?.get_tag()).toBe("home");
|
|
150
|
+
expect(model.get_item(1)?.get_tag()).toBe("todos");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("ignores navigation to routes outside the manifest", () => {
|
|
154
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
96
155
|
attachDomHost();
|
|
97
156
|
stackNavigator.push("/not-in-manifest");
|
|
98
157
|
expect(stackNavigator.getSnapshot().tag).toBe("home");
|
|
99
158
|
log.mockRestore();
|
|
100
159
|
});
|
|
101
160
|
|
|
161
|
+
// Warm/early deep links: navigation requests are QUEUED while no host is
|
|
162
|
+
// attached (mount ordering, StrictMode's detach→re-attach cycle, host
|
|
163
|
+
// remounts) and applied on attach — the same contract as react-navigation
|
|
164
|
+
// queueing actions dispatched before the navigator is ready. Dropping them
|
|
165
|
+
// is what made deep links no-op depending on arrival timing.
|
|
166
|
+
it("queues a deep link that arrives before a host attaches and applies it on attach", () => {
|
|
167
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
168
|
+
stackNavigator.push("/pokemon/mew");
|
|
169
|
+
// Nothing landed yet — no host.
|
|
170
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "home", depth: 1 });
|
|
171
|
+
attachDomHost();
|
|
172
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({
|
|
173
|
+
tag: "pokemon-detail",
|
|
174
|
+
pathname: "/pokemon/mew",
|
|
175
|
+
depth: 2,
|
|
176
|
+
canGoBack: true,
|
|
177
|
+
});
|
|
178
|
+
expect(stackNavigator.paramsSnapshot("pokemon-detail")).toEqual({ id: "mew" });
|
|
179
|
+
log.mockRestore();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("applies a deep link that arrives while the host is detached (remount cycle)", () => {
|
|
183
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
184
|
+
const adapter = attachDomHost();
|
|
185
|
+
stackNavigator.push("/todos");
|
|
186
|
+
stackNavigator.detach();
|
|
187
|
+
// Warm deep link lands in the detach window (e.g. StrictMode remount).
|
|
188
|
+
stackNavigator.push("/pokemon/mew");
|
|
189
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "todos", depth: 2 });
|
|
190
|
+
stackNavigator.attach(adapter);
|
|
191
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "pokemon-detail", depth: 3 });
|
|
192
|
+
log.mockRestore();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("resolves a queued navigation's loader at apply time, not enqueue time", async () => {
|
|
196
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
197
|
+
const loader = vi.fn(() => Promise.resolve("fresh"));
|
|
198
|
+
stackNavigator.configure([
|
|
199
|
+
ROUTES[0]!,
|
|
200
|
+
{ pattern: "/todos", tag: "todos", title: "Todos", loader },
|
|
201
|
+
]);
|
|
202
|
+
stackNavigator.push("/todos");
|
|
203
|
+
// Queued — the loader must not run until a host can apply the result.
|
|
204
|
+
expect(loader).not.toHaveBeenCalled();
|
|
205
|
+
attachDomHost();
|
|
206
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
207
|
+
await flush();
|
|
208
|
+
expect(stackNavigator.getSnapshot().tag).toBe("todos");
|
|
209
|
+
expect(stackNavigator.loaderSnapshot("todos")).toBe("fresh");
|
|
210
|
+
log.mockRestore();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("applies the initial href before flushing queued deep links (freshest wins)", () => {
|
|
214
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
215
|
+
stackNavigator.push("/pokemon/mew");
|
|
216
|
+
const adapter = new DomStackAdapter("home", () => stackNavigator.syncFromHost());
|
|
217
|
+
stackNavigator.attach(adapter, { initialHref: "/todos" });
|
|
218
|
+
// Restore landed first ([home, todos]), then the queued deep link on top.
|
|
219
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "pokemon-detail", depth: 3 });
|
|
220
|
+
stackNavigator.back();
|
|
221
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "todos", depth: 2 });
|
|
222
|
+
log.mockRestore();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("clears queued navigations on configure", () => {
|
|
226
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
227
|
+
stackNavigator.push("/todos");
|
|
228
|
+
stackNavigator.configure(ROUTES);
|
|
229
|
+
attachDomHost();
|
|
230
|
+
expect(stackNavigator.getSnapshot()).toMatchObject({ tag: "home", depth: 1 });
|
|
231
|
+
log.mockRestore();
|
|
232
|
+
});
|
|
233
|
+
|
|
102
234
|
it("resolves loaders before the host action (loader bridge)", async () => {
|
|
103
235
|
let resolveLoader: (value: string) => void = () => {};
|
|
104
236
|
const loader = vi.fn(
|
package/src/seam/navigator.ts
CHANGED
|
@@ -28,6 +28,21 @@
|
|
|
28
28
|
// until written), read via useSyncExternalStore under the page's
|
|
29
29
|
// RouteTagContext. A backgrounded page keeps its last params — same
|
|
30
30
|
// behavior as One keeping a stack screen's route params alive.
|
|
31
|
+
// - Navigations that arrive while NO host is attached are QUEUED and
|
|
32
|
+
// flushed on attach() (react-navigation queues pre-ready actions the
|
|
33
|
+
// same way). The no-host window is real even on mounted trees: child
|
|
34
|
+
// effects run before the navigation host's attach effect, and StrictMode
|
|
35
|
+
// remounts open a detach→attach gap — deep links must survive both, not
|
|
36
|
+
// no-op based on arrival timing.
|
|
37
|
+
// - REPLACE swaps the top NAVIGATION ENTRY. The manifest root is a
|
|
38
|
+
// permanent shell, not a navigation entry: with nothing above the root
|
|
39
|
+
// there is nothing to discard, so replace lands the target ABOVE the
|
|
40
|
+
// root (like push). Cold deep links / initialHref restores therefore
|
|
41
|
+
// always leave a live back affordance — the same shape react-navigation/
|
|
42
|
+
// expo-router synthesize for deep links via the anchor/initial route,
|
|
43
|
+
// and Adw.NavigationView's replace on an empty view (which is a push).
|
|
44
|
+
// A tag appears at most once in the stack (Adw hard-errors on
|
|
45
|
+
// duplicates): replacing to a page already in the back stack pops to it.
|
|
31
46
|
//
|
|
32
47
|
// No host imports here: the adapter arrives duck-typed via attach(),
|
|
33
48
|
// keeping this module loadable in any entry (single-screen entries pull
|
|
@@ -155,6 +170,9 @@ export class StackNavigator {
|
|
|
155
170
|
/** Monotonic navigation sequence — a loader that resolves after a NEWER
|
|
156
171
|
* navigation started must not perform its (stale) host action. */
|
|
157
172
|
private navSeq = 0;
|
|
173
|
+
/** Navigations that arrived while no host was attached — flushed FIFO on
|
|
174
|
+
* attach() so deep links survive mount ordering and remount cycles. */
|
|
175
|
+
private pendingNavigations: Array<{ href: Href; mode: "push" | "replace" }> = [];
|
|
158
176
|
private generation = 0;
|
|
159
177
|
private snapshot: NavSnapshot = {
|
|
160
178
|
pathname: "/",
|
|
@@ -173,6 +191,7 @@ export class StackNavigator {
|
|
|
173
191
|
this.routes = defs.map(compileRoute);
|
|
174
192
|
this.visitedTags = new Set();
|
|
175
193
|
this.loaderDataByTag = new Map();
|
|
194
|
+
this.pendingNavigations = [];
|
|
176
195
|
const root = defs[0];
|
|
177
196
|
if (root) {
|
|
178
197
|
this.stackTags = [root.tag];
|
|
@@ -194,6 +213,19 @@ export class StackNavigator {
|
|
|
194
213
|
return this.loaderDataByTag.get(tag);
|
|
195
214
|
}
|
|
196
215
|
|
|
216
|
+
/** Seed a restored route's params/pathname BEFORE the first React render
|
|
217
|
+
* (no host action, no stack mutation). The navigation host applies
|
|
218
|
+
* `initialHref` from its attach EFFECT, which runs after the root page's
|
|
219
|
+
* content already rendered once — a restored route's query params would
|
|
220
|
+
* miss any mount-time state seeding (e.g. a list initializing filters
|
|
221
|
+
* from useLocalSearchParams). Hosts call this while lazily initializing,
|
|
222
|
+
* when no subscribers exist yet, so the rebuild notifies nobody. */
|
|
223
|
+
seedInitialRoute(href: Href): void {
|
|
224
|
+
const hit = this.match(href);
|
|
225
|
+
if (!hit) return;
|
|
226
|
+
this.setRouteState(hit.route.tag, hit.pathname, hit.params);
|
|
227
|
+
}
|
|
228
|
+
|
|
197
229
|
/** Resolve a route's loader WITHOUT navigating. For the initial page:
|
|
198
230
|
* entries can `await stackNavigator.preload("/")` during top-level await
|
|
199
231
|
* (where GJS reliably drains promise jobs) so even the root page's first
|
|
@@ -211,10 +243,18 @@ export class StackNavigator {
|
|
|
211
243
|
}
|
|
212
244
|
|
|
213
245
|
/** Bind the live host stack (an Adw.NavigationView widget on GTK, a
|
|
214
|
-
* DomStackAdapter on DOM targets).
|
|
215
|
-
|
|
246
|
+
* DomStackAdapter on DOM targets). `initialHref` (state restore) applies
|
|
247
|
+
* as a replace BEFORE queued navigations flush: a deep link that arrived
|
|
248
|
+
* while unattached is fresher intent than a persisted route, so it lands
|
|
249
|
+
* on top. */
|
|
250
|
+
attach(host: NavStackAdapter, options?: { initialHref?: Href }): void {
|
|
216
251
|
this.host = host;
|
|
217
252
|
this.syncFromHost();
|
|
253
|
+
const initialHref = options?.initialHref;
|
|
254
|
+
if (initialHref !== undefined) this.replace(initialHref);
|
|
255
|
+
for (const { href, mode } of this.pendingNavigations.splice(0)) {
|
|
256
|
+
void this.navigate(href, mode);
|
|
257
|
+
}
|
|
218
258
|
}
|
|
219
259
|
|
|
220
260
|
detach(): void {
|
|
@@ -282,7 +322,12 @@ export class StackNavigator {
|
|
|
282
322
|
return;
|
|
283
323
|
}
|
|
284
324
|
if (!this.host) {
|
|
285
|
-
|
|
325
|
+
// No host YET — mount ordering (child effects run before the
|
|
326
|
+
// navigation host attaches) and remount cycles (StrictMode) both open
|
|
327
|
+
// this window. Queue and let attach() flush; loaders run at apply
|
|
328
|
+
// time so their data is fresh when the page actually shows.
|
|
329
|
+
this.pendingNavigations.push({ href, mode });
|
|
330
|
+
console.log(`[stack nav] ${mode} ${hrefToString(href)} — queued until a stack host attaches`);
|
|
286
331
|
return;
|
|
287
332
|
}
|
|
288
333
|
const { route, pathname, params } = hit;
|
|
@@ -303,10 +348,23 @@ export class StackNavigator {
|
|
|
303
348
|
this.visitedTags.add(route.tag);
|
|
304
349
|
this.setRouteState(route.tag, pathname, params);
|
|
305
350
|
if (mode === "replace") {
|
|
351
|
+
// Replace swaps the top NAVIGATION ENTRY (the layer above the
|
|
352
|
+
// permanent manifest root) — see the module design notes for the
|
|
353
|
+
// documented semantics + precedent.
|
|
306
354
|
const top = this.stackTags[this.stackTags.length - 1];
|
|
307
|
-
if (top === route.tag) return;
|
|
308
|
-
|
|
309
|
-
|
|
355
|
+
if (top === route.tag) return; // param-only refresh of the showing page
|
|
356
|
+
if (this.stackTags.length <= 1) {
|
|
357
|
+
// Fresh/empty stack: no navigation entry to discard — land above
|
|
358
|
+
// the root like a push so back affordances survive. replace (not
|
|
359
|
+
// push_by_tag) also materializes the root on a host whose real
|
|
360
|
+
// stack is still empty (GTK cold start).
|
|
361
|
+
this.host.replace_with_tags([...this.stackTags, route.tag]);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
// A tag may appear only once (Adw hard-errors on duplicates): drop an
|
|
365
|
+
// existing occurrence of the target from the back stack.
|
|
366
|
+
const below = this.stackTags.slice(0, -1).filter((tag) => tag !== route.tag);
|
|
367
|
+
this.host.replace_with_tags([...below, route.tag]);
|
|
310
368
|
return;
|
|
311
369
|
}
|
|
312
370
|
const idx = this.stackTags.indexOf(route.tag);
|
|
@@ -24,8 +24,9 @@ export interface DomRouteEntry extends RouteDef {
|
|
|
24
24
|
export interface DomNavigationHostProps {
|
|
25
25
|
routes: DomRouteEntry[];
|
|
26
26
|
/** Restore a previous route on mount (e.g. from vscode.getState()) —
|
|
27
|
-
* applied as a root
|
|
28
|
-
* semantics
|
|
27
|
+
* applied as a replace ABOVE the manifest root (see the replace
|
|
28
|
+
* semantics in navigator.ts), so a restored detail page keeps a working
|
|
29
|
+
* back affordance to the root instead of dead-ending. */
|
|
29
30
|
initialHref?: Href;
|
|
30
31
|
style?: CSSProperties;
|
|
31
32
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DomNavigationHost.d.ts","sourceRoot":"","sources":["../../src/seam/DomNavigationHost.tsx"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEtD,OAAO,EAIL,KAAK,IAAI,EACT,KAAK,eAAe,EACpB,KAAK,QAAQ,EACd,MAAM,aAAa,CAAC;AAErB;;qBAEqB;AACrB,qBAAa,eAAgB,YAAW,eAAe;IACrD,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAa;gBAE3B,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,IAAI;IAKlD,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAK9B,GAAG,IAAI,OAAO;IAOd,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAQhC,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;IAKvC,oBAAoB,IAAI;QACtB,WAAW,IAAI,MAAM,CAAC;QACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG;YAAE,OAAO,IAAI,MAAM,GAAG,IAAI,CAAA;SAAE,GAAG,IAAI,CAAC;KAC9D;CAQF;AAED,MAAM,WAAW,aAAc,SAAQ,QAAQ;IAC7C,OAAO,EAAE,SAAS,CAAC;CACpB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,aAAa,EAAE,CAAC;IACxB
|
|
1
|
+
{"version":3,"file":"DomNavigationHost.d.ts","sourceRoot":"","sources":["../../src/seam/DomNavigationHost.tsx"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEtD,OAAO,EAIL,KAAK,IAAI,EACT,KAAK,eAAe,EACpB,KAAK,QAAQ,EACd,MAAM,aAAa,CAAC;AAErB;;qBAEqB;AACrB,qBAAa,eAAgB,YAAW,eAAe;IACrD,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAa;gBAE3B,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,IAAI;IAKlD,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAK9B,GAAG,IAAI,OAAO;IAOd,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAQhC,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;IAKvC,oBAAoB,IAAI;QACtB,WAAW,IAAI,MAAM,CAAC;QACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG;YAAE,OAAO,IAAI,MAAM,GAAG,IAAI,CAAA;SAAE,GAAG,IAAI,CAAC;KAC9D;CAQF;AAED,MAAM,WAAW,aAAc,SAAQ,QAAQ;IAC7C,OAAO,EAAE,SAAS,CAAC;CACpB;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,aAAa,EAAE,CAAC;IACxB;;;8DAG0D;IAC1D,WAAW,CAAC,EAAE,IAAI,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;CACvB;AAmDD,wBAAgB,iBAAiB,CAAC,EAChC,MAAM,EACN,WAAW,EACX,KAAK,GACN,EAAE,sBAAsB,GAAG,SAAS,CA8DpC"}
|
|
@@ -74,6 +74,9 @@ export declare class StackNavigator {
|
|
|
74
74
|
/** Monotonic navigation sequence — a loader that resolves after a NEWER
|
|
75
75
|
* navigation started must not perform its (stale) host action. */
|
|
76
76
|
private navSeq;
|
|
77
|
+
/** Navigations that arrived while no host was attached — flushed FIFO on
|
|
78
|
+
* attach() so deep links survive mount ordering and remount cycles. */
|
|
79
|
+
private pendingNavigations;
|
|
77
80
|
private generation;
|
|
78
81
|
private snapshot;
|
|
79
82
|
get isConfigured(): boolean;
|
|
@@ -84,14 +87,27 @@ export declare class StackNavigator {
|
|
|
84
87
|
/** The route's last loader result (stable identity until the loader runs
|
|
85
88
|
* again). undefined when the route has no loader / hasn't resolved. */
|
|
86
89
|
loaderSnapshot(tag: string | null): unknown;
|
|
90
|
+
/** Seed a restored route's params/pathname BEFORE the first React render
|
|
91
|
+
* (no host action, no stack mutation). The navigation host applies
|
|
92
|
+
* `initialHref` from its attach EFFECT, which runs after the root page's
|
|
93
|
+
* content already rendered once — a restored route's query params would
|
|
94
|
+
* miss any mount-time state seeding (e.g. a list initializing filters
|
|
95
|
+
* from useLocalSearchParams). Hosts call this while lazily initializing,
|
|
96
|
+
* when no subscribers exist yet, so the rebuild notifies nobody. */
|
|
97
|
+
seedInitialRoute(href: Href): void;
|
|
87
98
|
/** Resolve a route's loader WITHOUT navigating. For the initial page:
|
|
88
99
|
* entries can `await stackNavigator.preload("/")` during top-level await
|
|
89
100
|
* (where GJS reliably drains promise jobs) so even the root page's first
|
|
90
101
|
* paint has loader data. */
|
|
91
102
|
preload(href: Href): Promise<void>;
|
|
92
103
|
/** Bind the live host stack (an Adw.NavigationView widget on GTK, a
|
|
93
|
-
* DomStackAdapter on DOM targets).
|
|
94
|
-
|
|
104
|
+
* DomStackAdapter on DOM targets). `initialHref` (state restore) applies
|
|
105
|
+
* as a replace BEFORE queued navigations flush: a deep link that arrived
|
|
106
|
+
* while unattached is fresher intent than a persisted route, so it lands
|
|
107
|
+
* on top. */
|
|
108
|
+
attach(host: NavStackAdapter, options?: {
|
|
109
|
+
initialHref?: Href;
|
|
110
|
+
}): void;
|
|
95
111
|
detach(): void;
|
|
96
112
|
subscribe: (fn: () => void) => (() => void);
|
|
97
113
|
getSnapshot: () => NavSnapshot;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"navigator.d.ts","sourceRoot":"","sources":["../../src/seam/navigator.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"navigator.d.ts","sourceRoot":"","sources":["../../src/seam/navigator.ts"],"names":[],"mappings":"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"}
|