@luigisbox/luma 0.3.0 → 0.5.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 (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +95 -14
  3. package/build/baseline.mjs +2 -2
  4. package/build/vite.mjs +253 -20
  5. package/docs/ACCESSIBILITY.md +9 -2
  6. package/package.json +3 -2
  7. package/src/analytics/chain.ts +10 -2
  8. package/src/analytics/collect.ts +14 -7
  9. package/src/analytics/from-ga4.ts +5 -2
  10. package/src/analytics/from-luma.ts +6 -4
  11. package/src/analytics/index.ts +1 -1
  12. package/src/analytics/pageview.ts +1 -1
  13. package/src/analytics/start.ts +3 -4
  14. package/src/analytics/wire.ts +1 -1
  15. package/src/api/ai.ts +32 -20
  16. package/src/api/token.ts +7 -7
  17. package/src/core/a11y.ts +6 -35
  18. package/src/core/context.ts +2 -0
  19. package/src/core/contrast-toggle.ts +32 -0
  20. package/src/core/default-button.ts +66 -0
  21. package/src/core/early-rules.ts +18 -4
  22. package/src/core/features.ts +109 -0
  23. package/src/core/frameworks-check.ts +32 -0
  24. package/src/core/frameworks.ts +35 -0
  25. package/src/core/image-load.ts +0 -26
  26. package/src/core/image-preload.ts +29 -0
  27. package/src/core/image-tags.ts +7 -1
  28. package/src/core/images.ts +3 -2
  29. package/src/core/integration.ts +42 -26
  30. package/src/core/listings.ts +9 -5
  31. package/src/core/locales.ts +2 -1
  32. package/src/core/own.ts +7 -3
  33. package/src/core/recommenders.ts +2 -2
  34. package/src/core/reconciler.ts +32 -23
  35. package/src/core/routes.ts +7 -6
  36. package/src/core/scheduler.ts +39 -5
  37. package/src/core/speculation.ts +2 -0
  38. package/src/core/stash.ts +15 -6
  39. package/src/core/styles.ts +6 -5
  40. package/src/core/support.ts +1 -1
  41. package/src/core/takeover.ts +6 -5
  42. package/src/core/templates.ts +5 -2
  43. package/src/core/tiles.ts +33 -10
  44. package/src/core/url.ts +6 -3
  45. package/src/core/widget-inits.ts +4 -1
  46. package/src/lit.ts +2 -1
  47. package/src/locales/cs.json +0 -1
  48. package/src/locales/de.json +0 -1
  49. package/src/locales/en.json +0 -1
  50. package/src/main.ts +70 -53
  51. package/src/types.ts +29 -9
  52. package/src/widgets/autocomplete/index.ts +58 -19
  53. package/src/widgets/listing/element.ts +81 -58
  54. package/src/widgets/listing/query.ts +6 -3
  55. package/src/widgets/listing/stash.ts +33 -16
  56. package/src/widgets/listing/state.ts +12 -0
  57. package/src/widgets/listing/view.ts +2 -1
  58. package/src/widgets/plp/route.ts +3 -2
  59. package/src/widgets/recommender/index.ts +3 -2
  60. package/src/widgets/search/direct.ts +39 -0
  61. package/src/widgets/search/form-url.ts +38 -34
  62. package/src/widgets/search/in-place.ts +43 -108
  63. package/src/widgets/search/index.ts +7 -2
  64. package/src/widgets/search/speculate.ts +48 -27
  65. package/src/widgets/search/submit.ts +136 -0
@@ -5,6 +5,7 @@
5
5
  // templates, translations and helpers the bundle would.
6
6
 
7
7
  import type { LumaApi, LumaConfigType, LumaInitType } from '../types';
8
+ import { AUTOCOMPLETE, HOST_STYLES, INITS, PLP, RECOMMENDER, SEARCH, TRANSLATIONS } from './features';
8
9
 
9
10
  export type Lazy<T> = Record<string, () => Promise<T>>;
10
11
 
@@ -52,26 +53,36 @@ export interface IntegrationFiles {
52
53
 
53
54
  /** A folder's style-host.css; a category page without its own uses the search page's. */
54
55
  export const hostStyle = (files: Pick<IntegrationFiles, 'hostStyles'>, folder: string): string => {
56
+ // None in the integration (core/features.ts): nothing of the host's to hide or hold.
57
+ if (!HOST_STYLES) return '';
55
58
  const css = Object.entries(files.hostStyles).find(([p]) => folderOf(p) === folder)?.[1] ?? '';
56
- return css || (folder === 'plp' ? hostStyle(files, 'search') : '');
59
+ return css || (PLP ? (folder === 'plp' ? hostStyle(files, 'search') : '') : '');
57
60
  };
58
61
 
59
- /** `_config.ts` is JSON by contract (checked at boot; code belongs in `_init.ts`). */
62
+ /**
63
+ * `_config.ts` is JSON by contract (code belongs in `_init.ts`): a function
64
+ * in it is dropped. Said in development (the dev server, unit tests), where
65
+ * configs are written; a build (the bundle, the render service's) leaves the
66
+ * check out: ~0.1 kB gzip of every page.
67
+ */
60
68
  const parseConfigJson = (value: any, file: string) => {
61
- const check = (v: any, key: string): void => {
62
- if (typeof v === 'function') console.error(`[lbx] ${file}: "${key}" is a function; _config.ts is parsed as JSON, set functions in _init.ts`);
63
- else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) check(x, k);
64
- };
65
- check(value, '');
69
+ if (import.meta.env.DEV) {
70
+ const check = (v: any, key: string): void => {
71
+ if (typeof v === 'function') console.error(`[lbx] ${file}: "${key}" is a function; _config.ts is parsed as JSON, set functions in _init.ts`);
72
+ else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) check(x, k);
73
+ };
74
+ check(value, '');
75
+ }
66
76
  return JSON.parse(JSON.stringify(value ?? {}));
67
77
  };
68
78
 
69
79
  /** Folder -> slot in the config: `recommender home` -> recommenders.home. */
70
80
  export const slot = (config: any, folder: string) => {
71
81
  const [type, ...rest] = folder.split(' ');
72
- if (type !== 'recommender') return (config[type] ??= {});
82
+ if (!RECOMMENDER || type !== 'recommender') return (config[type] ??= {});
73
83
  const name = rest.join(' ');
74
- if (!name) throw new Error(`[lbx] "${folder}": a recommender folder needs a name, e.g. "recommender home"`);
84
+ // A build fails on such a folder (build/vite.mjs featureDefines): said here in development only.
85
+ if (import.meta.env.DEV && !name) throw Error(`[lbx] "${folder}": a recommender folder needs a name, e.g. "recommender home"`);
75
86
  return ((config.recommenders ??= {})[name] ??= {});
76
87
  };
77
88
 
@@ -83,32 +94,37 @@ export const loadConfig = async (files: Pick<IntegrationFiles, 'configs' | 'init
83
94
  const config = { _global: {} } as LumaConfigType;
84
95
  let globalInit: LumaInitType | undefined;
85
96
  for (const [p, v] of Object.entries(files.configs)) Object.assign(slot(config, folderOf(p)), parseConfigJson(v, p));
86
- for (const [p, v] of Object.entries(files.translations)) if (typeof v !== 'function') slot(config, folderOf(p)).translations = parseConfigJson(v, p);
87
- for (const [p, v] of Object.entries(files.inits)) {
88
- if (folderOf(p) === '_global') globalInit = v;
89
- else slot(config, folderOf(p)).initialize = v;
90
- }
97
+ if (TRANSLATIONS) for (const [p, v] of Object.entries(files.translations)) if (typeof v !== 'function') slot(config, folderOf(p)).translations = parseConfigJson(v, p);
98
+ // No _init.ts in the integration (core/features.ts INITS): no function sets initialize either.
99
+ if (INITS)
100
+ for (const [p, v] of Object.entries(files.inits)) {
101
+ if (folderOf(p) === '_global') globalInit = v;
102
+ else slot(config, folderOf(p)).initialize = v;
103
+ }
91
104
  const Luma: LumaApi = { version, config };
92
105
  if (globalInit && (await globalInit(config, Luma)) === false) return null;
93
106
  if (config._global.disabled || !config._global.publishableKey || !config._global.channelId) return null;
94
- const apps = [config.autocomplete, config.search, config.plp, ...Object.values(config.recommenders ?? {})];
95
- for (const app of apps) if (app && !app.disabled && app.initialize && (await (app.initialize as any)(app, Luma)) === false) app.disabled = true;
107
+ const apps = [AUTOCOMPLETE ? config.autocomplete : null, SEARCH ? config.search : null, PLP ? config.plp : null, ...(RECOMMENDER ? Object.values(config.recommenders ?? {}) : [])];
108
+ if (INITS) for (const app of apps) if (app && !app.disabled && app.initialize && (await (app.initialize as any)(app, Luma)) === false) app.disabled = true;
96
109
  // Anything a category page does not set is the search page's.
97
- const { search, plp } = config;
98
- if (plp) for (const k of LISTING) if (plp[k] == null && search?.[k] != null) (plp as any)[k] = search[k];
99
- for (const l of [search, plp]) if (l && l.reserveSelector == null && l.anchor) l.reserveSelector = [l.anchor].flat()[0];
110
+ const plp = PLP ? config.plp : null;
111
+ if (plp) for (const k of LISTING) if (plp[k] == null && config.search?.[k] != null) (plp as any)[k] = config.search[k];
112
+ if (SEARCH || PLP) for (const l of [config.search, config.plp]) if (l && l.reserveSelector == null && l.anchor) l.reserveSelector = [l.anchor].flat()[0];
100
113
  return config;
101
114
  };
102
115
 
103
116
  /** A widget's lazy `_translations.ts` (see IntegrationFiles), into their folders' slots before its context is built. */
104
117
  export const loadTranslations = (config: LumaConfigType, files: Pick<IntegrationFiles, 'translations'>, folders: readonly string[]) =>
105
- Promise.all(
106
- Object.entries(files.translations)
107
- .filter(([p, v]) => typeof v === 'function' && folders.includes(folderOf(p)))
108
- .map(async ([p, f]) => {
109
- slot(config, folderOf(p)).translations = parseConfigJson(await f(), p);
110
- }),
111
- );
118
+ // None in the integration (core/features.ts): Luma's dictionary alone.
119
+ TRANSLATIONS
120
+ ? Promise.all(
121
+ Object.entries(files.translations)
122
+ .filter(([p, v]) => typeof v === 'function' && folders.includes(folderOf(p)))
123
+ .map(async ([p, f]) => {
124
+ slot(config, folderOf(p)).translations = parseConfigJson(await f(), p);
125
+ }),
126
+ )
127
+ : Promise.resolve([]);
112
128
 
113
129
  /** A widget's files from a glob, in folder order; from _global/, files named after another widget stay out. */
114
130
  export const widgetFiles = <X>(kind: string, folders: string[], glob: Lazy<X>) => {
@@ -6,6 +6,8 @@
6
6
  // CSS); each route's takeover continues that hold.
7
7
 
8
8
  import type { SearchConfigType, PlpConfigType, GlobalConfigType } from '../types';
9
+ import { SEARCH, SSR } from './features';
10
+ import { TURBO } from './frameworks';
9
11
  import { hasPrefetch, productsChannel, putPrefetch } from './prefetch';
10
12
  import type { Rule } from './reconciler';
11
13
  import { searchReadQuery } from './routes';
@@ -43,6 +45,8 @@ const toArray = <X>(v: X | X[] | undefined): X[] => (v == null ? [] : Array.isAr
43
45
 
44
46
  const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run, held, addRule }: ListingDeps) => {
45
47
  const tag = `lbx-${kind}`;
48
+ /** The query a search URL holds (the route's readQuery / urlMatch), once. */
49
+ const read = kind === 'search' ? searchReadQuery(cfg as SearchConfigType) : null;
46
50
  const tk: Takeover = takeover({
47
51
  id: kind,
48
52
  tag,
@@ -51,13 +55,13 @@ const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run,
51
55
  reserveSelector: cfg.reserveSelector!,
52
56
  holdTimeoutMs: cfg.holdTimeoutMs ?? 4000,
53
57
  // Another category, another query: a new visit (hold, or hand back anew).
54
- visitKey: kind === 'plp' ? (u) => u.pathname : searchReadQuery(cfg as SearchConfigType),
58
+ visitKey: read ?? ((u) => u.pathname),
55
59
  held: held.has(kind),
56
60
  });
57
61
  // The element is placed by the reconciler; its class arrives with the
58
62
  // widget's chunk and upgrades it in place (custom elements are upgraded when
59
63
  // defined). Where their server fell back to their own listing, the page is theirs.
60
- const active = (u: URL) => accepts(u) && tk.ssr !== 'fellback';
64
+ const active = (u: URL) => accepts(u) && (SSR ? tk.ssr !== 'fellback' : true);
61
65
  addRule({ id: kind, active, anchors: toArray(cfg.anchor), position: cfg.position!, settleMs: cfg.settleMs, create: () => document.createElement(tag) });
62
66
  // Search: the request starts here, in parallel with the widget's chunks
63
67
  // (the entry asked for them with this one). Once our element is on the page
@@ -66,8 +70,8 @@ const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run,
66
70
  // the listing starts, so it waits for this request rather than asking twice.
67
71
  // A failure (the chunk, the request) is the listing's, as a failed request is.
68
72
  const prefetch = (url: URL) => {
69
- // A result stashed for this load (typed on the previous page, or left for Back): the listing renders that.
70
- if (kind !== 'search' || !active(url) || tk.ssr || hasPrefetch(url.href) || document.querySelector(tag) || hasStash(url.href)) return;
73
+ // A result stashed for this load (typed on the previous page: under the query; or left for Back: under the page): the listing renders that.
74
+ if (!SEARCH || !read || !active(url) || (SSR ? !!tk.ssr : false) || hasPrefetch(url.href) || document.querySelector(tag) || hasStash(url.href, { route: kind, q: read(url) })) return;
71
75
  const { products, onProducts } = productsChannel();
72
76
  putPrefetch(url.href, import('../widgets/search/first').then((m) => m.searchFirst(g, cfg as SearchConfigType, url, onProducts)), { products });
73
77
  };
@@ -95,6 +99,6 @@ const listing = ({ kind, cfg, accepts, hostCss }: ListingRoute, { g, load, run,
95
99
  /** Each route's takeover, placement rule and loading; Turbo Drive's history around ours (core/turbo.ts). */
96
100
  export const startListings = (routes: ListingRoute[], deps: ListingDeps, setUp: <X>(where: string, f: () => X) => X | null) => {
97
101
  const listings = routes.map((r) => setUp(r.kind, () => listing(r, deps))).filter((l): l is NonNullable<typeof l> => !!l);
98
- if (listings.length) setUp('turbo', () => startTurbo(listings));
102
+ if (TURBO && listings.length) setUp('turbo', () => startTurbo(listings));
99
103
  return listings;
100
104
  };
@@ -1,5 +1,6 @@
1
1
  // Luma's dictionaries (src/locales/<locale>.json), each its own chunk, only
2
- // the site's locale ever loaded. In the entry (a few bytes: the glob's map),
2
+ // the site's locale ever loaded. In the entry (a few bytes per locale: the
3
+ // glob's map, narrowed to the declared ones: build/vite.mjs onlyLocales),
3
4
  // so a widget's load asks for its dictionary in parallel with its chunks
4
5
  // rather than after lbx-core arrived. No DOM: the server renderer uses it too.
5
6
 
package/src/core/own.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // Everything we insert into the page is marked, so sensing can ignore our own
2
2
  // mutations and the reconciler can find its nodes.
3
3
 
4
+ import { DOCUMENT_REACT } from './frameworks';
5
+
4
6
  export const OWN_ATTR = 'data-lbx';
5
7
 
6
8
  /**
@@ -28,17 +30,19 @@ export const modalOf = (el: Element): Element | null => {
28
30
  return null;
29
31
  };
30
32
 
31
- /** React hydrating the whole document (Next.js app router, Remix, React Router 7 / Hydrogen). */
32
- export const wholeDocument = () => {
33
+ /** The page's own signs of React hydrating the whole document (Next.js app router, Remix, React Router 7 / Hydrogen). */
34
+ export const reactDocumentSigns = () => {
33
35
  const w = window as any;
34
36
  return !!(w.__next_f || w.__remixContext || w.__reactRouterContext);
35
37
  };
38
+ /** React hydrating the whole document. */
39
+ export const wholeDocument = DOCUMENT_REACT ? reactDocumentSigns : () => false;
36
40
  /**
37
41
  * A page that may yet be hydrated as a whole: known whole-document React, or a
38
42
  * Next.js page still parsing (its flag comes at the end of <body>, its assets
39
43
  * show before). Anywhere else, nothing of ours has to wait for a framework.
40
44
  */
41
- export const documentMayHydrate = () => wholeDocument() || (document.readyState === 'loading' && !!document.querySelector('script[src*="/_next/"]'));
45
+ export const documentMayHydrate = () => (DOCUMENT_REACT ? wholeDocument() || (document.readyState === 'loading' && !!document.querySelector('script[src*="/_next/"]')) : false);
42
46
 
43
47
  /** React has hydrated this element (claimed it: a fiber, or React 16's instance). */
44
48
  export const hasFiber = (n: object) => Object.keys(n).some((k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
@@ -40,7 +40,7 @@ export const startRecommenders = (recos: Reco[], run: Run, setUp: <X>(where: str
40
40
  * (2026-10-11).
41
41
  */
42
42
  function inlineRecommender(id: string, rc: RecommenderConfigType, get: () => Promise<Loaded>, run: Run) {
43
- const re = rc.urlMatch ? new RegExp(rc.urlMatch) : null;
43
+ const re = rc.urlMatch ? RegExp(rc.urlMatch) : null;
44
44
  const page = rc.active ?? ((u: URL) => !re || re.test(u.pathname));
45
45
  const io = new IntersectionObserver(
46
46
  (entries) => {
@@ -113,7 +113,7 @@ function inlineRecommender(id: string, rc: RecommenderConfigType, get: () => Pro
113
113
  * their way) when the notice appears.
114
114
  */
115
115
  function popupTrigger(rc: RecommenderConfigType, get: () => Promise<Loaded>, run: Run) {
116
- if (!rc.intent) return reportError('reco popup', new Error('a popup recommender needs intent() in its _init.ts'));
116
+ if (!rc.intent) return reportError('reco popup', Error('a popup recommender needs intent() in its _init.ts'));
117
117
  let intent: { item: string; t: number } | null = null;
118
118
  // Sensing sees nodes come and go, not attributes: a notice the host shows by
119
119
  // a class (or style, hidden, open) on a node that stays is watched for while
@@ -6,6 +6,8 @@
6
6
  // are stable and need no settle wait. Read at the entry's boot (this module arrives later, in lbx-place).
7
7
  import { lateBoot } from './boot-state';
8
8
  import { documentMayHydrate, hasFiber, OWN_ATTR, watchOwnRemovals, wholeDocument } from './own';
9
+ import { SSR } from './features';
10
+ import { ANGULAR, ASTRO, NEXT, REACT, SVELTEKIT, TURBO, VUE } from './frameworks';
9
11
  import { onFlush, scheduleIn, scheduleNow } from './scheduler';
10
12
  import { bump, health, mark, reportError } from './health';
11
13
 
@@ -46,11 +48,12 @@ const pausedUntil = new Map<string, number>();
46
48
  // re-renders the root on the client (a full-page flash), Vue drops our node,
47
49
  // Angular reports NG0500. The host CSS hold covers the wait. Any other React
48
50
  // root is known by its container key once createRoot/hydrateRoot has run
49
- // (reactContainer(), below).
51
+ // (reactContainer(), below). Only the frameworks the build has (core/frameworks.ts).
50
52
  const REACT_ROOTS = '#__next, #___gatsby, [data-reactroot]';
51
53
  const VUE_ROOTS = '#__nuxt, [data-server-rendered]';
52
54
  // Astro removes `ssr` once an island has hydrated.
53
- const SSR_ROOTS = `${REACT_ROOTS}, ${VUE_ROOTS}, [ng-server-context], astro-island[ssr]`;
55
+ // (Ternaries, not &&: a false flag must leave no reference behind, or this chunk imports it and keeps the code.)
56
+ const SSR_ROOTS = [REACT ? REACT_ROOTS : '', VUE ? VUE_ROOTS : '', ANGULAR ? '[ng-server-context]' : '', ASTRO ? 'astro-island[ssr]' : ''].filter(Boolean).join(', ');
54
57
  const keyOf = (n: object, prefix: string) => Object.keys(n).find((k) => k.startsWith(prefix));
55
58
 
56
59
  const CONTAINER = '__reactContainer$';
@@ -60,6 +63,7 @@ const CONTAINER = '__reactContainer$';
60
63
  * is as common as `#__next`), or the document React hydrates as a whole.
61
64
  */
62
65
  const reactContainer = (el: Element): Element | Document | null => {
66
+ if (!REACT) return null;
63
67
  for (let a: Element | null = el; a; a = a.parentElement) if (keyOf(a, CONTAINER)) return a;
64
68
  return wholeDocument() ? document : null;
65
69
  };
@@ -70,7 +74,7 @@ const reactContainer = (el: Element): Element | Document | null => {
70
74
  * ours in that markup was React error #418, the root rendered anew).
71
75
  */
72
76
  const reactDehydrated = (c: Element | Document | null) => {
73
- const k = c && keyOf(c, CONTAINER);
77
+ const k = REACT ? c && keyOf(c, CONTAINER) : null;
74
78
  return !!k && (c as any)[k]?.stateNode?.current?.memoizedState?.isDehydrated === true;
75
79
  };
76
80
  // A wait for the parse to end (hydrated(), below) needs a reconcile when it does.
@@ -97,10 +101,12 @@ const beforeCache = () => {
97
101
  // (Turbolinks snapshots in a setTimeout of 1 ms: a few ms more covers it.)
98
102
  setTimeout(() => setTimeout(endSnapshot, 5));
99
103
  };
100
- addEventListener('turbo:before-cache', beforeCache);
101
- addEventListener('turbolinks:before-cache', beforeCache);
102
- addEventListener('turbo:render', endSnapshot);
103
- addEventListener('turbolinks:render', endSnapshot);
104
+ if (TURBO) {
105
+ addEventListener('turbo:before-cache', beforeCache);
106
+ addEventListener('turbolinks:before-cache', beforeCache);
107
+ addEventListener('turbo:render', endSnapshot);
108
+ addEventListener('turbolinks:render', endSnapshot);
109
+ }
104
110
  const sinceLoad = () => {
105
111
  const end = (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.loadEventEnd;
106
112
  return end ? performance.now() - end : -1;
@@ -173,7 +179,7 @@ const inDehydratedSuspense = (el: Element): boolean => {
173
179
  * meanwhile (their listing shows); ours is placed once React is done.
174
180
  */
175
181
  const reactHydrating = (anchor: Element, age: number) => {
176
- return age <= MAX_REACT_WAIT && reactDehydrated(reactContainer(anchor));
182
+ return REACT ? age <= MAX_REACT_WAIT && reactDehydrated(reactContainer(anchor)) : false;
177
183
  };
178
184
  /** However long React takes, not longer than this. */
179
185
  const MAX_REACT_WAIT = 15_000;
@@ -185,7 +191,7 @@ const MAX_REACT_WAIT = 15_000;
185
191
  * production Angular binds the server's next node to the wrong element.
186
192
  */
187
193
  const ngHydrating = (anchor: Element, pos: Position, age: number) => {
188
- if (age > MAX_REACT_WAIT) return false;
194
+ if (!ANGULAR || age > MAX_REACT_WAIT) return false;
189
195
  const node = pos === 'before' || pos === 'after' ? anchor.parentElement ?? anchor : anchor;
190
196
  return !!node.closest('[ng-server-context][ngh], [ng-server-context] [ngh]');
191
197
  };
@@ -203,37 +209,39 @@ const ngHydrating = (anchor: Element, pos: Position, age: number) => {
203
209
  let svelteKit: Element | null | undefined;
204
210
  let svelteKitStarted = false;
205
211
  const svelteKitRoot = (anchor: Element) => {
212
+ if (!SVELTEKIT) return null;
206
213
  if (svelteKit === undefined) {
207
214
  const boot = [...document.scripts].find((s) => !s.src && s.text.includes('__sveltekit_') && s.text.includes('.start('));
208
215
  if (boot || document.readyState !== 'loading') svelteKit = boot?.parentElement ?? null;
209
216
  }
210
217
  return svelteKit?.contains(anchor) ? svelteKit : null;
211
218
  };
212
- const svelteKitParsing = () => svelteKit === undefined && !!document.querySelector('link[href*="/immutable/entry/start."]');
219
+ const svelteKitParsing = () => (SVELTEKIT ? svelteKit === undefined && !!document.querySelector('link[href*="/immutable/entry/start."]') : false);
213
220
 
214
221
  const hydrated = (anchor: Element, pos: Position) => {
215
222
  if (svelteKitRoot(anchor)) return (svelteKitStarted ||= history.scrollRestoration === 'manual');
216
223
  // Any React root (its container key), and not before it has committed its hydration.
217
224
  const react = reactContainer(anchor);
218
225
  if (reactDehydrated(react)) return false;
219
- const root = anchor.closest(SSR_ROOTS) ?? (react !== document ? (react as Element | null) : null) ?? (wholeDocument() ? document.body : null);
226
+ const root = (SSR_ROOTS ? anchor.closest(SSR_ROOTS) : null) ?? (REACT ? (react !== document ? (react as Element | null) : null) : null) ?? (wholeDocument() ? document.body : null);
220
227
  // Still parsing a Next.js page (its whole-document flag comes at the end of
221
228
  // <body>, its assets are visible before) or a SvelteKit page (its boot
222
229
  // script is at the end of <body>): wait for the parse. Any other page is
223
230
  // placed at once (before first paint, as the hold intends).
224
231
  if (!root) return !documentMayHydrate() && !svelteKitParsing();
225
- // For a sibling insert, the parent's state is what counts (all of its children claimed).
226
- const node = pos === 'before' || pos === 'after' ? anchor.parentElement ?? anchor : anchor;
232
+ // For a sibling insert, the parent's state is what counts (all of its
233
+ // children claimed): React's and Angular's only (a read the rest leave out).
234
+ const node = REACT || ANGULAR ? (pos === 'before' || pos === 'after' ? anchor.parentElement ?? anchor : anchor) : anchor;
227
235
  // Frameworks with a signal: wait for it (the cap bounds the wait).
228
236
  // For a sibling insert, the anchor's own place counts too: its parent may be
229
237
  // claimed while the anchor sits in a boundary React has not hydrated yet.
230
- if (root === document.body || root === react || root.matches(REACT_ROOTS)) {
238
+ if (REACT && (root === document.body || root === react || root.matches(REACT_ROOTS))) {
231
239
  // Next.js pages router: its hydration measure (it stays; the marks it is
232
240
  // made of do not) says the shell is done, not the Suspense boundaries.
233
- const shell = performance.getEntriesByName('Next.js-hydration', 'measure').length ? !inAnyDehydratedBoundary(node) : reactHydrated(node);
241
+ const shell = NEXT && performance.getEntriesByName('Next.js-hydration', 'measure').length ? !inAnyDehydratedBoundary(node) : reactHydrated(node);
234
242
  return shell && (node === anchor || !inDehydratedSuspense(anchor));
235
243
  }
236
- if (root.matches(VUE_ROOTS)) {
244
+ if (VUE && root.matches(VUE_ROOTS)) {
237
245
  const vue = (root as any).__vue_app__;
238
246
  const nuxt = vue?.config?.globalProperties?.$nuxt;
239
247
  if (nuxt) return nuxt.isHydrating === false; // Nuxt 3 hydrates async parts after mount
@@ -243,11 +251,11 @@ const hydrated = (anchor: Element, pos: Position) => {
243
251
  // that task), and no component around the anchor still has its hydration
244
252
  // key (`ngh`, removed once Angular has read it): a lazy route's page
245
253
  // hydrates later than the shell, when its chunk has arrived.
246
- if ('__ngContext__' in root) {
254
+ if (ANGULAR && '__ngContext__' in root) {
247
255
  const waiting = node.closest('[ngh]');
248
256
  return !waiting || waiting === root || !root.contains(waiting);
249
257
  }
250
- if (root.localName === 'astro-island') return false; // it drops `ssr` when hydrated (the cap bounds the wait)
258
+ if (ASTRO && root.localName === 'astro-island') return false; // it drops `ssr` when hydrated (the cap bounds the wait)
251
259
  // No signal (a framework we cannot see): after load and a moment.
252
260
  return lateBoot || sinceLoad() > 1000;
253
261
  };
@@ -300,8 +308,9 @@ const allowInsert = (id: string, now: number) => {
300
308
  * markup in an island that hydrates after the swap; ours in it then fails
301
309
  * that hydration and the island renders anew).
302
310
  */
311
+ // (hydrated() reads the position for React and Angular only: no read of it in a build without them.)
303
312
  const mustWait = (r: Rule, anchor: Element, age: number) =>
304
- (!lost.has(r.id) || !!anchor.closest('astro-island[ssr]')) && !hydrated(anchor, r.position) && (age < MAX_HYDRATION_WAIT || reactHydrating(anchor, age) || ngHydrating(anchor, r.position, age));
313
+ (!lost.has(r.id) || (ASTRO ? !!anchor.closest('astro-island[ssr]') : false)) && !hydrated(anchor, REACT || ANGULAR ? r.position : 'prepend') && (age < MAX_HYDRATION_WAIT || reactHydrating(anchor, age) || (ANGULAR ? ngHydrating(anchor, r.position, age) : false));
305
314
 
306
315
  const reconcileRule = (r: Rule, url: URL, now: number) => {
307
316
  const mine = [...document.querySelectorAll(`[${OWN_ATTR}="${r.id}"]`)];
@@ -322,21 +331,21 @@ const reconcileRule = (r: Rule, url: URL, now: number) => {
322
331
  // placed. Otherwise only our own instance counts: a copy of it (a Turbo
323
332
  // snapshot restored) is not live, it goes.
324
333
  const keep =
325
- mine.find((el) => el.hasAttribute('data-lbx-ssr') && isPlaced(el, anchor!, r.position)) ??
334
+ (SSR ? mine.find((el) => el.hasAttribute('data-lbx-ssr') && isPlaced(el, anchor!, r.position)) : undefined) ??
326
335
  mine.find((el) => el === instances.get(r.id) && isPlaced(el, anchor!, r.position));
327
336
  if (!keep && placedOn.has(r.id) && placedOn.get(r.id) !== anchor) lost.add(r.id);
328
337
  mine.filter((el) => el !== keep).forEach((el) => el.remove());
329
338
  if (keep) {
330
339
  // The server's element becomes ours (it drops its marker once hydrated).
331
340
  instances.set(r.id, keep as HTMLElement);
332
- if (!keep.hasAttribute('data-turbo-temporary')) keep.setAttribute('data-turbo-temporary', '');
341
+ if (TURBO && !keep.hasAttribute('data-turbo-temporary')) keep.setAttribute('data-turbo-temporary', '');
333
342
  // Placed while parsing, before the page said React hydrates it as a whole
334
343
  // (Remix, React Router 7 and Hydrogen say so at the end of <body>): out
335
344
  // again until React has it, or React re-renders the document. Their
336
345
  // scripts run after the parse, so it is gone before hydration starts.
337
346
  // Within the same limits as the wait before an insert (React never hydrating must not keep ours out).
338
347
  if (!firstSeen.has(anchor)) firstSeen.set(anchor, now);
339
- if (!keep.hasAttribute('data-lbx-ssr') && wholeDocument() && mustWait(r, anchor, now - (firstSeen.get(anchor) ?? now))) {
348
+ if ((!SSR || !keep.hasAttribute('data-lbx-ssr')) && wholeDocument() && mustWait(r, anchor, now - (firstSeen.get(anchor) ?? now))) {
340
349
  keep.remove();
341
350
  scheduleIn(200);
342
351
  }
@@ -361,7 +370,7 @@ const reconcileRule = (r: Rule, url: URL, now: number) => {
361
370
  instances.set(r.id, el);
362
371
  el.setAttribute(OWN_ATTR, r.id);
363
372
  // Turbo Drive: not in its page snapshots (a restored copy would sit next to ours).
364
- el.setAttribute('data-turbo-temporary', '');
373
+ if (TURBO) el.setAttribute('data-turbo-temporary', '');
365
374
  place(el, anchor, r.position);
366
375
  placedOn.set(r.id, anchor);
367
376
  lost.delete(r.id);
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { ParamMap, PlpConfigType, SearchConfigType } from '../types';
5
5
  import { reportError } from './health';
6
+ import { READ_QUERY } from './features';
6
7
 
7
8
  /** An integration's URL hook (active, readQuery) that throws: the page is not ours. */
8
9
  const guarded = (where: string, f: (u: URL) => boolean) => (u: URL) => {
@@ -19,7 +20,7 @@ export const P = 'lbx.';
19
20
 
20
21
  // Tracking params, and the LBX loader's own (_lbx_env=development, …): never a reason to leave a page to the host.
21
22
  export const TRACKING = '^(utm_|_lbx_|gclid$|gbraid$|wbraid$|fbclid$|msclkid$|_gl$|mc_(cid|eid)$|srsltid$)';
22
- const TRACKING_RE = new RegExp(TRACKING);
23
+ const TRACKING_RE = RegExp(TRACKING);
23
24
 
24
25
  /** A URL without the params in `ignore` (default: tracking) and without its #hash: the page it shows. */
25
26
  export const withoutTracking = (href: string, ignore = TRACKING_RE) => {
@@ -47,7 +48,7 @@ export const firstPage = (v: string) => /^0*1$/.test(v.trim());
47
48
  export const has = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);
48
49
 
49
50
  export const searchReadQuery = (c: SearchConfigType) => {
50
- if (c.readQuery) {
51
+ if (READ_QUERY && c.readQuery) {
51
52
  const read = c.readQuery;
52
53
  return (u: URL) => {
53
54
  try {
@@ -58,13 +59,13 @@ export const searchReadQuery = (c: SearchConfigType) => {
58
59
  }
59
60
  };
60
61
  }
61
- const re = c.urlMatch ? new RegExp(c.urlMatch) : null;
62
+ const re = c.urlMatch ? RegExp(c.urlMatch) : null;
62
63
  return (u: URL) => (c.queryParam ? u.searchParams.get(c.queryParam) : decode((re?.exec(u.pathname)?.[1] ?? '').replace(/\+/g, ' ')))?.trim() || '';
63
64
  };
64
65
 
65
66
  export const searchAccepts = (c: SearchConfigType) => {
66
67
  if (c.active) return guarded('search active', c.active);
67
- const re = c.urlMatch ? new RegExp(c.urlMatch) : null;
68
+ const re = c.urlMatch ? RegExp(c.urlMatch) : null;
68
69
  const read = searchReadQuery(c);
69
70
  return guarded('search readQuery', (u: URL) => (!re || re.test(u.pathname)) && !!read(u));
70
71
  };
@@ -83,9 +84,9 @@ export const flagId = (f: { param: string; value: string }) => `${f.param}=${f.v
83
84
  * anything else leaves the page to the host, so no URL ever loses a filter.
84
85
  */
85
86
  export const plpAccepts = (c: Pick<PlpConfigType, 'urlMatch' | 'params' | 'active'>) => {
86
- const re = new RegExp(c.urlMatch);
87
+ const re = RegExp(c.urlMatch);
87
88
  const pm: ParamMap = c.params ?? {};
88
- const ignore = new RegExp(pm.ignore ?? TRACKING);
89
+ const ignore = RegExp(pm.ignore ?? TRACKING);
89
90
  const flags = pm.flags ?? [];
90
91
  const facetParams = pm.facetParams ?? {};
91
92
  const byParam = new Map(Object.entries(facetParams).map(([facet, fp]) => [fp.param, facet]));
@@ -28,12 +28,36 @@ let framesRunning = false;
28
28
  const firstPaintDone = () =>
29
29
  framesRunning || (framesRunning = performance.getEntriesByType('paint').length > 0 || document.readyState === 'complete');
30
30
 
31
+ /** Longest wait for a frame: past it the reconcile runs in a task (afterFrame too, once frames are stalled). */
32
+ const FRAME_FALLBACK_MS = 100;
33
+ /** A frame asked for this long ago that has not come: frames are stalled (WebKit on a busy machine: none for ~5 s). */
34
+ const STALL_MS = 250;
35
+ let asked = 0; // our requestAnimationFrame callbacks not yet run
36
+ let askedAt = 0; // when the earliest of them was asked for, or the last frame of ours ran
37
+ /** requestAnimationFrame, counted: whether a frame is overdue is known to every caller. */
38
+ const frame = (cb: () => void) => {
39
+ if (!asked++) askedAt = performance.now();
40
+ requestAnimationFrame(() => {
41
+ asked--;
42
+ askedAt = performance.now(); // a frame came: the asks still waiting start the clock anew
43
+ cb();
44
+ });
45
+ };
46
+ const framesStalled = () => asked > 0 && performance.now() - askedAt > STALL_MS;
47
+
31
48
  export const schedule = () => {
32
49
  if (pending) return;
33
50
  pending = true;
34
51
  // rAF does not run in background tabs or before the first paint; use a timeout there.
35
- if (document.visibilityState === 'visible' && firstPaintDone()) requestAnimationFrame(flush);
36
- else setTimeout(flush, document.visibilityState === 'visible' ? 16 : 50);
52
+ if (document.visibilityState !== 'visible' || !firstPaintDone()) return void setTimeout(flush, document.visibilityState === 'visible' ? 16 : 50);
53
+ // A visible page can still run no frames for seconds while its timers run
54
+ // (WebKit on a busy 2-CPU machine: ~5 s after typing). Waiting for one
55
+ // then outlasted the hold's timeout: their listing came back, ours stayed
56
+ // hidden. Whichever comes first, once.
57
+ let done = false;
58
+ const run = () => !done && ((done = true), flush());
59
+ frame(run);
60
+ setTimeout(run, FRAME_FALLBACK_MS);
37
61
  };
38
62
 
39
63
  export const scheduleIn = (ms: number) => setTimeout(schedule, ms);
@@ -43,7 +67,11 @@ export const scheduleIn = (ms: number) => setTimeout(schedule, ms);
43
67
  * are clean then, so reading computed styles or sizes costs only what we read,
44
68
  * not a style pass over the whole (host) document. Where no frames run (a
45
69
  * hidden tab, before the first paint) in a task of its own; a tab hidden
46
- * meanwhile (rAF waits until it is shown) by a timeout.
70
+ * meanwhile (rAF waits until it is shown) by a timeout: 500 ms, so a slow
71
+ * frame (a large page at 4x CPU) still comes first and the read costs no
72
+ * forced style pass; FRAME_FALLBACK_MS once frames are stalled (none for
73
+ * STALL_MS while asked for: WebKit ran none for ~5 s on a busy machine, and a
74
+ * listing's first render, one step per frame, showed nothing for 2.5 s then).
47
75
  */
48
76
  export const afterFrame = (f: Task) => {
49
77
  if (document.visibilityState !== 'visible') {
@@ -57,8 +85,14 @@ export const afterFrame = (f: Task) => {
57
85
  const run = () => !done && ((done = true), f());
58
86
  // Ahead of the page's own queued tasks where the browser can (Chrome's scheduler).
59
87
  const post = (globalThis as any).scheduler?.postTask;
60
- requestAnimationFrame(() => (post ? post.call((globalThis as any).scheduler, run, { priority: 'user-blocking' }) : setTimeout(run, 0)));
61
- setTimeout(run, 500);
88
+ frame(() => (post ? post.call((globalThis as any).scheduler, run, { priority: 'user-blocking' }) : setTimeout(run, 0)));
89
+ const t0 = performance.now();
90
+ const tick = () => {
91
+ if (done) return;
92
+ if (framesStalled() || performance.now() - t0 >= 500) run();
93
+ else setTimeout(tick, FRAME_FALLBACK_MS);
94
+ };
95
+ setTimeout(tick, FRAME_FALLBACK_MS);
62
96
  };
63
97
 
64
98
  let pendingNow = false;
@@ -18,6 +18,7 @@
18
18
  // activation (core/takeover.ts).
19
19
 
20
20
  import { mark } from './health';
21
+ import { cspNonce } from './styles';
21
22
  import type { GlobalConfigType } from '../types';
22
23
 
23
24
  const LINKS = ':is(lbx-search, lbx-plp) [data-lbx-list] a[href], lbx-reco a[href], [data-lbx="ac-overlay"] a[href]';
@@ -49,6 +50,7 @@ export const speculate = (g: GlobalConfigType, onUrlChange: (f: () => void) => u
49
50
  el = document.createElement('script');
50
51
  el.type = 'speculationrules';
51
52
  el.setAttribute('data-lbx', 'speculation');
53
+ el.nonce = cspNonce(); // a strict script-src applies to inline speculation rules
52
54
  el.textContent = JSON.stringify(rules);
53
55
  document.head.append(el);
54
56
  };
package/src/core/stash.ts CHANGED
@@ -1,11 +1,17 @@
1
- // A listing result kept in sessionStorage for the next page load of its URL
2
- // (widgets/listing/stash.ts reads and writes them). The entry only asks
1
+ // A listing result kept in sessionStorage for the next page load that shows
2
+ // it (widgets/listing/stash.ts reads and writes them): a listing left for
3
+ // Back, under its page's URL; a typed query's search page, under the query
4
+ // (the URL its form submits to is often not the one its page loads at: a
5
+ // server that redirects `/search?q=x` to `/search/x/`). The entry only asks
3
6
  // whether this load can use one: a search page then does not send its own
4
7
  // request before the listing's chunk has read it.
5
8
  import { pageKey } from './page';
6
9
 
7
10
  export const STASH = 'lbx:r:';
11
+ /** A listing left for Back: its page. */
8
12
  export const stashKey = (href: string) => STASH + pageKey(href);
13
+ /** A typed query's result (`spec`): the query, on its route, whatever URL its page loads at. */
14
+ export const specKey = (route: string, q: string) => `${STASH}spec:${route}:${q}`;
9
15
 
10
16
  /** How this document was loaded, and its URL (a later SPA visit is not the load a stash is for). */
11
17
  export const navigation = () => {
@@ -47,11 +53,14 @@ export const stashFits = (kind: string, at = 0) => {
47
53
  return Date.now() - at < ROUND_TRIP_MS && (t === 'reload' || (t === 'navigate' && fromItself()));
48
54
  };
49
55
 
50
- /** A stash this load will likely use (the listing checks it fully). Reads only the start of the entry. */
51
- export const hasStash = (href: string) => {
56
+ /** A stash this load will likely use (the listing checks it fully): the page's for Back, or the search query's (`spec`). Reads only the start of the entry. */
57
+ export const hasStash = (href: string, spec?: { route: string; q: string }) => {
52
58
  try {
53
- const head = sessionStorage.getItem(stashKey(href))?.slice(0, 60);
54
- return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec', Number(/"at":(\d+)/.exec(head)?.[1]) || 0);
59
+ const fits = (key: string) => {
60
+ const head = sessionStorage.getItem(key)?.slice(0, 60);
61
+ return !!head && stashFits(head.includes('"kind":"back"') ? 'back' : 'spec', Number(/"at":(\d+)/.exec(head)?.[1]) || 0);
62
+ };
63
+ return fits(stashKey(href)) || (!!spec?.q && fits(specKey(spec.route, spec.q)));
55
64
  } catch {
56
65
  return false;
57
66
  }
@@ -13,13 +13,14 @@
13
13
  // route-scoped. CSS survives host re-renders, DOM mutations would not.
14
14
  // Every sheet is put back when a host re-render drops it (onFlush).
15
15
 
16
+ import { DOCUMENT_REACT } from './frameworks';
16
17
  import { documentMayHydrate, hasFiber, watchOwnRemovals } from './own';
17
18
  import { onFlush } from './scheduler';
18
19
 
19
- // While <head> is still parsing, on every page: whole-document frameworks
20
- // announce themselves only at the end of <body>, and the body-start observer
21
- // puts ours in before anything of the body paints.
22
- const deferred = () => !document.body;
20
+ // While <head> is still parsing, on every page that may be whole-document
21
+ // React (DOCUMENT_REACT): it announces itself only at the end of <body>, and
22
+ // the body-start observer puts ours in before anything of the body paints.
23
+ const deferred = () => (DOCUMENT_REACT ? !document.body : false);
23
24
 
24
25
  const sheets = new Map<string, HTMLStyleElement>();
25
26
 
@@ -38,7 +39,7 @@ const declareLayer = () => {
38
39
  const head = document.head || document.documentElement;
39
40
  // React has it once it has claimed <head>, or <html> when its root renders
40
41
  // no <head> of its own (Next's app router: Next fills it): hydrated past it.
41
- const early = (document.readyState === 'loading' || documentMayHydrate()) && !hasFiber(head) && !hasFiber(document.documentElement);
42
+ const early = DOCUMENT_REACT ? (document.readyState === 'loading' || documentMayHydrate()) && !hasFiber(head) && !hasFiber(document.documentElement) : false;
42
43
  if (order?.isConnected && (early || head.firstElementChild === order)) return;
43
44
  order ??= styleEl();
44
45
  order.textContent = '@layer lbx;';
@@ -9,4 +9,4 @@ export { speculate } from './speculation';
9
9
  // Images (./images.ts, ./image-tags.ts, ./image-load.ts): every widget's templates and pages.
10
10
  export { imageHelpers } from './image-tags';
11
11
  export { firstRowCount, firstRowImages, imageOf } from './images';
12
- export { preloadImages, settleImages, warmImages } from './image-load';
12
+ export { settleImages, warmImages } from './image-load';