@athoscommerce/snap-preact 1.12.0 → 1.13.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 (58) hide show
  1. package/dist/cjs/components/src/components/Atoms/Price/Price.js +2 -2
  2. package/dist/cjs/components/src/components/Molecules/CustomComponentWrapper/CustomComponentWrapper.js +1 -1
  3. package/dist/cjs/components/src/components/Molecules/Slideshow/Slideshow.js +59 -30
  4. package/dist/cjs/components/src/components/Organisms/AutocompleteLayout/AutocompleteLayout.js +13 -8
  5. package/dist/cjs/components/src/components/Organisms/QuickviewLayout/QuickviewLayout.js +13 -8
  6. package/dist/cjs/components/src/hooks/useLayoutOptions.js +19 -14
  7. package/dist/cjs/components/src/utilities/mergeProps.js +647 -184
  8. package/dist/cjs/src/Snap.js +1 -1
  9. package/dist/cjs/src/Templates/SnapTemplates.js +17 -53
  10. package/dist/cjs/src/Templates/Stores/LibraryStore.js +2 -0
  11. package/dist/cjs/src/Templates/Stores/TemplateStore.js +37 -8
  12. package/dist/cjs/src/Templates/Stores/ThemeStore.js +125 -32
  13. package/dist/cjs/src/Templates/Stores/library/currencies/currencies.js +52 -29
  14. package/dist/cjs/src/Templates/Stores/library/plugins/shopify/pluginCurrency.js +5 -0
  15. package/dist/esm/components/src/components/Atoms/Price/Price.d.ts +2 -0
  16. package/dist/esm/components/src/components/Atoms/Price/Price.d.ts.map +1 -1
  17. package/dist/esm/components/src/components/Atoms/Price/Price.js +2 -2
  18. package/dist/esm/components/src/components/Molecules/CustomComponentWrapper/CustomComponentWrapper.d.ts.map +1 -1
  19. package/dist/esm/components/src/components/Molecules/CustomComponentWrapper/CustomComponentWrapper.js +1 -1
  20. package/dist/esm/components/src/components/Molecules/Select/Select.d.ts +2 -2
  21. package/dist/esm/components/src/components/Molecules/Select/Select.d.ts.map +1 -1
  22. package/dist/esm/components/src/components/Molecules/Slideshow/Slideshow.d.ts +1 -0
  23. package/dist/esm/components/src/components/Molecules/Slideshow/Slideshow.d.ts.map +1 -1
  24. package/dist/esm/components/src/components/Molecules/Slideshow/Slideshow.js +59 -30
  25. package/dist/esm/components/src/components/Organisms/AutocompleteLayout/AutocompleteLayout.d.ts.map +1 -1
  26. package/dist/esm/components/src/components/Organisms/AutocompleteLayout/AutocompleteLayout.js +13 -8
  27. package/dist/esm/components/src/components/Organisms/QuickviewLayout/QuickviewLayout.d.ts.map +1 -1
  28. package/dist/esm/components/src/components/Organisms/QuickviewLayout/QuickviewLayout.js +13 -8
  29. package/dist/esm/components/src/hooks/useLayoutOptions.d.ts.map +1 -1
  30. package/dist/esm/components/src/hooks/useLayoutOptions.js +19 -14
  31. package/dist/esm/components/src/providers/langComponents.d.ts +5 -0
  32. package/dist/esm/components/src/providers/langComponents.d.ts.map +1 -1
  33. package/dist/esm/components/src/providers/themeComponents.d.ts +214 -193
  34. package/dist/esm/components/src/providers/themeComponents.d.ts.map +1 -1
  35. package/dist/esm/components/src/utilities/mergeProps.d.ts +11 -0
  36. package/dist/esm/components/src/utilities/mergeProps.d.ts.map +1 -1
  37. package/dist/esm/components/src/utilities/mergeProps.js +647 -184
  38. package/dist/esm/src/Snap.js +1 -1
  39. package/dist/esm/src/Templates/SnapTemplates.d.ts +32 -19
  40. package/dist/esm/src/Templates/SnapTemplates.d.ts.map +1 -1
  41. package/dist/esm/src/Templates/SnapTemplates.js +16 -51
  42. package/dist/esm/src/Templates/Stores/LibraryStore.d.ts +7 -0
  43. package/dist/esm/src/Templates/Stores/LibraryStore.d.ts.map +1 -1
  44. package/dist/esm/src/Templates/Stores/LibraryStore.js +2 -0
  45. package/dist/esm/src/Templates/Stores/TemplateStore.d.ts +11 -7
  46. package/dist/esm/src/Templates/Stores/TemplateStore.d.ts.map +1 -1
  47. package/dist/esm/src/Templates/Stores/TemplateStore.js +31 -8
  48. package/dist/esm/src/Templates/Stores/ThemeStore.d.ts +5 -2
  49. package/dist/esm/src/Templates/Stores/ThemeStore.d.ts.map +1 -1
  50. package/dist/esm/src/Templates/Stores/ThemeStore.js +125 -32
  51. package/dist/esm/src/Templates/Stores/library/currencies/currencies.d.ts +27 -0
  52. package/dist/esm/src/Templates/Stores/library/currencies/currencies.d.ts.map +1 -1
  53. package/dist/esm/src/Templates/Stores/library/currencies/currencies.js +52 -29
  54. package/dist/esm/src/Templates/Stores/library/plugins/shopify/pluginCurrency.d.ts +2 -0
  55. package/dist/esm/src/Templates/Stores/library/plugins/shopify/pluginCurrency.d.ts.map +1 -0
  56. package/dist/esm/src/Templates/Stores/library/plugins/shopify/pluginCurrency.js +1 -0
  57. package/eslint/src/validate-config.cjs +520 -0
  58. package/package.json +13 -13
@@ -3,185 +3,456 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.mergeProps = mergeProps;
4
4
  exports.sortSelectors = sortSelectors;
5
5
  exports.filterSelectors = filterSelectors;
6
- // Symbol to track prop-value pairs that originated from theme configuration
7
- const THEME_PROPS_MAP_SYMBOL = Symbol.for('__themePropsMap__');
8
- function mergeProps(componentType, globalTheme, defaultProps, props) {
9
- /*
6
+ /*
7
+ mergeProps resolves the final props for a component from its default props, its JSX props,
8
+ and the theme(s) in play. It runs on EVERY render of every component (~76 call sites):
10
9
 
11
- production behaviour:
12
-
13
- 1. start with default props
14
- 2. spreads global theme props of component
15
- 3. spreads standard props (directly passed via JSX) - these may be provided by the integration or sub-props
16
- 4. spreads component theme props of component
10
+ mergeProps(componentType, globalTheme, defaultProps, properties)
17
11
 
18
- templates behaviour:
19
-
20
- 1. start with default props
21
- 2. spreads standard props (directly passed via JSX) - these may be provided by the integration or sub-props
22
- 3. spreads component theme props of component and named component
23
- 4. spreads global theme props of component and named component
24
- 5. ensure templates theme variables pass on in `theme`
25
- 6. if treepath contains 'custom' do #2 again
12
+ - componentType: hardcoded per component ('select', 'icon', ...); becomes a treePath segment
13
+ - globalTheme: from useTheme() (Emotion context); Snap Templates themes carry `type: 'templates'`
14
+ - defaultProps: component-defined defaults (usually includes treePath from useTreePath())
15
+ - properties: raw JSX props — integration-authored or a parent's subProps
26
16
 
27
- */
28
- const theme = props.theme;
29
- const componentName = props?.name || defaultProps.name;
17
+ CLASSIC mode (globalTheme.type !== 'templates') — priority lowest → highest:
18
+ 1. defaultProps
19
+ 2. globalTheme.components[componentType] (exact key lookup — no selectors)
20
+ 3. JSX props (+ computed treePath)
21
+ 4. props.theme.components[componentType] (exact key lookup — no selectors)
22
+
23
+ TEMPLATES mode — priority lowest → highest:
24
+ 1. defaultProps
25
+ 2. JSX props
26
+ 3. global base theme selectors ('*'-prefixed keys, added by ThemeStore for the theme library)
27
+ 4. parent-theme-derived respread (passed props whose values came from an ancestor's theme)
28
+ 5. global user override selectors (no '*' — project config overrides)
29
+ 6. props.theme.components selectors (parent-injected, e.g. layout option overrides)
30
+ 7. exceptions: customComponent paths respread raw props at every depth; storybook-rooted
31
+ paths respread the FULL raw props only at the subtree root (2 treePath segments) —
32
+ deeper in the subtree, only keys provably forwarded from that root's args/JSX win
33
+ (STORYBOOK_ARGS_PROPS_MAP_SYMBOL; same-key/cross-key provenance, mirrors step 4).
34
+ Both respreads exclude theme, keeping the enriched theme for the subtree.
35
+
36
+ NOTE: application order is NOT priority order — the respreads (4 and 7) re-apply earlier
37
+ values over later layers. Follow the numbered step functions (T1–T8) below in order.
38
+
39
+ Behavior is pinned by the characterization suite (mergeProps*.test.ts — 6 files, 102 tests).
40
+ Change behavior only together with a test change in the same review.
41
+
42
+ QUIRK REGISTRY — grep "QUIRK[<id>]" to find the source contract and every pinning test:
43
+ [respread-value-identity] T2 matches same-key values for all types; cross-key (renamed forwarding) for strings/objects only
44
+ [map-untracked-spreads] mergeThemeProps spreads undefined/null/function values but never tracks them
45
+ [final-respread-keeps-enriched-theme] T8 respreads exclude theme, keeping the enriched theme for the subtree
46
+ [symbol-map-reference-propagation] parent map travels BY REFERENCE via T5's theme spread when the child map is
47
+ empty; T7's combined map goes only on merged.theme (top-level map differs)
48
+ [name-fallback-empty-string] componentName uses `||`, so a name of '' falls back to defaultProps.name
49
+ [name-regex-silent-drop] names failing /^[A-Za-z0-9-]+$/ are silently omitted from the treePath
50
+ [stale-treepath-until-enrichment] templates merged.treePath keeps the PARENT's value until T5 attaches the
51
+ computed one
52
+ */
53
+ // Symbol to track prop-value pairs that originated from theme configuration
54
+ const THEME_PROPS_MAP_SYMBOL = Symbol.for('__themePropsMap__');
55
+ // Symbol to track prop-value pairs that originated from a Storybook story's ROOT args/JSX
56
+ // props (as opposed to an intermediate component's own internal default props, which merely
57
+ // get forwarded through the same prop key to a descendant several levels down). Built once at
58
+ // the root of a storybook-rooted tree (T8) and passed down by reference — it never needs
59
+ // per-component merging the way THEME_PROPS_MAP_SYMBOL does.
60
+ const STORYBOOK_ARGS_PROPS_MAP_SYMBOL = Symbol.for('__storybookArgsPropsMap__');
61
+ function mergeProps(componentType, globalTheme, defaultProps, props) {
30
62
  let treePath = props.treePath || defaultProps.treePath || '';
31
63
  treePath += `${treePath ? ' ' : ''}${componentType}`;
32
- // start with defaultProps
64
+ if (!isTemplatesTheme(globalTheme)) {
65
+ return mergeClassicLayers(componentType, globalTheme, defaultProps, props, treePath);
66
+ }
67
+ const ctx = createTemplatesContext(componentType, globalTheme, defaultProps, props, treePath);
68
+ // QUIRK[stale-treepath-until-enrichment]: unlike the classic branch, this spread deliberately
69
+ // does NOT set the computed treePath; merged.treePath stays stale (the parent's value from
70
+ // props/defaultProps) until enrichThemeMeta (T5) attaches the computed one.
71
+ let merged = {
72
+ ...defaultProps,
73
+ ...props,
74
+ };
75
+ merged = applyGlobalBaseSelectors(merged, ctx); // T1: base `*` selectors (+ "specific" tracking)
76
+ merged = respreadParentThemeDerivedProps(merged, ctx); // T2: parent-theme-derived props win back
77
+ merged = applyGlobalOverrideSelectors(merged, ctx); // T3: user overrides (no `*`)
78
+ merged = applyParentThemeSelectors(merged, ctx); // T4: props.theme.components (no bucket split)
79
+ merged = enrichThemeMeta(merged, ctx); // T5: theme.name/activeBreakpoint + treePath
80
+ merged = restoreLangProp(merged, ctx); // T6: lang always wins from raw props
81
+ merged = finalizeThemeForChildren(merged, ctx); // T7: theme.variables + combined THEME_PROPS_MAP
82
+ merged = applyFinalRespreadExceptions(merged, ctx); // T8: customComponent / storybook respreads
83
+ return merged;
84
+ }
85
+ function isTemplatesTheme(globalTheme) {
86
+ return globalTheme?.type === 'templates';
87
+ }
88
+ /**
89
+ * CLASSIC mode merge — globalTheme has no `type: 'templates'` (plain Snap integrations,
90
+ * defaultTheme, or no ThemeProvider at all).
91
+ *
92
+ * Layers (lowest → highest): defaultProps → globalTheme.components[componentType] →
93
+ * JSX props (+ treePath) → props.theme.components[componentType].
94
+ * Only exact componentType keys are looked up — selector-style keys ('*x', '(M)x',
95
+ * 'search x', comma groups) are ignored entirely in this mode.
96
+ */
97
+ function mergeClassicLayers(componentType, globalTheme, defaultProps, props, treePath) {
98
+ const theme = props.theme;
33
99
  let mergedProps = {
34
100
  ...defaultProps,
35
101
  };
36
- if (globalTheme?.type !== 'templates') {
37
- // add globalTheme props if they exist
38
- const globalComponent = globalTheme?.components && globalTheme.components[componentType];
39
- if (globalComponent) {
40
- mergedProps = mergeThemeProps(globalComponent, mergedProps);
102
+ // add globalTheme props if they exist
103
+ const globalComponent = globalTheme?.components && globalTheme.components[componentType];
104
+ if (globalComponent) {
105
+ mergedProps = mergeThemeProps(globalComponent, mergedProps);
106
+ }
107
+ // normal props
108
+ mergedProps = {
109
+ ...mergedProps,
110
+ ...props,
111
+ treePath,
112
+ };
113
+ // add theme props if they exist
114
+ const themeComponent = theme?.components && theme.components[componentType];
115
+ if (themeComponent) {
116
+ mergedProps = mergeThemeProps(themeComponent, mergedProps);
117
+ }
118
+ return mergedProps;
119
+ }
120
+ function createTemplatesContext(componentType, globalTheme, defaultProps, props, treePath) {
121
+ // QUIRK[name-fallback-empty-string]: `||` coerces an empty-string props.name to
122
+ // defaultProps.name for the treePath suffix, while the name prop itself stays ''.
123
+ const componentName = props?.name || defaultProps.name;
124
+ // QUIRK[name-regex-silent-drop]: names with characters outside [A-Za-z0-9-] (spaces,
125
+ // underscores, dots) are silently omitted from the treePath — selectors targeting such
126
+ // names can never match.
127
+ treePath += componentName?.match(/^[A-Za-z0-9-]+$/) ? `.${componentName}` : '';
128
+ // selectors from the global theme that apply to this component's treePath, in weight order
129
+ // of the part that actually matched (memoized per components-object + treePath). Raw sort
130
+ // order equals layer priority (see selectorWeight); the base/override bucket split exists
131
+ // solely as the interleaving point for the T2 respread (base < respread < overrides).
132
+ const { baseSelectors, overrideSelectors } = getSelectorMatches(globalTheme?.components, treePath);
133
+ const rawParentThemePropsMap = props.theme?.[THEME_PROPS_MAP_SYMBOL];
134
+ return {
135
+ componentType,
136
+ globalTheme,
137
+ props,
138
+ treePath,
139
+ parentTheme: props.theme,
140
+ parentThemePropsMap: rawParentThemePropsMap instanceof Map ? rawParentThemePropsMap : undefined,
141
+ globalBaseSelectors: baseSelectors,
142
+ globalOverrideSelectors: overrideSelectors,
143
+ propsSetBySpecificSelectors: new Set(),
144
+ };
145
+ }
146
+ /**
147
+ * T1 — Apply base theme selectors ('*'-prefixed) from the global theme, lowest weight first.
148
+ * Reads: ctx.globalBaseSelectors, ctx.globalTheme.
149
+ * Writes: ctx.propsSetBySpecificSelectors (consumed by T2).
150
+ *
151
+ * "Specific" selectors are those with multiple path segments (like 'facet icon.collapse');
152
+ * props they set must NOT be overridden by the parent's passed values in T2.
153
+ */
154
+ function applyGlobalBaseSelectors(merged, ctx) {
155
+ ctx.globalBaseSelectors.forEach(({ original, matchedPart }) => {
156
+ const componentProps = ctx.globalTheme.components?.[original];
157
+ if (componentProps) {
158
+ // specificity is classified on the comma part that MATCHED (a comma group behaves
159
+ // exactly like its individual selectors); strip the layer prefixes before counting
160
+ // path segments ('*(M)facet select' → 'facet select' → specific)
161
+ const cleanSelector = matchedPart
162
+ .replace(/^\*/, '')
163
+ .replace(/^\([MDT]\)/, '')
164
+ .trim();
165
+ const isSpecificSelector = cleanSelector.split(' ').length > 1;
166
+ if (isSpecificSelector) {
167
+ Object.keys(componentProps).forEach((key) => ctx.propsSetBySpecificSelectors.add(key));
168
+ }
169
+ merged = mergeThemeProps(componentProps, merged);
41
170
  }
42
- // normal props
43
- mergedProps = {
44
- ...mergedProps,
45
- ...props,
46
- treePath,
47
- };
48
- // add theme props if they exist
49
- const themeComponent = theme?.components && theme.components[componentType];
50
- if (themeComponent) {
51
- mergedProps = mergeThemeProps(themeComponent, mergedProps);
171
+ });
172
+ return merged;
173
+ }
174
+ /**
175
+ * T2 — Respread passed props whose VALUES originated from a parent's theme.
176
+ * This ensures theme-derived props from the parent beat the child's base theme,
177
+ * BUT skips props that were set by "specific" selectors (T1). User overrides (T3)
178
+ * still get the final say.
179
+ * Reads: ctx.props, ctx.parentThemePropsMap, ctx.propsSetBySpecificSelectors.
180
+ * Skips meta keys: treePath, theme, name (note: unlike lang in T6, name set by a theme
181
+ * selector is never restored from props).
182
+ *
183
+ * QUIRK[respread-value-identity]: a prop counts as parent-theme-derived when the parent
184
+ * map has the SAME key with the identical value (any type), or — to support renamed-prop
185
+ * forwarding (parent `overlayColor` forwarded to the child as `color`) — when its value is
186
+ * identical to ANY parent map value and the value is a string or an object. Booleans and
187
+ * numbers never match cross-key: they collide with unrelated theme values far too easily
188
+ * to be treated as provenance.
189
+ */
190
+ function respreadParentThemeDerivedProps(merged, ctx) {
191
+ if (ctx.parentThemePropsMap && ctx.parentThemePropsMap.size > 0) {
192
+ const propsToRespread = {};
193
+ const parentThemeValues = getParentThemeValuesSet(ctx.parentThemePropsMap);
194
+ for (const key of Object.keys(ctx.props)) {
195
+ // Skip meta props
196
+ if (key === 'treePath' || key === 'theme' || key === 'name')
197
+ continue;
198
+ // Skip props that were set by a specific selector in the child's theme
199
+ if (ctx.propsSetBySpecificSelectors.has(key))
200
+ continue;
201
+ const propValue = ctx.props[key];
202
+ if (propValue === undefined)
203
+ continue;
204
+ // same-key match: the parent's theme set this exact prop to this exact value
205
+ const sameKeyMatch = ctx.parentThemePropsMap.get(key) === propValue;
206
+ // cross-key match: renamed-prop forwarding — restricted to strings and (non-null)
207
+ // objects; booleans/numbers collide too easily to be treated as theme provenance
208
+ const crossKeyMatch = (typeof propValue === 'string' || (typeof propValue === 'object' && propValue !== null)) && parentThemeValues.has(propValue);
209
+ if (sameKeyMatch || crossKeyMatch) {
210
+ propsToRespread[key] = propValue;
211
+ }
212
+ }
213
+ if (Object.keys(propsToRespread).length > 0) {
214
+ merged = {
215
+ ...merged,
216
+ ...propsToRespread,
217
+ };
52
218
  }
53
219
  }
54
- else {
55
- // normal props
56
- mergedProps = {
57
- ...mergedProps,
58
- ...props,
59
- };
60
- treePath += componentName?.match(/^[A-Za-z0-9-]+$/) ? `.${componentName}` : '';
61
- // component props from the theme
62
- // add globalTheme props for components with selector matches if they exist
63
- const filteredGlobalApplicableSelectors = filterSelectors(globalTheme?.components || {}, treePath);
64
- const globalApplicableSelectors = filteredGlobalApplicableSelectors.sort(sortSelectors);
65
- // Split selectors into base theme (with *) and user overrides (without *)
66
- const baseThemeSelectors = globalApplicableSelectors.filter((s) => s.includes('*'));
67
- const userOverrideSelectors = globalApplicableSelectors.filter((s) => !s.includes('*'));
68
- // 1. Apply base theme props first, tracking which props are set by SPECIFIC selectors
69
- // Specific selectors are those with multiple path segments (like 'facet icon.collapse')
70
- // Props set by specific selectors should NOT be overridden by parent's passed values
71
- const propsSetBySpecificSelectors = new Set();
72
- baseThemeSelectors.forEach((selector) => {
73
- const componentProps = globalTheme.components?.[selector];
74
- if (componentProps) {
75
- // Check if this is a "specific" selector (has parent component in path)
76
- // Remove the * prefix and responsive suffixes for checking
77
- const cleanSelector = selector
78
- .replace(/^\*/, '')
79
- .replace(/\([MDT]\)$/, '')
80
- .trim();
81
- const isSpecificSelector = cleanSelector.split(' ').length > 1;
82
- if (isSpecificSelector) {
83
- // Track which props are set by specific selectors
84
- Object.keys(componentProps).forEach((key) => propsSetBySpecificSelectors.add(key));
220
+ return merged;
221
+ }
222
+ /**
223
+ * T3 — Apply user override selectors (no '*') from the global theme, lowest weight first.
224
+ * Reads: ctx.globalOverrideSelectors, ctx.globalTheme.
225
+ *
226
+ * preserveExistingMapKeys=true so props already tracked in the theme map (from T1) keep
227
+ * their theme-derived value in the map; new keys introduced only by user overrides are
228
+ * still added. Child components use the map to identify theme-derived values.
229
+ */
230
+ function applyGlobalOverrideSelectors(merged, ctx) {
231
+ ctx.globalOverrideSelectors.forEach(({ original }) => {
232
+ const componentProps = ctx.globalTheme.components?.[original];
233
+ if (componentProps) {
234
+ merged = mergeThemeProps(componentProps, merged, true);
235
+ }
236
+ });
237
+ return merged;
238
+ }
239
+ /**
240
+ * T4 — Apply props.theme.components selector overrides last. These come from
241
+ * parent-injected theme data (e.g. layout option overrides set by useLayoutOptions) and
242
+ * must win over global user overrides because they represent a more specific context —
243
+ * e.g. `searchCollapsible results: { columns: 6 }` beats a global `results: { columns: 5 }`.
244
+ * Reads: ctx.parentTheme, ctx.treePath.
245
+ *
246
+ * Selectors here are applied in raw sorted order with no bucket split; since raw sort
247
+ * order equals layer priority (see selectorWeight), this matches the effective
248
+ * globalTheme.components ordering.
249
+ */
250
+ function applyParentThemeSelectors(merged, ctx) {
251
+ const { matches } = getSelectorMatches(ctx.parentTheme?.components, ctx.treePath);
252
+ matches.forEach(({ original }) => {
253
+ const componentProps = ctx.parentTheme?.components?.[original];
254
+ if (componentProps) {
255
+ merged = mergeThemeProps(componentProps, merged);
256
+ }
257
+ });
258
+ return merged;
259
+ }
260
+ /**
261
+ * T5 — Build the child-facing `theme` (name + activeBreakpoint) and attach the computed
262
+ * treePath (see QUIRK[stale-treepath-until-enrichment] at the pipeline).
263
+ *
264
+ * QUIRK[symbol-map-reference-propagation] (part 1): the `...merged.theme` spread copies
265
+ * enumerable SYMBOL keys too — when this component has no theme-derived props of its own,
266
+ * this is how the PARENT's THEME_PROPS_MAP travels to children by reference (T7's guard
267
+ * won't replace it). Note `name` is written even when globalTheme.name is undefined (the
268
+ * key is present with value undefined).
269
+ */
270
+ function enrichThemeMeta(merged, ctx) {
271
+ return {
272
+ ...merged,
273
+ theme: {
274
+ ...merged.theme,
275
+ name: ctx.globalTheme.name,
276
+ ...(ctx.globalTheme.activeBreakpoint ? { activeBreakpoint: ctx.globalTheme.activeBreakpoint } : {}),
277
+ },
278
+ treePath: ctx.treePath,
279
+ };
280
+ }
281
+ /**
282
+ * T6 — Directly-passed `lang` always survives theme overrides (mutates `merged` in place).
283
+ * The theme-provided lang value remains tracked in the theme props map.
284
+ */
285
+ function restoreLangProp(merged, ctx) {
286
+ if (ctx.props.lang) {
287
+ merged.lang = ctx.props.lang;
288
+ }
289
+ return merged;
290
+ }
291
+ /**
292
+ * T7 — Finalize the child-facing theme: share globalTheme.variables (by REFERENCE — it
293
+ * overwrites any props.theme.variables) and store the combined theme prop-value map on
294
+ * merged.theme so it travels to children.
295
+ *
296
+ * QUIRK[symbol-map-reference-propagation] (part 2): the combined map (this component's
297
+ * entries win; parent extras appended) is attached ONLY to merged.theme. The top-level
298
+ * merged[THEME_PROPS_MAP_SYMBOL] set by mergeThemeProps keeps this component's own entries
299
+ * — the two maps intentionally differ. When this component has NO theme entries of its
300
+ * own, no combined map is built at all (size guard) and the parent's map reference from
301
+ * T5 remains.
302
+ */
303
+ function finalizeThemeForChildren(merged, ctx) {
304
+ if (ctx.globalTheme.variables && merged.theme) {
305
+ merged.theme.variables = ctx.globalTheme.variables;
306
+ }
307
+ const currentThemePropsMap = merged[THEME_PROPS_MAP_SYMBOL] || new Map();
308
+ if (currentThemePropsMap.size > 0 && merged.theme) {
309
+ // Merge with any existing parent theme values
310
+ const combinedThemePropsMap = new Map(currentThemePropsMap);
311
+ if (ctx.parentThemePropsMap) {
312
+ ctx.parentThemePropsMap.forEach((val, key) => {
313
+ if (!combinedThemePropsMap.has(key)) {
314
+ combinedThemePropsMap.set(key, val);
85
315
  }
86
- mergedProps = mergeThemeProps(componentProps, mergedProps);
316
+ });
317
+ }
318
+ merged.theme[THEME_PROPS_MAP_SYMBOL] = combinedThemePropsMap;
319
+ }
320
+ return merged;
321
+ }
322
+ /**
323
+ * T8 — Final respread exceptions: subtrees where authored JSX props must beat theme merges.
324
+ *
325
+ * customComponent paths (a treePath segment of exactly 'customComponent' or prefixed
326
+ * 'customComponent-', as injected by CustomComponentWrapper): the custom component
327
+ * author's props win at every depth of the subtree.
328
+ *
329
+ * storybook-rooted paths: story controls/args must have the final say — but ONLY for
330
+ * values that actually trace back to the story's root args/JSX, not for an intermediate
331
+ * component's own internal default props that merely get forwarded through the same key
332
+ * (e.g. Facet always passing `size: '12px'` to every icon it renders, regardless of any
333
+ * story control). At the root of the subtree (treePath === 'storybook <type>', 2 segments)
334
+ * every raw prop genuinely IS the story's args, so the respread stays a full blanket
335
+ * respread — and that props object is captured as a Map (STORYBOOK_ARGS_PROPS_MAP_SYMBOL)
336
+ * that travels down via merged.theme, unchanged, the same way THEME_PROPS_MAP_SYMBOL does.
337
+ * At any deeper level, only keys whose value provably came from that root map (same-key,
338
+ * or cross-key for a renamed forward — mirrors T2's respreadParentThemeDerivedProps) are
339
+ * respread; everything else is left for the normal theme-selector layers to decide.
340
+ *
341
+ * QUIRK[final-respread-keeps-enriched-theme]: both respreads exclude `theme` from the raw
342
+ * props, keeping the enriched theme built by T5–T7 (name, variables, activeBreakpoint,
343
+ * combined theme props map) so children still receive full theme context.
344
+ */
345
+ function applyFinalRespreadExceptions(merged, ctx) {
346
+ const treePath = ctx.treePath;
347
+ if (!treePath)
348
+ return merged;
349
+ const isCustomComponentPath = treePath.split(' ').some((segment) => segment === 'customComponent' || segment.startsWith('customComponent-'));
350
+ const isStorybookPath = treePath.startsWith('storybook ');
351
+ if (isCustomComponentPath) {
352
+ const propsWithoutTheme = { ...ctx.props };
353
+ delete propsWithoutTheme.theme;
354
+ merged = {
355
+ ...merged,
356
+ ...propsWithoutTheme,
357
+ treePath,
358
+ };
359
+ return merged;
360
+ }
361
+ if (isStorybookPath) {
362
+ const isStorybookRoot = treePath.split(' ').length === 2;
363
+ const propsWithoutTheme = { ...ctx.props };
364
+ delete propsWithoutTheme.theme;
365
+ if (isStorybookRoot) {
366
+ merged = {
367
+ ...merged,
368
+ ...propsWithoutTheme,
369
+ treePath,
370
+ };
371
+ if (merged.theme) {
372
+ const argsMapSource = { ...propsWithoutTheme };
373
+ delete argsMapSource.treePath;
374
+ merged.theme[STORYBOOK_ARGS_PROPS_MAP_SYMBOL] = buildStorybookArgsMap(argsMapSource);
87
375
  }
88
- });
89
- // 2. Respread props whose VALUES originated from a parent's theme.
90
- // This ensures theme-derived props from parent beat child's base theme,
91
- // BUT skip props that were set by SPECIFIC selectors (like 'facet icon.collapse')
92
- // User overrides (applied next) will still have the final say.
93
- const parentThemePropsMap = props.theme?.[THEME_PROPS_MAP_SYMBOL];
94
- if (parentThemePropsMap instanceof Map && parentThemePropsMap.size > 0) {
376
+ return merged;
377
+ }
378
+ const argsMap = ctx.parentTheme?.[STORYBOOK_ARGS_PROPS_MAP_SYMBOL];
379
+ if (argsMap && argsMap.size > 0) {
380
+ const argsValues = getStorybookArgsValuesSet(argsMap);
95
381
  const propsToRespread = {};
96
- const parentThemeValuesArray = Array.from(parentThemePropsMap.values());
97
- for (const key of Object.keys(props)) {
98
- // Skip meta props
99
- if (key === 'treePath' || key === 'theme' || key === 'name')
382
+ for (const key of Object.keys(propsWithoutTheme)) {
383
+ if (key === 'treePath')
100
384
  continue;
101
- // Skip props that were set by a specific selector in child's theme
102
- if (propsSetBySpecificSelectors.has(key))
385
+ const value = propsWithoutTheme[key];
386
+ if (value === undefined)
103
387
  continue;
104
- const propValue = props[key];
105
- // Only respread if the VALUE came from a parent's theme (check all parent theme values)
106
- if (propValue !== undefined && parentThemeValuesArray.indexOf(propValue) !== -1) {
107
- propsToRespread[key] = propValue;
388
+ const sameKeyMatch = argsMap.get(key) === value;
389
+ const crossKeyMatch = (typeof value === 'string' || (typeof value === 'object' && value !== null)) && argsValues.has(value);
390
+ if (sameKeyMatch || crossKeyMatch) {
391
+ propsToRespread[key] = value;
108
392
  }
109
393
  }
110
- if (Object.keys(propsToRespread).length > 0) {
111
- mergedProps = {
112
- ...mergedProps,
113
- ...propsToRespread,
114
- };
115
- }
116
- }
117
- // 3. Apply user overrides.
118
- // Pass preserveExistingMapKeys=true so that props already tracked in the theme map
119
- // (e.g. from baseThemeSelectors) keep their theme-derived value in the map.
120
- // New props introduced only by user overrides are still added to the map.
121
- userOverrideSelectors.forEach((selector) => {
122
- const componentProps = globalTheme.components?.[selector];
123
- if (componentProps) {
124
- mergedProps = mergeThemeProps(componentProps, mergedProps, true);
125
- }
126
- });
127
- // 4. Apply props.theme component overrides last.
128
- // These come from parent-injected theme data (e.g. layout option overrides set by useLayoutOptions)
129
- // and must win over global user overrides because they represent a more specific context.
130
- // e.g. `searchCollapsible results: { columns: 6 }` beats a global `results: { columns: 5 }` override.
131
- const themeApplicableSelectors = filterSelectors(theme?.components || {}, treePath).sort(sortSelectors);
132
- themeApplicableSelectors.forEach((selector) => {
133
- const componentProps = theme?.components?.[selector];
134
- if (componentProps) {
135
- mergedProps = mergeThemeProps(componentProps, mergedProps);
136
- }
137
- });
138
- // tacking on name, variables, and activeBreakpoint to `theme`
139
- mergedProps = {
140
- ...mergedProps,
141
- theme: {
142
- ...mergedProps.theme,
143
- name: globalTheme.name,
144
- ...(globalTheme.activeBreakpoint ? { activeBreakpoint: globalTheme.activeBreakpoint } : {}),
145
- },
146
- treePath,
147
- };
148
- if (props.lang) {
149
- mergedProps.lang = props.lang;
150
- }
151
- if (globalTheme.variables && mergedProps.theme) {
152
- mergedProps.theme.variables = globalTheme.variables;
153
- }
154
- // Store current theme prop-value pairs on props.theme so they travel to children
155
- const currentThemePropsMap = mergedProps[THEME_PROPS_MAP_SYMBOL] || new Map();
156
- if (currentThemePropsMap.size > 0 && mergedProps.theme) {
157
- // Merge with any existing parent theme values
158
- const combinedThemePropsMap = new Map(currentThemePropsMap);
159
- if (parentThemePropsMap instanceof Map) {
160
- parentThemePropsMap.forEach((val, key) => {
161
- if (!combinedThemePropsMap.has(key)) {
162
- combinedThemePropsMap.set(key, val);
163
- }
164
- });
165
- }
166
- mergedProps.theme[THEME_PROPS_MAP_SYMBOL] = combinedThemePropsMap;
394
+ merged = {
395
+ ...merged,
396
+ ...propsToRespread,
397
+ treePath,
398
+ };
167
399
  }
168
- if (treePath && (treePath.indexOf('customComponent') > -1 || (treePath.startsWith('storybook') && treePath.split(' ').length == 2))) {
169
- mergedProps = {
170
- ...mergedProps,
171
- ...props,
400
+ else {
401
+ merged = {
402
+ ...merged,
172
403
  treePath,
173
404
  };
174
405
  }
406
+ if (merged.theme && argsMap) {
407
+ merged.theme[STORYBOOK_ARGS_PROPS_MAP_SYMBOL] = argsMap;
408
+ }
175
409
  }
176
- return mergedProps;
410
+ return merged;
411
+ }
412
+ /** Build the root args map for a storybook-rooted tree, filtering out non-trackable values
413
+ * (mirrors the tracking filter mergeThemeProps uses for THEME_PROPS_MAP_SYMBOL). */
414
+ function buildStorybookArgsMap(props) {
415
+ const map = new Map();
416
+ for (const [key, value] of Object.entries(props)) {
417
+ if (value !== undefined && value !== null && typeof value !== 'function') {
418
+ map.set(key, value);
419
+ }
420
+ }
421
+ return map;
422
+ }
423
+ // PERF: mirrors parentThemeValuesSetCache — a value Set built once per storybook args map
424
+ // instead of once per respread check.
425
+ const storybookArgsValuesSetCache = new WeakMap();
426
+ function getStorybookArgsValuesSet(argsMap) {
427
+ let values = storybookArgsValuesSetCache.get(argsMap);
428
+ if (!values) {
429
+ const built = new Set();
430
+ argsMap.forEach((value) => {
431
+ if (value === value) {
432
+ built.add(value);
433
+ }
434
+ });
435
+ storybookArgsValuesSetCache.set(argsMap, built);
436
+ values = built;
437
+ }
438
+ return values;
177
439
  }
440
+ /**
441
+ * Merge one selector's theme props over the accumulated props, tracking prop-value pairs
442
+ * that came from theme configuration (children use the map to detect theme-derived props).
443
+ *
444
+ * When preserveExistingMapKeys is true (used for user overrides in T3), existing map
445
+ * entries are not overwritten — only new keys are added — preserving original
446
+ * theme-derived values in the map.
447
+ *
448
+ * QUIRK[map-untracked-spreads]: undefined/null/function values are SPREAD onto the merged
449
+ * props (an explicit `undefined` clobbers an earlier value with the key kept present — a
450
+ * shipped theming idiom for clearing defaults) but are never tracked in the map; the map
451
+ * (possibly empty) is attached unconditionally.
452
+ */
178
453
  function mergeThemeProps(componentThemeProps, mergedProps, preserveExistingMapKeys = false) {
179
454
  // add theme props if they exist
180
455
  if (componentThemeProps) {
181
- // Track prop-value pairs that came from theme (for detecting theme-derived props in children).
182
- // When preserveExistingMapKeys is true (used for user overrides), existing map entries are not
183
- // overwritten — only new keys are added. This preserves original theme-derived values in the
184
- // map so child components can correctly identify which props came from the theme.
185
456
  const existingThemePropsMap = mergedProps[THEME_PROPS_MAP_SYMBOL] || new Map();
186
457
  for (const [key, value] of Object.entries(componentThemeProps)) {
187
458
  // Only track primitive values and non-null objects (skip functions, undefined, etc.)
@@ -199,24 +470,94 @@ function mergeThemeProps(componentThemeProps, mergedProps, preserveExistingMapKe
199
470
  }
200
471
  return mergedProps;
201
472
  }
202
- // sort function is necessary in order for the user overrides to take priority over the base theme and also for responsive overrides
203
- // weights are used in the reduce initial value
204
- // priority is: Base - * < Override < Responsive - "(M)" || "(T)" || "(D)" [regex find responsive matches]
205
- function sortSelectors(a, b) {
206
- const aWeight = a
207
- .split(' ')
208
- .map((selector, i) => (i * 2) ** (selector.includes('.') ? 2 : 1))
209
- .reduce((acc, val) => acc + val, (a.includes('*') ? 0 : 1000) + (a.match(/\([MDT]\)/) ? 2000 : 0));
210
- const bWeight = b
473
+ /*
474
+ ==========================================================================================
475
+ Selector matching & ordering
476
+ ==========================================================================================
477
+ Selector keys in templates themes look like:
478
+ 'select' user override for every select
479
+ '*select' base theme (ThemeStore prefixes base components with '*')
480
+ '*(M)select' base theme, mobile breakpoint layer
481
+ '(M)select' user override, mobile breakpoint layer
482
+ 'search toolbar select' ancestor-scoped
483
+ 'facet icon.collapse' named component
484
+ 'a icon, b icon' comma group (matches when any part matches)
485
+ */
486
+ /**
487
+ * Weight of a single (non-comma) selector. Higher weight = applied later = wins.
488
+ * layer prefix: base '*' = 0, base responsive '*(M|T|D)' = 1000,
489
+ * user override = 2000, user override responsive '(M|T|D)' = 3000
490
+ * — raw sort order therefore equals the intended layer priority (base < base-responsive
491
+ * < override < override-responsive); the T1/T3 bucket split no longer changes ordering
492
+ * and exists solely as the interleaving point for the T2 respread.
493
+ * per segment (1-based): ((i + 1) * 2) ** (segment has '.name' ? 2 : 1) — deeper paths
494
+ * sort later, and a named segment always outweighs its unnamed twin at the same position
495
+ * (including the first: 'icon.next' (4) beats 'icon' (2) deterministically).
496
+ *
497
+ * Comma keys are weighed by their FIRST part as a representative — parts of a comma group
498
+ * share the same layer prefix (ThemeStore prefixes each part uniformly) and target the same
499
+ * component type (enforced by lint). The merge pipeline sorts comma groups by the part that
500
+ * actually MATCHED the treePath (see matchSelectors), which is exact even when parts have
501
+ * different path depths.
502
+ */
503
+ function selectorWeight(key) {
504
+ const selector = key.includes(',') ? key.split(/\s*,\s*/)[0] : key;
505
+ const prefixWeight = (selector.includes('*') ? 0 : 2000) + (selector.match(/\([MDT]\)/) ? 1000 : 0);
506
+ return selector
211
507
  .split(' ')
212
- .map((selector, i) => (i * 2) ** (selector.includes('.') ? 2 : 1))
213
- .reduce((acc, val) => acc + val, (b.includes('*') ? 0 : 1000) + (b.match(/\([MDT]\)/) ? 2000 : 0));
214
- return aWeight - bWeight;
508
+ .map((segment, i) => ((i + 1) * 2) ** (segment.includes('.') ? 2 : 1))
509
+ .reduce((acc, val) => acc + val, prefixWeight);
215
510
  }
216
- function filterSelectors(themeComponents, treePath) {
217
- let selectors = Object.keys(themeComponents);
511
+ /**
512
+ * Comparator ordering selectors so later-applied (higher-priority) selectors sort last.
513
+ * See selectorWeight for the weight model. Equal weights keep insertion order (stable sort).
514
+ */
515
+ function sortSelectors(a, b) {
516
+ return selectorWeight(a) - selectorWeight(b);
517
+ }
518
+ /**
519
+ * Expand comma-separated selector keys into individual entries, preserving the original
520
+ * key for value lookup. Splits on comma with optional surrounding whitespace to handle
521
+ * both 'a, b' and 'a,b' formats.
522
+ */
523
+ function expandCommaSelectors(selectors) {
524
+ const expandedSelectors = [];
525
+ for (const key of selectors) {
526
+ if (key.includes(',')) {
527
+ const parts = key.split(/\s*,\s*/);
528
+ for (const part of parts) {
529
+ expandedSelectors.push({ original: key, selector: part });
530
+ }
531
+ }
532
+ else {
533
+ expandedSelectors.push({ original: key, selector: key });
534
+ }
535
+ }
536
+ return expandedSelectors;
537
+ }
538
+ const DEV_MODE = typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production';
539
+ // dev-mode legacy-match warnings are emitted once per selector/treePath pair
540
+ const warnedLegacySelectors = new Set();
541
+ /**
542
+ * Return every selector part that applies to the given treePath, in first-match order.
543
+ * Comma groups yield one entry PER MATCHING PART (each carrying the original key for value
544
+ * lookup), so a comma group behaves exactly like the equivalent individual selectors —
545
+ * including sort position and specificity classification by the matched part.
546
+ *
547
+ * Matching phases: expand comma groups → match the LAST selector segment against the
548
+ * component type/name (exact, after stripping the '*'/'(M|T|D)' layer prefixes) → verify
549
+ * the OTHER (ancestor) segments appear in the treePath's ancestors as an ordered
550
+ * subsequence: left to right, each segment matching strictly after the previous one, and
551
+ * never matching the final (component) segment. Non-adjacent (descendant) matches are
552
+ * allowed; duplicated selector segments require genuinely duplicated treePath segments.
553
+ *
554
+ * In dev mode, a selector that only matches under the legacy rules (unordered, duplicate-
555
+ * tolerant) logs a console warning so out-of-order merchant selectors surface immediately.
556
+ */
557
+ function matchSelectors(themeComponents, treePath) {
558
+ const expandedSelectors = expandCommaSelectors(Object.keys(themeComponents));
218
559
  const paths = treePath.split(' ');
219
- const componentTypeAndName = paths.slice(-1).pop() ?? '';
560
+ const componentTypeAndName = paths.slice(-1).pop() ?? ''; // `?? ''` satisfies TS only; split(' ') always yields ≥1 element
220
561
  const [componentType, componentName] = componentTypeAndName.split('.');
221
562
  const mappedSplitTreePath = paths.map((path) => {
222
563
  const [type, name] = path.split('.');
@@ -226,8 +567,47 @@ function filterSelectors(themeComponents, treePath) {
226
567
  path,
227
568
  };
228
569
  });
570
+ // ancestor segments must appear in order, each strictly after the previous match, and may
571
+ // only match treePath segments BEFORE the component itself (the last segment)
572
+ const matchesAncestorsOrdered = (split) => {
573
+ let cursor = 0;
574
+ for (let s = 0; s < split.length; s++) {
575
+ const value = split[s];
576
+ let found = false;
577
+ for (let i = cursor; i < mappedSplitTreePath.length - 1; i++) {
578
+ const pathValue = mappedSplitTreePath[i];
579
+ if (value === pathValue.path || value === pathValue.type) {
580
+ cursor = i + 1;
581
+ found = true;
582
+ break;
583
+ }
584
+ }
585
+ if (!found)
586
+ return false;
587
+ }
588
+ return true;
589
+ };
590
+ // the pre-fix matching rules: every segment present ANYWHERE in the treePath (unordered,
591
+ // duplicate-tolerant, component segment included) — used only for the dev-mode warning
592
+ const matchesAncestorsLegacy = (split) => {
593
+ for (let s = 0; s < split.length; s++) {
594
+ const value = split[s];
595
+ let found = false;
596
+ for (let i = 0; i < mappedSplitTreePath.length; i++) {
597
+ const pathValue = mappedSplitTreePath[i];
598
+ if (value === pathValue.path || value === pathValue.type) {
599
+ found = true;
600
+ break;
601
+ }
602
+ }
603
+ if (!found)
604
+ return false;
605
+ }
606
+ return true;
607
+ };
608
+ let filtered;
229
609
  if (componentName) {
230
- selectors = selectors.filter((key) => {
610
+ filtered = expandedSelectors.filter(({ selector: key }) => {
231
611
  const keys = key.split(' ');
232
612
  const lastkey = keys[keys.length - 1].replace(/\*?(\([MDT]\))?/g, '');
233
613
  if (lastkey == componentType || lastkey == `${componentType}.${componentName}`) {
@@ -236,9 +616,16 @@ function filterSelectors(themeComponents, treePath) {
236
616
  });
237
617
  }
238
618
  else {
239
- selectors = selectors.filter((key) => key.endsWith(componentType));
619
+ filtered = expandedSelectors.filter(({ selector: key }) => {
620
+ // exact last-segment compare after stripping the layer prefixes ('*'/'(M|T|D)') —
621
+ // mirrors the named branch; a selector for a longer type sharing a suffix
622
+ // ('multiselect') no longer leaks onto a shorter type ('select')
623
+ const keys = key.split(' ');
624
+ const lastkey = keys[keys.length - 1].replace(/\*?(\([MDT]\))?/g, '');
625
+ return lastkey === componentType;
626
+ });
240
627
  }
241
- return selectors.filter((selector) => {
628
+ const matched = filtered.filter(({ selector }) => {
242
629
  // when considering matches, we do not care about the base theme prefix "*" nor the responsive prefix "(M)" || "(T)" || "(D)"
243
630
  // these are automatically added by the ThemeStore in Snap Templates in order to preserve merge ordering of these overrides
244
631
  const split = selector
@@ -247,21 +634,97 @@ function filterSelectors(themeComponents, treePath) {
247
634
  .slice(0, -1);
248
635
  if (split.length == 0)
249
636
  return true;
250
- for (let s = 0; s < split.length; s++) {
251
- let prevIndex = -1;
252
- const value = split[s];
253
- for (let i = prevIndex == -1 ? 0 : prevIndex; i < mappedSplitTreePath.length; i++) {
254
- const pathValue = mappedSplitTreePath[i];
255
- if (value === pathValue.path || value === pathValue.type) {
256
- prevIndex = s;
257
- break;
258
- }
259
- }
260
- if (prevIndex == -1) {
261
- // selector path not found at all - selector is invalid
262
- return false;
637
+ const matches = matchesAncestorsOrdered(split);
638
+ if (DEV_MODE && !matches && matchesAncestorsLegacy(split)) {
639
+ const warnKey = `${selector}|${treePath}`;
640
+ if (!warnedLegacySelectors.has(warnKey)) {
641
+ warnedLegacySelectors.add(warnKey);
642
+ // eslint-disable-next-line no-console
643
+ console.warn(`Snap Templates: theme selector '${selector}' does not match treePath '${treePath}' — ` +
644
+ `ancestor segments must appear in the same order as the component tree. ` +
645
+ `This selector would have matched under the previous (unordered) rules; reorder its segments to restore it.`);
263
646
  }
264
647
  }
265
- return true;
648
+ return matches;
266
649
  });
650
+ return matched.map(({ original, selector }) => ({ original, matchedPart: selector }));
651
+ }
652
+ const EMPTY_SELECTOR_MATCHES = { rawMatches: [], matches: [], baseSelectors: [], overrideSelectors: [] };
653
+ // PERF: selector matching is pure in (themeComponents, treePath) and runs on every render of
654
+ // every component, so results are memoized per components-object reference. Theme objects are
655
+ // replaced (not mutated) when a theme changes — ThemeStore's computed getter emits fresh
656
+ // objects — so a WeakMap keyed on the components object invalidates naturally and cannot leak.
657
+ // The cached arrays are shared: callers must never mutate them.
658
+ const selectorMatchCache = new WeakMap();
659
+ function getSelectorMatches(themeComponents, treePath) {
660
+ if (!themeComponents)
661
+ return EMPTY_SELECTOR_MATCHES;
662
+ // allocation-free emptiness check (common case: themes without components)
663
+ let isEmpty = true;
664
+ for (const key in themeComponents) {
665
+ if (key) {
666
+ isEmpty = false;
667
+ break;
668
+ }
669
+ }
670
+ if (isEmpty)
671
+ return EMPTY_SELECTOR_MATCHES;
672
+ let byPath = selectorMatchCache.get(themeComponents);
673
+ if (!byPath) {
674
+ byPath = new Map();
675
+ selectorMatchCache.set(themeComponents, byPath);
676
+ }
677
+ let entry = byPath.get(treePath);
678
+ if (!entry) {
679
+ const rawMatches = matchSelectors(themeComponents, treePath);
680
+ // decorate-sort-undecorate: weights are computed once per match instead of per comparison
681
+ const matches = rawMatches
682
+ .map((match) => ({ match, weight: selectorWeight(match.matchedPart) }))
683
+ .sort((a, b) => a.weight - b.weight)
684
+ .map(({ match }) => match);
685
+ entry = {
686
+ rawMatches,
687
+ matches,
688
+ baseSelectors: matches.filter(({ matchedPart }) => matchedPart.includes('*')),
689
+ overrideSelectors: matches.filter(({ matchedPart }) => !matchedPart.includes('*')),
690
+ };
691
+ byPath.set(treePath, entry);
692
+ }
693
+ return entry;
694
+ }
695
+ // PERF: a parent's finished theme props map is shared by reference with every child, so the
696
+ // value Set for the T2 cross-key lookup is built once per map. NaN is excluded to preserve
697
+ // strict-equality semantics (Set.has would match NaN; the previous indexOf never did).
698
+ const parentThemeValuesSetCache = new WeakMap();
699
+ function getParentThemeValuesSet(parentThemePropsMap) {
700
+ let values = parentThemeValuesSetCache.get(parentThemePropsMap);
701
+ if (!values) {
702
+ const built = new Set();
703
+ parentThemePropsMap.forEach((value) => {
704
+ if (value === value) {
705
+ built.add(value);
706
+ }
707
+ });
708
+ parentThemeValuesSetCache.set(parentThemePropsMap, built);
709
+ values = built;
710
+ }
711
+ return values;
712
+ }
713
+ /**
714
+ * Return the theme component keys whose selector applies to the given treePath, in
715
+ * first-match order, deduplicated back to original keys (a comma group returns the whole
716
+ * original key once when any of its parts match). Callers sort the result with
717
+ * sortSelectors. The merge pipeline uses getSelectorMatches directly for exact per-part
718
+ * comma semantics; this deduplicated view is the public API (also used by ThemeStore).
719
+ */
720
+ function filterSelectors(themeComponents, treePath) {
721
+ const seen = new Set();
722
+ const result = [];
723
+ for (const { original } of getSelectorMatches(themeComponents, treePath).rawMatches) {
724
+ if (!seen.has(original)) {
725
+ seen.add(original);
726
+ result.push(original);
727
+ }
728
+ }
729
+ return result;
267
730
  }