@dojo-ng/history 0.0.0-stage → 0.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/README.md CHANGED
@@ -1,3 +1,169 @@
1
- # Temporary Holding Version
1
+ # @dojo-ng/history
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Navigation history for single-page apps: real paths with `pushState`, Back and Forward that skip
4
+ pages the user only skimmed, and hooks for tracking. It publishes the location on a
5
+ [`@dojo-ng/pubsub`](https://www.npmjs.com/package/@dojo-ng/pubsub) bus. Your app decides what to show.
6
+
7
+ Part of the [Dojo NG framework](https://foss.heptapod.net/dojo-ng/framework) packages: plain ESM
8
+ and no Lit. BSD-3-Clause.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @dojo-ng/history @dojo-ng/pubsub
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```js
19
+ import { createPubSub } from "@dojo-ng/pubsub";
20
+ import { installHistory, match } from "@dojo-ng/history";
21
+
22
+ const bus = createPubSub();
23
+ const history = installHistory(bus);
24
+
25
+ // Follow the location. The current one arrives at once, so this also covers a reload.
26
+ history.subscribe((location) => {
27
+ const order = match("/orders/:id", location.path);
28
+ if (order) showOrder(order.id);
29
+ });
30
+
31
+ history.go("/orders/7"); // pushes /orders/7 and publishes the new location
32
+ history.back();
33
+ ```
34
+
35
+ Call `installHistory` once, at startup. A second call logs a warning and returns the first
36
+ instance, because there is one history stack.
37
+
38
+ ## Real paths need a server fallback
39
+
40
+ Every navigation is a real URL. A reload, a pasted link, or a bookmark asks the server for that
41
+ path, so the server must answer every route your app uses with the app itself. Without that, a
42
+ reload on `/orders/7` is a 404. The app must also be able to render a view from the path alone.
43
+ The [playground](playground/server.mjs) has a ten-line example.
44
+
45
+ ## The bus
46
+
47
+ | Topic | Payload | Meaning |
48
+ |---|---|---|
49
+ | `nav:go` | `{ path, state?, trail?, replace? }` | Navigate. Anything can publish this. `history.go(path, state)` is a shortcut. |
50
+ | `nav:location` | `{ path, state, index, committed, source, trail? }` | The location changed. |
51
+
52
+ - `path` is the app path: no `base`, but it keeps any `?query` and `#hash` you gave to `go`.
53
+ - `source` is `"go"` (a `nav:go`), `"pop"` (Back or Forward), or `"load"` (the page opened).
54
+ - `index` is the entry's position in the stack. The entry the app landed on is 0.
55
+ - `committed` is `false` while the entry is provisional (see Dwell). When it commits,
56
+ `nav:location` is published again with `committed: true`.
57
+ - The bus keeps the last value, so a view that subscribes late still gets the current location.
58
+
59
+ Only a `nav:go` writes to the browser's history. A `popstate` publishes `nav:location` and does
60
+ nothing else, so Back never adds an entry.
61
+
62
+ ## The history object
63
+
64
+ `installHistory(bus, options)` returns:
65
+
66
+ - `go(path, state?, { replace?, trail? }?)`, `back()`, `forward()`
67
+ - `subscribe(fn)` returns an unsubscribe function
68
+ - `current`: the latest `nav:location` payload, or `undefined` before the first one
69
+ - `canGoBack`, `canGoForward`: properties. The browser does not say whether Back or Forward has
70
+ somewhere to go, so each entry carries a private `{ id, index }` in `history.state`, and the
71
+ highest index is kept in `sessionStorage`. Without `sessionStorage`, `canGoForward` is `false`.
72
+ - `href(path)`: `base` plus `path`, for a link's `href`
73
+ - `annotate({ state?, trail? })`: replace those fields on the current entry (with `replaceState`)
74
+ and publish the location again. It sets whichever of the two you pass; it does not merge inside `state`.
75
+ - `commit()`: commit the current entry now
76
+ - `destroy()`: remove the listeners and unregister. For tests.
77
+
78
+ ## Options
79
+
80
+ | Option | Default | Meaning |
81
+ |---|---|---|
82
+ | `base` | `""` | A path prefix such as `/acme`. Added to every URL written and stripped from every path read. |
83
+ | `dwellMs` | `3000` | Visible milliseconds before an entry commits. `0` gives plain browser behavior. |
84
+ | `onCommit` | none | Called once for each entry that commits. See Hooks. |
85
+ | `resolve` | none | Rebuilds the state of a landing entry that has none. See Hooks. |
86
+ | `window` | `globalThis.window` | For tests: any object shaped like the parts of `window` this uses. |
87
+
88
+ ## Dwell: skimmed pages stay off the stack
89
+
90
+ A browser cannot remove an entry from history, but it can replace the current one. So a new entry
91
+ is provisional until the user has stayed on it for `dwellMs`. If `go` runs while the current entry
92
+ is provisional, the new path replaces it instead of pushing. Open A, read it, then click through B, C
93
+ and D quickly and settle on E: the stack holds A and E, and Back from E goes to A.
94
+
95
+ - The entry the app lands on is committed at once.
96
+ - An entry reached by Back or Forward is committed. It already earned its place.
97
+ - `commit()` commits early, for when the user clearly engaged (typing in a form, for example).
98
+ - Only visible time counts. The timer pauses while the tab is hidden and resumes with the time that was left.
99
+ - `go(path, state, { replace: true })` on a provisional entry replaces it and restarts the timer. On
100
+ an entry that has already committed, the replacement commits too and keeps its place.
101
+ - `dwellMs: 0` pushes every entry, as a browser does.
102
+
103
+ ## Hooks
104
+
105
+ `onCommit({ path, state, trail? })` runs once per entry that commits. It never runs for a skimmed
106
+ entry or for the landing entry. This is the place to send a location to a server. This package
107
+ ships no server code.
108
+
109
+ `resolve(path)` is async. When the page opens on an entry with no state of ours (a new tab, a
110
+ pasted link, a bookmark), it is awaited before the first `nav:location` (`source: "load"`), and its
111
+ result becomes the entry's state. If it rejects, the error is logged and the bare path is
112
+ published. A reload in the same tab keeps its state, so `resolve` is not called. If the app
113
+ navigates before `resolve` settles, the late result is dropped.
114
+
115
+ ## match
116
+
117
+ ```js
118
+ import { match } from "@dojo-ng/history";
119
+
120
+ match("/orders/:id", "/orders/42"); // { id: "42" }
121
+ match("/files/*", "/files/a/b"); // { "*": "a/b" }
122
+ match("/orders/:id", "/orders"); // null
123
+ ```
124
+
125
+ Static segments, `:name` params (decoded with `decodeURIComponent`), and a trailing `*` that takes
126
+ the rest, zero or more segments. A trailing slash on either side is ignored. Pass a pathname: a
127
+ `?query` or `#hash` is not stripped. Navigation never calls `match`; mapping a path to a view is the app's job.
128
+
129
+ ## For components: the handle subpath
130
+
131
+ ```js
132
+ import { getHistory, whenHistory } from "@dojo-ng/history/handle";
133
+ ```
134
+
135
+ `getHistory()` returns the installed instance or `undefined`. `whenHistory()` returns a promise
136
+ that resolves at install. The subpath also exports the types and imports nothing, so a component
137
+ that only reads the location does not pull in the rest of the package. The instance is stored on
138
+ `globalThis[Symbol.for("dojo-ng.history")]`, so two copies of the package in one bundle share one.
139
+
140
+ ## The dj-navigate event
141
+
142
+ A component that has a path does not need the instance. It dispatches a bubbling, composed
143
+ `dj-navigate` event, and `installHistory` listens for it on `document`:
144
+
145
+ ```js
146
+ this.dispatchEvent(new CustomEvent("dj-navigate", {
147
+ detail: { path: "/orders/7", state: undefined, trail: [{ path: "/orders", label: "Orders" }, { path: "/orders/7", label: "Order 7" }], replace: false },
148
+ bubbles: true,
149
+ composed: true,
150
+ }));
151
+ ```
152
+
153
+ It becomes a `nav:go`. `trail` is stored in the entry's state, so it comes back on Back, Forward,
154
+ and a same-tab reload. Other keys in `detail` (such as `label`) are ignored here.
155
+
156
+ ## Another router on the page
157
+
158
+ If something else seems to be writing history, one `console.warn` per kind of signal is logged and
159
+ the layer carries on: `history.pushState` or `replaceState` is not native; Back or Forward lands
160
+ on an entry this layer did not create; or the location changed without a `nav:go`. This package
161
+ never patches `pushState`.
162
+
163
+ ## Playground
164
+
165
+ ```bash
166
+ cd packages/history
167
+ npm run build
168
+ npm run playground # http://localhost:8902/
169
+ ```
@@ -0,0 +1,75 @@
1
+ /** One step of the path from the root to a node, carried in navigation state. */
2
+ export interface TrailEntry {
3
+ path: string;
4
+ label: string;
5
+ }
6
+ /** Where a `NavLocation` came from: a `nav:go`, a Back/Forward (`pop`), or the install itself. */
7
+ export type NavSource = "go" | "pop" | "load";
8
+ /** The payload of every `nav:location` publish. `path` never includes the `base` option. */
9
+ export interface NavLocation {
10
+ path: string;
11
+ /** The state passed to `go`, or the result of `resolve`. `undefined` when there is none. */
12
+ state: unknown;
13
+ /** Position of this entry in the stack, 0 for the entry the app landed on. */
14
+ index: number;
15
+ /** False while the entry is provisional (see `dwellMs`). */
16
+ committed: boolean;
17
+ source: NavSource;
18
+ /** Root-to-node trail, when whoever navigated supplied one. */
19
+ trail?: TrailEntry[];
20
+ }
21
+ /** What `annotate` can change on the current entry. */
22
+ export interface NavAnnotation {
23
+ state?: unknown;
24
+ trail?: TrailEntry[];
25
+ }
26
+ /** The payload of `nav:go`, and of the `dj-navigate` event detail (which may carry extra keys). */
27
+ export interface NavGo {
28
+ path: string;
29
+ state?: unknown;
30
+ trail?: TrailEntry[];
31
+ replace?: boolean;
32
+ }
33
+ /** The `detail` of the `dj-navigate` event a component dispatches. `label` is for whoever listens; history ignores it. */
34
+ export interface NavigateDetail extends NavGo {
35
+ label?: string;
36
+ }
37
+ export interface GoOptions {
38
+ /** Replace the current entry instead of pushing a new one. */
39
+ replace?: boolean;
40
+ trail?: TrailEntry[];
41
+ }
42
+ /** What is passed to `onCommit` when an entry commits. */
43
+ export interface CommitEntry {
44
+ path: string;
45
+ state: unknown;
46
+ trail?: TrailEntry[];
47
+ }
48
+ export interface HistoryInstance {
49
+ /** Publish `nav:go`. The only way anything here writes to the browser's history. */
50
+ go(path: string, state?: unknown, options?: GoOptions): void;
51
+ back(): void;
52
+ forward(): void;
53
+ /** Commit the current entry now, without waiting out the dwell time. */
54
+ commit(): void;
55
+ /** Follow the location. The current one is delivered at once. Returns an unsubscribe function. */
56
+ subscribe(fn: (location: NavLocation) => void): () => void;
57
+ /** The current location, or `undefined` until the first one is published. */
58
+ readonly current: NavLocation | undefined;
59
+ readonly canGoBack: boolean;
60
+ readonly canGoForward: boolean;
61
+ /** The URL for an app path: `base` plus `path`. */
62
+ href(path: string): string;
63
+ /** Merge `state` and/or `trail` into the current entry, then publish the location again. */
64
+ annotate(partial: NavAnnotation): void;
65
+ /** Remove listeners and unregister. For tests. */
66
+ destroy(): void;
67
+ }
68
+ /** The installed instance, or `undefined` when `installHistory` has not run. */
69
+ export declare function getHistory(): HistoryInstance | undefined;
70
+ /** Resolves with the instance: at once if installed, otherwise when `installHistory` runs. */
71
+ export declare function whenHistory(): Promise<HistoryInstance>;
72
+ /** @internal Called by `installHistory`. Not part of the public API. */
73
+ export declare function registerHistory(instance: HistoryInstance): void;
74
+ /** @internal Called by `destroy()`. Only removes the registration if it is still `instance`. */
75
+ export declare function unregisterHistory(instance: HistoryInstance): void;
package/dist/handle.js ADDED
@@ -0,0 +1,44 @@
1
+ // The subpath components import: `@dojo-ng/history/handle`. It holds the types, the lookup of the
2
+ // installed instance, and the promise that resolves at install. It imports nothing (no pub/sub, no
3
+ // install code), so a component that only reads the location pulls in a few lines, not the package.
4
+ //
5
+ // The instance lives on `globalThis[Symbol.for("dojo-ng.history")]` (that is `window` in a
6
+ // browser), so two copies of the package in one bundle agree on one instance (decision D3). The
7
+ // callbacks waiting for an install live under a second well-known symbol for the same reason.
8
+ const INSTANCE_KEY = Symbol.for("dojo-ng.history");
9
+ const WAITERS_KEY = Symbol.for("dojo-ng.history.waiters");
10
+ function slot() {
11
+ return globalThis;
12
+ }
13
+ /** The installed instance, or `undefined` when `installHistory` has not run. */
14
+ export function getHistory() {
15
+ return slot()[INSTANCE_KEY];
16
+ }
17
+ /** Resolves with the instance: at once if installed, otherwise when `installHistory` runs. */
18
+ export function whenHistory() {
19
+ const installed = getHistory();
20
+ if (installed)
21
+ return Promise.resolve(installed);
22
+ return new Promise((resolve) => {
23
+ const s = slot();
24
+ const waiters = s[WAITERS_KEY] ?? [];
25
+ waiters.push(resolve);
26
+ s[WAITERS_KEY] = waiters;
27
+ });
28
+ }
29
+ /** @internal Called by `installHistory`. Not part of the public API. */
30
+ export function registerHistory(instance) {
31
+ const s = slot();
32
+ s[INSTANCE_KEY] = instance;
33
+ const waiters = s[WAITERS_KEY];
34
+ s[WAITERS_KEY] = undefined;
35
+ if (waiters)
36
+ for (const resolve of waiters)
37
+ resolve(instance);
38
+ }
39
+ /** @internal Called by `destroy()`. Only removes the registration if it is still `instance`. */
40
+ export function unregisterHistory(instance) {
41
+ const s = slot();
42
+ if (s[INSTANCE_KEY] === instance)
43
+ s[INSTANCE_KEY] = undefined;
44
+ }
@@ -0,0 +1,45 @@
1
+ import type { PubSub } from "@dojo-ng/pubsub";
2
+ import { type CommitEntry, type HistoryInstance } from "./handle.js";
3
+ /** The parts of `window` that `installHistory` uses. Tests pass a fake with this shape. */
4
+ export interface HistoryWindow {
5
+ history: {
6
+ pushState(state: unknown, title: string, url?: string | null): void;
7
+ replaceState(state: unknown, title: string, url?: string | null): void;
8
+ back(): void;
9
+ forward(): void;
10
+ readonly state: unknown;
11
+ };
12
+ location: {
13
+ readonly pathname: string;
14
+ readonly search: string;
15
+ readonly hash: string;
16
+ readonly href: string;
17
+ };
18
+ addEventListener(type: string, listener: (event: never) => void): void;
19
+ removeEventListener(type: string, listener: (event: never) => void): void;
20
+ document: {
21
+ readonly visibilityState?: string;
22
+ addEventListener(type: string, listener: (event: never) => void): void;
23
+ removeEventListener(type: string, listener: (event: never) => void): void;
24
+ };
25
+ readonly sessionStorage?: {
26
+ getItem(key: string): string | null;
27
+ setItem(key: string, value: string): void;
28
+ };
29
+ }
30
+ export interface InstallOptions {
31
+ /** A path prefix such as `/acme`. Added to every URL written, stripped from every path read. */
32
+ base?: string;
33
+ /** Default `globalThis.window`. Exists so tests can inject a fake. */
34
+ window?: HistoryWindow;
35
+ /** How long (visible) milliseconds an entry must stay current before it is committed. Default 3000. 0 turns skimming off. */
36
+ dwellMs?: number;
37
+ /** Called once per entry when it commits. Never for a skimmed entry, never for the landing entry. */
38
+ onCommit?: (entry: CommitEntry) => void;
39
+ /**
40
+ * Called at install when the landing entry carries no state of ours (a new tab, a pasted link, a
41
+ * bookmark). Its result becomes the entry's state before `nav:location` is published as `load`.
42
+ */
43
+ resolve?: (path: string) => Promise<unknown>;
44
+ }
45
+ export declare function installHistory(bus: PubSub, options?: InstallOptions): HistoryInstance;
@@ -0,0 +1,371 @@
1
+ import { getHistory, registerHistory, unregisterHistory, } from "./handle.js";
2
+ const DEFAULT_DWELL_MS = 3000;
3
+ const MAX_INDEX_KEY = "dojo-ng.history.max";
4
+ const DUPLICATE_INSTALL_WARNED = new WeakSet();
5
+ let idCounter = 0;
6
+ function newId() {
7
+ return `${Date.now().toString(36)}-${(++idCounter).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
8
+ }
9
+ function isRecord(value) {
10
+ return typeof value === "object" && value !== null;
11
+ }
12
+ function readStamp(raw) {
13
+ if (!isRecord(raw))
14
+ return undefined;
15
+ const stamp = raw.__djnav;
16
+ if (!isRecord(stamp) || typeof stamp.id !== "string" || typeof stamp.index !== "number")
17
+ return undefined;
18
+ return { id: stamp.id, index: stamp.index };
19
+ }
20
+ function isNative(fn) {
21
+ try {
22
+ return Function.prototype.toString.call(fn).includes("[native code]");
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function normalizeBase(base) {
29
+ if (!base)
30
+ return "";
31
+ const trimmed = base.replace(/\/+$/, "");
32
+ return trimmed === "" || trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
33
+ }
34
+ export function installHistory(bus, options = {}) {
35
+ const existing = getHistory();
36
+ if (existing) {
37
+ if (!DUPLICATE_INSTALL_WARNED.has(existing)) {
38
+ DUPLICATE_INSTALL_WARNED.add(existing);
39
+ console.warn("[@dojo-ng/history] installHistory was called more than once. There is one history stack; returning the first instance.");
40
+ }
41
+ return existing;
42
+ }
43
+ const win = options.window ?? globalThis.window;
44
+ if (!win)
45
+ throw new Error("[@dojo-ng/history] installHistory needs a window. Pass options.window outside a browser.");
46
+ const base = normalizeBase(options.base);
47
+ const dwellMs = options.dwellMs ?? DEFAULT_DWELL_MS;
48
+ const warned = new Set();
49
+ const provisionalIds = new Set();
50
+ let cur;
51
+ let last;
52
+ let expectedPlace = "";
53
+ let destroyed = false;
54
+ let timer;
55
+ let remaining = dwellMs;
56
+ let startedAt = 0;
57
+ // --- helpers -------------------------------------------------------------------------------
58
+ function warnOnce(signal, message) {
59
+ if (warned.has(signal))
60
+ return;
61
+ warned.add(signal);
62
+ console.warn(`[@dojo-ng/history] ${message} (${signal}). Dojo NG routing stays in charge; this is the only warning of this kind.`);
63
+ }
64
+ function currentPath() {
65
+ const full = win.location.pathname + win.location.search + win.location.hash;
66
+ if (base && (full === base || full.startsWith(`${base}/`) || full.startsWith(`${base}?`) || full.startsWith(`${base}#`))) {
67
+ const rest = full.slice(base.length);
68
+ return rest.startsWith("/") ? rest : `/${rest}`;
69
+ }
70
+ return full;
71
+ }
72
+ function place() {
73
+ return win.location.pathname + win.location.search;
74
+ }
75
+ function stateFor(entry) {
76
+ return { __djnav: { id: entry.id, index: entry.index }, state: entry.state, trail: entry.trail };
77
+ }
78
+ /** The one place that writes to the browser's history. Called from `onGo`, install, and annotate; never from `onPop`. */
79
+ function writeEntry(mode, entry, url) {
80
+ if (mode === "push")
81
+ win.history.pushState(stateFor(entry), "", url);
82
+ else
83
+ win.history.replaceState(stateFor(entry), "", url);
84
+ }
85
+ function entryFromState(raw, stamp) {
86
+ const record = raw;
87
+ return {
88
+ id: stamp.id,
89
+ index: stamp.index,
90
+ path: currentPath(),
91
+ state: record.state,
92
+ trail: Array.isArray(record.trail) ? record.trail : undefined,
93
+ committed: !provisionalIds.has(stamp.id),
94
+ source: "pop",
95
+ };
96
+ }
97
+ function readMax() {
98
+ try {
99
+ const value = win.sessionStorage?.getItem(MAX_INDEX_KEY);
100
+ if (value === null || value === undefined)
101
+ return undefined;
102
+ const n = Number(value);
103
+ return Number.isFinite(n) ? n : undefined;
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ function writeMax(n) {
110
+ try {
111
+ win.sessionStorage?.setItem(MAX_INDEX_KEY, String(n));
112
+ }
113
+ catch {
114
+ // Storage can be blocked. canGoForward is then false, which is harmless.
115
+ }
116
+ }
117
+ function publish() {
118
+ const location = {
119
+ path: cur.path,
120
+ state: cur.state,
121
+ index: cur.index,
122
+ committed: cur.committed,
123
+ source: cur.source,
124
+ ...(cur.trail ? { trail: cur.trail } : {}),
125
+ };
126
+ last = location;
127
+ bus.publish("nav:location", location);
128
+ }
129
+ function fireCommit(entry) {
130
+ if (!options.onCommit)
131
+ return;
132
+ try {
133
+ options.onCommit({ path: entry.path, state: entry.state, ...(entry.trail ? { trail: entry.trail } : {}) });
134
+ }
135
+ catch (err) {
136
+ console.error("[@dojo-ng/history] onCommit threw:", err);
137
+ }
138
+ }
139
+ // --- dwell ---------------------------------------------------------------------------------
140
+ function isVisible() {
141
+ return win.document.visibilityState !== "hidden";
142
+ }
143
+ function stopTimer() {
144
+ if (timer !== undefined)
145
+ clearTimeout(timer);
146
+ timer = undefined;
147
+ }
148
+ function runTimer() {
149
+ startedAt = Date.now();
150
+ timer = setTimeout(() => {
151
+ timer = undefined;
152
+ commitCurrent();
153
+ }, remaining);
154
+ }
155
+ /** Start counting dwell time for a provisional entry. Counts visible time only. */
156
+ function startDwell() {
157
+ stopTimer();
158
+ remaining = dwellMs;
159
+ if (isVisible())
160
+ runTimer();
161
+ }
162
+ function onVisibility() {
163
+ if (destroyed)
164
+ return;
165
+ if (!isVisible()) {
166
+ if (timer !== undefined) {
167
+ stopTimer();
168
+ remaining = Math.max(0, remaining - (Date.now() - startedAt));
169
+ }
170
+ }
171
+ else if (!cur.committed && timer === undefined) {
172
+ runTimer();
173
+ }
174
+ }
175
+ function commitCurrent() {
176
+ if (destroyed || cur.committed)
177
+ return;
178
+ stopTimer();
179
+ cur.committed = true;
180
+ provisionalIds.delete(cur.id);
181
+ publish();
182
+ fireCommit(cur);
183
+ }
184
+ // --- the coexistence checks (D6) -----------------------------------------------------------
185
+ function checkForeignLocation() {
186
+ if (place() !== expectedPlace) {
187
+ warnOnce("foreign-location", "The location changed without a nav:go, so another router or script may be pushing history");
188
+ }
189
+ }
190
+ // --- inputs --------------------------------------------------------------------------------
191
+ function onGo(request) {
192
+ if (destroyed || !request || typeof request.path !== "string")
193
+ return;
194
+ checkForeignLocation();
195
+ const previous = cur;
196
+ const replace = request.replace === true || !previous.committed;
197
+ const entry = {
198
+ id: newId(),
199
+ index: replace ? previous.index : previous.index + 1,
200
+ path: request.path,
201
+ state: request.state,
202
+ trail: request.trail,
203
+ committed: false,
204
+ source: "go",
205
+ };
206
+ provisionalIds.delete(previous.id);
207
+ stopTimer();
208
+ writeEntry(replace ? "replace" : "push", entry, base + request.path);
209
+ if (!replace)
210
+ writeMax(entry.index);
211
+ expectedPlace = place();
212
+ cur = entry;
213
+ // A replace of an entry that had already committed keeps its place on the stack, so the
214
+ // replacement commits too. Otherwise the entry is provisional until it has been read.
215
+ const commitNow = dwellMs <= 0 || (replace && previous.committed);
216
+ if (commitNow) {
217
+ entry.committed = true;
218
+ publish();
219
+ fireCommit(entry);
220
+ }
221
+ else {
222
+ provisionalIds.add(entry.id);
223
+ publish();
224
+ startDwell();
225
+ }
226
+ }
227
+ function onPop() {
228
+ if (destroyed)
229
+ return;
230
+ stopTimer();
231
+ const raw = win.history.state;
232
+ const stamp = readStamp(raw);
233
+ let entry;
234
+ let newlyCommitted = false;
235
+ if (!stamp) {
236
+ warnOnce("foreign-entry", "Back or Forward landed on a history entry this layer did not create, so another router or script may be pushing history");
237
+ // Keeps the id of the entry we were on, so a later Back onto a stamped entry that is
238
+ // already "current" still reads as history changing without a nav:go.
239
+ entry = {
240
+ id: cur.id,
241
+ index: cur.index,
242
+ path: currentPath(),
243
+ state: undefined,
244
+ committed: true,
245
+ source: "pop",
246
+ };
247
+ }
248
+ else {
249
+ if (stamp.id === cur.id) {
250
+ warnOnce("foreign-location", "Back landed on the entry already current, so history changed without a nav:go");
251
+ }
252
+ entry = entryFromState(raw, stamp);
253
+ if (!entry.committed) {
254
+ entry.committed = true;
255
+ provisionalIds.delete(entry.id);
256
+ newlyCommitted = true;
257
+ }
258
+ }
259
+ entry.source = "pop";
260
+ cur = entry;
261
+ expectedPlace = place();
262
+ publish();
263
+ if (newlyCommitted)
264
+ fireCommit(entry);
265
+ }
266
+ function onNavigate(event) {
267
+ const detail = event.detail;
268
+ if (!detail || typeof detail.path !== "string")
269
+ return;
270
+ bus.publish("nav:go", {
271
+ path: detail.path,
272
+ state: detail.state,
273
+ trail: detail.trail,
274
+ replace: detail.replace === true,
275
+ });
276
+ }
277
+ // --- the instance --------------------------------------------------------------------------
278
+ const instance = {
279
+ go(path, state, goOptions) {
280
+ bus.publish("nav:go", { path, state, trail: goOptions?.trail, replace: goOptions?.replace === true });
281
+ },
282
+ back() {
283
+ win.history.back();
284
+ },
285
+ forward() {
286
+ win.history.forward();
287
+ },
288
+ commit: commitCurrent,
289
+ subscribe(fn) {
290
+ return bus.subscribe("nav:location", (location) => fn(location));
291
+ },
292
+ get current() {
293
+ return last;
294
+ },
295
+ get canGoBack() {
296
+ return cur.index > 0;
297
+ },
298
+ get canGoForward() {
299
+ const max = readMax();
300
+ return max !== undefined && cur.index < max;
301
+ },
302
+ href(path) {
303
+ return base + path;
304
+ },
305
+ annotate(partial) {
306
+ if (destroyed)
307
+ return;
308
+ if ("state" in partial)
309
+ cur.state = partial.state;
310
+ if ("trail" in partial)
311
+ cur.trail = partial.trail;
312
+ writeEntry("replace", cur);
313
+ publish();
314
+ },
315
+ destroy() {
316
+ if (destroyed)
317
+ return;
318
+ destroyed = true;
319
+ stopTimer();
320
+ unsubscribeGo();
321
+ win.removeEventListener("popstate", onPop);
322
+ win.document.removeEventListener("visibilitychange", onVisibility);
323
+ win.document.removeEventListener("dj-navigate", onNavigate);
324
+ unregisterHistory(instance);
325
+ },
326
+ };
327
+ // --- install -------------------------------------------------------------------------------
328
+ if (!isNative(win.history.pushState) || !isNative(win.history.replaceState)) {
329
+ warnOnce("patched-pushstate", "history.pushState or replaceState is not the browser's own, so another router may be patching them");
330
+ }
331
+ const raw = win.history.state;
332
+ const stamp = readStamp(raw);
333
+ let fresh = false;
334
+ if (stamp) {
335
+ cur = { ...entryFromState(raw, stamp), committed: true, source: "load" };
336
+ }
337
+ else {
338
+ // A fresh tab, pasted link, or bookmark. Stamp the entry so none ever has null state (app3 lesson 5).
339
+ fresh = true;
340
+ cur = { id: newId(), index: 0, path: currentPath(), state: undefined, committed: true, source: "load" };
341
+ writeEntry("replace", cur);
342
+ writeMax(0);
343
+ }
344
+ expectedPlace = place();
345
+ const unsubscribeGo = bus.subscribe("nav:go", onGo, { replay: false });
346
+ win.addEventListener("popstate", onPop);
347
+ win.document.addEventListener("visibilitychange", onVisibility);
348
+ win.document.addEventListener("dj-navigate", onNavigate);
349
+ registerHistory(instance);
350
+ if (fresh && options.resolve) {
351
+ const landing = cur;
352
+ new Promise((resolve) => resolve(options.resolve(landing.path))).then((result) => {
353
+ if (destroyed || cur !== landing)
354
+ return;
355
+ if (result !== undefined) {
356
+ landing.state = result;
357
+ writeEntry("replace", landing);
358
+ }
359
+ publish();
360
+ }, (err) => {
361
+ console.error("[@dojo-ng/history] resolve rejected; publishing the bare path:", err);
362
+ if (destroyed || cur !== landing)
363
+ return;
364
+ publish();
365
+ });
366
+ }
367
+ else {
368
+ publish();
369
+ }
370
+ return instance;
371
+ }
@@ -0,0 +1,5 @@
1
+ export { installHistory } from "./history.js";
2
+ export type { InstallOptions, HistoryWindow } from "./history.js";
3
+ export { match } from "./match.js";
4
+ export { getHistory, whenHistory } from "./handle.js";
5
+ export type { CommitEntry, GoOptions, HistoryInstance, NavAnnotation, NavGo, NavigateDetail, NavLocation, NavSource, TrailEntry, } from "./handle.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { installHistory } from "./history.js";
2
+ export { match } from "./match.js";
3
+ export { getHistory, whenHistory } from "./handle.js";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Match a path against a pattern of static segments, `:name` params, and an optional trailing `*`.
3
+ * Returns the params, or `null` when the path does not match. Params are decoded with
4
+ * `decodeURIComponent`; a malformed escape is a non-match. A trailing slash on either side is
5
+ * ignored. `*` takes the rest of the path, zero or more segments, under the key `"*"`.
6
+ *
7
+ * Pass a pathname. A `?query` or `#hash` is not stripped.
8
+ */
9
+ export declare function match(pattern: string, path: string): Record<string, string> | null;
package/dist/match.js ADDED
@@ -0,0 +1,58 @@
1
+ // Path matching. A separate export that navigation never calls (decision D10): mapping a path to a
2
+ // view stays the app's job, and this is only a helper for doing it.
3
+ function segments(path) {
4
+ const parts = path.split("/");
5
+ if (parts[0] === "")
6
+ parts.shift();
7
+ if (parts.length > 0 && parts[parts.length - 1] === "")
8
+ parts.pop();
9
+ return parts;
10
+ }
11
+ function decode(value) {
12
+ try {
13
+ return decodeURIComponent(value);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /**
20
+ * Match a path against a pattern of static segments, `:name` params, and an optional trailing `*`.
21
+ * Returns the params, or `null` when the path does not match. Params are decoded with
22
+ * `decodeURIComponent`; a malformed escape is a non-match. A trailing slash on either side is
23
+ * ignored. `*` takes the rest of the path, zero or more segments, under the key `"*"`.
24
+ *
25
+ * Pass a pathname. A `?query` or `#hash` is not stripped.
26
+ */
27
+ export function match(pattern, path) {
28
+ const want = segments(pattern);
29
+ const have = segments(path);
30
+ const rest = want[want.length - 1] === "*";
31
+ const fixed = rest ? want.slice(0, -1) : want;
32
+ if (rest ? have.length < fixed.length : have.length !== fixed.length)
33
+ return null;
34
+ const params = {};
35
+ for (let i = 0; i < fixed.length; i++) {
36
+ const piece = fixed[i];
37
+ if (piece.length > 1 && piece[0] === ":") {
38
+ const value = decode(have[i]);
39
+ if (value === null)
40
+ return null;
41
+ params[piece.slice(1)] = value;
42
+ }
43
+ else if (piece !== have[i]) {
44
+ return null;
45
+ }
46
+ }
47
+ if (rest) {
48
+ const decoded = [];
49
+ for (const part of have.slice(fixed.length)) {
50
+ const value = decode(part);
51
+ if (value === null)
52
+ return null;
53
+ decoded.push(value);
54
+ }
55
+ params["*"] = decoded.join("/");
56
+ }
57
+ return params;
58
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "@dojo-ng/history",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Dojo NG navigation history: real paths with pushState, Back and Forward that skip skimmed pages, and hooks for tracking",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./handle": {
15
+ "types": "./dist/handle.d.ts",
16
+ "default": "./dist/handle.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "license": "BSD-3-Clause",
23
+ "keywords": [
24
+ "dojo",
25
+ "history",
26
+ "navigation",
27
+ "pushstate",
28
+ "spa",
29
+ "esm"
30
+ ],
31
+ "repository": {
32
+ "type": "hg",
33
+ "url": "https://foss.heptapod.net/dojo-ng/framework"
34
+ },
35
+ "dependencies": {
36
+ "@dojo-ng/pubsub": "^0.1.3"
37
+ },
38
+ "scripts": {
39
+ "build": "rm -rf dist && tsc -p tsconfig.json",
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "npm run build && node --test test/*.test.mjs",
42
+ "test:browser": "npm run build && web-test-runner",
43
+ "playground": "node playground/server.mjs",
44
+ "prepublishOnly": "npm run build"
45
+ }
46
+ }