@dojo-ng/history 0.0.0-stage → 0.2.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 +260 -2
- package/dist/handle.d.ts +88 -0
- package/dist/handle.js +44 -0
- package/dist/history.d.ts +82 -0
- package/dist/history.js +642 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/match.d.ts +9 -0
- package/dist/match.js +58 -0
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -1,3 +1,261 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dojo-ng/history
|
|
2
2
|
|
|
3
|
-
|
|
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` (`#` plus `path` in hash mode)
|
|
73
|
+
- `block(blocker)`: ask before a move; returns a function that removes the blocker. See Guards.
|
|
74
|
+
- `annotate({ state?, trail? })`: replace those fields on the current entry (with `replaceState`)
|
|
75
|
+
and publish the location again. It sets whichever of the two you pass; it does not merge inside `state`.
|
|
76
|
+
- `commit()`: commit the current entry now
|
|
77
|
+
- `destroy()`: remove the listeners and unregister. For tests.
|
|
78
|
+
|
|
79
|
+
## Options
|
|
80
|
+
|
|
81
|
+
| Option | Default | Meaning |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `base` | `""` | A path prefix such as `/acme`. Added to every URL written and stripped from every path read. |
|
|
84
|
+
| `dwellMs` | `3000` | Visible milliseconds before an entry commits. `0` gives plain browser behavior. |
|
|
85
|
+
| `mode` | `"path"` | `"hash"` keeps the route after `#`. See Hash mode. |
|
|
86
|
+
| `scroll` | `false` | `true` or `{ targets?, ready? }`: restore scroll on Back, Forward and reload. See Scroll restoration. |
|
|
87
|
+
| `focus` | `false` | A selector to focus after a `go`. See Focus after a navigation. |
|
|
88
|
+
| `onCommit` | none | Called once for each entry that commits. See Hooks. |
|
|
89
|
+
| `resolve` | none | Rebuilds the state of a landing entry that has none. See Hooks. |
|
|
90
|
+
| `window` | `globalThis.window` | For tests: any object shaped like the parts of `window` this uses. |
|
|
91
|
+
|
|
92
|
+
## Dwell: skimmed pages stay off the stack
|
|
93
|
+
|
|
94
|
+
A browser cannot remove an entry from history, but it can replace the current one. So a new entry
|
|
95
|
+
is provisional until the user has stayed on it for `dwellMs`. If `go` runs while the current entry
|
|
96
|
+
is provisional, the new path replaces it instead of pushing. Open A, read it, then click through B, C
|
|
97
|
+
and D quickly and settle on E: the stack holds A and E, and Back from E goes to A.
|
|
98
|
+
|
|
99
|
+
- The entry the app lands on is committed at once.
|
|
100
|
+
- An entry reached by Back or Forward is committed. It already earned its place.
|
|
101
|
+
- `commit()` commits early, for when the user clearly engaged (typing in a form, for example).
|
|
102
|
+
- Only visible time counts. The timer pauses while the tab is hidden and resumes with the time that was left.
|
|
103
|
+
- `go(path, state, { replace: true })` on a provisional entry replaces it and restarts the timer. On
|
|
104
|
+
an entry that has already committed, the replacement commits too and keeps its place.
|
|
105
|
+
- `dwellMs: 0` pushes every entry, as a browser does.
|
|
106
|
+
|
|
107
|
+
## Hooks
|
|
108
|
+
|
|
109
|
+
`onCommit({ path, state, trail? })` runs once per entry that commits. It never runs for a skimmed
|
|
110
|
+
entry or for the landing entry. This is the place to send a location to a server. This package
|
|
111
|
+
ships no server code.
|
|
112
|
+
|
|
113
|
+
`resolve(path)` is async. When the page opens on an entry with no state of ours (a new tab, a
|
|
114
|
+
pasted link, a bookmark), it is awaited before the first `nav:location` (`source: "load"`), and its
|
|
115
|
+
result becomes the entry's state. If it rejects, the error is logged and the bare path is
|
|
116
|
+
published. A reload in the same tab keeps its state, so `resolve` is not called. If the app
|
|
117
|
+
navigates before `resolve` settles, the late result is dropped.
|
|
118
|
+
|
|
119
|
+
## Hash mode
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
installHistory(bus, { mode: "hash" }); // https://example.com/app.html#/orders/7
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The route lives after the `#`, so a static server with no fallback can serve every deep link: a
|
|
126
|
+
reload on `#/orders/7` just loads `app.html`. Everything else works as in path mode. `go` writes
|
|
127
|
+
`#/orders/7`, `href("/orders/7")` returns `#/orders/7`, and `location.path` is still `/orders/7`.
|
|
128
|
+
|
|
129
|
+
- A user editing the hash in the address bar, or clicking a plain `<a href="#/x">`, is published as
|
|
130
|
+
a `go` and stamped like any other entry. Nothing in your app has to listen for `hashchange`.
|
|
131
|
+
- Hash mode owns the whole hash, so an in-page `#section` link can't also scroll to a section. Use
|
|
132
|
+
`element.scrollIntoView()` for that.
|
|
133
|
+
- `base` is ignored, with one `console.warn`, because the page's own path is fixed.
|
|
134
|
+
- Components build their links with `getHistory().href(path)`, so hash mode needs `installHistory`
|
|
135
|
+
to have run. Without it a component falls back to its plain `href`.
|
|
136
|
+
- Blockers (see Guards) are not asked about an address-bar hash edit: the browser has already
|
|
137
|
+
moved, and it can't be put back safely.
|
|
138
|
+
|
|
139
|
+
## Scroll restoration
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
installHistory(bus, { scroll: true });
|
|
143
|
+
installHistory(bus, {
|
|
144
|
+
scroll: {
|
|
145
|
+
targets: () => [document.getElementById("list")], // scrolling elements; each needs an id
|
|
146
|
+
ready: (location) => view.updateComplete, // resolve when the view has rendered
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Off by default, because turning it on sets `history.scrollRestoration` to `"manual"` (put back by
|
|
152
|
+
`destroy()`), which an app should choose.
|
|
153
|
+
|
|
154
|
+
- Back, Forward and a same-tab reload return the window, and each `targets` element, to where the
|
|
155
|
+
user left that entry. A `go` scrolls to the top, or to the element named by a `#fragment` in the
|
|
156
|
+
path (path mode only).
|
|
157
|
+
- Positions are saved when leaving an entry and on `pagehide`. They are kept in `sessionStorage`
|
|
158
|
+
by entry id, newest 50, never in `history.state`. Without `sessionStorage`, nothing is restored.
|
|
159
|
+
- A skimmed entry that gets replaced (see Dwell) loses its saved position.
|
|
160
|
+
- **`ready` is the contract.** History publishes the location and does not render, so it can't know
|
|
161
|
+
when the view is there. Without `ready`, it waits two animation frames, which is right for a view
|
|
162
|
+
that renders at once and not enough for data that loads later. With a Lit view, `ready` can
|
|
163
|
+
return `view.updateComplete`; with fetched data, a promise for the fetch. A `ready` that rejects
|
|
164
|
+
is logged and the scroll still happens.
|
|
165
|
+
|
|
166
|
+
## Focus after a navigation
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
installHistory(bus, { focus: "h1, [role=main], main" });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
After a `go`, once the view has had its frames (or `scroll.ready`), the first element matching the
|
|
173
|
+
selector gets focus, so keyboard and screen reader users learn the page changed. Without this,
|
|
174
|
+
focus stays on the link that was clicked, which may no longer exist. Most apps will want it on.
|
|
175
|
+
|
|
176
|
+
- An element that can't take focus, such as a heading, gets `tabindex="-1"` until it blurs.
|
|
177
|
+
- Focus does not move on Back, Forward or a reload. The browser's own behavior is right there.
|
|
178
|
+
- Focus already inside the matched element is left alone, so choosing a tree node keeps focus on
|
|
179
|
+
the tree.
|
|
180
|
+
|
|
181
|
+
## Guards
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
const unblock = history.block((to, from) => !formIsDirty() || confirm("Leave without saving?"));
|
|
185
|
+
// later, after saving:
|
|
186
|
+
unblock();
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`block` runs the blocker before every `go` and every Back or Forward. Return `true` to allow the
|
|
190
|
+
move; the function may be async. With several blockers, all must allow. Anything other than `true`
|
|
191
|
+
(or a throw) refuses.
|
|
192
|
+
|
|
193
|
+
- A refused `go` writes nothing, publishes nothing and leaves the dwell state alone. If a second
|
|
194
|
+
`go` arrives while a check is pending, only the latest is checked.
|
|
195
|
+
- A browser can't cancel a Back, only undo it. So a guarded Back is traversed straight back with
|
|
196
|
+
`history.go`, the blocker is asked, and if it allows the move the traversal is replayed and
|
|
197
|
+
published as usual. No `pushState` or `replaceState` is involved. Entries this package did not
|
|
198
|
+
create are not guarded.
|
|
199
|
+
- While any blocker is active, closing or reloading the tab shows the browser's own prompt. Browsers
|
|
200
|
+
ignore custom text there.
|
|
201
|
+
- `to` is the `nav:go` payload, or `{ path, source: "pop" }` for Back and Forward. `from` is the
|
|
202
|
+
current location.
|
|
203
|
+
- `dj-back-button`'s cancelable `dj-back` event runs first; the two compose.
|
|
204
|
+
- The Navigation API could later give a true cancel in place of the undo and replay, where it is
|
|
205
|
+
available. It isn't used here, so older Safari works.
|
|
206
|
+
|
|
207
|
+
## match
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
import { match } from "@dojo-ng/history";
|
|
211
|
+
|
|
212
|
+
match("/orders/:id", "/orders/42"); // { id: "42" }
|
|
213
|
+
match("/files/*", "/files/a/b"); // { "*": "a/b" }
|
|
214
|
+
match("/orders/:id", "/orders"); // null
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Static segments, `:name` params (decoded with `decodeURIComponent`), and a trailing `*` that takes
|
|
218
|
+
the rest, zero or more segments. A trailing slash on either side is ignored. Pass a pathname: a
|
|
219
|
+
`?query` or `#hash` is not stripped. Navigation never calls `match`; mapping a path to a view is the app's job.
|
|
220
|
+
|
|
221
|
+
## For components: the handle subpath
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
import { getHistory, whenHistory } from "@dojo-ng/history/handle";
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`getHistory()` returns the installed instance or `undefined`. `whenHistory()` returns a promise
|
|
228
|
+
that resolves at install. The subpath also exports the types and imports nothing, so a component
|
|
229
|
+
that only reads the location does not pull in the rest of the package. The instance is stored on
|
|
230
|
+
`globalThis[Symbol.for("dojo-ng.history")]`, so two copies of the package in one bundle share one.
|
|
231
|
+
|
|
232
|
+
## The dj-navigate event
|
|
233
|
+
|
|
234
|
+
A component that has a path does not need the instance. It dispatches a bubbling, composed
|
|
235
|
+
`dj-navigate` event, and `installHistory` listens for it on `document`:
|
|
236
|
+
|
|
237
|
+
```js
|
|
238
|
+
this.dispatchEvent(new CustomEvent("dj-navigate", {
|
|
239
|
+
detail: { path: "/orders/7", state: undefined, trail: [{ path: "/orders", label: "Orders" }, { path: "/orders/7", label: "Order 7" }], replace: false },
|
|
240
|
+
bubbles: true,
|
|
241
|
+
composed: true,
|
|
242
|
+
}));
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
It becomes a `nav:go`. `trail` is stored in the entry's state, so it comes back on Back, Forward,
|
|
246
|
+
and a same-tab reload. Other keys in `detail` (such as `label`) are ignored here.
|
|
247
|
+
|
|
248
|
+
## Another router on the page
|
|
249
|
+
|
|
250
|
+
If something else seems to be writing history, one `console.warn` per kind of signal is logged and
|
|
251
|
+
the layer carries on: `history.pushState` or `replaceState` is not native; Back or Forward lands
|
|
252
|
+
on an entry this layer did not create; or the location changed without a `nav:go`. This package
|
|
253
|
+
never patches `pushState`.
|
|
254
|
+
|
|
255
|
+
## Playground
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
cd packages/history
|
|
259
|
+
npm run build
|
|
260
|
+
npm run playground # http://localhost:8902/
|
|
261
|
+
```
|
package/dist/handle.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
/** What a blocker is asked about: a `nav:go`, or a Back/Forward (`source: "pop"`) to `path`. */
|
|
49
|
+
export type BlockTarget = NavGo | {
|
|
50
|
+
path: string;
|
|
51
|
+
source: "pop";
|
|
52
|
+
};
|
|
53
|
+
/** Return true to allow the move. May be async. See `HistoryInstance.block`. */
|
|
54
|
+
export type Blocker = (to: BlockTarget, from: NavLocation) => boolean | Promise<boolean>;
|
|
55
|
+
export interface HistoryInstance {
|
|
56
|
+
/** Publish `nav:go`. The only way anything here writes to the browser's history. */
|
|
57
|
+
go(path: string, state?: unknown, options?: GoOptions): void;
|
|
58
|
+
back(): void;
|
|
59
|
+
forward(): void;
|
|
60
|
+
/** Commit the current entry now, without waiting out the dwell time. */
|
|
61
|
+
commit(): void;
|
|
62
|
+
/** Follow the location. The current one is delivered at once. Returns an unsubscribe function. */
|
|
63
|
+
subscribe(fn: (location: NavLocation) => void): () => void;
|
|
64
|
+
/** The current location, or `undefined` until the first one is published. */
|
|
65
|
+
readonly current: NavLocation | undefined;
|
|
66
|
+
readonly canGoBack: boolean;
|
|
67
|
+
readonly canGoForward: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Ask before a move. `blocker` runs for every `nav:go` and every Back/Forward; all active blockers
|
|
70
|
+
* must return true. A refused move writes and publishes nothing. Returns a function that removes the
|
|
71
|
+
* blocker. While any blocker is active, closing the tab shows the browser's own prompt.
|
|
72
|
+
*/
|
|
73
|
+
block(blocker: Blocker): () => void;
|
|
74
|
+
/** The URL for an app path: `base` plus `path`. In `mode: "hash"`, `#` plus `path`. */
|
|
75
|
+
href(path: string): string;
|
|
76
|
+
/** Merge `state` and/or `trail` into the current entry, then publish the location again. */
|
|
77
|
+
annotate(partial: NavAnnotation): void;
|
|
78
|
+
/** Remove listeners and unregister. For tests. */
|
|
79
|
+
destroy(): void;
|
|
80
|
+
}
|
|
81
|
+
/** The installed instance, or `undefined` when `installHistory` has not run. */
|
|
82
|
+
export declare function getHistory(): HistoryInstance | undefined;
|
|
83
|
+
/** Resolves with the instance: at once if installed, otherwise when `installHistory` runs. */
|
|
84
|
+
export declare function whenHistory(): Promise<HistoryInstance>;
|
|
85
|
+
/** @internal Called by `installHistory`. Not part of the public API. */
|
|
86
|
+
export declare function registerHistory(instance: HistoryInstance): void;
|
|
87
|
+
/** @internal Called by `destroy()`. Only removes the registration if it is still `instance`. */
|
|
88
|
+
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,82 @@
|
|
|
1
|
+
import type { PubSub } from "@dojo-ng/pubsub";
|
|
2
|
+
import { type CommitEntry, type HistoryInstance, type NavLocation } 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
|
+
go(delta: number): void;
|
|
11
|
+
scrollRestoration?: "auto" | "manual";
|
|
12
|
+
readonly state: unknown;
|
|
13
|
+
};
|
|
14
|
+
location: {
|
|
15
|
+
readonly pathname: string;
|
|
16
|
+
readonly search: string;
|
|
17
|
+
readonly hash: string;
|
|
18
|
+
readonly href: string;
|
|
19
|
+
};
|
|
20
|
+
readonly scrollX?: number;
|
|
21
|
+
readonly scrollY?: number;
|
|
22
|
+
scrollTo?(x: number, y: number): void;
|
|
23
|
+
requestAnimationFrame?(callback: () => void): unknown;
|
|
24
|
+
addEventListener(type: string, listener: (event: never) => void): void;
|
|
25
|
+
removeEventListener(type: string, listener: (event: never) => void): void;
|
|
26
|
+
document: {
|
|
27
|
+
readonly visibilityState?: string;
|
|
28
|
+
readonly activeElement?: Element | null;
|
|
29
|
+
getElementById?(id: string): Element | null;
|
|
30
|
+
querySelector?(selector: string): Element | null;
|
|
31
|
+
addEventListener(type: string, listener: (event: never) => void): void;
|
|
32
|
+
removeEventListener(type: string, listener: (event: never) => void): void;
|
|
33
|
+
};
|
|
34
|
+
readonly sessionStorage?: {
|
|
35
|
+
getItem(key: string): string | null;
|
|
36
|
+
setItem(key: string, value: string): void;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** What `scroll` takes beyond `true`. */
|
|
40
|
+
export interface ScrollOptions {
|
|
41
|
+
/** Scrollable elements besides the window. Each needs an `id`; one without is skipped, with a warning. */
|
|
42
|
+
targets?: () => Iterable<Element>;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve when the view for `location` has rendered. Without it, restoring waits two animation
|
|
45
|
+
* frames, which is right for a synchronous render and not enough for data that loads later.
|
|
46
|
+
*/
|
|
47
|
+
ready?: (location: NavLocation) => Promise<void> | void;
|
|
48
|
+
}
|
|
49
|
+
export interface InstallOptions {
|
|
50
|
+
/** A path prefix such as `/acme`. Added to every URL written, stripped from every path read. */
|
|
51
|
+
base?: string;
|
|
52
|
+
/** Default `globalThis.window`. Exists so tests can inject a fake. */
|
|
53
|
+
window?: HistoryWindow;
|
|
54
|
+
/** How long (visible) milliseconds an entry must stay current before it is committed. Default 3000. 0 turns skimming off. */
|
|
55
|
+
dwellMs?: number;
|
|
56
|
+
/** Called once per entry when it commits. Never for a skimmed entry, never for the landing entry. */
|
|
57
|
+
onCommit?: (entry: CommitEntry) => void;
|
|
58
|
+
/**
|
|
59
|
+
* Called at install when the landing entry carries no state of ours (a new tab, a pasted link, a
|
|
60
|
+
* bookmark). Its result becomes the entry's state before `nav:location` is published as `load`.
|
|
61
|
+
*/
|
|
62
|
+
resolve?: (path: string) => Promise<unknown>;
|
|
63
|
+
/**
|
|
64
|
+
* `"hash"` keeps the route after `#` (`#/orders/7`), so a server with no SPA fallback can serve any
|
|
65
|
+
* deep link. Default `"path"`. Hash mode owns the whole hash, so in-page `#anchor` links don't work
|
|
66
|
+
* there, and `base` is ignored.
|
|
67
|
+
*/
|
|
68
|
+
mode?: "path" | "hash";
|
|
69
|
+
/**
|
|
70
|
+
* Restore the scroll position on Back, Forward and reload, and scroll to the top (or to a
|
|
71
|
+
* `#fragment`) after a `go`. Off by default. Turning it on sets `history.scrollRestoration` to
|
|
72
|
+
* `"manual"` until `destroy()`.
|
|
73
|
+
*/
|
|
74
|
+
scroll?: boolean | ScrollOptions;
|
|
75
|
+
/**
|
|
76
|
+
* A selector. After a `go` (not a Back or a reload), focus the first match so keyboard and screen
|
|
77
|
+
* reader users learn the view changed. An element that can't take focus gets `tabindex="-1"` until
|
|
78
|
+
* it blurs. Focus already inside the match is left alone. Default off.
|
|
79
|
+
*/
|
|
80
|
+
focus?: string | false;
|
|
81
|
+
}
|
|
82
|
+
export declare function installHistory(bus: PubSub, options?: InstallOptions): HistoryInstance;
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
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 SCROLL_KEY = "dojo-ng.history.scroll";
|
|
5
|
+
const SCROLL_CAP = 50;
|
|
6
|
+
const FOCUSABLE = "a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable]";
|
|
7
|
+
const DUPLICATE_INSTALL_WARNED = new WeakSet();
|
|
8
|
+
let idCounter = 0;
|
|
9
|
+
function newId() {
|
|
10
|
+
return `${Date.now().toString(36)}-${(++idCounter).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
11
|
+
}
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return typeof value === "object" && value !== null;
|
|
14
|
+
}
|
|
15
|
+
function readStamp(raw) {
|
|
16
|
+
if (!isRecord(raw))
|
|
17
|
+
return undefined;
|
|
18
|
+
const stamp = raw.__djnav;
|
|
19
|
+
if (!isRecord(stamp) || typeof stamp.id !== "string" || typeof stamp.index !== "number")
|
|
20
|
+
return undefined;
|
|
21
|
+
return { id: stamp.id, index: stamp.index };
|
|
22
|
+
}
|
|
23
|
+
function isNative(fn) {
|
|
24
|
+
try {
|
|
25
|
+
return Function.prototype.toString.call(fn).includes("[native code]");
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function normalizeBase(base) {
|
|
32
|
+
if (!base)
|
|
33
|
+
return "";
|
|
34
|
+
const trimmed = base.replace(/\/+$/, "");
|
|
35
|
+
return trimmed === "" || trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
36
|
+
}
|
|
37
|
+
export function installHistory(bus, options = {}) {
|
|
38
|
+
const existing = getHistory();
|
|
39
|
+
if (existing) {
|
|
40
|
+
if (!DUPLICATE_INSTALL_WARNED.has(existing)) {
|
|
41
|
+
DUPLICATE_INSTALL_WARNED.add(existing);
|
|
42
|
+
console.warn("[@dojo-ng/history] installHistory was called more than once. There is one history stack; returning the first instance.");
|
|
43
|
+
}
|
|
44
|
+
return existing;
|
|
45
|
+
}
|
|
46
|
+
const win = options.window ?? globalThis.window;
|
|
47
|
+
if (!win)
|
|
48
|
+
throw new Error("[@dojo-ng/history] installHistory needs a window. Pass options.window outside a browser.");
|
|
49
|
+
const hashMode = options.mode === "hash";
|
|
50
|
+
const base = hashMode ? "" : normalizeBase(options.base);
|
|
51
|
+
const dwellMs = options.dwellMs ?? DEFAULT_DWELL_MS;
|
|
52
|
+
const scrollOn = Boolean(options.scroll);
|
|
53
|
+
const scrollOpts = typeof options.scroll === "object" ? options.scroll : {};
|
|
54
|
+
const focusSelector = options.focus || undefined;
|
|
55
|
+
const blockers = new Set();
|
|
56
|
+
const warned = new Set();
|
|
57
|
+
let guardToken = 0;
|
|
58
|
+
let undoing;
|
|
59
|
+
let redoing = false;
|
|
60
|
+
let pendingAdopt = false;
|
|
61
|
+
let settleToken = 0;
|
|
62
|
+
let savedRestoration;
|
|
63
|
+
const provisionalIds = new Set();
|
|
64
|
+
let cur;
|
|
65
|
+
let last;
|
|
66
|
+
let expectedPlace = "";
|
|
67
|
+
let destroyed = false;
|
|
68
|
+
let timer;
|
|
69
|
+
let remaining = dwellMs;
|
|
70
|
+
let startedAt = 0;
|
|
71
|
+
// --- helpers -------------------------------------------------------------------------------
|
|
72
|
+
function warnOnce(signal, message) {
|
|
73
|
+
if (warned.has(signal))
|
|
74
|
+
return;
|
|
75
|
+
warned.add(signal);
|
|
76
|
+
console.warn(`[@dojo-ng/history] ${message} (${signal}). Dojo NG routing stays in charge; this is the only warning of this kind.`);
|
|
77
|
+
}
|
|
78
|
+
function currentPath() {
|
|
79
|
+
if (hashMode) {
|
|
80
|
+
const route = win.location.hash.replace(/^#/, "");
|
|
81
|
+
return route === "" ? "/" : route.startsWith("/") ? route : `/${route}`;
|
|
82
|
+
}
|
|
83
|
+
const full = win.location.pathname + win.location.search + win.location.hash;
|
|
84
|
+
if (base && (full === base || full.startsWith(`${base}/`) || full.startsWith(`${base}?`) || full.startsWith(`${base}#`))) {
|
|
85
|
+
const rest = full.slice(base.length);
|
|
86
|
+
return rest.startsWith("/") ? rest : `/${rest}`;
|
|
87
|
+
}
|
|
88
|
+
return full;
|
|
89
|
+
}
|
|
90
|
+
function place() {
|
|
91
|
+
return win.location.pathname + win.location.search + (hashMode ? win.location.hash : "");
|
|
92
|
+
}
|
|
93
|
+
function hrefOf(path) {
|
|
94
|
+
return hashMode ? `#${path}` : base + path;
|
|
95
|
+
}
|
|
96
|
+
function stateFor(entry) {
|
|
97
|
+
return { __djnav: { id: entry.id, index: entry.index }, state: entry.state, trail: entry.trail };
|
|
98
|
+
}
|
|
99
|
+
/** The one place that writes to the browser's history. Called from `onGo`, install, and annotate; never from `onPop`. */
|
|
100
|
+
function writeEntry(mode, entry, url) {
|
|
101
|
+
if (mode === "push")
|
|
102
|
+
win.history.pushState(stateFor(entry), "", url);
|
|
103
|
+
else
|
|
104
|
+
win.history.replaceState(stateFor(entry), "", url);
|
|
105
|
+
}
|
|
106
|
+
function entryFromState(raw, stamp) {
|
|
107
|
+
const record = raw;
|
|
108
|
+
return {
|
|
109
|
+
id: stamp.id,
|
|
110
|
+
index: stamp.index,
|
|
111
|
+
path: currentPath(),
|
|
112
|
+
state: record.state,
|
|
113
|
+
trail: Array.isArray(record.trail) ? record.trail : undefined,
|
|
114
|
+
committed: !provisionalIds.has(stamp.id),
|
|
115
|
+
source: "pop",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function readMax() {
|
|
119
|
+
try {
|
|
120
|
+
const value = win.sessionStorage?.getItem(MAX_INDEX_KEY);
|
|
121
|
+
if (value === null || value === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
const n = Number(value);
|
|
124
|
+
return Number.isFinite(n) ? n : undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function writeMax(n) {
|
|
131
|
+
try {
|
|
132
|
+
win.sessionStorage?.setItem(MAX_INDEX_KEY, String(n));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Storage can be blocked. canGoForward is then false, which is harmless.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function toLocation(entry) {
|
|
139
|
+
return {
|
|
140
|
+
path: entry.path,
|
|
141
|
+
state: entry.state,
|
|
142
|
+
index: entry.index,
|
|
143
|
+
committed: entry.committed,
|
|
144
|
+
source: entry.source,
|
|
145
|
+
...(entry.trail ? { trail: entry.trail } : {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function publish() {
|
|
149
|
+
const location = toLocation(cur);
|
|
150
|
+
last = location;
|
|
151
|
+
bus.publish("nav:location", location);
|
|
152
|
+
}
|
|
153
|
+
function fireCommit(entry) {
|
|
154
|
+
if (!options.onCommit)
|
|
155
|
+
return;
|
|
156
|
+
try {
|
|
157
|
+
options.onCommit({ path: entry.path, state: entry.state, ...(entry.trail ? { trail: entry.trail } : {}) });
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
console.error("[@dojo-ng/history] onCommit threw:", err);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// --- dwell ---------------------------------------------------------------------------------
|
|
164
|
+
function isVisible() {
|
|
165
|
+
return win.document.visibilityState !== "hidden";
|
|
166
|
+
}
|
|
167
|
+
function stopTimer() {
|
|
168
|
+
if (timer !== undefined)
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
timer = undefined;
|
|
171
|
+
}
|
|
172
|
+
function runTimer() {
|
|
173
|
+
startedAt = Date.now();
|
|
174
|
+
timer = setTimeout(() => {
|
|
175
|
+
timer = undefined;
|
|
176
|
+
commitCurrent();
|
|
177
|
+
}, remaining);
|
|
178
|
+
}
|
|
179
|
+
/** Start counting dwell time for a provisional entry. Counts visible time only. */
|
|
180
|
+
function startDwell() {
|
|
181
|
+
stopTimer();
|
|
182
|
+
remaining = dwellMs;
|
|
183
|
+
if (isVisible())
|
|
184
|
+
runTimer();
|
|
185
|
+
}
|
|
186
|
+
function onVisibility() {
|
|
187
|
+
if (destroyed)
|
|
188
|
+
return;
|
|
189
|
+
if (!isVisible()) {
|
|
190
|
+
if (timer !== undefined) {
|
|
191
|
+
stopTimer();
|
|
192
|
+
remaining = Math.max(0, remaining - (Date.now() - startedAt));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (!cur.committed && timer === undefined) {
|
|
196
|
+
runTimer();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function commitCurrent() {
|
|
200
|
+
if (destroyed || cur.committed)
|
|
201
|
+
return;
|
|
202
|
+
stopTimer();
|
|
203
|
+
cur.committed = true;
|
|
204
|
+
provisionalIds.delete(cur.id);
|
|
205
|
+
publish();
|
|
206
|
+
fireCommit(cur);
|
|
207
|
+
}
|
|
208
|
+
// --- scroll and focus ----------------------------------------------------------------------
|
|
209
|
+
// Positions live in sessionStorage by entry id, never in history state: writing them into state
|
|
210
|
+
// would need a replaceState from a scroll or pop handler, and only nav:go may write.
|
|
211
|
+
function readScrollStore() {
|
|
212
|
+
try {
|
|
213
|
+
const raw = win.sessionStorage?.getItem(SCROLL_KEY);
|
|
214
|
+
const parsed = raw ? JSON.parse(raw) : undefined;
|
|
215
|
+
if (parsed && Array.isArray(parsed.order) && isRecord(parsed.pos))
|
|
216
|
+
return parsed;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Blocked storage or a damaged value: start empty.
|
|
220
|
+
}
|
|
221
|
+
return { order: [], pos: {} };
|
|
222
|
+
}
|
|
223
|
+
function writeScrollStore(store) {
|
|
224
|
+
try {
|
|
225
|
+
win.sessionStorage?.setItem(SCROLL_KEY, JSON.stringify(store));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Storage can be blocked. Restoring is then a no-op.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function saveScroll(id) {
|
|
232
|
+
if (!scrollOn)
|
|
233
|
+
return;
|
|
234
|
+
const record = { x: win.scrollX ?? 0, y: win.scrollY ?? 0 };
|
|
235
|
+
if (scrollOpts.targets) {
|
|
236
|
+
for (const el of scrollOpts.targets()) {
|
|
237
|
+
if (!el.id) {
|
|
238
|
+
warnOnce("scroll-target-id", "A scroll target has no id, so its position can't be saved");
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
(record.t ?? (record.t = {}))[el.id] = [el.scrollLeft, el.scrollTop];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const store = readScrollStore();
|
|
245
|
+
if (!(id in store.pos))
|
|
246
|
+
store.order.push(id);
|
|
247
|
+
store.pos[id] = record;
|
|
248
|
+
while (store.order.length > SCROLL_CAP)
|
|
249
|
+
delete store.pos[store.order.shift()];
|
|
250
|
+
writeScrollStore(store);
|
|
251
|
+
}
|
|
252
|
+
function dropScroll(id) {
|
|
253
|
+
if (!scrollOn)
|
|
254
|
+
return;
|
|
255
|
+
const store = readScrollStore();
|
|
256
|
+
if (!(id in store.pos))
|
|
257
|
+
return;
|
|
258
|
+
delete store.pos[id];
|
|
259
|
+
store.order = store.order.filter((other) => other !== id);
|
|
260
|
+
writeScrollStore(store);
|
|
261
|
+
}
|
|
262
|
+
function restoreScroll(id) {
|
|
263
|
+
const record = readScrollStore().pos[id];
|
|
264
|
+
if (!record)
|
|
265
|
+
return;
|
|
266
|
+
win.scrollTo?.(record.x, record.y);
|
|
267
|
+
if (!scrollOpts.targets || !record.t)
|
|
268
|
+
return;
|
|
269
|
+
for (const el of scrollOpts.targets()) {
|
|
270
|
+
const at = el.id ? record.t[el.id] : undefined;
|
|
271
|
+
if (at) {
|
|
272
|
+
el.scrollLeft = at[0];
|
|
273
|
+
el.scrollTop = at[1];
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function scrollForGo(path) {
|
|
278
|
+
const hash = path.indexOf("#");
|
|
279
|
+
let target;
|
|
280
|
+
if (!hashMode && hash >= 0) {
|
|
281
|
+
try {
|
|
282
|
+
target = win.document.getElementById?.(decodeURIComponent(path.slice(hash + 1)));
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
target = undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (target)
|
|
289
|
+
target.scrollIntoView();
|
|
290
|
+
else
|
|
291
|
+
win.scrollTo?.(0, 0);
|
|
292
|
+
}
|
|
293
|
+
function moveFocus() {
|
|
294
|
+
const target = win.document.querySelector?.(focusSelector);
|
|
295
|
+
if (!target)
|
|
296
|
+
return;
|
|
297
|
+
const active = win.document.activeElement;
|
|
298
|
+
if (active && target.contains(active))
|
|
299
|
+
return;
|
|
300
|
+
if (!target.matches(FOCUSABLE)) {
|
|
301
|
+
target.setAttribute("tabindex", "-1");
|
|
302
|
+
target.addEventListener("blur", () => target.removeAttribute("tabindex"), { once: true });
|
|
303
|
+
}
|
|
304
|
+
target.focus({ preventScroll: scrollOn });
|
|
305
|
+
}
|
|
306
|
+
/** The view renders after the publish, and history can't see it (D4), so wait for the app's word or two frames. */
|
|
307
|
+
function whenRendered(location) {
|
|
308
|
+
if (scrollOpts.ready) {
|
|
309
|
+
return new Promise((resolve) => resolve(scrollOpts.ready(location))).catch((err) => {
|
|
310
|
+
console.error("[@dojo-ng/history] scroll.ready rejected; restoring anyway:", err);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const frame = win.requestAnimationFrame?.bind(win);
|
|
314
|
+
if (!frame)
|
|
315
|
+
return Promise.resolve();
|
|
316
|
+
return new Promise((resolve) => frame(() => frame(() => resolve())));
|
|
317
|
+
}
|
|
318
|
+
/** Called after a published location. A newer navigation cancels the wait of an older one. */
|
|
319
|
+
function afterNavigation(entry) {
|
|
320
|
+
if (!scrollOn && !focusSelector)
|
|
321
|
+
return;
|
|
322
|
+
const token = ++settleToken;
|
|
323
|
+
const isGo = entry.source === "go";
|
|
324
|
+
const path = entry.path;
|
|
325
|
+
void whenRendered(toLocation(entry)).then(() => {
|
|
326
|
+
if (destroyed || token !== settleToken)
|
|
327
|
+
return;
|
|
328
|
+
if (scrollOn) {
|
|
329
|
+
if (isGo)
|
|
330
|
+
scrollForGo(path);
|
|
331
|
+
else
|
|
332
|
+
restoreScroll(entry.id);
|
|
333
|
+
}
|
|
334
|
+
if (focusSelector && isGo)
|
|
335
|
+
moveFocus();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
function onPageHide() {
|
|
339
|
+
if (!destroyed)
|
|
340
|
+
saveScroll(cur.id);
|
|
341
|
+
}
|
|
342
|
+
// --- guards --------------------------------------------------------------------------------
|
|
343
|
+
async function allows(to) {
|
|
344
|
+
const from = last ?? toLocation(cur);
|
|
345
|
+
try {
|
|
346
|
+
const answers = await Promise.all([...blockers].map(({ fn }) => fn(to, from)));
|
|
347
|
+
return answers.every((answer) => answer === true);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
console.error("[@dojo-ng/history] a blocker threw; the move is refused:", err);
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function onBeforeUnload(event) {
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
}
|
|
357
|
+
async function runUndone(pending) {
|
|
358
|
+
const ok = await allows(pending.to);
|
|
359
|
+
if (!ok || destroyed || pending.token !== guardToken)
|
|
360
|
+
return;
|
|
361
|
+
redoing = true;
|
|
362
|
+
win.history.go(pending.delta);
|
|
363
|
+
}
|
|
364
|
+
// --- the coexistence checks (D6) -----------------------------------------------------------
|
|
365
|
+
function checkForeignLocation() {
|
|
366
|
+
if (place() !== expectedPlace) {
|
|
367
|
+
warnOnce("foreign-location", "The location changed without a nav:go, so another router or script may be pushing history");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// --- inputs --------------------------------------------------------------------------------
|
|
371
|
+
function onGo(request) {
|
|
372
|
+
if (destroyed || !request || typeof request.path !== "string")
|
|
373
|
+
return;
|
|
374
|
+
if (pendingAdopt) {
|
|
375
|
+
// An address-bar hash edit the browser already made. A blocker can't undo it safely, so it isn't asked.
|
|
376
|
+
pendingAdopt = false;
|
|
377
|
+
performGo(request, true);
|
|
378
|
+
}
|
|
379
|
+
else if (blockers.size === 0) {
|
|
380
|
+
performGo(request, false);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
// Last one wins: a newer request, or any pop, makes this answer stale.
|
|
384
|
+
const token = ++guardToken;
|
|
385
|
+
void allows(request).then((ok) => {
|
|
386
|
+
if (ok && !destroyed && token === guardToken)
|
|
387
|
+
performGo(request, false);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** `adopt`: the browser has already pushed this entry (unstamped), so stamp it in place as the next index. */
|
|
392
|
+
function performGo(request, adopt) {
|
|
393
|
+
guardToken++;
|
|
394
|
+
if (!adopt)
|
|
395
|
+
checkForeignLocation();
|
|
396
|
+
const previous = cur;
|
|
397
|
+
const replace = !adopt && (request.replace === true || !previous.committed);
|
|
398
|
+
if (replace)
|
|
399
|
+
dropScroll(previous.id);
|
|
400
|
+
else
|
|
401
|
+
saveScroll(previous.id);
|
|
402
|
+
const entry = {
|
|
403
|
+
id: newId(),
|
|
404
|
+
index: replace ? previous.index : previous.index + 1,
|
|
405
|
+
path: request.path,
|
|
406
|
+
state: request.state,
|
|
407
|
+
trail: request.trail,
|
|
408
|
+
committed: false,
|
|
409
|
+
source: "go",
|
|
410
|
+
};
|
|
411
|
+
provisionalIds.delete(previous.id);
|
|
412
|
+
stopTimer();
|
|
413
|
+
writeEntry(replace || adopt ? "replace" : "push", entry, hrefOf(request.path));
|
|
414
|
+
if (!replace)
|
|
415
|
+
writeMax(entry.index);
|
|
416
|
+
expectedPlace = place();
|
|
417
|
+
cur = entry;
|
|
418
|
+
// A replace of an entry that had already committed keeps its place on the stack, so the
|
|
419
|
+
// replacement commits too. Otherwise the entry is provisional until it has been read.
|
|
420
|
+
const commitNow = dwellMs <= 0 || adopt || (replace && previous.committed);
|
|
421
|
+
if (commitNow) {
|
|
422
|
+
entry.committed = true;
|
|
423
|
+
publish();
|
|
424
|
+
fireCommit(entry);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
provisionalIds.add(entry.id);
|
|
428
|
+
publish();
|
|
429
|
+
startDwell();
|
|
430
|
+
}
|
|
431
|
+
afterNavigation(entry);
|
|
432
|
+
}
|
|
433
|
+
function onPop() {
|
|
434
|
+
if (destroyed)
|
|
435
|
+
return;
|
|
436
|
+
if (undoing) {
|
|
437
|
+
// The traversal we made to put a guarded Back back. Nothing changed; ask the blockers.
|
|
438
|
+
const pending = undoing;
|
|
439
|
+
undoing = undefined;
|
|
440
|
+
void runUndone(pending);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (redoing) {
|
|
444
|
+
redoing = false;
|
|
445
|
+
settlePop();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (blockers.size > 0) {
|
|
449
|
+
const target = readStamp(win.history.state);
|
|
450
|
+
if (target && target.id !== cur.id && target.index !== cur.index) {
|
|
451
|
+
const delta = target.index - cur.index;
|
|
452
|
+
undoing = { delta, token: ++guardToken, to: { path: currentPath(), source: "pop" } };
|
|
453
|
+
win.history.go(-delta);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
settlePop();
|
|
458
|
+
}
|
|
459
|
+
function settlePop() {
|
|
460
|
+
guardToken++;
|
|
461
|
+
const raw = win.history.state;
|
|
462
|
+
const stamp = readStamp(raw);
|
|
463
|
+
// Hash mode: an unstamped entry here is an address-bar edit. The hashchange that follows adopts it.
|
|
464
|
+
if (hashMode && !stamp && place() !== expectedPlace)
|
|
465
|
+
return;
|
|
466
|
+
stopTimer();
|
|
467
|
+
saveScroll(cur.id);
|
|
468
|
+
let entry;
|
|
469
|
+
let newlyCommitted = false;
|
|
470
|
+
if (!stamp) {
|
|
471
|
+
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");
|
|
472
|
+
// Keeps the id of the entry we were on, so a later Back onto a stamped entry that is
|
|
473
|
+
// already "current" still reads as history changing without a nav:go.
|
|
474
|
+
entry = {
|
|
475
|
+
id: cur.id,
|
|
476
|
+
index: cur.index,
|
|
477
|
+
path: currentPath(),
|
|
478
|
+
state: undefined,
|
|
479
|
+
committed: true,
|
|
480
|
+
source: "pop",
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
if (stamp.id === cur.id) {
|
|
485
|
+
warnOnce("foreign-location", "Back landed on the entry already current, so history changed without a nav:go");
|
|
486
|
+
}
|
|
487
|
+
entry = entryFromState(raw, stamp);
|
|
488
|
+
if (!entry.committed) {
|
|
489
|
+
entry.committed = true;
|
|
490
|
+
provisionalIds.delete(entry.id);
|
|
491
|
+
newlyCommitted = true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
entry.source = "pop";
|
|
495
|
+
cur = entry;
|
|
496
|
+
expectedPlace = place();
|
|
497
|
+
publish();
|
|
498
|
+
if (newlyCommitted)
|
|
499
|
+
fireCommit(entry);
|
|
500
|
+
afterNavigation(entry);
|
|
501
|
+
}
|
|
502
|
+
function onHashChange() {
|
|
503
|
+
if (destroyed || readStamp(win.history.state))
|
|
504
|
+
return;
|
|
505
|
+
pendingAdopt = true;
|
|
506
|
+
bus.publish("nav:go", { path: currentPath(), replace: true });
|
|
507
|
+
pendingAdopt = false;
|
|
508
|
+
}
|
|
509
|
+
function onNavigate(event) {
|
|
510
|
+
const detail = event.detail;
|
|
511
|
+
if (!detail || typeof detail.path !== "string")
|
|
512
|
+
return;
|
|
513
|
+
bus.publish("nav:go", {
|
|
514
|
+
path: detail.path,
|
|
515
|
+
state: detail.state,
|
|
516
|
+
trail: detail.trail,
|
|
517
|
+
replace: detail.replace === true,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
// --- the instance --------------------------------------------------------------------------
|
|
521
|
+
const instance = {
|
|
522
|
+
go(path, state, goOptions) {
|
|
523
|
+
bus.publish("nav:go", { path, state, trail: goOptions?.trail, replace: goOptions?.replace === true });
|
|
524
|
+
},
|
|
525
|
+
back() {
|
|
526
|
+
win.history.back();
|
|
527
|
+
},
|
|
528
|
+
forward() {
|
|
529
|
+
win.history.forward();
|
|
530
|
+
},
|
|
531
|
+
commit: commitCurrent,
|
|
532
|
+
subscribe(fn) {
|
|
533
|
+
return bus.subscribe("nav:location", (location) => fn(location));
|
|
534
|
+
},
|
|
535
|
+
get current() {
|
|
536
|
+
return last;
|
|
537
|
+
},
|
|
538
|
+
get canGoBack() {
|
|
539
|
+
return cur.index > 0;
|
|
540
|
+
},
|
|
541
|
+
get canGoForward() {
|
|
542
|
+
const max = readMax();
|
|
543
|
+
return max !== undefined && cur.index < max;
|
|
544
|
+
},
|
|
545
|
+
block(blocker) {
|
|
546
|
+
const entry = { fn: blocker };
|
|
547
|
+
blockers.add(entry);
|
|
548
|
+
if (blockers.size === 1)
|
|
549
|
+
win.addEventListener("beforeunload", onBeforeUnload);
|
|
550
|
+
return () => {
|
|
551
|
+
if (!blockers.delete(entry) || blockers.size > 0)
|
|
552
|
+
return;
|
|
553
|
+
win.removeEventListener("beforeunload", onBeforeUnload);
|
|
554
|
+
};
|
|
555
|
+
},
|
|
556
|
+
href: hrefOf,
|
|
557
|
+
annotate(partial) {
|
|
558
|
+
if (destroyed)
|
|
559
|
+
return;
|
|
560
|
+
if ("state" in partial)
|
|
561
|
+
cur.state = partial.state;
|
|
562
|
+
if ("trail" in partial)
|
|
563
|
+
cur.trail = partial.trail;
|
|
564
|
+
writeEntry("replace", cur);
|
|
565
|
+
publish();
|
|
566
|
+
},
|
|
567
|
+
destroy() {
|
|
568
|
+
if (destroyed)
|
|
569
|
+
return;
|
|
570
|
+
destroyed = true;
|
|
571
|
+
stopTimer();
|
|
572
|
+
unsubscribeGo();
|
|
573
|
+
win.removeEventListener("popstate", onPop);
|
|
574
|
+
win.removeEventListener("hashchange", onHashChange);
|
|
575
|
+
win.removeEventListener("pagehide", onPageHide);
|
|
576
|
+
if (blockers.size > 0)
|
|
577
|
+
win.removeEventListener("beforeunload", onBeforeUnload);
|
|
578
|
+
blockers.clear();
|
|
579
|
+
if (savedRestoration !== undefined)
|
|
580
|
+
win.history.scrollRestoration = savedRestoration;
|
|
581
|
+
win.document.removeEventListener("visibilitychange", onVisibility);
|
|
582
|
+
win.document.removeEventListener("dj-navigate", onNavigate);
|
|
583
|
+
unregisterHistory(instance);
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
// --- install -------------------------------------------------------------------------------
|
|
587
|
+
if (!isNative(win.history.pushState) || !isNative(win.history.replaceState)) {
|
|
588
|
+
warnOnce("patched-pushstate", "history.pushState or replaceState is not the browser's own, so another router may be patching them");
|
|
589
|
+
}
|
|
590
|
+
if (hashMode && options.base)
|
|
591
|
+
warnOnce("hash-base", "`base` is ignored in hash mode, because the route lives after the #");
|
|
592
|
+
if (scrollOn && win.history.scrollRestoration !== undefined) {
|
|
593
|
+
savedRestoration = win.history.scrollRestoration;
|
|
594
|
+
win.history.scrollRestoration = "manual";
|
|
595
|
+
}
|
|
596
|
+
const raw = win.history.state;
|
|
597
|
+
const stamp = readStamp(raw);
|
|
598
|
+
let fresh = false;
|
|
599
|
+
if (stamp) {
|
|
600
|
+
cur = { ...entryFromState(raw, stamp), committed: true, source: "load" };
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
// A fresh tab, pasted link, or bookmark. Stamp the entry so none ever has null state (app3 lesson 5).
|
|
604
|
+
fresh = true;
|
|
605
|
+
cur = { id: newId(), index: 0, path: currentPath(), state: undefined, committed: true, source: "load" };
|
|
606
|
+
writeEntry("replace", cur);
|
|
607
|
+
writeMax(0);
|
|
608
|
+
}
|
|
609
|
+
expectedPlace = place();
|
|
610
|
+
const unsubscribeGo = bus.subscribe("nav:go", onGo, { replay: false });
|
|
611
|
+
win.addEventListener("popstate", onPop);
|
|
612
|
+
if (hashMode)
|
|
613
|
+
win.addEventListener("hashchange", onHashChange);
|
|
614
|
+
if (scrollOn)
|
|
615
|
+
win.addEventListener("pagehide", onPageHide);
|
|
616
|
+
win.document.addEventListener("visibilitychange", onVisibility);
|
|
617
|
+
win.document.addEventListener("dj-navigate", onNavigate);
|
|
618
|
+
registerHistory(instance);
|
|
619
|
+
if (fresh && options.resolve) {
|
|
620
|
+
const landing = cur;
|
|
621
|
+
new Promise((resolve) => resolve(options.resolve(landing.path))).then((result) => {
|
|
622
|
+
if (destroyed || cur !== landing)
|
|
623
|
+
return;
|
|
624
|
+
if (result !== undefined) {
|
|
625
|
+
landing.state = result;
|
|
626
|
+
writeEntry("replace", landing);
|
|
627
|
+
}
|
|
628
|
+
publish();
|
|
629
|
+
}, (err) => {
|
|
630
|
+
console.error("[@dojo-ng/history] resolve rejected; publishing the bare path:", err);
|
|
631
|
+
if (destroyed || cur !== landing)
|
|
632
|
+
return;
|
|
633
|
+
publish();
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
publish();
|
|
638
|
+
if (!fresh)
|
|
639
|
+
afterNavigation(cur);
|
|
640
|
+
}
|
|
641
|
+
return instance;
|
|
642
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { installHistory } from "./history.js";
|
|
2
|
+
export type { InstallOptions, HistoryWindow, ScrollOptions } from "./history.js";
|
|
3
|
+
export { match } from "./match.js";
|
|
4
|
+
export { getHistory, whenHistory } from "./handle.js";
|
|
5
|
+
export type { Blocker, BlockTarget, CommitEntry, GoOptions, HistoryInstance, NavAnnotation, NavGo, NavigateDetail, NavLocation, NavSource, TrailEntry, } from "./handle.js";
|
package/dist/index.js
ADDED
package/dist/match.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.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
|
+
}
|