@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.
Files changed (31) hide show
  1. package/dist/cjs/seam/DomNavigationHost.cjs +7 -4
  2. package/dist/cjs/seam/DomNavigationHost.native.js +11 -8
  3. package/dist/cjs/seam/DomNavigationHost.native.js.map +1 -1
  4. package/dist/cjs/seam/navigator.cjs +40 -5
  5. package/dist/cjs/seam/navigator.native.js +61 -5
  6. package/dist/cjs/seam/navigator.native.js.map +1 -1
  7. package/dist/esm/seam/DomNavigationHost.mjs +7 -4
  8. package/dist/esm/seam/DomNavigationHost.mjs.map +1 -1
  9. package/dist/esm/seam/DomNavigationHost.native.js +11 -8
  10. package/dist/esm/seam/DomNavigationHost.native.js.map +1 -1
  11. package/dist/esm/seam/navigator.mjs +40 -5
  12. package/dist/esm/seam/navigator.mjs.map +1 -1
  13. package/dist/esm/seam/navigator.native.js +61 -5
  14. package/dist/esm/seam/navigator.native.js.map +1 -1
  15. package/dist/jsx/seam/DomNavigationHost.mjs +7 -4
  16. package/dist/jsx/seam/DomNavigationHost.mjs.map +1 -1
  17. package/dist/jsx/seam/DomNavigationHost.native.js +11 -8
  18. package/dist/jsx/seam/DomNavigationHost.native.js.map +1 -1
  19. package/dist/jsx/seam/navigator.mjs +40 -5
  20. package/dist/jsx/seam/navigator.mjs.map +1 -1
  21. package/dist/jsx/seam/navigator.native.js +61 -5
  22. package/dist/jsx/seam/navigator.native.js.map +1 -1
  23. package/package.json +5 -5
  24. package/src/seam/DomNavigationHost.spec.tsx +95 -4
  25. package/src/seam/DomNavigationHost.tsx +17 -8
  26. package/src/seam/navigator.spec.ts +140 -8
  27. package/src/seam/navigator.ts +64 -6
  28. package/types/seam/DomNavigationHost.d.ts +3 -2
  29. package/types/seam/DomNavigationHost.d.ts.map +1 -1
  30. package/types/seam/navigator.d.ts +18 -2
  31. 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": "6.7.0",
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": "6.7.0"
65
+ "@multiplatform.one/platform": "7.1.0"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@react-navigation/native": "^7.2.2",
69
- "@tamagui/build": "2.0.0-rc.41",
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/test-utils": "6.7.0",
81
- "@multiplatform.one/config": "6.7.0"
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 { beforeEach, describe, expect, it } from "vitest";
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 as a root replace", () => {
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: 1,
117
- canGoBack: false,
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 replace, matching MemoryRouter initialEntries
97
- * semantics (no back stack to it). */
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 ??= new DomStackAdapter(routes[0]?.tag ?? "", () =>
159
- stackNavigator.syncFromHost(),
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
- stackNavigator.attach(adapter);
169
- const restore = initialHrefRef.current;
170
- if (restore) stackNavigator.replace(restore);
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
- it("replace swaps the top of the stack", () => {
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("/todos");
90
+ stackNavigator.replace("/pokemon/mew");
85
91
  expect(stackNavigator.getSnapshot()).toMatchObject({
86
- tag: "todos",
87
- depth: 1,
88
- canGoBack: false,
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("ignores navigation without an attached host or matching route", () => {
93
- const log = vi.spyOn(console, "log").mockImplementation(() => {});
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
- expect(stackNavigator.getSnapshot().tag).toBe("home");
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(
@@ -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
- attach(host: NavStackAdapter): void {
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
- console.log(`[stack nav] ${mode} ${hrefToString(href)}no stack host attached, ignored`);
325
+ // No host YETmount 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
- const tags = [...this.stackTags.slice(0, -1), route.tag];
309
- this.host.replace_with_tags(tags);
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 replace, matching MemoryRouter initialEntries
28
- * semantics (no back stack to it). */
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;;2CAEuC;IACvC,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,CAsDpC"}
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
- attach(host: NavStackAdapter): void;
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":"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"}
1
+ {"version":3,"file":"navigator.d.ts","sourceRoot":"","sources":["../../src/seam/navigator.ts"],"names":[],"mappings":"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"}