@open-mercato/shared 0.7.1-develop.7137.1.26575786c0 → 0.7.1-develop.7148.1.3076e5ccf7

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.
@@ -258,11 +258,21 @@ const TRACKING_ISSUE_HINT =
258
258
  * Call this exactly once from `apps/<app>/src/bootstrap.ts` BEFORE any
259
259
  * registry first-loads. Calling it more than once is safe but
260
260
  * accumulates per-domain entries each time.
261
+ *
262
+ * `options.domains` narrows the dispatch to the domains a given runtime wires.
263
+ * The browser re-registers the widget and notification registries from
264
+ * `ClientBootstrap`, so it needs those overrides too, but it never loads the
265
+ * server-only appliers — dispatching every domain there would report wired
266
+ * domains as unwired (#5152).
261
267
  */
262
268
  export function applyModuleOverridesFromEnabledModules(
263
269
  modules: ReadonlyArray<ModuleEntryWithOverrides>,
270
+ options?: { domains?: readonly ModuleOverrideDomain[] },
264
271
  ): void {
265
272
  if (!Array.isArray(modules) || modules.length === 0) return
273
+ const selectedDomains = options?.domains
274
+ ? DOMAIN_KEYS.filter((domain) => options.domains!.includes(domain))
275
+ : DOMAIN_KEYS
266
276
 
267
277
  // Bucket entries by domain in module-load order.
268
278
  const buckets = new Map<ModuleOverrideDomain, Array<ModuleOverrideEntry<unknown>>>()
@@ -272,7 +282,7 @@ export function applyModuleOverridesFromEnabledModules(
272
282
  const overrides = entry.overrides
273
283
  if (!overrides || typeof overrides !== 'object') continue
274
284
 
275
- for (const domain of DOMAIN_KEYS) {
285
+ for (const domain of selectedDomains) {
276
286
  const value = (overrides as Record<string, unknown>)[domain]
277
287
  if (value === undefined || value === null) continue
278
288
  if (typeof value !== 'object') continue
@@ -572,14 +582,33 @@ function composeStore<T>(store: OverrideStore<T>): OverrideMap<T> {
572
582
  return { ...store.modules, ...store.programmatic }
573
583
  }
574
584
 
585
+ type ArrayOverrideOptions<T> = {
586
+ label: string
587
+ getId: (value: T) => string | null
588
+ /**
589
+ * Secondary identifiers the same item may also be addressed by. A domain whose
590
+ * entries carry more than one id (injection widgets expose both `key` and
591
+ * `widgetId`) would otherwise force authors to guess which spelling the
592
+ * override map is matched against — see issue #5152.
593
+ */
594
+ getAliasIds?: (value: T) => readonly (string | undefined)[]
595
+ isReplacement?: (value: unknown) => value is T
596
+ }
597
+
598
+ function resolveOverrideIds<T>(value: T, options: ArrayOverrideOptions<T>): string[] {
599
+ const ids: string[] = []
600
+ const primary = options.getId(value)
601
+ if (primary) ids.push(primary)
602
+ for (const alias of options.getAliasIds?.(value) ?? []) {
603
+ if (alias && !ids.includes(alias)) ids.push(alias)
604
+ }
605
+ return ids
606
+ }
607
+
575
608
  function applyArrayOverrides<T>(
576
609
  items: readonly T[] | undefined,
577
610
  overrides: Readonly<OverrideMap<T>>,
578
- options: {
579
- label: string
580
- getId: (value: T) => string | null
581
- isReplacement?: (value: unknown) => value is T
582
- },
611
+ options: ArrayOverrideOptions<T>,
583
612
  ): { items: T[] | undefined; consumed: Set<string>; changed: boolean } {
584
613
  if (!items || Object.keys(overrides).length === 0) {
585
614
  return { items: items ? Array.from(items) : items, consumed: new Set(), changed: false }
@@ -590,12 +619,31 @@ function applyArrayOverrides<T>(
590
619
  let changed = false
591
620
 
592
621
  for (const item of items) {
593
- const id = options.getId(item)
594
- if (!id || !Object.prototype.hasOwnProperty.call(overrides, id)) {
622
+ const matched = resolveOverrideIds(item, options)
623
+ .filter((candidate) => Object.prototype.hasOwnProperty.call(overrides, candidate))
624
+ if (matched.length === 0) {
595
625
  result.push(item)
596
626
  continue
597
627
  }
598
- consumed.add(id)
628
+ // Every matching spelling is consumed, not just the winner: addressing one item
629
+ // under both of its ids is one instruction written twice, and reporting the loser
630
+ // as stale is the log noise #5152 asks to remove. Genuinely conflicting values
631
+ // still get their own warning, since only one of them can take effect.
632
+ const id = matched[0]
633
+ for (const candidate of matched) consumed.add(candidate)
634
+ if (matched.some((candidate) => overrides[candidate] !== overrides[id])) {
635
+ // The two spellings resolve to different values, and only the first takes
636
+ // effect. Disable-versus-replace is the case a reader has to act on; two
637
+ // separate replacement objects are more often the same instruction written
638
+ // twice, so say which one this is rather than reporting both as a conflict.
639
+ const disablesAndReplaces = matched.some((candidate) => (overrides[candidate] === null) !== (overrides[id] === null))
640
+ logger.warn(
641
+ disablesAndReplaces
642
+ ? 'Conflicting overrides for the same entry — one key disables it while another replaces it; the first matching key wins'
643
+ : 'Duplicate replacement overrides for the same entry — only the first matching key takes effect',
644
+ { label: options.label, id, keys: matched },
645
+ )
646
+ }
599
647
  changed = true
600
648
  const replacement = overrides[id]
601
649
  if (replacement === null) continue
@@ -604,12 +652,27 @@ function applyArrayOverrides<T>(
604
652
  result.push(item)
605
653
  continue
606
654
  }
607
- const replacementId = options.getId(replacement)
608
- if (replacementId !== id) {
655
+ const replacementIds = resolveOverrideIds(replacement, options)
656
+ if (!replacementIds.includes(id)) {
609
657
  logger.warn('Skipping malformed override — replacement id must match the override key', { label: options.label, id })
610
658
  result.push(item)
611
659
  continue
612
660
  }
661
+ // Matching on ONE id is enough to accept the replacement, so the other one can
662
+ // still drift: an injection widget matched by `widgetId` may carry a foreign
663
+ // `key` (colliding with another entry's registry slot) and one matched by `key`
664
+ // a foreign `widgetId` (orphaning every table slot that placed the original).
665
+ // The override still applies — it named this entry — but it does not do it quietly.
666
+ const itemIds = resolveOverrideIds(item, options)
667
+ const divergent = itemIds.filter((candidate) => !replacementIds.includes(candidate))
668
+ if (divergent.length > 0) {
669
+ logger.warn('Replacement override changes an identifier it was not matched on — references to the old value are not rewritten', {
670
+ label: options.label,
671
+ id,
672
+ replaced: divergent,
673
+ replacementIds,
674
+ })
675
+ }
613
676
  result.push(replacement)
614
677
  }
615
678
 
@@ -804,6 +867,7 @@ export function resetModuleContractOverridesForTests(): void {
804
867
  clearStore(aclFeatureOverrideStore)
805
868
  clearStore(encryptionMapOverrideStore)
806
869
  clearStore(diOverrideStore)
870
+ getInjectionWidgetIdAliases().clear()
807
871
  for (const key of Object.keys(setupOverridesByModule)) delete setupOverridesByModule[key]
808
872
  const navState = getNavOverrideState()
809
873
  navState.modules = null
@@ -1165,13 +1229,60 @@ function applyEntryListOverrides<TEntry extends { moduleId: string }, TValue>(
1165
1229
  return changed ? result : Array.from(entries)
1166
1230
  }
1167
1231
 
1232
+ /**
1233
+ * `entry.key` (`module:widget:file`) and `entry.widgetId` (the widget module's own
1234
+ * metadata id) are two spellings of the same widget, and the generator never emits
1235
+ * the same value for both. Injection tables reference widgets by `widgetId` while
1236
+ * the entries registry is keyed by `key`, so an override map keyed by one spelling
1237
+ * used to reach only one of the two consumers (#5152). Remembering the pairs lets
1238
+ * either spelling address the widget everywhere.
1239
+ *
1240
+ * Pairs are additive for the process lifetime — re-registration under HMR re-adds
1241
+ * identical pairs, which the sets deduplicate, and a renamed widget leaves behind a
1242
+ * pairing that can only ever over-match an override key naming something that no
1243
+ * longer exists. Only the test reset clears it.
1244
+ *
1245
+ * Persisted on `globalThis` for the same reason the nav state above is: the index is
1246
+ * written when `@open-mercato/ui` filters the entries and read when
1247
+ * `@open-mercato/core` filters the tables, and a standalone build can evaluate
1248
+ * `@open-mercato/shared` through more than one chunk — a module-local `Map` would let
1249
+ * the write and the read land in different instances.
1250
+ */
1251
+ const GLOBAL_INJECTION_WIDGET_ID_ALIASES_KEY = '__openMercatoInjectionWidgetIdAliases__'
1252
+
1253
+ function getInjectionWidgetIdAliases(): Map<string, Set<string>> {
1254
+ const existing = (globalThis as Record<string, unknown>)[GLOBAL_INJECTION_WIDGET_ID_ALIASES_KEY]
1255
+ if (existing instanceof Map) return existing as Map<string, Set<string>>
1256
+ const initial = new Map<string, Set<string>>()
1257
+ ;(globalThis as Record<string, unknown>)[GLOBAL_INJECTION_WIDGET_ID_ALIASES_KEY] = initial
1258
+ return initial
1259
+ }
1260
+
1261
+ function rememberInjectionWidgetIdAliases(entries: readonly ModuleInjectionWidgetEntry[] | undefined): void {
1262
+ if (!entries) return
1263
+ const aliases = getInjectionWidgetIdAliases()
1264
+ for (const entry of entries) {
1265
+ const ids = [entry?.key, entry?.widgetId].filter((id): id is string => typeof id === 'string' && id.length > 0)
1266
+ if (ids.length < 2) continue
1267
+ for (const id of ids) {
1268
+ const siblings = aliases.get(id) ?? new Set<string>()
1269
+ for (const sibling of ids) {
1270
+ if (sibling !== id) siblings.add(sibling)
1271
+ }
1272
+ aliases.set(id, siblings)
1273
+ }
1274
+ }
1275
+ }
1276
+
1168
1277
  export function applyInjectionWidgetOverridesToEntries(
1169
1278
  entries: readonly ModuleInjectionWidgetEntry[],
1170
1279
  overrides: Readonly<InjectionWidgetOverridesMap> = composeInjectionWidgetOverrides(),
1171
1280
  ): ModuleInjectionWidgetEntry[] {
1281
+ rememberInjectionWidgetIdAliases(entries)
1172
1282
  const applied = applyArrayOverrides(entries, overrides as OverrideMap<ModuleInjectionWidgetEntry>, {
1173
1283
  label: 'widgets.injection',
1174
1284
  getId: (entry) => entry.key,
1285
+ getAliasIds: (entry) => [entry.widgetId],
1175
1286
  isReplacement: isInjectionWidgetEntry,
1176
1287
  })
1177
1288
  warnStaleOverrides('widgets.injection', overrides, applied.consumed)
@@ -1210,11 +1321,25 @@ export function applyWorkerOverridesToDescriptors<T extends { id: string; queue:
1210
1321
  return applied.items ?? []
1211
1322
  }
1212
1323
 
1324
+ /**
1325
+ * Injection tables reference widgets by `widgetId`, so a `key`-spelled override has to
1326
+ * be expanded before the slots can be filtered. Pass `entries` whenever the caller has
1327
+ * them: the shared alias index is only a fallback for callers that do not, and it can
1328
+ * only expand pairs some earlier `applyInjectionWidgetOverridesToEntries` call already
1329
+ * recorded.
1330
+ */
1213
1331
  export function applyInjectionWidgetOverridesToTables(
1214
1332
  tables: readonly { moduleId: string; table: ModuleInjectionTable }[],
1215
1333
  overrides: Readonly<InjectionWidgetOverridesMap> = composeInjectionWidgetOverrides(),
1334
+ entries: readonly ModuleInjectionWidgetEntry[] = [],
1216
1335
  ): Array<{ moduleId: string; table: ModuleInjectionTable }> {
1217
- const disabled = new Set(Object.entries(overrides).filter(([, value]) => value === null).map(([key]) => key))
1336
+ rememberInjectionWidgetIdAliases(entries)
1337
+ const disabled = new Set<string>()
1338
+ for (const [key, value] of Object.entries(overrides)) {
1339
+ if (value !== null) continue
1340
+ disabled.add(key)
1341
+ for (const alias of getInjectionWidgetIdAliases().get(key) ?? []) disabled.add(alias)
1342
+ }
1218
1343
  if (disabled.size === 0) return Array.from(tables)
1219
1344
 
1220
1345
  const filterSlot = (slot: unknown): unknown => {
@@ -164,11 +164,20 @@ export function getCoreInjectionWidgets(): ModuleInjectionWidgetEntry[] {
164
164
  return _coreInjectionWidgetEntries
165
165
  }
166
166
 
167
- export function registerCoreInjectionTables(tables: Array<{ moduleId: string; table: ModuleInjectionTable }>) {
167
+ /**
168
+ * `widgetEntries` is optional and carries the *unfiltered* generated entries so a
169
+ * `key`-spelled injection-widget override can be resolved to the `widgetId` the table
170
+ * slots reference (#5152). Bootstraps that skip core widget registration have no other
171
+ * source for that mapping, since the registered entries are already override-filtered.
172
+ */
173
+ export function registerCoreInjectionTables(
174
+ tables: Array<{ moduleId: string; table: ModuleInjectionTable }>,
175
+ widgetEntries?: readonly ModuleInjectionWidgetEntry[],
176
+ ) {
168
177
  if (_coreInjectionTables !== null && process.env.NODE_ENV === 'development') {
169
178
  logger.debug('Core injection tables re-registered (this may occur during HMR)')
170
179
  }
171
- const finalTables = applyInjectionWidgetOverridesToTables(tables)
180
+ const finalTables = applyInjectionWidgetOverridesToTables(tables, undefined, widgetEntries)
172
181
  _coreInjectionTables = finalTables
173
182
  writeGlobalInjectionTables(finalTables)
174
183
  notifyInjectionRegistryChanged()