@real-router/navigation-plugin 0.0.1

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/src/plugin.ts ADDED
@@ -0,0 +1,298 @@
1
+ import { UNKNOWN_ROUTE } from "@real-router/core";
2
+ import {
3
+ shouldReplaceHistory,
4
+ buildUrl,
5
+ extractPath,
6
+ urlToPath,
7
+ } from "browser-env";
8
+
9
+ import { LOGGER_CONTEXT } from "./constants";
10
+ import {
11
+ peekBack,
12
+ peekForward,
13
+ hasVisited,
14
+ getVisitedRoutes,
15
+ getRouteVisitCount,
16
+ findLastEntryForRoute,
17
+ canGoBack,
18
+ canGoForward,
19
+ canGoBackTo,
20
+ } from "./history-extensions";
21
+ import { createNavigateHandler } from "./navigate-handler";
22
+ import {
23
+ createStartInterceptor,
24
+ createReplaceHistoryState,
25
+ } from "./plugin-utils";
26
+
27
+ import type {
28
+ NavigationBrowser,
29
+ NavigationMeta,
30
+ NavigationPluginOptions,
31
+ NavigationSharedState,
32
+ } from "./types";
33
+ import type {
34
+ NavigationOptions,
35
+ Params,
36
+ Router,
37
+ State,
38
+ Plugin,
39
+ } from "@real-router/core";
40
+ import type { PluginApi } from "@real-router/core/api";
41
+
42
+ function deriveNavigationType(
43
+ navOptions: NavigationOptions,
44
+ toState: State,
45
+ fromState: State | undefined,
46
+ ): NavigationMeta["navigationType"] {
47
+ if (navOptions.reload && toState.path === fromState?.path) {
48
+ return "reload";
49
+ }
50
+
51
+ if (shouldReplaceHistory(navOptions, toState, fromState)) {
52
+ return "replace";
53
+ }
54
+
55
+ return "push";
56
+ }
57
+
58
+ export class NavigationPlugin {
59
+ readonly #router: Router;
60
+ readonly #api: PluginApi;
61
+ readonly #options: Required<NavigationPluginOptions>;
62
+ readonly #browser: NavigationBrowser;
63
+ readonly #removeStartInterceptor: () => void;
64
+ readonly #removeExtensions: () => void;
65
+ readonly #lifecycle: Pick<Plugin, "onStart" | "onStop" | "teardown">;
66
+
67
+ #isSyncingFromRouter = false;
68
+ readonly #metaByState = new WeakMap<State, NavigationMeta>();
69
+ #pendingMeta: NavigationMeta | undefined;
70
+ #pendingTraverseKey: string | undefined;
71
+
72
+ constructor(
73
+ router: Router,
74
+ api: PluginApi,
75
+ options: Required<NavigationPluginOptions>,
76
+ browser: NavigationBrowser,
77
+ transitionOptions: {
78
+ source: string;
79
+ replace: true;
80
+ forceDeactivate?: boolean;
81
+ },
82
+ shared: NavigationSharedState,
83
+ ) {
84
+ this.#router = router;
85
+ this.#api = api;
86
+ this.#options = options;
87
+ this.#browser = browser;
88
+
89
+ this.#removeStartInterceptor = createStartInterceptor(api, browser);
90
+
91
+ const pluginBuildUrl = (route: string, params?: Params) => {
92
+ const path = router.buildPath(route, params);
93
+
94
+ return buildUrl(path, options.base);
95
+ };
96
+
97
+ this.#removeExtensions = api.extendRouter({
98
+ buildUrl: pluginBuildUrl,
99
+ matchUrl: (url: string) => {
100
+ const path = urlToPath(url, options.base, LOGGER_CONTEXT);
101
+
102
+ return path ? api.matchPath(path) : undefined;
103
+ },
104
+ replaceHistoryState: createReplaceHistoryState(
105
+ api,
106
+ router,
107
+ browser,
108
+ pluginBuildUrl,
109
+ (syncing) => {
110
+ this.#isSyncingFromRouter = syncing;
111
+ },
112
+ ),
113
+
114
+ peekBack: () => peekBack(browser, api, options.base),
115
+ peekForward: () => peekForward(browser, api, options.base),
116
+ hasVisited: (routeName: string) =>
117
+ hasVisited(browser, api, options.base, routeName),
118
+ getVisitedRoutes: () => getVisitedRoutes(browser, api, options.base),
119
+ getRouteVisitCount: (routeName: string) =>
120
+ getRouteVisitCount(browser, api, options.base, routeName),
121
+ traverseToLast: (routeName: string) => this.traverseToLast(routeName),
122
+ getNavigationMeta: (state?: State): NavigationMeta | undefined => {
123
+ if (!state) {
124
+ return this.#pendingMeta;
125
+ }
126
+
127
+ return this.#metaByState.get(state);
128
+ },
129
+ canGoBack: () => canGoBack(browser),
130
+ canGoForward: () => canGoForward(browser),
131
+ canGoBackTo: (routeName: string) =>
132
+ canGoBackTo(browser, api, options.base, routeName),
133
+ });
134
+
135
+ const handler = createNavigateHandler({
136
+ router,
137
+ api,
138
+ browser,
139
+ isSyncingFromRouter: () => this.#isSyncingFromRouter,
140
+ setSyncing: (syncing) => {
141
+ this.#isSyncingFromRouter = syncing;
142
+ },
143
+ setPendingMeta: (meta) => {
144
+ this.#pendingMeta = meta;
145
+ },
146
+ base: options.base,
147
+ transitionOptions,
148
+ });
149
+
150
+ this.#lifecycle = createNavigateLifecycle({
151
+ browser,
152
+ shared,
153
+ handler,
154
+ removeStartInterceptor: this.#removeStartInterceptor,
155
+ removeExtensions: this.#removeExtensions,
156
+ });
157
+ }
158
+
159
+ async traverseToLast(routeName: string): Promise<State> {
160
+ const entries = this.#browser.entries();
161
+ const currentKey = this.#browser.currentEntry?.key;
162
+ const entry = findLastEntryForRoute(
163
+ entries,
164
+ routeName,
165
+ this.#api,
166
+ this.#options.base,
167
+ currentKey,
168
+ );
169
+
170
+ if (!entry) {
171
+ throw new Error(`No history entry for route "${routeName}"`);
172
+ }
173
+
174
+ if (!entry.url) {
175
+ throw new Error(`No matching route for entry URL "${entry.url}"`);
176
+ }
177
+
178
+ const parsedUrl = new URL(entry.url);
179
+ const path =
180
+ extractPath(parsedUrl.pathname, this.#options.base) + parsedUrl.search;
181
+ const matchedState = this.#api.matchPath(path);
182
+
183
+ if (!matchedState) {
184
+ throw new Error(`No matching route for entry URL "${entry.url}"`);
185
+ }
186
+
187
+ this.#pendingMeta = {
188
+ navigationType: "traverse",
189
+ userInitiated: false,
190
+ };
191
+ this.#pendingTraverseKey = entry.key;
192
+
193
+ return this.#router.navigate(matchedState.name, matchedState.params);
194
+ }
195
+
196
+ getPlugin(): Plugin {
197
+ return {
198
+ ...this.#lifecycle,
199
+
200
+ onTransitionSuccess: (
201
+ toState: State,
202
+ fromState: State | undefined,
203
+ navOptions: NavigationOptions,
204
+ ) => {
205
+ if (!this.#pendingMeta) {
206
+ this.#pendingMeta = {
207
+ navigationType: deriveNavigationType(
208
+ navOptions,
209
+ toState,
210
+ fromState,
211
+ ),
212
+ userInitiated: false,
213
+ };
214
+ }
215
+
216
+ this.#metaByState.set(toState, this.#pendingMeta);
217
+ this.#pendingMeta = undefined;
218
+
219
+ this.#isSyncingFromRouter = true;
220
+
221
+ if (this.#pendingTraverseKey) {
222
+ this.#browser.traverseTo(this.#pendingTraverseKey);
223
+ this.#pendingTraverseKey = undefined;
224
+ } else {
225
+ const url = this.#router.buildUrl(toState.name, toState.params);
226
+ const shouldPreserveHash =
227
+ !fromState || fromState.path === toState.path;
228
+ const finalUrl = shouldPreserveHash
229
+ ? url + this.#browser.getHash()
230
+ : url;
231
+ const historyState = {
232
+ name: toState.name,
233
+ params: toState.params,
234
+ path: toState.path,
235
+ };
236
+
237
+ if (toState.name === UNKNOWN_ROUTE) {
238
+ this.#browser.updateCurrentEntry({ state: historyState });
239
+ } else {
240
+ const replace = shouldReplaceHistory(
241
+ navOptions,
242
+ toState,
243
+ fromState,
244
+ );
245
+
246
+ this.#browser.navigate(finalUrl, {
247
+ state: historyState,
248
+ history: replace ? "replace" : "push",
249
+ });
250
+ }
251
+ }
252
+
253
+ this.#isSyncingFromRouter = false;
254
+ },
255
+
256
+ onTransitionCancel: () => {
257
+ this.#pendingMeta = undefined;
258
+ this.#pendingTraverseKey = undefined;
259
+ },
260
+
261
+ onTransitionError: () => {
262
+ this.#pendingMeta = undefined;
263
+ this.#pendingTraverseKey = undefined;
264
+ },
265
+ };
266
+ }
267
+ }
268
+
269
+ interface NavigateLifecycleDeps {
270
+ browser: NavigationBrowser;
271
+ handler: (event: NavigateEvent) => void;
272
+ removeStartInterceptor: () => void;
273
+ removeExtensions: () => void;
274
+ shared: NavigationSharedState;
275
+ }
276
+
277
+ function createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {
278
+ return {
279
+ onStart() {
280
+ deps.shared.removeNavigateListener?.();
281
+ deps.shared.removeNavigateListener = deps.browser.addNavigateListener(
282
+ deps.handler,
283
+ );
284
+ },
285
+
286
+ onStop() {
287
+ deps.shared.removeNavigateListener?.();
288
+ deps.shared.removeNavigateListener = undefined;
289
+ },
290
+
291
+ teardown() {
292
+ deps.shared.removeNavigateListener?.();
293
+ deps.shared.removeNavigateListener = undefined;
294
+ deps.removeStartInterceptor();
295
+ deps.removeExtensions();
296
+ },
297
+ };
298
+ }
@@ -0,0 +1,47 @@
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
+ };
47
+ };
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
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 true
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
+
40
+ /**
41
+ * Shared mutable state across plugin instances created by the same factory.
42
+ * Enables cleanup of a previous instance's navigate listener when the factory is reused.
43
+ */
44
+ export interface NavigationSharedState {
45
+ removeNavigateListener: (() => void) | undefined;
46
+ }
47
+
48
+ /**
49
+ * Navigation metadata attached to State via WeakMap.
50
+ * Available in guards (via pendingMeta) and subscribe callbacks (via metaByState).
51
+ */
52
+ export interface NavigationMeta {
53
+ /** Type of navigation: push, replace, traverse, or reload */
54
+ navigationType: "push" | "replace" | "traverse" | "reload";
55
+ /** Whether the navigation was initiated by the user (back/forward button, link click) */
56
+ userInitiated: boolean;
57
+ /** Ephemeral info passed via navigation.navigate({ info }) — lost on page reload */
58
+ info?: unknown;
59
+ }
@@ -0,0 +1,10 @@
1
+ import { createOptionsValidator } from "browser-env";
2
+
3
+ import { LOGGER_CONTEXT, defaultOptions } from "./constants";
4
+
5
+ import type { NavigationPluginOptions } from "./types";
6
+
7
+ export const validateOptions = createOptionsValidator<NavigationPluginOptions>(
8
+ defaultOptions,
9
+ LOGGER_CONTEXT,
10
+ );