@takazudo/zudo-doc 6.1.1 → 6.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.
@@ -7,79 +7,410 @@ interface SidebarScrollPreserveOptions {
7
7
  document: Document;
8
8
  requestAnimationFrame: (callback: FrameRequestCallback) => number;
9
9
  cancelAnimationFrame: (handle: number) => void;
10
+ ResizeObserver?: typeof ResizeObserver;
10
11
  }
11
12
 
12
- const installedControllers = new WeakMap<Document, () => void>();
13
+ interface ScrollSlot {
14
+ scrollTop: number;
15
+ anchorId?: string;
16
+ anchorOffset?: number;
17
+ contextId?: string;
18
+ scopeId: string;
19
+ viewKey: string;
20
+ contentHeight: number;
21
+ }
22
+
23
+ type ViewportKind = "desktop" | "mobile";
24
+ type SlotMap = Partial<Record<ViewportKind, ScrollSlot>>;
25
+
26
+ interface Controller {
27
+ cleanup: () => void;
28
+ notify: (root: Element) => void;
29
+ }
30
+
31
+ const installedControllers = new WeakMap<Document, Controller>();
32
+ const HISTORY_KEY = "zdSidebarScroll";
33
+ const PENDING_KEY = "zd-sidebar-scroll-pending";
34
+ const VIEWPORTS: ViewportKind[] = ["desktop", "mobile"];
35
+
36
+ /** Directory URLs redirect between slash forms. The snapshot must survive that. */
37
+ function canonicalPath(pathname: string): string {
38
+ return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
39
+ }
13
40
 
14
41
  /**
15
42
  * Install the scroll-preservation state machine on a document.
16
43
  *
17
- * This low-level entrypoint owns an explicit cleanup for focused tests and
18
- * non-singleton hosts. The sidebar's browser module uses the durable,
19
- * duplicate-safe `ensureSidebarScrollPreserve` wrapper below instead.
44
+ * The desktop aside is replaced when sidebar island props change: preparation
45
+ * drops `data-zfb-transition-persist`, so element identity is not enough for
46
+ * the same logical tree. A saved offset is applied only after the destination
47
+ * can hold it. Chromium clamps a write that happens while `scrollHeight` is
48
+ * still too small, and that clamped 0 would stick.
20
49
  */
21
50
  export function installSidebarScrollPreserve({
22
51
  document,
23
52
  requestAnimationFrame,
24
53
  cancelAnimationFrame,
54
+ ResizeObserver: ResizeObserverImpl,
25
55
  }: SidebarScrollPreserveOptions): () => void {
26
- let snapshot: { element: HTMLElement; scrollTop: number } | undefined;
56
+ const view = document.defaultView;
57
+ let epoch = 0;
58
+ let shortRetries = 0;
59
+ let traverse = false;
60
+ let awaiting = false;
27
61
  let restoreFrame: number | undefined;
62
+ let didInitial = false;
63
+ let writing = false;
64
+ let observer: { disconnect: () => void } | undefined;
65
+ const observed = new Set<ViewportKind>();
66
+ const skipped = new Set<ViewportKind>();
67
+ const applied = new Set<ViewportKind>();
68
+ let elements: Partial<Record<ViewportKind, HTMLElement>> = {};
69
+ let memory: SlotMap = {};
70
+ let pending: SlotMap | undefined;
71
+ let held: SlotMap = {};
28
72
 
29
- const cancelPendingRestore = () => {
73
+ const cancelFrame = () => {
30
74
  if (restoreFrame === undefined) return;
31
75
  cancelAnimationFrame(restoreFrame);
32
76
  restoreFrame = undefined;
33
77
  };
78
+ const disconnectObserver = () => {
79
+ observer?.disconnect();
80
+ observer = undefined;
81
+ };
34
82
 
35
- const onBefore = () => {
36
- cancelPendingRestore();
37
- const element = document.querySelector<HTMLElement>("#desktop-sidebar");
38
- snapshot = element ? { element, scrollTop: element.scrollTop } : undefined;
83
+ const capture = (): SlotMap => {
84
+ const next: SlotMap = { ...memory };
85
+ for (const kind of VIEWPORTS) {
86
+ const element = findViewport(document, kind);
87
+ if (!element) {
88
+ delete next[kind];
89
+ delete elements[kind];
90
+ continue;
91
+ }
92
+ // A closed drawer stays in the DOM. Its zero must not replace the open offset.
93
+ if (!measurable(element)) continue;
94
+ if (isEmptyTree(element) && element.scrollTop === 0 && (next[kind]?.scrollTop ?? 0) > 0) continue;
95
+ next[kind] = readSlot(element);
96
+ elements[kind] = element;
97
+ }
98
+ memory = next;
99
+ return next;
39
100
  };
40
101
 
41
- const onAfter = () => {
42
- if (!snapshot) return;
43
- const saved = snapshot;
44
- snapshot = undefined;
102
+ const writeHistory = (slots: SlotMap) => {
103
+ if (!view || traverse) return;
104
+ const state = view.history.state;
105
+ if (!state || typeof state !== "object") return;
106
+ if (JSON.stringify((state as { zdSidebarScroll?: unknown }).zdSidebarScroll) === JSON.stringify(slots)) return;
107
+ try { view.history.replaceState({ ...state, [HISTORY_KEY]: slots }, ""); } catch { /* Optional persistence. */ }
108
+ };
109
+
110
+ const schedule = () => {
111
+ if (restoreFrame !== undefined) return;
45
112
  restoreFrame = requestAnimationFrame(() => {
46
113
  restoreFrame = undefined;
47
- const current = document.querySelector<HTMLElement>("#desktop-sidebar");
48
- // Only the persisted aside keeps its scroll identity across a body swap.
49
- // A replacement aside belongs to a different section and starts fresh.
50
- if (current === saved.element) current.scrollTop = saved.scrollTop;
114
+ apply();
115
+ });
116
+ };
117
+
118
+ const arm = (kind: ViewportKind, element: HTMLElement): boolean => {
119
+ const Observer = ResizeObserverImpl ?? view?.ResizeObserver;
120
+ if (!Observer || observed.has(kind)) return false;
121
+ observed.add(kind);
122
+ disconnectObserver();
123
+ const epochAt = epoch;
124
+ try {
125
+ const next = new Observer(() => {
126
+ disconnectObserver();
127
+ if (epochAt !== epoch) return;
128
+ apply();
129
+ });
130
+ next.observe(element);
131
+ observer = next;
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ };
137
+
138
+ const finishIfSettled = (waiting: boolean) => {
139
+ if (waiting) return;
140
+ pending = undefined;
141
+ awaiting = false;
142
+ writeHistory(memory);
143
+ };
144
+
145
+ const applyHeld = () => {
146
+ let restored = false;
147
+ for (const kind of VIEWPORTS) {
148
+ const slot = held[kind];
149
+ if (!slot) continue;
150
+ const element = findViewport(document, kind);
151
+ if (!element || !measurable(element)) continue;
152
+ const outcome = restoreViewport(kind, element, slot);
153
+ if (outcome === "wait") {
154
+ // Stay held until a later notify sees a tree tall enough to keep the offset.
155
+ arm(kind, element);
156
+ continue;
157
+ }
158
+ memory[kind] = readSlot(element);
159
+ delete held[kind];
160
+ restored = true;
161
+ }
162
+ if (restored && !awaiting) writeHistory(memory);
163
+ };
164
+
165
+ const apply = () => {
166
+ if (!pending) {
167
+ if (Object.keys(held).length) applyHeld();
168
+ if (!didInitial) {
169
+ didInitial = true;
170
+ for (const kind of VIEWPORTS) {
171
+ const element = findViewport(document, kind);
172
+ if (element && measurable(element)) revealCurrent(element, writeScroll);
173
+ }
174
+ }
175
+ return;
176
+ }
177
+ let waiting = false;
178
+ for (const kind of VIEWPORTS) {
179
+ const slot = pending[kind];
180
+ if (!slot || skipped.has(kind) || applied.has(kind)) continue;
181
+ const element = findViewport(document, kind);
182
+ if (!element || !measurable(element)) {
183
+ // A closed drawer stays in the DOM. Park the snapshot for the next
184
+ // open, and keep it in memory so settling this cycle cannot delete it
185
+ // from the history entry. An absent viewport is not a saved zero.
186
+ if (element) {
187
+ held[kind] = slot;
188
+ memory[kind] = slot;
189
+ }
190
+ applied.add(kind);
191
+ continue;
192
+ }
193
+ const outcome = restoreViewport(kind, element, slot);
194
+ if (outcome === "wait") {
195
+ // A short tree must not accept a clamped 0. Retry a few frames, then reveal.
196
+ if (!isEmptyTree(element)) shortRetries += 1;
197
+ arm(kind, element);
198
+ waiting = true;
199
+ if (!isEmptyTree(element) && shortRetries < 8) schedule();
200
+ continue;
201
+ }
202
+ memory[kind] = readSlot(element);
203
+ applied.add(kind);
204
+ }
205
+ finishIfSettled(waiting);
206
+ };
207
+
208
+ const restoreViewport = (kind: ViewportKind, element: HTMLElement, slot: ScrollSlot): "wait" | "done" => {
209
+ const context = readContext(element);
210
+ if (slot.contextId) {
211
+ if (!context) return observed.has(kind) ? "done" : "wait";
212
+ if (context !== slot.contextId) {
213
+ revealCurrent(element, writeScroll);
214
+ return "done";
215
+ }
216
+ } else if (elements[kind] !== element) {
217
+ return "done";
218
+ }
219
+ const canHold = slot.scrollTop === 0 || element.scrollHeight - element.clientHeight + 2 >= slot.scrollTop;
220
+ if (!canHold) {
221
+ // A settled, non-empty tree that is simply shorter than the saved offset
222
+ // cannot grow into that offset. Reveal the current row instead of waiting.
223
+ if (!isEmptyTree(element) && shortRetries >= 8) {
224
+ revealCurrent(element, writeScroll);
225
+ return "done";
226
+ }
227
+ return "wait";
228
+ }
229
+ const compatible = readViewKey(element) === slot.viewKey
230
+ && readScope(element) === slot.scopeId
231
+ && Math.abs(element.scrollHeight - slot.contentHeight) <= 2;
232
+ if (compatible) {
233
+ writeScroll(element, slot.scrollTop);
234
+ return "done";
235
+ }
236
+ if (slot.anchorId) {
237
+ const row = findAnchor(element, slot.anchorId);
238
+ if (row) {
239
+ const delta = row.getBoundingClientRect().top - element.getBoundingClientRect().top - (slot.anchorOffset ?? 0);
240
+ const next = element.scrollTop + delta;
241
+ if (next > element.scrollHeight - element.clientHeight + 2) return "wait";
242
+ writeScroll(element, next);
243
+ return "done";
244
+ }
245
+ }
246
+ revealCurrent(element, writeScroll);
247
+ return "done";
248
+ };
249
+
250
+ const writeScroll = (element: HTMLElement, next: number) => {
251
+ if (Math.abs(element.scrollTop - next) <= 2) return;
252
+ writing = true;
253
+ element.scrollTop = next;
254
+ writing = false;
255
+ };
256
+
257
+ const begin = (slots: SlotMap) => {
258
+ epoch += 1;
259
+ cancelFrame();
260
+ disconnectObserver();
261
+ observed.clear();
262
+ skipped.clear();
263
+ applied.clear();
264
+ pending = slots;
265
+ held = {};
266
+ awaiting = true;
267
+ didInitial = true;
268
+ shortRetries = 0;
269
+ };
270
+
271
+ const onBefore = () => {
272
+ if (traverse) {
273
+ traverse = false;
274
+ return;
275
+ }
276
+ const slots = capture();
277
+ begin(slots);
278
+ awaiting = false;
279
+ writeHistory(slots);
280
+ };
281
+
282
+ const onAfter = () => {
283
+ try { view?.sessionStorage.removeItem(PENDING_KEY); } catch { /* Optional persistence. */ }
284
+ if (!pending) return;
285
+ awaiting = true;
286
+ };
287
+
288
+ const onPopState = () => {
289
+ traverse = true;
290
+ begin(readHistory(view) ?? {});
291
+ };
292
+
293
+ const onClick = (event: Event) => {
294
+ const mouse = event as MouseEvent;
295
+ if (mouse.button !== 0 || mouse.metaKey || mouse.ctrlKey || mouse.shiftKey || mouse.altKey) return;
296
+ const target = mouse.target;
297
+ if (!(target instanceof Element) || !view) return;
298
+ const link = target.closest("a[href]");
299
+ if (!(link instanceof HTMLAnchorElement) || link.target === "_blank") return;
300
+ if (!link.closest("#desktop-sidebar, [data-zd-mobile-sidebar]")) return;
301
+ let url: URL;
302
+ try { url = new URL(link.href, view.location.href); } catch { return; }
303
+ if (url.origin !== view.location.origin) return;
304
+ if (url.pathname === view.location.pathname && url.search === view.location.search) return;
305
+ try {
306
+ view.sessionStorage.setItem(PENDING_KEY, JSON.stringify({ to: canonicalPath(url.pathname), slots: capture() }));
307
+ } catch { /* Optional persistence. */ }
308
+ };
309
+
310
+ const onUserScroll = (event: Event) => {
311
+ if (writing) return;
312
+ const kind = viewportKind(event.target);
313
+ if (!kind || skipped.has(kind)) return;
314
+ if (!awaiting && !held[kind]) return;
315
+ if (event.type === "keydown") {
316
+ const key = (event as KeyboardEvent).key;
317
+ if (!["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(key)) return;
318
+ }
319
+ skipped.add(kind);
320
+ applied.add(kind);
321
+ delete held[kind];
322
+ cancelFrame();
323
+ schedule();
324
+ };
325
+
326
+ const onScroll = (event: Event) => {
327
+ if (writing || traverse || awaiting) return;
328
+ if (!viewportKind(event.target)) return;
329
+ capture();
330
+ scheduleHistory();
331
+ };
332
+
333
+ let historyFrame: number | undefined;
334
+ const scheduleHistory = () => {
335
+ if (historyFrame !== undefined) return;
336
+ historyFrame = requestAnimationFrame(() => {
337
+ historyFrame = undefined;
338
+ writeHistory(memory);
51
339
  });
52
340
  };
53
341
 
342
+ const notify = (root: Element) => {
343
+ if (root.ownerDocument !== document) return;
344
+ if (awaiting) schedule();
345
+ else if (Object.keys(held).length || !didInitial) schedule();
346
+ };
347
+
348
+ const consumePendingStorage = () => {
349
+ if (!view) return;
350
+ try {
351
+ const raw = view.sessionStorage.getItem(PENDING_KEY);
352
+ if (!raw) return;
353
+ view.sessionStorage.removeItem(PENDING_KEY);
354
+ const parsed: unknown = JSON.parse(raw);
355
+ if (!isRecord(parsed) || typeof parsed.to !== "string") return;
356
+ if (canonicalPath(parsed.to) !== canonicalPath(view.location.pathname)) return;
357
+ const slots = sanitizeSlots(parsed.slots);
358
+ if (!slots) return;
359
+ begin(slots);
360
+ } catch { /* Malformed storage is ignored. */ }
361
+ };
362
+
363
+ consumePendingStorage();
364
+ if (!pending) {
365
+ const stored = readHistory(view);
366
+ if (stored) begin(stored);
367
+ }
368
+
54
369
  document.addEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
55
370
  document.addEventListener(AFTER_NAVIGATE_EVENT, onAfter);
371
+ document.addEventListener("click", onClick, true);
372
+ document.addEventListener("pointerdown", onUserScroll, true);
373
+ document.addEventListener("wheel", onUserScroll, true);
374
+ document.addEventListener("touchmove", onUserScroll, true);
375
+ document.addEventListener("keydown", onUserScroll, true);
376
+ document.addEventListener("scroll", onScroll, true);
377
+ view?.addEventListener("popstate", onPopState);
56
378
 
57
- return () => {
379
+ const cleanup = () => {
58
380
  document.removeEventListener(BEFORE_NAVIGATE_EVENT, onBefore);
59
381
  document.removeEventListener(AFTER_NAVIGATE_EVENT, onAfter);
60
- cancelPendingRestore();
61
- snapshot = undefined;
382
+ document.removeEventListener("click", onClick, true);
383
+ document.removeEventListener("pointerdown", onUserScroll, true);
384
+ document.removeEventListener("wheel", onUserScroll, true);
385
+ document.removeEventListener("touchmove", onUserScroll, true);
386
+ document.removeEventListener("keydown", onUserScroll, true);
387
+ document.removeEventListener("scroll", onScroll, true);
388
+ view?.removeEventListener("popstate", onPopState);
389
+ cancelFrame();
390
+ if (historyFrame !== undefined) cancelAnimationFrame(historyFrame);
391
+ disconnectObserver();
392
+ pending = undefined;
393
+ awaiting = false;
394
+ if (installedControllers.get(document)?.cleanup === cleanup) installedControllers.delete(document);
62
395
  };
396
+ installedControllers.set(document, { cleanup, notify });
397
+ return cleanup;
63
398
  }
64
399
 
65
- /**
66
- * Install exactly one module-lifetime controller for a browser document.
67
- * Repeated calls from component renders or duplicate boot paths are no-ops.
68
- */
69
- export function ensureSidebarScrollPreserve(
70
- options?: SidebarScrollPreserveOptions,
71
- ): void {
400
+ /** Install exactly one module-lifetime controller for a browser document. */
401
+ export function ensureSidebarScrollPreserve(options?: SidebarScrollPreserveOptions): void {
72
402
  const resolved = options ?? resolveBrowserOptions();
73
403
  if (!resolved || installedControllers.has(resolved.document)) return;
74
- installedControllers.set(
75
- resolved.document,
76
- installSidebarScrollPreserve(resolved),
77
- );
404
+ installSidebarScrollPreserve(resolved);
405
+ }
406
+
407
+ /** The sidebar island calls this after the destination tree has committed. */
408
+ export function notifySidebarScrollReady(root: Element): void {
409
+ installedControllers.get(root.ownerDocument)?.notify(root);
78
410
  }
79
411
 
80
412
  function resolveBrowserOptions(): SidebarScrollPreserveOptions | undefined {
81
- if (typeof document === "undefined" || typeof window === "undefined")
82
- return undefined;
413
+ if (typeof document === "undefined" || typeof window === "undefined") return undefined;
83
414
  return {
84
415
  document,
85
416
  requestAnimationFrame: window.requestAnimationFrame.bind(window),
@@ -89,6 +420,122 @@ function resolveBrowserOptions(): SidebarScrollPreserveOptions | undefined {
89
420
 
90
421
  /** Test/HMR teardown for a controller installed through the singleton wrapper. */
91
422
  export function disposeSidebarScrollPreserve(document: Document): void {
92
- installedControllers.get(document)?.();
423
+ installedControllers.get(document)?.cleanup();
93
424
  installedControllers.delete(document);
94
425
  }
426
+
427
+ function findViewport(document: Document, kind: ViewportKind): HTMLElement | null {
428
+ if (kind === "desktop") return document.querySelector<HTMLElement>("#desktop-sidebar");
429
+ return document.querySelector<HTMLElement>("aside[data-zd-mobile-sidebar] .flex-1.overflow-y-auto");
430
+ }
431
+
432
+ function measurable(element: HTMLElement): boolean {
433
+ if (element.clientHeight <= 0 || element.closest("[inert]")) return false;
434
+ const style = element.ownerDocument.defaultView?.getComputedStyle(element);
435
+ return !style || (style.display !== "none" && style.visibility !== "hidden");
436
+ }
437
+
438
+ function isEmptyTree(element: HTMLElement): boolean {
439
+ return element.querySelector("a[href], [data-sidebar-focus-scope]") === null;
440
+ }
441
+
442
+ function readContext(element: HTMLElement): string | undefined {
443
+ const value = element.querySelector("nav")?.getAttribute("data-sidebar-context");
444
+ return value || undefined;
445
+ }
446
+
447
+ function readScope(element: HTMLElement): string {
448
+ return element.querySelector("nav")?.getAttribute("data-sidebar-scope") ?? "";
449
+ }
450
+
451
+ function readViewKey(element: HTMLElement): string {
452
+ const parts: string[] = [];
453
+ for (const node of element.querySelectorAll<HTMLElement>("[data-sidebar-focus-scope], a[href]")) {
454
+ const id = node.dataset.sidebarFocusScope || node.getAttribute("href");
455
+ if (id) parts.push(id);
456
+ }
457
+ let hash = 2166136261;
458
+ const serialized = parts.join("\n");
459
+ for (let i = 0; i < serialized.length; i++) hash = Math.imul(hash ^ serialized.charCodeAt(i), 16777619);
460
+ return String(hash >>> 0);
461
+ }
462
+
463
+ function readSlot(element: HTMLElement): ScrollSlot {
464
+ const anchor = captureAnchor(element);
465
+ return {
466
+ scrollTop: element.scrollTop,
467
+ ...anchor,
468
+ contextId: readContext(element),
469
+ scopeId: readScope(element),
470
+ viewKey: readViewKey(element),
471
+ contentHeight: element.scrollHeight,
472
+ };
473
+ }
474
+
475
+ function captureAnchor(element: HTMLElement): { anchorId?: string; anchorOffset?: number } {
476
+ const bounds = element.getBoundingClientRect();
477
+ for (const row of element.querySelectorAll<HTMLElement>("[data-sidebar-focus-scope], a[href]")) {
478
+ const rect = row.getBoundingClientRect();
479
+ if (rect.height === 0 && rect.top === 0 && rect.bottom === 0) continue;
480
+ if (rect.bottom > bounds.top && rect.top < bounds.bottom) {
481
+ const anchorId = row.dataset.sidebarFocusScope || row.getAttribute("href") || undefined;
482
+ return anchorId ? { anchorId, anchorOffset: rect.top - bounds.top } : {};
483
+ }
484
+ }
485
+ return {};
486
+ }
487
+
488
+ function findAnchor(element: HTMLElement, id: string): HTMLElement | null {
489
+ for (const node of element.querySelectorAll<HTMLElement>("[data-sidebar-focus-scope], a[href]")) {
490
+ if (node.dataset.sidebarFocusScope === id || node.getAttribute("href") === id) return node;
491
+ }
492
+ return null;
493
+ }
494
+
495
+ function revealCurrent(element: HTMLElement, write: (element: HTMLElement, next: number) => void): void {
496
+ const current = element.querySelector<HTMLElement>('[aria-current="page"]');
497
+ if (!current) return;
498
+ const bounds = element.getBoundingClientRect();
499
+ const row = current.getBoundingClientRect();
500
+ if (row.height === 0 && row.top === 0 && row.bottom === 0) return;
501
+ if (row.top < bounds.top) write(element, element.scrollTop + row.top - bounds.top - 8);
502
+ else if (row.bottom > bounds.bottom) write(element, element.scrollTop + row.bottom - bounds.bottom + 8);
503
+ }
504
+
505
+ function viewportKind(target: EventTarget | null): ViewportKind | undefined {
506
+ if (!(target instanceof Element)) return undefined;
507
+ if (target.id === "desktop-sidebar" || target.closest("#desktop-sidebar")) return "desktop";
508
+ if (target.closest("[data-zd-mobile-sidebar]")) return "mobile";
509
+ return undefined;
510
+ }
511
+
512
+ function isRecord(value: unknown): value is Record<string, unknown> {
513
+ return typeof value === "object" && value !== null && !Array.isArray(value);
514
+ }
515
+
516
+ function sanitizeSlots(value: unknown): SlotMap | undefined {
517
+ if (!isRecord(value)) return undefined;
518
+ const slots: SlotMap = {};
519
+ for (const kind of VIEWPORTS) {
520
+ const slot = value[kind];
521
+ if (!isRecord(slot) || typeof slot.scrollTop !== "number" || !Number.isFinite(slot.scrollTop)) continue;
522
+ if (typeof slot.viewKey !== "string" || typeof slot.scopeId !== "string" || typeof slot.contentHeight !== "number") continue;
523
+ const next: ScrollSlot = {
524
+ scrollTop: slot.scrollTop,
525
+ scopeId: slot.scopeId,
526
+ viewKey: slot.viewKey,
527
+ contentHeight: slot.contentHeight,
528
+ };
529
+ if (typeof slot.contextId === "string") next.contextId = slot.contextId;
530
+ if (typeof slot.anchorId === "string") next.anchorId = slot.anchorId;
531
+ if (typeof slot.anchorOffset === "number") next.anchorOffset = slot.anchorOffset;
532
+ slots[kind] = next;
533
+ }
534
+ return slots;
535
+ }
536
+
537
+ function readHistory(view: Window | null): SlotMap | undefined {
538
+ const state = view?.history.state;
539
+ if (!isRecord(state)) return undefined;
540
+ return sanitizeSlots(state[HISTORY_KEY]);
541
+ }