@luigisbox/luma 0.6.0 → 0.7.1

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.
@@ -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
  };