| 1 | const KEY = 'backLayer'; |
| 2 | const SETTLE_MS = 400; |
| 3 | |
| 4 | export interface HistoryLike { |
| 5 | state: unknown; |
| 6 | pushState(state: unknown, unused: string, url?: string): void; |
| 7 | replaceState(state: unknown, unused: string, url?: string): void; |
| 8 | back(): void; |
| 9 | } |
| 10 | |
| 11 | interface Layer { |
| 12 | id: string; |
| 13 | close: (id: string) => void; |
| 14 | } |
| 15 | |
| 16 | function layerOf(state: unknown): string | undefined { |
| 17 | if (state && typeof state === 'object' && KEY in state) return String((state as Record<string, unknown>)[KEY]); |
| 18 | return undefined; |
| 19 | } |
| 20 | |
| 21 | function plain(state: unknown): Record<string, unknown> { |
| 22 | const { [KEY]: _drop, ...rest } = (state && typeof state === 'object' ? state : {}) as Record<string, unknown>; |
| 23 | return rest; |
| 24 | } |
| 25 | |
| 26 | export class BackStack { |
| 27 | private stack: Layer[] = []; |
| 28 | private queue: Array<() => void> = []; |
| 29 | private waiting = false; |
| 30 | private timer: ReturnType<typeof setTimeout> | null = null; |
| 31 | |
| 32 | constructor(private readonly history: HistoryLike) {} |
| 33 | |
| 34 | /** A reload can land on a layer entry whose layer no longer exists. */ |
| 35 | init(): void { |
| 36 | if (layerOf(this.history.state) !== undefined) this.history.replaceState(plain(this.history.state), ''); |
| 37 | } |
| 38 | |
| 39 | get depth(): number { |
| 40 | return this.stack.length; |
| 41 | } |
| 42 | |
| 43 | sync(id: string, open: boolean, close: (id: string) => void): void { |
| 44 | const index = this.stack.findIndex((l) => l.id === id); |
| 45 | if (open && index < 0) { |
| 46 | this.stack.push({ id, close }); |
| 47 | this.run(() => this.history.pushState({ ...plain(this.history.state), [KEY]: id }, '')); |
| 48 | } else if (open) { |
| 49 | this.stack[index].close = close; |
| 50 | } else if (index >= 0) { |
| 51 | const top = index === this.stack.length - 1; |
| 52 | this.stack.splice(index, 1); |
| 53 | if (top) this.run(() => this.rewind()); |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | /** Records a route, replacing the layer entry on top when layers are being dismissed by the navigation. */ |
| 58 | route(state: unknown, url: string): void { |
| 59 | this.run(() => { |
| 60 | if (this.stack.length > 0 && layerOf(this.history.state) !== undefined) { |
| 61 | this.stack = []; |
| 62 | this.history.replaceState(state, '', url); |
| 63 | } else { |
| 64 | this.history.pushState(state, '', url); |
| 65 | } |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | /** Returns true when the popstate belonged to the layer stack, false when it is a plain route change. */ |
| 70 | pop(state: unknown): boolean { |
| 71 | if (this.waiting) { |
| 72 | this.settle(); |
| 73 | return true; |
| 74 | } |
| 75 | const id = layerOf(state); |
| 76 | if (id === undefined) { |
| 77 | if (this.stack.length === 0) return false; |
| 78 | this.closeAbove(-1); |
| 79 | return true; |
| 80 | } |
| 81 | const index = this.stack.findIndex((l) => l.id === id); |
| 82 | if (index < 0) { |
| 83 | this.history.back(); |
| 84 | return true; |
| 85 | } |
| 86 | this.closeAbove(index); |
| 87 | return true; |
| 88 | } |
| 89 | |
| 90 | private closeAbove(index: number): void { |
| 91 | const closing = this.stack.splice(index + 1); |
| 92 | for (const layer of closing.reverse()) layer.close(layer.id); |
| 93 | } |
| 94 | |
| 95 | private rewind(): void { |
| 96 | if (layerOf(this.history.state) === undefined) return; |
| 97 | this.waiting = true; |
| 98 | this.timer = setTimeout(() => this.settle(), SETTLE_MS); |
| 99 | this.history.back(); |
| 100 | } |
| 101 | |
| 102 | private settle(): void { |
| 103 | this.waiting = false; |
| 104 | if (this.timer !== null) clearTimeout(this.timer); |
| 105 | this.timer = null; |
| 106 | const next = this.queue.shift(); |
| 107 | if (next) this.run(next); |
| 108 | } |
| 109 | |
| 110 | private run(op: () => void): void { |
| 111 | if (this.waiting) { |
| 112 | this.queue.push(op); |
| 113 | return; |
| 114 | } |
| 115 | op(); |
| 116 | if (!this.waiting) { |
| 117 | const next = this.queue.shift(); |
| 118 | if (next) this.run(next); |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | export const backStack = new BackStack(typeof window === 'undefined' ? ({} as HistoryLike) : window.history); |
| 124 | |
| 125 | let instances = 0; |
| 126 | |
| 127 | export function instanceLayerId(prefix: string): string { |
| 128 | return `${prefix}-${++instances}`; |
| 129 | } |