@luigisbox/luma 0.1.0 → 0.3.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.
Files changed (41) hide show
  1. package/README.md +208 -76
  2. package/build/vite.mjs +9 -0
  3. package/chrome/block.mjs +8 -0
  4. package/chrome/run.mjs +10 -5
  5. package/docs/ACCESSIBILITY.md +9 -5
  6. package/docs/INTEGRATION-PERFORMANCE.md +5 -6
  7. package/package.json +1 -1
  8. package/parity/hold.mjs +1 -1
  9. package/src/analytics/chain.ts +137 -0
  10. package/src/analytics/collect.ts +64 -0
  11. package/src/analytics/datalayer.ts +67 -0
  12. package/src/analytics/from-ga4.ts +68 -0
  13. package/src/analytics/from-luma.ts +21 -0
  14. package/src/analytics/index.ts +24 -0
  15. package/src/analytics/outbox.ts +118 -0
  16. package/src/analytics/page.ts +46 -0
  17. package/src/analytics/pageview.ts +82 -0
  18. package/src/analytics/ref.ts +19 -0
  19. package/src/analytics/setup.ts +41 -0
  20. package/src/analytics/start.ts +53 -0
  21. package/src/analytics/triggers.ts +239 -0
  22. package/src/analytics/user.ts +12 -0
  23. package/src/analytics/wire.ts +67 -0
  24. package/src/api/token.ts +3 -2
  25. package/src/core/entries.ts +10 -6
  26. package/src/core/integration.ts +6 -0
  27. package/src/core/kept.ts +25 -0
  28. package/src/core/stash.ts +24 -4
  29. package/src/core/tiles.ts +137 -0
  30. package/src/main.ts +54 -4
  31. package/src/types.ts +29 -2
  32. package/src/widgets/autocomplete/index.ts +206 -31
  33. package/src/widgets/autocomplete/style-autocomplete.css +11 -5
  34. package/src/widgets/autocomplete/template-autocomplete-sheet.ts +1 -1
  35. package/src/widgets/listing/element.ts +212 -21
  36. package/src/widgets/listing/instant.ts +83 -23
  37. package/src/widgets/listing/stash.ts +31 -8
  38. package/src/widgets/listing/view.ts +12 -2
  39. package/src/widgets/recommender/index.ts +15 -4
  40. package/src/widgets/search/range.ts +40 -18
  41. package/src/widgets/search/style-search.css +2 -2
@@ -24,6 +24,7 @@ import { firstRowCount, firstRowImages, imageHelpers, imageOf, preloadImages, se
24
24
  import { view } from '../../core/templates';
25
25
  import { afterFrame, isPromise } from '../../core/scheduler';
26
26
  import { onUrlChange } from '../../core/sense';
27
+ import { onOwnBack } from '../../core/entries';
27
28
  import { layered, setCss } from '../../core/styles';
28
29
  import type { Takeover } from '../../core/takeover';
29
30
  import { canon, pageKey, writeUrl } from '../../core/url';
@@ -36,8 +37,9 @@ import { flowKey, notAbove, onQuickLanded, quickChannel } from './flow';
36
37
  import { firstPages, targetOf, thrifty, watchIntent } from './intent';
37
38
  import type { Page } from '../../api';
38
39
  import { atWidth, CV_CSS, DIM_MS, LOADING_CSS, listingData, ssrStateId, viewport, type ListingView, type SsrState } from './view';
39
- import { dropStash, readStash, writeStash, type Stash } from './stash';
40
- import { keepViewport, leavingLink, nearEnd, preconnectImages, restoreScroll, scrollSpot } from './instant';
40
+ import { dropStash, peekStash, readStash, writeStash, type Stash } from './stash';
41
+ import { adoptScroll, holdScroll, keepViewport, leavingLink, nearEnd, preconnectImages, releaseScroll, restoreScroll, scrollSpot } from './instant';
42
+ import { watch } from '../../core/tiles';
41
43
 
42
44
  /** A re-render answered within DIM_MS (no dim shown) fades its list in from this opacity, over FADE_MS. */
43
45
  const FADE_FROM = 0.6;
@@ -198,7 +200,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
198
200
  let rehearsed = false;
199
201
  if (!seeded.size) {
200
202
  stashed = readStash(route.id, location.href);
201
- if (stashed) {
203
+ if (stashed?.sr) adoptScroll();
204
+ // Too old to render from: where the shopper was only (the results are asked for as on any load).
205
+ if (stashed?.stale) mark(`${route.id} back: stash too old, its scroll only`);
206
+ else if (stashed) {
202
207
  seeded.set(pageKey(location.href), stashed.query);
203
208
  cache.set(keyOf(stashed.query), stashed.result);
204
209
  mark(`${route.id} from stash (${stashed.kind}, ${stashed.result.hits.length} hits)`);
@@ -307,6 +312,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
307
312
  private drawnUrl: URL | null = null;
308
313
  /** Facet groups the shopper opened or closed, and the text in a group's search box, for the visit. */
309
314
  private groupOpen: Record<string, boolean> = {};
315
+ /** Groups shown open for their selection in the visit (ListingView `kept`). */
316
+ private groupKept: Record<string, boolean> = {};
310
317
  private groupText: Record<string, string> = {};
311
318
  private groupSaid = 0;
312
319
  /**
@@ -316,6 +323,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
316
323
  */
317
324
  private staged: UiState | null = null;
318
325
  private stagedMode = false;
326
+ /** Stops watching the `facetsApply` breakpoints of the open drawer (watchApply()). */
327
+ private unwatchApply: (() => void) | null = null;
319
328
  /** Renders started: a render in chunks stops when a newer one has started. */
320
329
  private renders = 0;
321
330
  // Accessibility (docs/ACCESSIBILITY.md, "Listings"): our status region (and
@@ -331,6 +340,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
331
340
  private shownBefore = 0;
332
341
  /** The drawer's opener (focus goes back to it) and the attributes lent to its panel. */
333
342
  private drawerFrom: HTMLElement | null = null;
343
+ /**
344
+ * The history entry of ours that stands for the open staged drawer (the
345
+ * same URL; Back closes the drawer, core/entries.ts `onOwnBack`), and what
346
+ * waits for our own history.back() of it (an Apply's navigation).
347
+ */
348
+ private drawerEntry = false;
349
+ private drawerBack: (() => void) | null = null;
350
+ private unBack: (() => void) | null = null;
334
351
  private panelAria: ReturnType<typeof borrow> | null = null;
335
352
  /** Another state's results awaited (loading()): the timer that dims the list, and whether it has. */
336
353
  private wait: { timer: number; dimmed: boolean } | null = null;
@@ -345,7 +362,18 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
345
362
 
346
363
  /** When the page was hidden (pagehide): a restore from the back/forward cache after long is revalidated. */
347
364
  private hiddenAt = 0;
348
- private onPageHide = () => void (this.hiddenAt = Date.now());
365
+ /** scrollY when a Back stash was last written (leave()): the page hidden from there is stashed already. */
366
+ private leftAt = -1;
367
+ private onPageHide = () => {
368
+ this.hiddenAt = Date.now();
369
+ // Left with more than the first page shown, not by a link of ours (a
370
+ // reload, their menu, the address bar): stashed too, so a reload within
371
+ // the minute shows it as left, in place, and a later load of the entry
372
+ // is placed by Luma (the browser's restoration landed in a first page
373
+ // shorter than where the shopper was). Into the back/forward cache
374
+ // too: it may be evicted there (then Back is a page load).
375
+ if (restoreOn && (this.last?.page ?? 0) > 1 && this.leftAt !== Math.round(scrollY)) this.leave();
376
+ };
349
377
  /**
350
378
  * Restored from the back/forward cache (pageshow, persisted): the page is as
351
379
  * the shopper left it, from memory. Our own Back restore (./stash.ts) is not
@@ -357,8 +385,14 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
357
385
  */
358
386
  private onPageShow = (e: PageTransitionEvent) => {
359
387
  if (!e.persisted) return;
388
+ // Where the shopper was: the browser's own restoration is off for the
389
+ // entry (holdScroll), and iOS Safari reads scrollY 0 at this event
390
+ // even when it restores it. Placed again, then the browser's back on.
391
+ const st = peekStash(location.href);
360
392
  dropStash(location.href);
361
393
  mark(`${route.id} restored from the back/forward cache`);
394
+ if (st?.kind === 'back' && st.scroll && tk.state === 'ours') restoreScroll(this, st.scroll, releaseScroll);
395
+ else releaseScroll();
362
396
  const away = this.hiddenAt ? Date.now() - this.hiddenAt : 0;
363
397
  if (away > BFCACHE_STALE_MS && this.last && this.lastQ && this.lastQ === this.current) this.revalidate(this.lastQ, this.last, new URL(location.href), this.href);
364
398
  };
@@ -391,7 +425,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
391
425
  if (!this.hasAttribute('role')) this.setAttribute('role', 'region');
392
426
  if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', t('label'));
393
427
  if (!this.hasAttribute('tabindex')) this.tabIndex = -1;
394
- this.unsub = onUrlChange(() => this.resync());
428
+ // Moved on within this document (an SPA's navigation): its scroll is the host's again.
429
+ this.unsub = onUrlChange(() => (releaseScroll(), this.urlMoved(), this.resync()));
430
+ this.unBack ??= onOwnBack(this.onBack);
431
+ // Product clicks and add-to-carts on our tiles: the framework's, never a template's (core/tiles.ts).
432
+ watch(this, { list: `lbx_${route.id}`, global: ctx.global, results: () => this.last?.hits.map(ctx.result) ?? [] });
395
433
  this.addEventListener('click', this.onClick);
396
434
  this.addEventListener('keydown', this.onKey);
397
435
  this.addEventListener('focusout', this.onFocusOut);
@@ -399,7 +437,10 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
399
437
  document.addEventListener('pointerdown', this.onOutside, true);
400
438
  addEventListener('pagehide', this.onPageHide);
401
439
  addEventListener('pageshow', this.onPageShow);
440
+ addEventListener('scroll', this.onScroll, { passive: true });
402
441
  if (prefetchFilters) this.intent ??= watchIntent(this, (el) => this.onIntent(el));
442
+ // Put back by a host re-render with its drawer open: still watched.
443
+ if (this.hasAttribute('data-drawer-open') && !this.unwatchApply) this.watchApply();
403
444
  // Microtask: runs before paint (no empty frame) but outside the
404
445
  // reconciler's placement pass.
405
446
  queueMicrotask(() => this.resync());
@@ -408,6 +449,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
408
449
  disconnectedCallback() {
409
450
  mark(`${route.tag} disconnected`);
410
451
  this.unsub?.();
452
+ this.unBack?.();
453
+ this.unBack = null;
411
454
  this.removeEventListener('click', this.onClick);
412
455
  this.removeEventListener('keydown', this.onKey);
413
456
  this.removeEventListener('focusout', this.onFocusOut);
@@ -415,12 +458,15 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
415
458
  document.removeEventListener('pointerdown', this.onOutside, true);
416
459
  removeEventListener('pagehide', this.onPageHide);
417
460
  removeEventListener('pageshow', this.onPageShow);
461
+ removeEventListener('scroll', this.onScroll);
418
462
  this.ctrl?.abort();
419
463
  this.ahead?.();
420
464
  this.intent?.stop();
421
465
  this.intent = null;
422
466
  this.sized?.ro.disconnect();
423
467
  this.sized = null;
468
+ this.unwatchApply?.();
469
+ this.unwatchApply = null;
424
470
  // Not the pending click: the reconciler puts this same element back after a
425
471
  // host re-render, and the click's timer then still applies it.
426
472
  this.href = ''; // re-sync if we are ever re-attached
@@ -455,26 +501,94 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
455
501
  if (!d || !this.contains(d)) {
456
502
  // A link to another page (a product): remembered for Back.
457
503
  const away = restoreOn && leavingLink(e, this);
458
- if (away) this.leave(away);
504
+ if (away) {
505
+ this.leave(away);
506
+ // Cancelled by a handler after ours (an SPA's router: no page load): its scroll is the host's.
507
+ setTimeout(() => e.defaultPrevented && releaseScroll(), 0);
508
+ }
459
509
  return;
460
510
  }
461
511
  e.preventDefault();
462
512
  const what = d.getAttribute('data-lbx-drawer');
463
513
  // "apply" commits what the drawer holds (staged) and closes; "close" (and the overlay) discards it.
464
- if (what === 'apply') this.applyStaged();
514
+ if (what === 'apply') return this.applyStaged(d as HTMLElement);
465
515
  this.setDrawer(what === 'open', d as HTMLElement);
466
516
  };
467
517
 
518
+ /**
519
+ * Back on the drawer's entry: the browser's Back (the drawer closes, what
520
+ * it held dropped), or ours (history.back() from closing it: then what
521
+ * waited for it).
522
+ */
523
+ private onBack = () => {
524
+ const then = this.drawerBack;
525
+ if (then) {
526
+ this.drawerBack = null;
527
+ // After the frame: the browser restores the entry's scroll position
528
+ // after its popstate, which would undo the scroll to the listing's top.
529
+ requestAnimationFrame(() => setTimeout(then, 0));
530
+ return true;
531
+ }
532
+ if (!this.drawerEntry || !this.isConnected) return false;
533
+ this.drawerEntry = false;
534
+ this.setDrawer(false);
535
+ return true;
536
+ };
537
+
538
+ /**
539
+ * The URL moved on while the drawer is open, not by us (Back or Forward
540
+ * across our states in a drawer with no entry of its own, the host's
541
+ * navigation): the drawer closes, as the page it was over is gone.
542
+ */
543
+ private urlMoved() {
544
+ const href = canon(location.href);
545
+ if (href === this.href || href === this.wantedHref) return;
546
+ // Back or Forward to a state of ours: where the shopper was in it, and in the one left.
547
+ if (this.href) this.scrolls.set(this.href, this.y);
548
+ const y = this.scrolls.get(href);
549
+ this.restoreY = y == null ? null : { href, y };
550
+ if (this.hasAttribute('data-drawer-open') && !this.drawerBack) this.setDrawer(false);
551
+ }
552
+
553
+ /**
554
+ * Where the shopper was in each state of the visit left by a change of
555
+ * ours or a traversal (canonical href → scrollY), and the one a traversal
556
+ * goes back to: drawn whole (not in steps) and placed there. Bug (a
557
+ * bicycle shop's QA round 4, 2026-10-15): Back after a drawer filter
558
+ * drew the list in steps, the browser's restored scroll was clamped to
559
+ * the first step's short page, and Chromium's scroll anchoring kept the
560
+ * footer in view as the tiles went in above it.
561
+ */
562
+ private scrolls = new Map<string, number>();
563
+ /** scrollY as of the last scroll event (read there: a read in a popstate after the host's DOM writes forced a style and layout). */
564
+ private y = 0;
565
+ private onScroll = () => void (this.y = Math.round(scrollY));
566
+ private restoreY: { href: string; y: number } | null = null;
567
+
468
568
  /** Changes go to the drawer's held state instead of the URL: it is open, where it stages. */
469
569
  private staging() {
470
570
  return this.stagedMode && this.hasAttribute('data-drawer-open');
471
571
  }
472
572
 
473
- /** The drawer's held changes, applied: one request, one history entry. */
474
- private applyStaged() {
573
+ /**
574
+ * The drawer's held changes, applied: one request, one history entry
575
+ * (after the drawer's own entry is gone), and the listing's top in view:
576
+ * a new result set under a shopper far down the old one showed its
577
+ * middle, the count and the first tiles above the viewport.
578
+ */
579
+ private applyStaged(from?: HTMLElement) {
475
580
  const next = this.staged;
476
581
  this.staged = null;
477
- if (next) this.navigate(next);
582
+ this.setDrawer(false, from, next ? () => (this.navigate(next), (this.topFor = this.wantedHref), this.toListTop()) : undefined);
583
+ }
584
+
585
+ /** The state an Apply asked for: its render brings the listing's top into view again (late layout, a smooth scroll cut short). */
586
+ private topFor: string | null = null;
587
+ /** The top of the listing in view when it is above the viewport (smooth, unless reduced motion is asked for). */
588
+ private toListTop() {
589
+ if (!this.isConnected || this.getBoundingClientRect().top >= 0) return;
590
+ const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
591
+ this.scrollIntoView({ block: 'start', behavior: (reduce ? 'instant' : 'smooth') as ScrollBehavior });
478
592
  }
479
593
 
480
594
  /** The drawer's held changes dropped: it shows the applied state again. */
@@ -491,8 +605,11 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
491
605
  * cycles), Escape closes it, and focus goes back to the button that opened
492
606
  * it. On wide screens the same markup is a sidebar: the role is lent while
493
607
  * open only. Without a panel: the buttons' state, Escape and the way back.
608
+ * Where it stages, a history entry of ours stands for it while open:
609
+ * Back closes it (as the autocomplete's phone sheet); closing it otherwise
610
+ * takes the entry back first, then runs `then`.
494
611
  */
495
- private setDrawer(open: boolean, from?: HTMLElement) {
612
+ private setDrawer(open: boolean, from?: HTMLElement, then?: () => void) {
496
613
  const was = this.hasAttribute('data-drawer-open');
497
614
  this.toggleAttribute('data-drawer-open', open);
498
615
  for (const b of this.querySelectorAll('[data-lbx-drawer="open"]')) b.setAttribute('aria-expanded', String(open));
@@ -501,6 +618,12 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
501
618
  if (!open) this.discardStaged();
502
619
  if (open && !was) {
503
620
  this.stagedMode = atWidth(cfg.facetsApply, 'instant', viewport) === 'staged';
621
+ if (this.stagedMode && !this.drawerEntry)
622
+ try {
623
+ history.pushState(null, '', location.href);
624
+ this.drawerEntry = true;
625
+ } catch {}
626
+ this.watchApply();
504
627
  this.drawerFrom = (from?.getAttribute('data-lbx-drawer') === 'open' ? from : document.activeElement) as HTMLElement | null;
505
628
  if (panel) {
506
629
  const a = (this.panelAria = borrow(panel));
@@ -516,13 +639,51 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
516
639
  (focusables(root).find((el) => el.getAttribute('data-lbx-drawer') !== 'open') ?? (root as HTMLElement)).focus();
517
640
  });
518
641
  } else if (!open && was) {
642
+ this.unwatchApply?.();
643
+ this.unwatchApply = null;
519
644
  this.panelAria?.restore();
520
645
  this.panelAria = null;
521
646
  const back = this.drawerFrom;
522
647
  this.drawerFrom = null;
523
648
  const at = document.activeElement;
524
649
  if (back?.isConnected && (!at || at === document.body || this.contains(at))) afterFrame(() => back.focus());
650
+ if (this.drawerEntry) {
651
+ this.drawerEntry = false;
652
+ const done = then ?? (() => {});
653
+ this.drawerBack = done;
654
+ history.back();
655
+ // A Back the browser does not deliver (an entry it dropped): on after a while.
656
+ setTimeout(() => {
657
+ if (this.drawerBack !== done) return;
658
+ this.drawerBack = null;
659
+ done();
660
+ }, 600);
661
+ return;
662
+ }
525
663
  }
664
+ then?.();
665
+ }
666
+
667
+ /**
668
+ * The open drawer closes when the viewport crosses into another
669
+ * `facetsApply` mode (a phone rotated, a window widened): the drawer was a
670
+ * phone's, and what it held was never confirmed. Regression (a bicycle
671
+ * shop, `{ 0: 'staged', 992: 'instant' }`, 2026-10-14): a value ticked in
672
+ * the drawer at 820 px stayed ticked in the 992 px sidebar with the
673
+ * results unfiltered and no apply button there, and rotating back showed
674
+ * the full-screen drawer again (`data-drawer-open` had stayed). Dropping
675
+ * is safer than applying: the shopper never pressed Apply.
676
+ */
677
+ private watchApply() {
678
+ const v = cfg.facetsApply;
679
+ if (!v || typeof v !== 'object' || typeof matchMedia !== 'function') return;
680
+ const mode = atWidth(v, 'instant', viewport);
681
+ const lists = Object.keys(v).map(Number).filter((w) => w > 0).map((w) => matchMedia(`(min-width: ${w}px)`));
682
+ const on = () => {
683
+ if (this.hasAttribute('data-drawer-open') && atWidth(v, 'instant', viewport) !== mode) this.setDrawer(false);
684
+ };
685
+ for (const l of lists) l.addEventListener('change', on);
686
+ this.unwatchApply = () => lists.forEach((l) => l.removeEventListener('change', on));
526
687
  }
527
688
 
528
689
  /**
@@ -671,6 +832,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
671
832
  if (!this.inFlight) this.setBusy(false);
672
833
  return;
673
834
  }
835
+ if (this.href) this.scrolls.set(this.href, this.y);
836
+ this.restoreY = null;
674
837
  this.wanted = next;
675
838
  this.wantedHref = canon(url.href);
676
839
  // Respond first, work later: the fetch and the history write run in later
@@ -829,7 +992,15 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
829
992
  if (c.signal.aborted || this.href !== href || (p.quickPending && !notAbove(route.id, 'quick'))) return;
830
993
  mark(`${route.id} products before their facets`);
831
994
  this.message = null;
832
- this.render(q, p, url);
995
+ // Another state of the visit shown (a filter, a sort): its sidebar
996
+ // stays until the new facets come, as from the intent prefetch.
997
+ // Bug (a bicycle shop's QA round 3, 2026-10-15): drawn with no
998
+ // facets, the sidebar collapsed to one group for ~250 ms and
999
+ // Chromium's scroll anchoring threw the page to its top.
1000
+ const last = this.last;
1001
+ const lq = this.lastQ;
1002
+ const keep = last && lq && tk.state === 'ours' && visitOf(lq) === visitOf(q);
1003
+ this.render(q, keep ? { ...p, facets: last.facets, facetsAll: last.facetsAll } : p, url);
833
1004
  productsShown = true;
834
1005
  });
835
1006
  if (productsShown) this.quiet = true; // said with the products
@@ -915,13 +1086,27 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
915
1086
  * The shopper follows a link to another page (a product): the listing as
916
1087
  * it is, and where they are, for Back (./stash.ts). Only what is shown.
917
1088
  */
918
- private leave(a: HTMLAnchorElement) {
1089
+ private leave(a?: HTMLAnchorElement) {
919
1090
  if (!this.last || !this.lastQ || this.lastQ !== this.current || this.inFlight) return;
920
- if (writeStash(location.href, { kind: 'back', route: route.id, query: this.lastQ, result: { ...this.last, quickPending: undefined }, scroll: scrollSpot(this, a) })) mark(`${route.id} stashed for back`);
1091
+ this.leftAt = Math.round(scrollY);
1092
+ // The browser's own restoration off for this entry: Luma places the page after its render (holdScroll).
1093
+ const sr = holdScroll();
1094
+ if (writeStash(location.href, { kind: 'back', route: route.id, query: this.lastQ, result: { ...this.last, quickPending: undefined }, scroll: scrollSpot(this, a), sr })) mark(`${route.id} stashed for back`);
1095
+ else releaseScroll();
921
1096
  }
922
1097
 
923
1098
  /** After a render: back where the shopper was (a stashed page), the next page when they near the end, the image host. */
924
1099
  private afterRender(q: Query, res: ListingResult, url: URL) {
1100
+ const back = this.restoreY;
1101
+ if (back && back.href === this.href && !res.partial) {
1102
+ this.restoreY = null;
1103
+ // After the frame, with layout clean (a read now forced it: the main-thread gates).
1104
+ if (tk.state === 'ours') afterFrame(() => this.href === back.href && restoreScroll(this, { y: back.y }));
1105
+ }
1106
+ if (this.topFor && this.topFor === this.href) {
1107
+ this.topFor = null;
1108
+ afterFrame(() => this.toListTop());
1109
+ }
925
1110
  // Whether the facets sit above the products (notAbove): read a frame
926
1111
  // later, when layout is clean (the read costs only itself). Shown only.
927
1112
  // The quick types likewise (notAbove), where some are shown.
@@ -953,16 +1138,19 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
953
1138
  const href = this.href;
954
1139
  // After the reveal (a font wait may delay it): ours must be laid out to find the tile.
955
1140
  const at = (n = 0) =>
956
- this.href === href && (tk.state === 'ours' ? (restoreScroll(this, st.scroll ?? { y: 0 }), mark(`${route.id} scroll restored`)) : n < 120 && requestAnimationFrame(() => at(n + 1)));
1141
+ this.href === href && (tk.state === 'ours' ? (restoreScroll(this, st.scroll ?? { y: 0 }, releaseScroll), mark(`${route.id} scroll restored`)) : n < 120 ? requestAnimationFrame(() => at(n + 1)) : releaseScroll());
957
1142
  at();
958
1143
  // Silently fresh after a while away: asked again in the background,
959
1144
  // re-rendered only when the results changed. A quick round trip to
960
1145
  // a product shows the listing as left (a reshuffle in front of the
961
- // shopper, on an API that orders ties anew, was the bug).
962
- if (Date.now() - st.at > BFCACHE_STALE_MS) setTimeout(() => this.revalidate(q, st.result, url, href), 1500);
963
- else mark(`${route.id} back: as left, not revalidated`);
1146
+ // shopper, on an API that orders ties anew, was the bug). A stale
1147
+ // stash's results were not used: these are fresh.
1148
+ if (!st.stale) {
1149
+ if (Date.now() - st.at > BFCACHE_STALE_MS) setTimeout(() => this.revalidate(q, st.result, url, href), 1500);
1150
+ else mark(`${route.id} back: as left, not revalidated`);
1151
+ }
964
1152
  }
965
- }
1153
+ } else if (st) releaseScroll();
966
1154
  this.ahead?.();
967
1155
  this.ahead = null;
968
1156
  if (skipOut) this.sizeTiles();
@@ -1128,6 +1316,8 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1128
1316
  const at = (st: UiState) => canon(route.write(new URL(location.href), st).href);
1129
1317
  return {
1130
1318
  open: this.groupOpen,
1319
+ kept: this.groupKept,
1320
+ keptOpen: (name) => void (this.groupKept[name] = true),
1131
1321
  openDefault: atWidth(cfg.facetGroups?.open, true, viewport),
1132
1322
  text: this.groupText,
1133
1323
  apply: atWidth(cfg.facetsApply, 'instant', viewport),
@@ -1268,12 +1458,13 @@ export const startListing = async (ctx: WidgetContext<ListingConfig>, tk: Takeov
1268
1458
  const total = () => void (this.dataset.total = String(res.total));
1269
1459
  if (tk.state !== 'hold') total();
1270
1460
  const visit = JSON.stringify([q.q, q.scope]);
1271
- if (visit !== this.uiVisit) (this.uiVisit = visit), (this.ui = {}), (this.groupOpen = {}), (this.groupText = {}), (this.staged = null);
1461
+ if (visit !== this.uiVisit) (this.uiVisit = visit), (this.ui = {}), (this.groupOpen = {}), (this.groupKept = {}), (this.groupText = {}), (this.staged = null);
1272
1462
  const d0 = performance.now();
1273
1463
  let data = this.dataFor(q, res, url);
1274
1464
  /** The scope's own time (ms): a re-render's first step goes to the next frame when the two would not fit BUDGET. */
1275
1465
  const dataMs = performance.now() - d0;
1276
- let cut: number | undefined;
1466
+ // Back or Forward to a state with a place in it: every tile at once, so the page is as tall as it was.
1467
+ let cut: number | undefined = this.restoreY?.href === this.href ? Infinity : undefined;
1277
1468
  this.drawn = data;
1278
1469
  // Server markup that is not hydrated (no cached result for it: its facets
1279
1470
  // were missing) is replaced, not rendered after.
@@ -24,36 +24,71 @@ export const leavingLink = (e: MouseEvent, root: Element): HTMLAnchorElement | n
24
24
  return a;
25
25
  };
26
26
 
27
- /** The first link to `href` in root (a tile has several: its image, its title). */
28
- const linkTo = (root: Element, href: string) => {
29
- for (const a of root.querySelectorAll<HTMLAnchorElement>('a[href]')) if (a.href === href) return a;
30
- return null;
27
+ /** A link laid out on the page: not display:none (a tile may carry a phone-only copy of a link, hidden on wider screens). */
28
+ const shown = (a: Element) => a.getClientRects().length > 0;
29
+ /** The links to `href` in root that are laid out (a tile has several: its image, its title, its compare links). */
30
+ const linksTo = (root: Element, href: string) => [...root.querySelectorAll<HTMLAnchorElement>('a[href]')].filter((a) => a.href === href && shown(a));
31
+ /** The `n`th laid-out link to `href` in root (the one followed, when it can be told), else the first. */
32
+ const linkTo = (root: Element, href: string, n = 0) => {
33
+ const as = linksTo(root, href);
34
+ return as[n] ?? as[0] ?? null;
31
35
  };
32
36
 
33
- /** Where the shopper is, pinned to the link they follow (layout above it may differ on the way back). */
34
- export const scrollSpot = (root: Element, a: HTMLAnchorElement) => ({ y: Math.round(scrollY), href: a.href, top: Math.round((linkTo(root, a.href) ?? a).getBoundingClientRect().top) });
37
+ /** Where the shopper was (a Back stash's `scroll`). */
38
+ export interface Spot {
39
+ y: number;
40
+ href?: string;
41
+ top?: number;
42
+ n?: number;
43
+ }
44
+
45
+ /**
46
+ * Where the shopper is, pinned to the link they follow (layout above it may
47
+ * differ on the way back): which of the laid-out links to its href it is
48
+ * (`n`), and its top. Without a link (the page left otherwise), the first
49
+ * tile link in the viewport.
50
+ */
51
+ export const scrollSpot = (root: Element, a?: HTMLAnchorElement | null): Spot => {
52
+ a ??= [...root.querySelectorAll<HTMLAnchorElement>('[data-lbx-list] a[href]')].find((l) => shown(l) && l.getBoundingClientRect().top >= 0) ?? null;
53
+ if (!a) return { y: Math.round(scrollY) };
54
+ const as = linksTo(root, a.href);
55
+ const n = as.indexOf(a);
56
+ const pin = n >= 0 ? a : as[0] ?? a;
57
+ return { y: Math.round(scrollY), href: a.href, top: Math.round(pin.getBoundingClientRect().top), ...(n > 0 ? { n } : {}) };
58
+ };
35
59
 
36
60
  const scroll = (top: number) => scrollTo({ top, left: scrollX, behavior: 'instant' as ScrollBehavior });
37
61
 
38
62
  /**
39
63
  * Back where the shopper was: the followed link at the same place in the
40
- * viewport (scrollY when it is gone). Late layout above us (their images,
41
- * banners, fonts) moves it: followed for a while, until the shopper scrolls,
42
- * taps or types themselves.
64
+ * viewport (scrollY when it is gone; the top of the listing when scrollY
65
+ * would land past its end: a listing shorter than the one left, the link
66
+ * on a page not shown). Late layout
67
+ * above us (their images, banners, fonts) moves it: followed for a while,
68
+ * until the shopper scrolls, taps or types themselves; then `after`.
43
69
  */
44
- export const restoreScroll = (root: Element, s: { y: number; href?: string; top?: number }) => {
70
+ export const restoreScroll = (root: Element, s: Spot, after?: () => void) => {
45
71
  let done = false;
72
+ /** The link gone and the listing too short for scrollY: its top, once decided. */
73
+ let short = false;
46
74
  const place = () => {
47
75
  if (done) return;
48
- const a = s.href ? linkTo(root, s.href) : null;
76
+ const a = s.href ? linkTo(root, s.href, s.n) : null;
49
77
  if (a) {
50
78
  const d = a.getBoundingClientRect().top - (s.top ?? 0);
51
79
  if (Math.abs(d) >= 1) scroll(scrollY + d);
52
- } else if (Math.abs(scrollY - s.y) >= 1) scroll(s.y);
80
+ return;
81
+ }
82
+ const box = root.getBoundingClientRect();
83
+ // A link in the listing was the place: gone, and the listing ends above where the viewport was (fewer pages than left).
84
+ if (!short && s.href && s.y > 0 && s.y + innerHeight > box.bottom + scrollY) short = true;
85
+ const y = short ? Math.max(0, Math.round(box.top + scrollY)) : s.y;
86
+ if (Math.abs(scrollY - y) >= 1) scroll(y);
53
87
  };
54
88
  const STOP = ['wheel', 'touchstart', 'keydown', 'pointerdown'];
55
89
  const ro = new ResizeObserver(() => place());
56
90
  const stop = () => {
91
+ if (!done) after?.();
57
92
  done = true;
58
93
  ro.disconnect();
59
94
  for (const t of STOP) removeEventListener(t, stop, true);
@@ -64,24 +99,49 @@ export const restoreScroll = (root: Element, s: { y: number; href?: string; top?
64
99
  setTimeout(stop, 2500);
65
100
  };
66
101
 
102
+ /**
103
+ * The browser's own scroll restoration, off for the entry the shopper leaves
104
+ * with a Back stash (`history.scrollRestoration = 'manual'`): a load of it
105
+ * (Back that is not a restore from memory: iOS Safari's) is placed by Luma
106
+ * after its render. The browser's restoration ran before it, on a page not
107
+ * grown to the stashed pages yet, and could land after Luma's place too.
108
+ * Only from `auto`: a host that set it (Turbo, a router) keeps its own.
109
+ * Back on (`releaseScroll`) once Luma has placed the page, or when the
110
+ * document moves on within itself (an SPA's navigation: theirs to restore).
111
+ */
112
+ let ownScroll = false;
113
+ /** Off for this entry, when it is the browser's (or already ours): 1, for the stash's `sr`. */
114
+ export const holdScroll = (): 1 | undefined => {
115
+ try {
116
+ if (!ownScroll && history.scrollRestoration !== 'auto') return undefined;
117
+ history.scrollRestoration = 'manual';
118
+ ownScroll = true;
119
+ return 1;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ };
124
+ /** A page loaded on an entry Luma turned it off for (the stash's `sr`): Luma's to turn back on. */
125
+ export const adoptScroll = () => void (ownScroll = true);
126
+ /** Back to the browser's, if Luma turned it off. */
127
+ export const releaseScroll = () => {
128
+ if (!ownScroll) return;
129
+ ownScroll = false;
130
+ try {
131
+ if (history.scrollRestoration === 'manual') history.scrollRestoration = 'auto';
132
+ } catch {}
133
+ };
134
+
67
135
  /**
68
136
  * Keep what the shopper is looking at in place across a re-render (a
69
137
  * revalidated result): the first tile link in the viewport stays where it is.
70
138
  */
71
139
  export const keepViewport = (root: Element, f: () => void) => {
72
- let pin: { href: string; top: number } | null = null;
73
- for (const a of root.querySelectorAll<HTMLAnchorElement>('[data-lbx-list] a[href]')) {
74
- const top = a.getBoundingClientRect().top;
75
- if (top >= 0) {
76
- pin = { href: a.href, top };
77
- break;
78
- }
79
- }
140
+ const pin = scrollSpot(root);
80
141
  f();
81
- if (!pin) return;
82
- const a = linkTo(root, pin.href);
142
+ const a = pin.href ? linkTo(root, pin.href, pin.n) : null;
83
143
  if (a) {
84
- const d = a.getBoundingClientRect().top - pin.top;
144
+ const d = a.getBoundingClientRect().top - (pin.top ?? 0);
85
145
  if (Math.abs(d) >= 1) scroll(scrollY + d);
86
146
  }
87
147
  };
@@ -5,8 +5,12 @@
5
5
  // (widgets/search/speculate.ts). Used by a plain navigation (or a prerender
6
6
  // of the page) within its `ttl` (2 minutes), once.
7
7
  // - `back`: the listing as the shopper left it for a product page (pages
8
- // 1..N, where they were). Used by a back/forward load within 30 minutes; the
9
- // listing then restores the scroll position and revalidates in the background.
8
+ // 1..N, where they were). Used by a back/forward load within 30 minutes, or
9
+ // by a reload or a navigation from the page itself within a minute (a link
10
+ // whose server redirects back to it); the listing then restores the scroll
11
+ // position and revalidates in the background. Otherwise, on a load of the
12
+ // entry left (back/forward, reload): where they were only (`stale`), the
13
+ // browser's own restoration being off for it (`sr`).
10
14
  // Bounded: at most 3 entries (one `spec`), 400k characters each; a full store
11
15
  // drops ours first. Best-effort: storage may be unavailable, never required.
12
16
 
@@ -14,6 +18,7 @@ import { navigation, STASH, stashFits, stashKey } from '../../core/stash';
14
18
  import { pageKey } from '../../core/page';
15
19
  import type { ListingResult } from './query';
16
20
  import type { Query } from './state';
21
+ import type { Spot } from './instant';
17
22
 
18
23
  export interface Stash {
19
24
  v: 1;
@@ -26,8 +31,12 @@ export interface Stash {
26
31
  route: string;
27
32
  query: Query;
28
33
  result: ListingResult;
29
- /** back: the link the shopper followed (its href, its top in the viewport) and scrollY where it is gone. */
30
- scroll?: { y: number; href?: string; top?: number };
34
+ /** back: the link the shopper followed (its href, which of the laid-out links to it, its top in the viewport) and scrollY where it is gone. */
35
+ scroll?: Spot;
36
+ /** back: Luma turned the browser's own scroll restoration off for the entry left (`history.scrollRestoration`): back on once restored. */
37
+ sr?: 1;
38
+ /** Read only: too old to render from, where the shopper was is all that is used. */
39
+ stale?: true;
31
40
  }
32
41
 
33
42
  const MAX_CHARS = 400_000;
@@ -48,7 +57,7 @@ const entries = (except?: string) => {
48
57
  return out.sort((a, b) => a.at - b.at);
49
58
  };
50
59
 
51
- /** The stashed result this page load may render from, or null. */
60
+ /** The stashed result this page load may render from, or null. `stale`: a Back entry too old to render, for its scroll only. */
52
61
  export const readStash = (route: string, href: string): Stash | null => {
53
62
  try {
54
63
  const key = stashKey(href);
@@ -59,8 +68,22 @@ export const readStash = (route: string, href: string): Stash | null => {
59
68
  const nav = navigation();
60
69
  const fresh = s.v === 1 && age >= 0 && age < (s.ttl ?? MAX_AGE[s.kind] ?? 0);
61
70
  if (!fresh || s.kind === 'spec') sessionStorage.removeItem(key); // a speculation is used once
62
- if (!fresh || s.route !== route || pageKey(nav.url) !== pageKey(href)) return null;
63
- return stashFits(s.kind) ? s : null;
71
+ if (s.route !== route || pageKey(nav.url) !== pageKey(href)) return null;
72
+ if (fresh && stashFits(s.kind, s.at)) return s;
73
+ // The entry left loaded again (Back after long, a reload after the round
74
+ // trip's minute): the browser restores nothing where Luma turned its
75
+ // restoration off; where the shopper was is still known.
76
+ const again = nav.type === 'back_forward' || nav.type === 'reload';
77
+ return s.v === 1 && s.kind === 'back' && s.sr && again ? { ...s, stale: true } : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ };
82
+
83
+ /** This URL's stash as it is, whatever the load (the back/forward cache's restore: where the shopper was). */
84
+ export const peekStash = (href: string): Stash | null => {
85
+ try {
86
+ return JSON.parse(sessionStorage.getItem(stashKey(href)) ?? 'null') as Stash | null;
64
87
  } catch {
65
88
  return null;
66
89
  }
@@ -76,7 +99,7 @@ export const dropStash = (href: string) => {
76
99
  /** false: too big, or storage refused it. */
77
100
  export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
78
101
  try {
79
- const json = JSON.stringify({ at: Date.now(), v: 1, kind: s.kind, ttl: s.ttl, route: s.route, query: s.query, result: s.result, scroll: s.scroll });
102
+ const json = JSON.stringify({ at: Date.now(), v: 1, kind: s.kind, ttl: s.ttl, route: s.route, query: s.query, result: s.result, scroll: s.scroll, sr: s.sr });
80
103
  if (json.length > MAX_CHARS) return false;
81
104
  const key = stashKey(href);
82
105
  const others = entries(key);