@dojo-ng/history 0.1.0 → 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 CHANGED
@@ -69,7 +69,8 @@ nothing else, so Back never adds an entry.
69
69
  - `canGoBack`, `canGoForward`: properties. The browser does not say whether Back or Forward has
70
70
  somewhere to go, so each entry carries a private `{ id, index }` in `history.state`, and the
71
71
  highest index is kept in `sessionStorage`. Without `sessionStorage`, `canGoForward` is `false`.
72
- - `href(path)`: `base` plus `path`, for a link's `href`
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.
73
74
  - `annotate({ state?, trail? })`: replace those fields on the current entry (with `replaceState`)
74
75
  and publish the location again. It sets whichever of the two you pass; it does not merge inside `state`.
75
76
  - `commit()`: commit the current entry now
@@ -81,6 +82,9 @@ nothing else, so Back never adds an entry.
81
82
  |---|---|---|
82
83
  | `base` | `""` | A path prefix such as `/acme`. Added to every URL written and stripped from every path read. |
83
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. |
84
88
  | `onCommit` | none | Called once for each entry that commits. See Hooks. |
85
89
  | `resolve` | none | Rebuilds the state of a landing entry that has none. See Hooks. |
86
90
  | `window` | `globalThis.window` | For tests: any object shaped like the parts of `window` this uses. |
@@ -112,6 +116,94 @@ result becomes the entry's state. If it rejects, the error is logged and the bar
112
116
  published. A reload in the same tab keeps its state, so `resolve` is not called. If the app
113
117
  navigates before `resolve` settles, the late result is dropped.
114
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
+
115
207
  ## match
116
208
 
117
209
  ```js
package/dist/handle.d.ts CHANGED
@@ -45,6 +45,13 @@ export interface CommitEntry {
45
45
  state: unknown;
46
46
  trail?: TrailEntry[];
47
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>;
48
55
  export interface HistoryInstance {
49
56
  /** Publish `nav:go`. The only way anything here writes to the browser's history. */
50
57
  go(path: string, state?: unknown, options?: GoOptions): void;
@@ -58,7 +65,13 @@ export interface HistoryInstance {
58
65
  readonly current: NavLocation | undefined;
59
66
  readonly canGoBack: boolean;
60
67
  readonly canGoForward: boolean;
61
- /** The URL for an app path: `base` plus `path`. */
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`. */
62
75
  href(path: string): string;
63
76
  /** Merge `state` and/or `trail` into the current entry, then publish the location again. */
64
77
  annotate(partial: NavAnnotation): void;
package/dist/history.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PubSub } from "@dojo-ng/pubsub";
2
- import { type CommitEntry, type HistoryInstance } from "./handle.js";
2
+ import { type CommitEntry, type HistoryInstance, type NavLocation } from "./handle.js";
3
3
  /** The parts of `window` that `installHistory` uses. Tests pass a fake with this shape. */
4
4
  export interface HistoryWindow {
5
5
  history: {
@@ -7,6 +7,8 @@ export interface HistoryWindow {
7
7
  replaceState(state: unknown, title: string, url?: string | null): void;
8
8
  back(): void;
9
9
  forward(): void;
10
+ go(delta: number): void;
11
+ scrollRestoration?: "auto" | "manual";
10
12
  readonly state: unknown;
11
13
  };
12
14
  location: {
@@ -15,10 +17,17 @@ export interface HistoryWindow {
15
17
  readonly hash: string;
16
18
  readonly href: string;
17
19
  };
20
+ readonly scrollX?: number;
21
+ readonly scrollY?: number;
22
+ scrollTo?(x: number, y: number): void;
23
+ requestAnimationFrame?(callback: () => void): unknown;
18
24
  addEventListener(type: string, listener: (event: never) => void): void;
19
25
  removeEventListener(type: string, listener: (event: never) => void): void;
20
26
  document: {
21
27
  readonly visibilityState?: string;
28
+ readonly activeElement?: Element | null;
29
+ getElementById?(id: string): Element | null;
30
+ querySelector?(selector: string): Element | null;
22
31
  addEventListener(type: string, listener: (event: never) => void): void;
23
32
  removeEventListener(type: string, listener: (event: never) => void): void;
24
33
  };
@@ -27,6 +36,16 @@ export interface HistoryWindow {
27
36
  setItem(key: string, value: string): void;
28
37
  };
29
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
+ }
30
49
  export interface InstallOptions {
31
50
  /** A path prefix such as `/acme`. Added to every URL written, stripped from every path read. */
32
51
  base?: string;
@@ -41,5 +60,23 @@ export interface InstallOptions {
41
60
  * bookmark). Its result becomes the entry's state before `nav:location` is published as `load`.
42
61
  */
43
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;
44
81
  }
45
82
  export declare function installHistory(bus: PubSub, options?: InstallOptions): HistoryInstance;
package/dist/history.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { getHistory, registerHistory, unregisterHistory, } from "./handle.js";
2
2
  const DEFAULT_DWELL_MS = 3000;
3
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]";
4
7
  const DUPLICATE_INSTALL_WARNED = new WeakSet();
5
8
  let idCounter = 0;
6
9
  function newId() {
@@ -43,9 +46,20 @@ export function installHistory(bus, options = {}) {
43
46
  const win = options.window ?? globalThis.window;
44
47
  if (!win)
45
48
  throw new Error("[@dojo-ng/history] installHistory needs a window. Pass options.window outside a browser.");
46
- const base = normalizeBase(options.base);
49
+ const hashMode = options.mode === "hash";
50
+ const base = hashMode ? "" : normalizeBase(options.base);
47
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();
48
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;
49
63
  const provisionalIds = new Set();
50
64
  let cur;
51
65
  let last;
@@ -62,6 +76,10 @@ export function installHistory(bus, options = {}) {
62
76
  console.warn(`[@dojo-ng/history] ${message} (${signal}). Dojo NG routing stays in charge; this is the only warning of this kind.`);
63
77
  }
64
78
  function currentPath() {
79
+ if (hashMode) {
80
+ const route = win.location.hash.replace(/^#/, "");
81
+ return route === "" ? "/" : route.startsWith("/") ? route : `/${route}`;
82
+ }
65
83
  const full = win.location.pathname + win.location.search + win.location.hash;
66
84
  if (base && (full === base || full.startsWith(`${base}/`) || full.startsWith(`${base}?`) || full.startsWith(`${base}#`))) {
67
85
  const rest = full.slice(base.length);
@@ -70,7 +88,10 @@ export function installHistory(bus, options = {}) {
70
88
  return full;
71
89
  }
72
90
  function place() {
73
- return win.location.pathname + win.location.search;
91
+ return win.location.pathname + win.location.search + (hashMode ? win.location.hash : "");
92
+ }
93
+ function hrefOf(path) {
94
+ return hashMode ? `#${path}` : base + path;
74
95
  }
75
96
  function stateFor(entry) {
76
97
  return { __djnav: { id: entry.id, index: entry.index }, state: entry.state, trail: entry.trail };
@@ -114,15 +135,18 @@ export function installHistory(bus, options = {}) {
114
135
  // Storage can be blocked. canGoForward is then false, which is harmless.
115
136
  }
116
137
  }
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 } : {}),
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 } : {}),
125
146
  };
147
+ }
148
+ function publish() {
149
+ const location = toLocation(cur);
126
150
  last = location;
127
151
  bus.publish("nav:location", location);
128
152
  }
@@ -181,6 +205,162 @@ export function installHistory(bus, options = {}) {
181
205
  publish();
182
206
  fireCommit(cur);
183
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
+ }
184
364
  // --- the coexistence checks (D6) -----------------------------------------------------------
185
365
  function checkForeignLocation() {
186
366
  if (place() !== expectedPlace) {
@@ -191,9 +371,34 @@ export function installHistory(bus, options = {}) {
191
371
  function onGo(request) {
192
372
  if (destroyed || !request || typeof request.path !== "string")
193
373
  return;
194
- checkForeignLocation();
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();
195
396
  const previous = cur;
196
- const replace = request.replace === true || !previous.committed;
397
+ const replace = !adopt && (request.replace === true || !previous.committed);
398
+ if (replace)
399
+ dropScroll(previous.id);
400
+ else
401
+ saveScroll(previous.id);
197
402
  const entry = {
198
403
  id: newId(),
199
404
  index: replace ? previous.index : previous.index + 1,
@@ -205,14 +410,14 @@ export function installHistory(bus, options = {}) {
205
410
  };
206
411
  provisionalIds.delete(previous.id);
207
412
  stopTimer();
208
- writeEntry(replace ? "replace" : "push", entry, base + request.path);
413
+ writeEntry(replace || adopt ? "replace" : "push", entry, hrefOf(request.path));
209
414
  if (!replace)
210
415
  writeMax(entry.index);
211
416
  expectedPlace = place();
212
417
  cur = entry;
213
418
  // A replace of an entry that had already committed keeps its place on the stack, so the
214
419
  // replacement commits too. Otherwise the entry is provisional until it has been read.
215
- const commitNow = dwellMs <= 0 || (replace && previous.committed);
420
+ const commitNow = dwellMs <= 0 || adopt || (replace && previous.committed);
216
421
  if (commitNow) {
217
422
  entry.committed = true;
218
423
  publish();
@@ -223,13 +428,43 @@ export function installHistory(bus, options = {}) {
223
428
  publish();
224
429
  startDwell();
225
430
  }
431
+ afterNavigation(entry);
226
432
  }
227
433
  function onPop() {
228
434
  if (destroyed)
229
435
  return;
230
- stopTimer();
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++;
231
461
  const raw = win.history.state;
232
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);
233
468
  let entry;
234
469
  let newlyCommitted = false;
235
470
  if (!stamp) {
@@ -262,6 +497,14 @@ export function installHistory(bus, options = {}) {
262
497
  publish();
263
498
  if (newlyCommitted)
264
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;
265
508
  }
266
509
  function onNavigate(event) {
267
510
  const detail = event.detail;
@@ -299,9 +542,18 @@ export function installHistory(bus, options = {}) {
299
542
  const max = readMax();
300
543
  return max !== undefined && cur.index < max;
301
544
  },
302
- href(path) {
303
- return base + path;
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
+ };
304
555
  },
556
+ href: hrefOf,
305
557
  annotate(partial) {
306
558
  if (destroyed)
307
559
  return;
@@ -319,6 +571,13 @@ export function installHistory(bus, options = {}) {
319
571
  stopTimer();
320
572
  unsubscribeGo();
321
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;
322
581
  win.document.removeEventListener("visibilitychange", onVisibility);
323
582
  win.document.removeEventListener("dj-navigate", onNavigate);
324
583
  unregisterHistory(instance);
@@ -328,6 +587,12 @@ export function installHistory(bus, options = {}) {
328
587
  if (!isNative(win.history.pushState) || !isNative(win.history.replaceState)) {
329
588
  warnOnce("patched-pushstate", "history.pushState or replaceState is not the browser's own, so another router may be patching them");
330
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
+ }
331
596
  const raw = win.history.state;
332
597
  const stamp = readStamp(raw);
333
598
  let fresh = false;
@@ -344,6 +609,10 @@ export function installHistory(bus, options = {}) {
344
609
  expectedPlace = place();
345
610
  const unsubscribeGo = bus.subscribe("nav:go", onGo, { replay: false });
346
611
  win.addEventListener("popstate", onPop);
612
+ if (hashMode)
613
+ win.addEventListener("hashchange", onHashChange);
614
+ if (scrollOn)
615
+ win.addEventListener("pagehide", onPageHide);
347
616
  win.document.addEventListener("visibilitychange", onVisibility);
348
617
  win.document.addEventListener("dj-navigate", onNavigate);
349
618
  registerHistory(instance);
@@ -366,6 +635,8 @@ export function installHistory(bus, options = {}) {
366
635
  }
367
636
  else {
368
637
  publish();
638
+ if (!fresh)
639
+ afterNavigation(cur);
369
640
  }
370
641
  return instance;
371
642
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { installHistory } from "./history.js";
2
- export type { InstallOptions, HistoryWindow } from "./history.js";
2
+ export type { InstallOptions, HistoryWindow, ScrollOptions } from "./history.js";
3
3
  export { match } from "./match.js";
4
4
  export { getHistory, whenHistory } from "./handle.js";
5
- export type { CommitEntry, GoOptions, HistoryInstance, NavAnnotation, NavGo, NavigateDetail, NavLocation, NavSource, TrailEntry, } from "./handle.js";
5
+ export type { Blocker, BlockTarget, CommitEntry, GoOptions, HistoryInstance, NavAnnotation, NavGo, NavigateDetail, NavLocation, NavSource, TrailEntry, } from "./handle.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dojo-ng/history",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Dojo NG navigation history: real paths with pushState, Back and Forward that skip skimmed pages, and hooks for tracking",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",