@luigisbox/luma 0.5.0 → 0.7.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.
@@ -10,7 +10,7 @@ import { facetFields, type Query, type Route } from './state';
10
10
  import { lru } from '../../core/storage';
11
11
  import { reportError } from '../../core/health';
12
12
  import { MAX_PAGE } from '../../core/routes';
13
- import { EMPTY_VALUES } from '../../core/features';
13
+ import { EMPTY_VALUES, VARIANTS } from '../../core/features';
14
14
 
15
15
  const num = (s: string | undefined) => (s === undefined || s === '' ? null : Number(s));
16
16
  /** A range filter value `min|max` (either end may be empty). */
@@ -156,9 +156,11 @@ export const firstRequest = (g: GlobalConfigType, cfg: ListingConfig, route: Rou
156
156
  * need their result set's guid and so a request after them. `onQuick`: the
157
157
  * quick types never wait for (where they do not sit above the products):
158
158
  * the result comes without them if they are not here yet (`quickPending`),
159
- * and they land in it later, `onQuick` called with it then.
159
+ * and they land in it later, `onQuick` called with it then. `onFirst`: the
160
+ * first page as soon as it is here, with the facets' request (`variants`:
161
+ * its products' groups are asked for then, not after the facets).
160
162
  */
161
- export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void, onQuick?: (r: ListingResult) => void): Promise<ListingResult> => {
163
+ export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingConfig, route: Route, q: Query, signal?: AbortSignal, pre?: Page, onProducts?: (r: ListingResult) => void, onQuick?: (r: ListingResult) => void, onFirst?: (p: Page) => void): Promise<ListingResult> => {
162
164
  // A range selection over a field that may not be a numeric one (a page's
163
165
  // own facet, e.g. a parameter holding its numbers as text): if the API facets
164
166
  // it as terms, the range is the values within it, from the values of the
@@ -259,6 +261,8 @@ export const listingQuery = async (api: Api, g: GlobalConfigType, cfg: ListingCo
259
261
  : {};
260
262
  // Optional too: without them no value shows as empty.
261
263
  const all = EMPTY_VALUES && cfg.emptyValues && narrowed ? allOf().catch(() => undefined) : undefined;
264
+ // Extras after what the listing waits for (its facets' requests are out).
265
+ if (VARIANTS) onFirst?.(first);
262
266
  // The products now, the facets when they come: a request (theirs needs
263
267
  // this result set's guid) the shopper would otherwise wait for.
264
268
  const early: ListingResult | null =
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { navigation, specKey, STASH, stashFits, stashKey } from '../../core/stash';
22
22
  import { pageKey } from '../../core/page';
23
+ import { VARIANTS } from '../../core/features';
23
24
  import type { ListingResult } from './query';
24
25
  import { queryKey, type Query } from './state';
25
26
  import type { Spot } from './instant';
@@ -39,6 +40,8 @@ export interface Stash {
39
40
  scroll?: Spot;
40
41
  /** back: Luma turned the browser's own scroll restoration off for the entry left (`history.scrollRestoration`): back on once restored. */
41
42
  sr?: 1;
43
+ /** back, `variants`: the groups of the products shown (./variants.ts), drawn with them. Storage: checked when read. */
44
+ groups?: unknown;
42
45
  /** Read only: too old to render from, where the shopper was is all that is used. */
43
46
  stale?: true;
44
47
  }
@@ -114,9 +117,15 @@ export const removeStash = (key: string) => {
114
117
  };
115
118
 
116
119
  /** Under the page's URL (`back`) or the query (`spec`). false: too big, or storage refused it. */
117
- export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
120
+ export const writeStash = (href: string, s: Omit<Stash, 'v' | 'at'>) =>
121
+ // Too big with the products' variant groups (extras): stashed without them rather than not at all.
122
+ put(href, s) || (VARIANTS ? !!s.groups && put(href, { ...s, groups: undefined }) : false);
123
+
124
+ const put = (href: string, s: Omit<Stash, 'v' | 'at'>) => {
118
125
  try {
119
- 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 });
126
+ // `at` and `kind` first: pruning reads them without parsing the rest.
127
+ const { kind, ...rest } = s;
128
+ const json = JSON.stringify({ at: Date.now(), v: 1, kind, ...rest });
120
129
  if (json.length > MAX_CHARS) return false;
121
130
  const key = s.kind === 'spec' ? specKey(s.route, s.query.q ?? '') : stashKey(href);
122
131
  const others = entries(key);
@@ -0,0 +1,111 @@
1
+ // `variants` (a listing's config): the variant groups of the products a
2
+ // listing shows, for a surface that collapses each group into one result
3
+ // (/variants). Asked per result set (its request_key, `guid`) for the
4
+ // products not asked yet: a page's as soon as it is here (the first, each
5
+ // "show more", the pages of a Back restore without a stash of them at
6
+ // once), at most MAX_IDS a request. Never waited for: the listing draws its
7
+ // tiles, and again when the groups land (widgets/listing/element.ts).
8
+
9
+ import type { Api, VariantGroup } from '../../api';
10
+ import { reportError } from '../../core/health';
11
+ import { lru } from '../../core/storage';
12
+ import type { ListingConfig, ListingResult } from './query';
13
+
14
+ /** /variants takes at most this many ids. */
15
+ const MAX_IDS = 200;
16
+ /** A failed request's products are asked again by a render this much later, not by every render in an outage. */
17
+ const RETRY_MS = 5000;
18
+
19
+ /** What a request needs of a result: its result set and its products (a page, or the pages loaded). */
20
+ type Products = Pick<ListingResult, 'guid' | 'hits'>;
21
+
22
+ interface Groups {
23
+ /** Ids asked for: answered, in flight, or failed less than RETRY_MS ago. */
24
+ asked: Set<string>;
25
+ /** The groups answered, by id: a new map whenever some land (a render compares it). */
26
+ got: ReadonlyMap<string, VariantGroup>;
27
+ /** Requests in flight. */
28
+ out: Set<Promise<void>>;
29
+ }
30
+ /** By result set (a request_key names one listing's request: its fields too), for the page's life (as the listing's result cache). */
31
+ const sets = lru<Groups>(30, Infinity);
32
+ const setOf = (guid: string) => {
33
+ let s = sets.get(guid);
34
+ if (!s) sets.set(guid, (s = { asked: new Set(), got: new Map(), out: new Set() }));
35
+ return s;
36
+ };
37
+ /**
38
+ * A group's shape, from the API or from storage: nothing else is kept, nothing
39
+ * coerced. Each sibling becomes a result once a template reads it (a hit's
40
+ * `@id` at least); a total `Number()` would throw on (`{ toString: null }`) is
41
+ * none. Anything else: no group, the tile as without one.
42
+ */
43
+ const group = (g: any): VariantGroup | null =>
44
+ Array.isArray(g?.siblings) && Number.isFinite(g.total) && g.siblings.every((s: any) => typeof s?.['@id'] === 'string') ? { siblings: g.siblings, total: g.total } : null;
45
+
46
+ /** The groups of `res`'s products answered so far: the same map until more land. */
47
+ export const groupsOf = (res: Products) => sets.get(res.guid)?.got;
48
+
49
+ /**
50
+ * Asks for the groups of `res`'s products not asked yet (all asked: no
51
+ * request). Settles once every request of its result set in flight has, and
52
+ * never rejects: what landed is in groupsOf(). An aborted request's
53
+ * products can be asked again at once (a host re-creating the listing
54
+ * renders it again in the same task), a failed one's (reported) after
55
+ * RETRY_MS.
56
+ */
57
+ export const askVariants = (api: Api, cfg: ListingConfig, res: Products, signal?: AbortSignal): Promise<unknown> => {
58
+ if (!cfg.variants || !res.guid) return Promise.resolve();
59
+ const s = setOf(res.guid);
60
+ const ids = signal?.aborted ? [] : res.hits.map((h) => h['@id']).filter((id) => !s.asked.has(id));
61
+ for (let i = 0; i < ids.length; i += MAX_IDS) {
62
+ const chunk = ids.slice(i, i + MAX_IDS);
63
+ for (const id of chunk) s.asked.add(id);
64
+ const forget = () => chunk.forEach((id) => s.asked.delete(id));
65
+ signal?.addEventListener('abort', forget, { once: true });
66
+ const p: Promise<void> = api
67
+ .variants!(chunk, res.guid, cfg.variants.fields, signal)
68
+ .then(
69
+ (got) => {
70
+ const next = new Map(s.got);
71
+ for (const id of chunk) {
72
+ const g = group(got[id]);
73
+ if (g) next.set(id, g);
74
+ }
75
+ s.got = next;
76
+ },
77
+ (e) => {
78
+ if ((e as Error)?.name === 'AbortError') return;
79
+ reportError('variants', e);
80
+ setTimeout(forget, RETRY_MS);
81
+ },
82
+ )
83
+ .finally(() => (s.out.delete(p), signal?.removeEventListener('abort', forget)));
84
+ s.out.add(p);
85
+ }
86
+ return Promise.all(s.out);
87
+ };
88
+
89
+ /** The groups landed of `res`'s products, for its Back stash: the restored page draws them at once. */
90
+ export const stashGroups = (res: Products) => {
91
+ const got = groupsOf(res);
92
+ return res.hits.flatMap((h): [string, VariantGroup][] => {
93
+ const g = got?.get(h['@id']);
94
+ return g ? [[h['@id'], g]] : [];
95
+ });
96
+ };
97
+
98
+ /** A Back stash's groups (storage: each checked) as answered for `res`'s products. */
99
+ export const seedGroups = (res: Products, stashed: unknown) => {
100
+ if (!res.guid || !Array.isArray(stashed)) return;
101
+ const s = setOf(res.guid);
102
+ const ids = new Set(res.hits.map((h) => h['@id']));
103
+ const next = new Map(s.got);
104
+ for (const e of stashed) {
105
+ const g = ids.has(e?.[0]) ? group(e[1]) : null;
106
+ if (!g) continue;
107
+ next.set(e[0], g);
108
+ s.asked.add(e[0]);
109
+ }
110
+ s.got = next;
111
+ };
@@ -9,8 +9,9 @@
9
9
  // bundle turns a click on one into a client-side update (`go(patch)`).
10
10
 
11
11
  import type { WidgetContext } from '../../main';
12
- import type { Helpers, SearchScope } from '../../types';
13
- import { SEARCH } from '../../core/features';
12
+ import type { VariantGroup } from '../../api';
13
+ import type { Helpers, Result, SearchScope } from '../../types';
14
+ import { SEARCH, VARIANTS } from '../../core/features';
14
15
  import { fold } from '../../core/fold';
15
16
  import { navigable } from '../../core/result';
16
17
  import { hasMore, parseRange, type ListingConfig, type ListingResult } from './query';
@@ -62,6 +63,8 @@ export interface ListingView {
62
63
  searchGroup?: (name: string, text: string) => void;
63
64
  /** A range committed (`setRange` of the scope): from–to, `null` an open end. */
64
65
  setRange?: (name: string, from: number | null, to: number | null) => void;
66
+ /** `variants`: the products' variant groups landed so far, by `@id` (./variants.ts). */
67
+ variants?: ReadonlyMap<string, VariantGroup>;
65
68
  }
66
69
 
67
70
  /** A DOM id for a facet group of a route: `lbx-plp-g-lbx_price` (names may hold ':' or spaces). */
@@ -83,6 +86,34 @@ const resultOf = <R>(make: (h: any) => R) => {
83
86
  };
84
87
  };
85
88
 
89
+ /**
90
+ * `variants`: a hit's Result with its variant group (`r.variants`), the same
91
+ * object for the same hit and group: a tile is drawn again when its group
92
+ * lands, and only then. Its own computed attributes (`r.c`), which may read
93
+ * the group.
94
+ */
95
+ const grouped = VARIANTS ? new WeakMap<VariantGroup, { h: object; r: Result }>() : null;
96
+ export const withGroups = (make: (h: any) => Result, groups: ReadonlyMap<string, VariantGroup>) => {
97
+ const plain = resultOf(make);
98
+ return (h: Record<string, any>): Result => {
99
+ const g = groups.get(h['@id']);
100
+ if (!g) return plain(h);
101
+ const was = grouped!.get(g);
102
+ if (was?.h === h) return was.r;
103
+ const r = make(h);
104
+ // Its siblings as results once a template reads them (up to 100 a tile, computed attributes each).
105
+ let siblings: Result[] | undefined;
106
+ r.variants = {
107
+ get siblings() {
108
+ return (siblings ??= g.siblings.map(make));
109
+ },
110
+ total: g.total,
111
+ };
112
+ grouped!.set(g, { h, r });
113
+ return r;
114
+ };
115
+ };
116
+
86
117
  /** A group's search: the text folded (case, diacritics: "size" finds "Size", "creme" "Crème"), as a substring. */
87
118
  const matcher = (text: string) => {
88
119
  const t = fold(text.trim());
@@ -222,7 +253,7 @@ export const listingData = (ctx: Pick<WidgetContext<ListingConfig>, 'config' | '
222
253
  title: q.title,
223
254
  showTitle: !!cfg.showTitle,
224
255
  total: res.total,
225
- results: res.hits.map(resultOf(ctx.result)),
256
+ results: res.hits.map(VARIANTS && view.variants ? withGroups(ctx.result, view.variants) : resultOf(ctx.result)),
226
257
  quick: SEARCH ? Object.fromEntries(Object.entries(res.quick ?? {}).map(([k, hs]) => [k, hs.map(ctx.result).filter(navigable)])) : {},
227
258
  facets,
228
259
  ranges,
@@ -3,7 +3,8 @@
3
3
  // entry: the query code (route, query building, the answer's shaping) is in
4
4
  // lbx-nav, which the listing imports anyway, and not on pages without a listing
5
5
  // (the API client's requests are in lbx-core, which the listing loads too).
6
- import { getApi } from '../../api';
6
+ import { getApi, type Page } from '../../api';
7
+ import { VARIANTS } from '../../core/features';
7
8
  import type { GlobalConfigType, SearchConfigType } from '../../types';
8
9
  import { quickChannel } from '../listing/flow';
9
10
  import { listingQuery, type ListingResult } from '../listing/query';
@@ -11,5 +12,12 @@ import { searchRoute } from './route';
11
12
 
12
13
  export const searchFirst = (g: GlobalConfigType, cfg: SearchConfigType, url: URL, onProducts?: (r: ListingResult) => void) => {
13
14
  const route = searchRoute(cfg);
14
- return listingQuery(getApi(g), g, cfg as any, route, route.resolve(url) as any, undefined, undefined, onProducts, quickChannel(route.id));
15
+ const api = getApi(g);
16
+ // `variants`: the first page's groups with its facets, as the listing's own
17
+ // request asks them (the listing finds them asked). Imported here, not
18
+ // statically: its module is lbx-listing's, which lbx-nav (search while
19
+ // typing on any page, this boot request) must not wait for. Its chunk
20
+ // failing is the listing's to report, not an unhandled rejection on the site.
21
+ const ask = VARIANTS && cfg.variants ? (f: Page) => void import('../listing/variants').then((m) => m.askVariants(api, cfg as any, f), () => {}) : undefined;
22
+ return listingQuery(api, g, cfg as any, route, route.resolve(url) as any, undefined, undefined, onProducts, quickChannel(route.id), ask);
15
23
  };