@real-router/navigation-plugin 0.7.6 → 0.7.7
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/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/package.json +5 -6
- package/src/constants.ts +0 -19
- package/src/factory.ts +0 -64
- package/src/history-extensions.ts +0 -214
- package/src/href-utils.ts +0 -65
- package/src/index.ts +0 -65
- package/src/navigate-handler.ts +0 -260
- package/src/navigation-browser.ts +0 -92
- package/src/plugin.ts +0 -468
- package/src/ssr-fallback.ts +0 -48
- package/src/types.ts +0 -71
- package/src/validation.ts +0 -10
package/src/plugin.ts
DELETED
|
@@ -1,468 +0,0 @@
|
|
|
1
|
-
import { UNKNOWN_ROUTE } from "@real-router/core";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
shouldReplaceHistory,
|
|
5
|
-
buildUrl,
|
|
6
|
-
urlToPath,
|
|
7
|
-
createPluginBuildUrl,
|
|
8
|
-
createStartInterceptor,
|
|
9
|
-
createReplaceHistoryState,
|
|
10
|
-
encodeHashFragment,
|
|
11
|
-
getDecodedHash,
|
|
12
|
-
normalizeHashInput,
|
|
13
|
-
safeParseUrl,
|
|
14
|
-
decodeHashFragment,
|
|
15
|
-
} from "./browser-env";
|
|
16
|
-
import {
|
|
17
|
-
peekBack,
|
|
18
|
-
peekForward,
|
|
19
|
-
hasVisited,
|
|
20
|
-
getVisitedRoutes,
|
|
21
|
-
getRouteVisitCount,
|
|
22
|
-
findLastEntryForRoute,
|
|
23
|
-
resolveEntryToMatchedState,
|
|
24
|
-
canGoBack,
|
|
25
|
-
canGoForward,
|
|
26
|
-
canGoBackTo,
|
|
27
|
-
} from "./history-extensions";
|
|
28
|
-
import { isSameHref } from "./href-utils";
|
|
29
|
-
import { createNavigateHandler } from "./navigate-handler";
|
|
30
|
-
|
|
31
|
-
import type { UrlContext } from "./browser-env";
|
|
32
|
-
import type {
|
|
33
|
-
NavigationBrowser,
|
|
34
|
-
NavigationMeta,
|
|
35
|
-
NavigationPluginOptions,
|
|
36
|
-
NavigationSharedState,
|
|
37
|
-
} from "./types";
|
|
38
|
-
import type {
|
|
39
|
-
NavigationOptions,
|
|
40
|
-
Router,
|
|
41
|
-
State,
|
|
42
|
-
Plugin,
|
|
43
|
-
} from "@real-router/core";
|
|
44
|
-
import type { PluginApi } from "@real-router/core/api";
|
|
45
|
-
|
|
46
|
-
export function deriveNavigationType(
|
|
47
|
-
navOptions: NavigationOptions,
|
|
48
|
-
toState: State,
|
|
49
|
-
fromState: State | undefined,
|
|
50
|
-
): NavigationMeta["navigationType"] {
|
|
51
|
-
if (navOptions.reload && toState.path === fromState?.path) {
|
|
52
|
-
return "reload";
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (shouldReplaceHistory(navOptions, toState, fromState)) {
|
|
56
|
-
return "replace";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return "push";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export class NavigationPlugin {
|
|
63
|
-
readonly #router: Router;
|
|
64
|
-
readonly #api: PluginApi;
|
|
65
|
-
readonly #options: Required<NavigationPluginOptions>;
|
|
66
|
-
readonly #browser: NavigationBrowser;
|
|
67
|
-
readonly #removeStartInterceptor: () => void;
|
|
68
|
-
readonly #removeExtensions: () => void;
|
|
69
|
-
readonly #claim: {
|
|
70
|
-
write: (state: State, value: NavigationMeta) => void;
|
|
71
|
-
release: () => void;
|
|
72
|
-
};
|
|
73
|
-
readonly #urlClaim: {
|
|
74
|
-
write: (state: State, value: UrlContext) => void;
|
|
75
|
-
release: () => void;
|
|
76
|
-
};
|
|
77
|
-
readonly #lifecycle: Pick<Plugin, "onStart" | "onStop" | "teardown">;
|
|
78
|
-
|
|
79
|
-
#capturedMeta: NavigationMeta | undefined;
|
|
80
|
-
#pendingTraverseKey: string | undefined;
|
|
81
|
-
// Always set together with #pendingTraverseKey; `""` means "destination has
|
|
82
|
-
// no fragment". Typed as `string` (not `string | undefined`) so the traverse
|
|
83
|
-
// branch reads it without a redundant `?? ""` fallback that coverage cannot
|
|
84
|
-
// exercise.
|
|
85
|
-
#pendingTraverseHash = "";
|
|
86
|
-
// Reusable buffer for the {name, params, path} payload passed to
|
|
87
|
-
// browser.navigate / browser.updateCurrentEntry. The Navigation API
|
|
88
|
-
// structured-clones state synchronously inside the call, so this object
|
|
89
|
-
// never escapes — same trick createReplaceHistoryState uses.
|
|
90
|
-
readonly #historyStateBuffer: { name: string; params: object; path: string } =
|
|
91
|
-
{
|
|
92
|
-
name: "",
|
|
93
|
-
params: {},
|
|
94
|
-
path: "",
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
constructor(
|
|
98
|
-
router: Router,
|
|
99
|
-
api: PluginApi,
|
|
100
|
-
options: Required<NavigationPluginOptions>,
|
|
101
|
-
browser: NavigationBrowser,
|
|
102
|
-
transitionOptions: {
|
|
103
|
-
source: string;
|
|
104
|
-
replace: true;
|
|
105
|
-
forceDeactivate?: boolean;
|
|
106
|
-
},
|
|
107
|
-
shared: NavigationSharedState,
|
|
108
|
-
) {
|
|
109
|
-
this.#router = router;
|
|
110
|
-
this.#api = api;
|
|
111
|
-
this.#options = options;
|
|
112
|
-
// The navigate handler short-circuits re-entrant events from plugin-
|
|
113
|
-
// initiated writes by checking `event.info === PLUGIN_SYNC_INFO`. The
|
|
114
|
-
// built-in `createNavigationBrowser` tags every mutation with that
|
|
115
|
-
// sentinel; consumer-supplied browsers must do the same — see CLAUDE.md
|
|
116
|
-
// "Router-driven mutations re-enter the navigate handler".
|
|
117
|
-
this.#browser = browser;
|
|
118
|
-
|
|
119
|
-
this.#claim = api.claimContextNamespace("navigation");
|
|
120
|
-
this.#urlClaim = api.claimContextNamespace("url");
|
|
121
|
-
this.#removeStartInterceptor = createStartInterceptor(api, this.#browser);
|
|
122
|
-
|
|
123
|
-
// Cross-document load priming (#531). On F5, browser back/forward across
|
|
124
|
-
// a page boundary, or a fresh URL bar entry, the prior JS context is
|
|
125
|
-
// discarded — the navigate event handler never sees the activation.
|
|
126
|
-
// Without this, deriveNavigationType in onTransitionSuccess falls through
|
|
127
|
-
// to "replace" for every initial transition, breaking scroll restore on
|
|
128
|
-
// reload (#497) and any consumer branching on navigationType.
|
|
129
|
-
// navigation.activation reflects the cross-document navigation that
|
|
130
|
-
// activated this document; it stays constant across same-document
|
|
131
|
-
// navigations, so this only affects the FIRST transition.
|
|
132
|
-
const activationType = this.#browser.getActivationType();
|
|
133
|
-
|
|
134
|
-
if (activationType) {
|
|
135
|
-
this.#capturedMeta = {
|
|
136
|
-
navigationType: activationType,
|
|
137
|
-
userInitiated: false,
|
|
138
|
-
direction: activationType === "push" ? "forward" : "unknown",
|
|
139
|
-
sourceElement: null,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Hash for the first transition (#532) is read lazily inside
|
|
144
|
-
// onTransitionSuccess via `getDecodedHash(browser)` — capturing in the
|
|
145
|
-
// constructor is too eager (in tests, the mock URL is set after the
|
|
146
|
-
// plugin is constructed). The lazy read still covers F5 / fresh URL
|
|
147
|
-
// bar entry: by the time onTransitionSuccess fires the browser already
|
|
148
|
-
// reflects the destination URL.
|
|
149
|
-
|
|
150
|
-
const pluginBuildUrl = createPluginBuildUrl(router, options.base);
|
|
151
|
-
|
|
152
|
-
this.#removeExtensions = api.extendRouter({
|
|
153
|
-
buildUrl: pluginBuildUrl,
|
|
154
|
-
matchUrl: (url: string) =>
|
|
155
|
-
api.matchPath(urlToPath(url, options.base)) ?? undefined,
|
|
156
|
-
replaceHistoryState: createReplaceHistoryState(
|
|
157
|
-
api,
|
|
158
|
-
router,
|
|
159
|
-
this.#browser,
|
|
160
|
-
pluginBuildUrl,
|
|
161
|
-
),
|
|
162
|
-
|
|
163
|
-
peekBack: () => peekBack(this.#browser, api, options.base),
|
|
164
|
-
peekForward: () => peekForward(this.#browser, api, options.base),
|
|
165
|
-
hasVisited: (routeName: string) =>
|
|
166
|
-
hasVisited(this.#browser, api, options.base, routeName),
|
|
167
|
-
getVisitedRoutes: () =>
|
|
168
|
-
getVisitedRoutes(this.#browser, api, options.base),
|
|
169
|
-
getRouteVisitCount: (routeName: string) =>
|
|
170
|
-
getRouteVisitCount(this.#browser, api, options.base, routeName),
|
|
171
|
-
traverseToLast: (routeName: string) => this.traverseToLast(routeName),
|
|
172
|
-
canGoBack: () => canGoBack(this.#browser),
|
|
173
|
-
canGoForward: () => canGoForward(this.#browser),
|
|
174
|
-
canGoBackTo: (routeName: string) =>
|
|
175
|
-
canGoBackTo(this.#browser, api, options.base, routeName),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const handler = createNavigateHandler({
|
|
179
|
-
router,
|
|
180
|
-
api,
|
|
181
|
-
browser: this.#browser,
|
|
182
|
-
setCapturedMeta: (meta) => {
|
|
183
|
-
this.#capturedMeta = meta;
|
|
184
|
-
},
|
|
185
|
-
base: options.base,
|
|
186
|
-
transitionOptions,
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
this.#lifecycle = createNavigateLifecycle({
|
|
190
|
-
browser: this.#browser,
|
|
191
|
-
shared,
|
|
192
|
-
handler,
|
|
193
|
-
removeStartInterceptor: this.#removeStartInterceptor,
|
|
194
|
-
removeExtensions: this.#removeExtensions,
|
|
195
|
-
releaseClaim: () => {
|
|
196
|
-
this.#claim.release();
|
|
197
|
-
this.#urlClaim.release();
|
|
198
|
-
},
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async traverseToLast(routeName: string): Promise<State> {
|
|
203
|
-
const entries = this.#browser.entries();
|
|
204
|
-
const currentKey = this.#browser.currentEntry?.key;
|
|
205
|
-
const candidate = findLastEntryForRoute(
|
|
206
|
-
entries,
|
|
207
|
-
routeName,
|
|
208
|
-
this.#api,
|
|
209
|
-
this.#options.base,
|
|
210
|
-
currentKey,
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
// resolveEntryToMatchedState throws for missing entry, null url, or
|
|
214
|
-
// unmatched url — same three error branches the old inline checks
|
|
215
|
-
// produced. Extracted so the error paths can be unit-tested directly
|
|
216
|
-
// without namespace-level vi.spyOn gymnastics.
|
|
217
|
-
const { entry, entryUrl, matchedState } = resolveEntryToMatchedState(
|
|
218
|
-
candidate,
|
|
219
|
-
routeName,
|
|
220
|
-
this.#api,
|
|
221
|
-
this.#options.base,
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
const currentEntry = this.#browser.currentEntry;
|
|
225
|
-
|
|
226
|
-
if (!currentEntry) {
|
|
227
|
-
// Invariant violation: traverseToLast is only callable after
|
|
228
|
-
// router.start(), which guarantees a current entry. A null here means
|
|
229
|
-
// the plugin was stopped mid-call or the browser abstraction is
|
|
230
|
-
// broken — either way, silently picking direction "forward" from a
|
|
231
|
-
// fallback `-1` would mask the bug. Fail loudly instead.
|
|
232
|
-
throw new Error(
|
|
233
|
-
`[navigation-plugin] Cannot determine direction for traverseToLast("${routeName}"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`,
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
this.#capturedMeta = {
|
|
238
|
-
navigationType: "traverse",
|
|
239
|
-
userInitiated: false,
|
|
240
|
-
direction: entry.index > currentEntry.index ? "forward" : "back",
|
|
241
|
-
sourceElement: null,
|
|
242
|
-
};
|
|
243
|
-
this.#pendingTraverseKey = entry.key;
|
|
244
|
-
// Capture the destination entry's hash so onTransitionSuccess can populate
|
|
245
|
-
// state.context.url for the traverse branch — mirrors what navigate-handler
|
|
246
|
-
// does via navOptions.hash for browser-initiated navigation.
|
|
247
|
-
this.#pendingTraverseHash = extractHashFromEntryUrl(entryUrl);
|
|
248
|
-
|
|
249
|
-
return this.#router.navigate(matchedState.name, matchedState.params);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
getPlugin(): Plugin {
|
|
253
|
-
return {
|
|
254
|
-
...this.#lifecycle,
|
|
255
|
-
|
|
256
|
-
onTransitionStart: (toState: State) => {
|
|
257
|
-
if (this.#capturedMeta) {
|
|
258
|
-
this.#claim.write(toState, this.#capturedMeta);
|
|
259
|
-
}
|
|
260
|
-
},
|
|
261
|
-
|
|
262
|
-
onTransitionSuccess: (
|
|
263
|
-
toState: State,
|
|
264
|
-
fromState: State | undefined,
|
|
265
|
-
navOptions: NavigationOptions,
|
|
266
|
-
) => {
|
|
267
|
-
if (!this.#capturedMeta) {
|
|
268
|
-
const navigationType = deriveNavigationType(
|
|
269
|
-
navOptions,
|
|
270
|
-
toState,
|
|
271
|
-
fromState,
|
|
272
|
-
);
|
|
273
|
-
|
|
274
|
-
this.#capturedMeta = {
|
|
275
|
-
navigationType,
|
|
276
|
-
userInitiated: false,
|
|
277
|
-
direction: navigationType === "push" ? "forward" : "unknown",
|
|
278
|
-
sourceElement: null,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const frozenMeta = Object.freeze(this.#capturedMeta);
|
|
283
|
-
|
|
284
|
-
this.#claim.write(toState, frozenMeta);
|
|
285
|
-
this.#capturedMeta = undefined;
|
|
286
|
-
|
|
287
|
-
// Consume pendingTraverseKey BEFORE calling browser.traverseTo.
|
|
288
|
-
// If traverseTo throws (Navigation API can reject on evicted keys
|
|
289
|
-
// under memory pressure), we must not leave the stale key behind —
|
|
290
|
-
// otherwise the NEXT transition's onTransitionSuccess would see it
|
|
291
|
-
// and replay the traverse against the same already-broken key.
|
|
292
|
-
const traverseKey = this.#pendingTraverseKey;
|
|
293
|
-
const traverseHash = this.#pendingTraverseHash;
|
|
294
|
-
|
|
295
|
-
this.#pendingTraverseKey = undefined;
|
|
296
|
-
this.#pendingTraverseHash = "";
|
|
297
|
-
|
|
298
|
-
const publishedPrevHash = readPublishedHash(fromState);
|
|
299
|
-
|
|
300
|
-
if (traverseKey) {
|
|
301
|
-
// Mirror the urlClaim.write the `else` branch does for non-traverse
|
|
302
|
-
// navigations — without this, `router.traverseToLast(name)` leaves
|
|
303
|
-
// state.context.url undefined for subscribers (#urlClaim was set in
|
|
304
|
-
// navigate-handler for browser-driven traverse, but programmatic
|
|
305
|
-
// traverseToLast bypasses that path).
|
|
306
|
-
this.#urlClaim.write(
|
|
307
|
-
toState,
|
|
308
|
-
Object.freeze({
|
|
309
|
-
hash: traverseHash,
|
|
310
|
-
hashChanged: traverseHash !== publishedPrevHash,
|
|
311
|
-
}),
|
|
312
|
-
);
|
|
313
|
-
this.#browser.traverseTo(traverseKey);
|
|
314
|
-
} else {
|
|
315
|
-
// Tri-state hash resolution (#532).
|
|
316
|
-
// navOptions.hash === undefined → preserve current browser hash
|
|
317
|
-
// navOptions.hash === "" → explicitly clear
|
|
318
|
-
// navOptions.hash === "value" → explicitly set
|
|
319
|
-
//
|
|
320
|
-
// The "preserve" branch reads location.hash from the browser, not
|
|
321
|
-
// fromState.context.url.hash — this captures dynamic fragment
|
|
322
|
-
// changes the user makes outside the plugin (anchor clicks,
|
|
323
|
-
// manual location.hash assignment) instead of replaying the
|
|
324
|
-
// last-published value.
|
|
325
|
-
//
|
|
326
|
-
// hashChanged compares the chosen hash against the *published*
|
|
327
|
-
// previous hash (fromState.context.url.hash), so subscribers see
|
|
328
|
-
// a true signal regardless of whether the value came from
|
|
329
|
-
// navOptions or the browser.
|
|
330
|
-
const browserHash = getDecodedHash(this.#browser);
|
|
331
|
-
|
|
332
|
-
const hash =
|
|
333
|
-
navOptions.hash === undefined
|
|
334
|
-
? browserHash
|
|
335
|
-
: normalizeHashInput(navOptions.hash);
|
|
336
|
-
|
|
337
|
-
this.#urlClaim.write(
|
|
338
|
-
toState,
|
|
339
|
-
Object.freeze({
|
|
340
|
-
hash,
|
|
341
|
-
hashChanged: navOptions.hashChange ?? hash !== publishedPrevHash,
|
|
342
|
-
}),
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
const url = buildUrl(toState.path, this.#options.base);
|
|
346
|
-
const finalUrl = hash ? `${url}#${encodeHashFragment(hash)}` : url;
|
|
347
|
-
|
|
348
|
-
this.#historyStateBuffer.name = toState.name;
|
|
349
|
-
this.#historyStateBuffer.params = toState.params;
|
|
350
|
-
this.#historyStateBuffer.path = toState.path;
|
|
351
|
-
|
|
352
|
-
// Two cases route through `updateCurrentEntry` (state-only mutation
|
|
353
|
-
// of the current history entry, no navigate event):
|
|
354
|
-
//
|
|
355
|
-
// 1. UNKNOWN_ROUTE — URL stays as the browser had it; we only need
|
|
356
|
-
// to tag the entry's state with the router's `name/params/path`.
|
|
357
|
-
// 2. Same-URL transition (#580) — the target URL is what the
|
|
358
|
-
// browser already shows, so a `nav.navigate(url,
|
|
359
|
-
// {history:"replace"})` would either be a no-op (Chromium fires
|
|
360
|
-
// a navigate event we short-circuit via `event.info ===
|
|
361
|
-
// PLUGIN_SYNC_INFO`) or — on Safari 26.2 WKWebView under custom
|
|
362
|
-
// protocols (`tauri://`, `app://`) — a *cross-document*
|
|
363
|
-
// navigation that discards the JS context. The bootstrap then
|
|
364
|
-
// re-runs the plugin which re-issues the same call, and the
|
|
365
|
-
// cycle becomes a render loop the user perceives as flicker.
|
|
366
|
-
// `updateCurrentEntry` is the spec-correct primitive for a
|
|
367
|
-
// state-only mutation and avoids both behaviours.
|
|
368
|
-
if (
|
|
369
|
-
toState.name === UNKNOWN_ROUTE ||
|
|
370
|
-
isSameHref(finalUrl, this.#browser.currentEntry?.url)
|
|
371
|
-
) {
|
|
372
|
-
this.#browser.updateCurrentEntry({
|
|
373
|
-
state: this.#historyStateBuffer,
|
|
374
|
-
});
|
|
375
|
-
} else {
|
|
376
|
-
// Initial transition (no fromState) means router.start() is
|
|
377
|
-
// resolving the cross-document load — the browser already created
|
|
378
|
-
// a history entry for it. A `push` here would duplicate that
|
|
379
|
-
// entry. Always `replace` on the first transition so the
|
|
380
|
-
// back/forward stack has only one entry (canGoBack === false).
|
|
381
|
-
// navigationType metadata stays "push"/"reload"/"replace" for
|
|
382
|
-
// downstream consumers (scroll restore, direction tracker).
|
|
383
|
-
const isInitialTransition = fromState === undefined;
|
|
384
|
-
const replace =
|
|
385
|
-
frozenMeta.navigationType !== "push" || isInitialTransition;
|
|
386
|
-
|
|
387
|
-
this.#browser.navigate(finalUrl, {
|
|
388
|
-
state: this.#historyStateBuffer,
|
|
389
|
-
history: replace ? "replace" : "push",
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
},
|
|
394
|
-
|
|
395
|
-
onTransitionCancel: () => {
|
|
396
|
-
this.#capturedMeta = undefined;
|
|
397
|
-
this.#pendingTraverseKey = undefined;
|
|
398
|
-
this.#pendingTraverseHash = "";
|
|
399
|
-
},
|
|
400
|
-
|
|
401
|
-
onTransitionError: () => {
|
|
402
|
-
this.#capturedMeta = undefined;
|
|
403
|
-
this.#pendingTraverseKey = undefined;
|
|
404
|
-
this.#pendingTraverseHash = "";
|
|
405
|
-
},
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
interface NavigateLifecycleDeps {
|
|
411
|
-
browser: NavigationBrowser;
|
|
412
|
-
handler: (event: NavigateEvent) => void;
|
|
413
|
-
removeStartInterceptor: () => void;
|
|
414
|
-
removeExtensions: () => void;
|
|
415
|
-
releaseClaim: () => void;
|
|
416
|
-
shared: NavigationSharedState;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/**
|
|
420
|
-
* Reads the previously published hash from `fromState.context.url`.
|
|
421
|
-
* Returns `""` for the initial transition (no `fromState`), for states whose
|
|
422
|
-
* `context.url` namespace was not claimed yet, or for the documented `{ hash:
|
|
423
|
-
* "" }` cleared form. Extracted from `onTransitionSuccess` to share between
|
|
424
|
-
* the traverse and non-traverse branches.
|
|
425
|
-
*/
|
|
426
|
-
function readPublishedHash(fromState: State | undefined): string {
|
|
427
|
-
return (
|
|
428
|
-
(fromState?.context as { url?: { hash?: string } } | undefined)?.url
|
|
429
|
-
?.hash ?? ""
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Decodes the URL fragment from a NavigationHistoryEntry's url string.
|
|
435
|
-
* Returns `""` when no fragment is present. The caller (NavigationPlugin's
|
|
436
|
-
* `traverseToLast`) only reaches here AFTER `resolveEntryToMatchedState`,
|
|
437
|
-
* which has already rejected `entry.url === null`, so the input is guaranteed
|
|
438
|
-
* non-null at runtime.
|
|
439
|
-
*/
|
|
440
|
-
function extractHashFromEntryUrl(entryUrl: string): string {
|
|
441
|
-
const rawHash = safeParseUrl(entryUrl).hash;
|
|
442
|
-
|
|
443
|
-
return rawHash ? decodeHashFragment(rawHash.slice(1)) : "";
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {
|
|
447
|
-
return {
|
|
448
|
-
onStart() {
|
|
449
|
-
deps.shared.removeNavigateListener?.();
|
|
450
|
-
deps.shared.removeNavigateListener = deps.browser.addNavigateListener(
|
|
451
|
-
deps.handler,
|
|
452
|
-
);
|
|
453
|
-
},
|
|
454
|
-
|
|
455
|
-
onStop() {
|
|
456
|
-
deps.shared.removeNavigateListener?.();
|
|
457
|
-
deps.shared.removeNavigateListener = undefined;
|
|
458
|
-
},
|
|
459
|
-
|
|
460
|
-
teardown() {
|
|
461
|
-
deps.shared.removeNavigateListener?.();
|
|
462
|
-
deps.shared.removeNavigateListener = undefined;
|
|
463
|
-
deps.removeStartInterceptor();
|
|
464
|
-
deps.removeExtensions();
|
|
465
|
-
deps.releaseClaim();
|
|
466
|
-
},
|
|
467
|
-
};
|
|
468
|
-
}
|
package/src/ssr-fallback.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { createWarnOnce } from "./browser-env";
|
|
2
|
-
|
|
3
|
-
import type { NavigationBrowser } from "./types";
|
|
4
|
-
|
|
5
|
-
const NOOP = (): void => {};
|
|
6
|
-
|
|
7
|
-
export const createNavigationFallbackBrowser = (
|
|
8
|
-
context: string,
|
|
9
|
-
): NavigationBrowser => {
|
|
10
|
-
const warnOnce = createWarnOnce(context);
|
|
11
|
-
|
|
12
|
-
return {
|
|
13
|
-
getLocation: () => {
|
|
14
|
-
warnOnce("getLocation");
|
|
15
|
-
|
|
16
|
-
return "/";
|
|
17
|
-
},
|
|
18
|
-
getHash: () => {
|
|
19
|
-
warnOnce("getHash");
|
|
20
|
-
|
|
21
|
-
return "";
|
|
22
|
-
},
|
|
23
|
-
navigate: () => {
|
|
24
|
-
warnOnce("navigate");
|
|
25
|
-
},
|
|
26
|
-
replaceState: () => {
|
|
27
|
-
warnOnce("replaceState");
|
|
28
|
-
},
|
|
29
|
-
updateCurrentEntry: () => {
|
|
30
|
-
warnOnce("updateCurrentEntry");
|
|
31
|
-
},
|
|
32
|
-
traverseTo: () => {
|
|
33
|
-
warnOnce("traverseTo");
|
|
34
|
-
},
|
|
35
|
-
addNavigateListener: () => {
|
|
36
|
-
warnOnce("addNavigateListener");
|
|
37
|
-
|
|
38
|
-
return NOOP;
|
|
39
|
-
},
|
|
40
|
-
entries: () => {
|
|
41
|
-
warnOnce("entries");
|
|
42
|
-
|
|
43
|
-
return [];
|
|
44
|
-
},
|
|
45
|
-
currentEntry: null,
|
|
46
|
-
getActivationType: () => undefined,
|
|
47
|
-
};
|
|
48
|
-
};
|
package/src/types.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Navigation plugin configuration.
|
|
3
|
-
* Same options as browser-plugin — plugins are interchangeable.
|
|
4
|
-
*/
|
|
5
|
-
export interface NavigationPluginOptions {
|
|
6
|
-
/**
|
|
7
|
-
* Bypass canDeactivate guards on browser back/forward.
|
|
8
|
-
*
|
|
9
|
-
* @default false
|
|
10
|
-
*/
|
|
11
|
-
forceDeactivate?: boolean;
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Base path for all routes (e.g., "/app" for hosted at /app/).
|
|
15
|
-
*
|
|
16
|
-
* @default ""
|
|
17
|
-
*/
|
|
18
|
-
base?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Browser abstraction over Navigation API.
|
|
23
|
-
* Replaces History API's Browser interface with Navigation API equivalents.
|
|
24
|
-
*/
|
|
25
|
-
export interface NavigationBrowser {
|
|
26
|
-
getLocation: () => string;
|
|
27
|
-
getHash: () => string;
|
|
28
|
-
navigate: (
|
|
29
|
-
url: string,
|
|
30
|
-
options: { state: unknown; history: "push" | "replace" },
|
|
31
|
-
) => void;
|
|
32
|
-
replaceState: (state: unknown, url: string) => void;
|
|
33
|
-
updateCurrentEntry: (options: { state: unknown }) => void;
|
|
34
|
-
traverseTo: (key: string) => void;
|
|
35
|
-
addNavigateListener: (fn: (evt: NavigateEvent) => void) => () => void;
|
|
36
|
-
entries: () => NavigationHistoryEntry[];
|
|
37
|
-
currentEntry: NavigationHistoryEntry | null;
|
|
38
|
-
/**
|
|
39
|
-
* Type of the cross-document navigation that activated this document.
|
|
40
|
-
* Reads `navigation.activation.navigationType` (Baseline 2026 — Chrome 123+, Firefox 147+, Safari 26.2+).
|
|
41
|
-
* Returns `undefined` when activation is unavailable (older browsers, SSR).
|
|
42
|
-
*/
|
|
43
|
-
getActivationType: () => NavigationMeta["navigationType"] | undefined;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Shared mutable state across plugin instances created by the same factory.
|
|
48
|
-
* Enables cleanup of a previous instance's navigate listener when the factory is reused.
|
|
49
|
-
*/
|
|
50
|
-
export interface NavigationSharedState {
|
|
51
|
-
removeNavigateListener: (() => void) | undefined;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export type NavigationDirection = "forward" | "back" | "unknown";
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Navigation metadata attached to State via state.context.navigation.
|
|
58
|
-
* Available in subscribe callbacks and components after transition completes.
|
|
59
|
-
*/
|
|
60
|
-
export interface NavigationMeta {
|
|
61
|
-
/** Type of navigation: push, replace, traverse, or reload */
|
|
62
|
-
navigationType: "push" | "replace" | "traverse" | "reload";
|
|
63
|
-
/** Whether the navigation was initiated by the user (back/forward button, link click) */
|
|
64
|
-
userInitiated: boolean;
|
|
65
|
-
/** Ephemeral info passed via navigation.navigate({ info }) — lost on page reload */
|
|
66
|
-
info?: unknown;
|
|
67
|
-
/** Direction of navigation in the history stack */
|
|
68
|
-
direction: NavigationDirection;
|
|
69
|
-
/** The DOM element that initiated the navigation (e.g., anchor tag), or null for programmatic */
|
|
70
|
-
sourceElement: Element | null;
|
|
71
|
-
}
|
package/src/validation.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { createOptionsValidator, safeBaseRule } from "./browser-env";
|
|
2
|
-
import { LOGGER_CONTEXT, defaultOptions } from "./constants";
|
|
3
|
-
|
|
4
|
-
import type { NavigationPluginOptions } from "./types";
|
|
5
|
-
|
|
6
|
-
export const validateOptions = createOptionsValidator<NavigationPluginOptions>(
|
|
7
|
-
defaultOptions,
|
|
8
|
-
LOGGER_CONTEXT,
|
|
9
|
-
{ base: safeBaseRule },
|
|
10
|
-
);
|