assign-gingerly 0.0.73 → 0.0.74

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.
package/assignFeatures.ts CHANGED
@@ -1,837 +1,713 @@
1
- /**
2
- * assignFeatures - Dependency injection for custom element features
3
- *
4
- * Allows custom element authors to declare supported feature slots via
5
- * `static supportedFeatures`, then have implementations injected via
6
- * `customElementRegistry.assignFeatures(Ctor, features)`.
7
- *
8
- * Features are lazily instantiated on first property access via getter-only
9
- * properties installed on the class prototype.
10
- */
11
-
12
- import { parseWithAttrs } from './parseWithAttrs.js';
13
- import { FeatureSpawnContext, SupportedFeatureConfig, FeaturesClassConfig, FeatureConfig, SupportedFeaturesMap, FeatureConfigsMap } from './types/assign-gingerly/types.js';
14
-
15
- export type { FeatureSpawnContext, SupportedFeatureConfig, FeaturesClassConfig, FeatureConfig, SupportedFeaturesMap, FeatureConfigsMap };
16
-
17
- /**
18
- * WeakMap storing per-instance feature caches.
19
- * Outer key: the instance (element or other object).
20
- * Inner map: feature key -> spawned instance.
21
- */
22
- const featureStorage = new WeakMap<object, Map<string, any>>();
23
-
24
- /**
25
- * The features registry: maps a constructor to its accumulated feature injections.
26
- */
27
- export class FeaturesRegistry {
28
- #registry = new Map<Function, Map<string, FeatureConfig>>();
29
-
30
- has(ctr: Function): boolean {
31
- return this.#registry.has(ctr);
32
- }
33
-
34
- get(ctr: Function): Map<string, FeatureConfig> | undefined {
35
- return this.#registry.get(ctr);
36
- }
37
-
38
- set(ctr: Function, key: string, injection: FeatureConfig): void {
39
- let features = this.#registry.get(ctr);
40
- if (!features) {
41
- features = new Map();
42
- this.#registry.set(ctr, features);
43
- }
44
- features.set(key, injection);
45
- }
46
-
47
- hasKey(ctr: Function, key: string): boolean {
48
- const features = this.#registry.get(ctr);
49
- return features ? features.has(key) : false;
50
- }
51
- }
52
-
53
- /**
54
- * Sentinel symbol to mark stored values as raw initVals (not yet spawned).
55
- */
56
- const RAW_INIT_VALS = Symbol('rawInitVals');
57
-
58
- /**
59
- * Sentinel symbol to mark stored values as error state from failed async spawn.
60
- */
61
- const FEATURE_ERROR = Symbol('featureError');
62
-
63
- /**
64
- * WeakMap storing pending Promises for async feature resolution.
65
- * Outer key: the instance. Inner map: feature key -> { promise, resolve, reject }.
66
- */
67
- const pendingFeatures = new WeakMap<object, Map<string, { promise: Promise<any>, resolve: Function, reject: Function }>>();
68
-
69
- /**
70
- * Resolves the whenFeatureReady method name from lifecycleKeys config.
71
- * Returns undefined if lifecycleKeys is not set.
72
- */
73
- function resolveWhenFeatureReadyName(lifecycleKeys: true | { whenFeatureReady?: string } | undefined): string | undefined {
74
- if (lifecycleKeys === undefined) return undefined;
75
- if (lifecycleKeys === true) return 'whenFeatureReady';
76
- return lifecycleKeys.whenFeatureReady || 'whenFeatureReady';
77
- }
78
-
79
- /**
80
- * Installs the whenFeatureReady method on the constructor prototype if not already present.
81
- */
82
- function installWhenFeatureReadyMethod(ctr: Function, methodName: string): void {
83
- // Only install once per class
84
- if (Object.getOwnPropertyDescriptor(ctr.prototype, methodName)) return;
85
-
86
- Object.defineProperty(ctr.prototype, methodName, {
87
- value: function (this: any, featureKey: string): Promise<any> {
88
- // Trigger the getter (starts async resolution if needed, or returns sync instance)
89
- const current = this[featureKey];
90
-
91
- // Check if there's a pending async resolution for this instance + key
92
- const pending = pendingFeatures.get(this)?.get(featureKey);
93
- if (pending) {
94
- return pending.promise;
95
- }
96
-
97
- // No pending — feature is already resolved (sync or async already completed)
98
- return Promise.resolve(current);
99
- },
100
- writable: true,
101
- enumerable: false,
102
- configurable: true
103
- });
104
- }
105
-
106
- /**
107
- * Determines if a function is an async spawner (returns a Promise<Constructor>)
108
- * rather than a synchronous constructor.
109
- *
110
- * Heuristic:
111
- * - AsyncFunction (async () => ...) → async spawner
112
- * - Arrow function (no .prototype) → async spawner (assumed to return Promise<Constructor>)
113
- * - Class or function declaration (has .prototype) → synchronous constructor
114
- */
115
- function isAsyncSpawn(fn: any): boolean {
116
- if (typeof fn !== 'function') return false;
117
- // Explicit async function
118
- if (fn.constructor.name === 'AsyncFunction') return true;
119
- // Arrow function or non-constructor function (no .prototype)
120
- if (fn.prototype === undefined) return true;
121
- return false;
122
- }
123
-
124
- /**
125
- * Installs a getter/setter pair on the constructor's prototype for the given feature key.
126
- *
127
- * - The setter stores raw values (pre-upgrade or early assignment) into the WeakMap
128
- * tagged with a sentinel so the getter knows they are initVals, not spawned instances.
129
- * - The getter spawns the feature instance on first access, using any stored raw value
130
- * as initVals, then caches the spawned instance.
131
- *
132
- * If an own-property with the same key exists on the instance (e.g., set before
133
- * the element upgraded), it is captured as initVals and deleted so the prototype
134
- * getter/setter is no longer shadowed.
135
- */
136
- function installFeatureGetter(
137
- ctr: Function,
138
- key: string,
139
- featuresRegistry: FeaturesRegistry
140
- ): void {
141
- Object.defineProperty(ctr.prototype, key, {
142
- get: function (this: any) {
143
- // Get or create the per-instance storage
144
- let storage = featureStorage.get(this);
145
- if (!storage) {
146
- storage = new Map();
147
- featureStorage.set(this, storage);
148
- }
149
-
150
- const stored = storage.get(key);
151
-
152
- // Check for error state from failed async spawn
153
- if (stored && typeof stored === 'object' && FEATURE_ERROR in stored) {
154
- throw stored[FEATURE_ERROR];
155
- }
156
-
157
- // If already spawned (not a raw sentinel, not undefined), return it
158
- if (stored !== undefined && !(stored && typeof stored === 'object' && RAW_INIT_VALS in stored)) {
159
- return stored;
160
- }
161
-
162
- // Determine initVals: from setter-stored raw value, or from own-property shadow
163
- let initVals: any = undefined;
164
- if (stored && typeof stored === 'object' && RAW_INIT_VALS in stored) {
165
- initVals = stored[RAW_INIT_VALS];
166
- storage.delete(key);
167
- } else if (Object.hasOwn(this, key)) {
168
- initVals = this[key];
169
- delete this[key]; // Unshadow the prototype accessor
170
- }
171
-
172
- // Resolve the registry — use scoped registry if available, fall back to global
173
- const registry = (this.customElementRegistry || customElements) as any;
174
- const fr: FeaturesRegistry = registry.featuresRegistry;
175
-
176
- if (!fr || !fr.has(ctr)) {
177
- throw new Error(`assignFeatures: featuresRegistry missing entry for constructor`);
178
- }
179
-
180
- const features = fr.get(ctr)!;
181
- const injection = features.get(key);
182
-
183
- if (!injection) {
184
- throw new Error(`assignFeatures: no injection found for feature "${key}"`);
185
- }
186
-
187
- // Resolve spawn: injection.spawn takes priority, then fallbackSpawn
188
- const supportedFeatures = (ctr as any).supportedFeatures;
189
- const optIn: SupportedFeatureConfig | undefined = supportedFeatures?.[key];
190
-
191
- if (!optIn) {
192
- throw new Error(`assignFeatures: "${key}" not in static supportedFeatures`);
193
- }
194
-
195
- const SpawnClass = injection.spawn || optIn.fallbackSpawn;
196
-
197
- if (!SpawnClass) {
198
- throw new Error(
199
- `assignFeatures: no spawn implementation found for feature "${key}". ` +
200
- `Provide spawn in assignFeatures() or fallbackSpawn in supportedFeatures.`
201
- );
202
- }
203
-
204
- // Build the spawn context
205
- const shared = optIn.getSharedContext?.(this);
206
- const ctx: FeatureSpawnContext = {
207
- key,
208
- optIn,
209
- injection,
210
- featuresRegistry: fr,
211
- shared
212
- };
213
-
214
- // Parse attributes if withAttrs is configured
215
- let attrInitVals: any = undefined;
216
- if (injection.withAttrs && this instanceof Element) {
217
- try {
218
- attrInitVals = parseWithAttrs(
219
- this as Element,
220
- injection.withAttrs,
221
- true // always unprefixed for features
222
- );
223
- } catch (e) {
224
- console.error('Error parsing feature attributes:', e);
225
- throw e;
226
- }
227
- }
228
-
229
- // Merge: attributes are base layer, programmatic initVals override
230
- if (attrInitVals) {
231
- initVals = initVals
232
- ? { ...attrInitVals, ...initVals }
233
- : attrInitVals;
234
- }
235
-
236
- if (isAsyncSpawn(SpawnClass)) {
237
- // Async path: SpawnClass is a function that returns Promise<Constructor>
238
- const placeholder = initVals && typeof initVals === 'object' ? initVals : {};
239
- storage.set(key, placeholder);
240
-
241
- // Capture host element reference for the async callback
242
- const hostElement = this;
243
-
244
- // Create a pending Promise for whenFeatureReady consumers
245
- let pendingMap = pendingFeatures.get(hostElement);
246
- if (!pendingMap) {
247
- pendingMap = new Map();
248
- pendingFeatures.set(hostElement, pendingMap);
249
- }
250
- let resolvePending: Function;
251
- let rejectPending: Function;
252
- const promise = new Promise<any>((resolve, reject) => {
253
- resolvePending = resolve;
254
- rejectPending = reject;
255
- });
256
- pendingMap.set(key, { promise, resolve: resolvePending!, reject: rejectPending! });
257
-
258
- // Kick off async resolution
259
- (SpawnClass as () => Promise<any>)().then((ResolvedClass: any) => {
260
- // Mutate injection so future getter calls see the resolved constructor
261
- (injection as any).spawn = ResolvedClass;
262
-
263
- // Get the current placeholder (may have accumulated properties via assignGingerly)
264
- const currentStorage = featureStorage.get(hostElement);
265
- const currentPlaceholder = currentStorage?.get(key);
266
-
267
- // Don't upgrade if an error was stored or if already upgraded
268
- if (!currentPlaceholder || (typeof currentPlaceholder === 'object' && FEATURE_ERROR in currentPlaceholder)) {
269
- return;
270
- }
271
-
272
- // Parse attributes at resolution time (element should be in DOM by now)
273
- let asyncAttrInitVals: any = undefined;
274
- if (injection.withAttrs && hostElement instanceof Element) {
275
- try {
276
- asyncAttrInitVals = parseWithAttrs(
277
- hostElement as Element,
278
- injection.withAttrs,
279
- true // always unprefixed for features
280
- );
281
- } catch (e) {
282
- // Non-fatal: log and continue with placeholder as initVals
283
- console.error('Error parsing feature attributes during async resolution:', e);
284
- }
285
- }
286
-
287
- // Merge: attributes are base, placeholder (programmatic) overrides
288
- const asyncInitVals = asyncAttrInitVals
289
- ? { ...asyncAttrInitVals, ...currentPlaceholder }
290
- : currentPlaceholder;
291
-
292
- // Instantiate the real class with merged initVals
293
- const realCtx: FeatureSpawnContext = {
294
- key,
295
- optIn,
296
- injection,
297
- featuresRegistry: fr,
298
- shared: optIn.getSharedContext?.(hostElement)
299
- };
300
- const instance = new ResolvedClass(hostElement, realCtx, asyncInitVals);
301
-
302
- // Validate shape if configured
303
- if (optIn.validateShape) {
304
- if (!optIn.validateShape(instance)) {
305
- const error: any = new Error(
306
- `assignFeatures: spawned instance for "${key}" failed shape validation`
307
- );
308
- error.placeholder = currentPlaceholder;
309
- currentStorage!.set(key, { [FEATURE_ERROR]: error });
310
- rejectPending!(error);
311
- pendingMap!.delete(key);
312
- return;
313
- }
314
- }
315
-
316
- // Replace placeholder with real instance
317
- currentStorage!.set(key, instance);
318
-
319
- // Resolve the pending Promise and clean up
320
- resolvePending!(instance);
321
- pendingMap!.delete(key);
322
- }).catch((err: any) => {
323
- // Store error state — getter will throw on next access
324
- const currentStorage = featureStorage.get(hostElement);
325
- const currentPlaceholder = currentStorage?.get(key);
326
- const error: any = new Error(
327
- `assignFeatures: async spawn for "${key}" failed: ${err.message}`
328
- );
329
- error.placeholder = currentPlaceholder;
330
- error.cause = err;
331
- currentStorage?.set(key, { [FEATURE_ERROR]: error });
332
-
333
- // Reject the pending Promise and clean up
334
- rejectPending!(error);
335
- pendingMap!.delete(key);
336
- });
337
-
338
- return placeholder;
339
- } else {
340
- // Synchronous path: SpawnClass is a constructor
341
- const instance = new (SpawnClass as any)(this, ctx, initVals);
342
-
343
- // Validate shape if configured
344
- if (optIn.validateShape) {
345
- if (!optIn.validateShape(instance)) {
346
- throw new Error(
347
- `assignFeatures: spawned instance for "${key}" failed shape validation`
348
- );
349
- }
350
- }
351
-
352
- storage.set(key, instance);
353
- return instance;
354
- }
355
- },
356
- enumerable: true,
357
- configurable: false
358
- });
359
- }
360
-
361
- /**
362
- * Valid lifecycle callback names that can be forwarded to features.
363
- */
364
- const VALID_CALLBACKS = new Set([
365
- 'connectedCallback',
366
- 'disconnectedCallback',
367
- 'attributeChangedCallback',
368
- 'adoptedCallback',
369
- 'formDisabledCallback',
370
- 'formResetCallback',
371
- 'formStateRestoreCallback'
372
- ]);
373
-
374
- /**
375
- * WeakMap tracking which callbacks have been patched on which constructors,
376
- * and which feature keys are registered for each callback.
377
- * Structure: Map<Function, Map<callbackName, Set<featureKey>>>
378
- */
379
- const callbackRegistry = new Map<Function, Map<string, Set<string>>>();
380
-
381
- /**
382
- * Installs or updates lifecycle callback forwarding on a constructor's prototype.
383
- * Patches the callback once per type, accumulating feature keys for each.
384
- */
385
- function installCallbackForwarding(
386
- ctr: Function,
387
- key: string,
388
- callbacks: string[]
389
- ): void {
390
- let ctrCallbacks = callbackRegistry.get(ctr);
391
- if (!ctrCallbacks) {
392
- ctrCallbacks = new Map();
393
- callbackRegistry.set(ctr, ctrCallbacks);
394
- }
395
-
396
- for (const callbackName of callbacks) {
397
- if (!VALID_CALLBACKS.has(callbackName)) {
398
- throw new Error(
399
- `assignFeatures: invalid callbackForwarding "${callbackName}" for feature "${key}". ` +
400
- `Valid values: ${[...VALID_CALLBACKS].join(', ')}`
401
- );
402
- }
403
-
404
- // Validate that the spawn class has the method (sync spawners only)
405
- // For async spawners, validation is deferred to runtime
406
-
407
- let featureKeys = ctrCallbacks.get(callbackName);
408
- if (!featureKeys) {
409
- featureKeys = new Set();
410
- ctrCallbacks.set(callbackName, featureKeys);
411
-
412
- // Patch the prototype callback (only once per callback type per class)
413
- const original = ctr.prototype[callbackName];
414
-
415
- Object.defineProperty(ctr.prototype, callbackName, {
416
- value: function (this: any, ...args: any[]) {
417
- // Call original first
418
- if (original) original.apply(this, args);
419
-
420
- // Forward to all registered features
421
- const keys = callbackRegistry.get(ctr)?.get(callbackName);
422
- if (keys) {
423
- for (const featureKey of keys) {
424
- // Access the getter (triggers lazy spawn on first connectedCallback)
425
- const feature = this[featureKey];
426
-
427
- // Only forward if it's a real instance (not a placeholder or error)
428
- if (feature && typeof feature === 'object' &&
429
- typeof feature[callbackName] === 'function' &&
430
- !(FEATURE_ERROR in feature)) {
431
- feature[callbackName](...args);
432
- }
433
- }
434
- }
435
- },
436
- writable: true,
437
- enumerable: false,
438
- configurable: true
439
- });
440
- }
441
-
442
- // Add this feature key to the set for this callback
443
- featureKeys.add(key);
444
- }
445
- }
446
-
447
- /**
448
- * Core assignFeatures implementation.
449
- * Validates inputs, registers injections, and installs lazy getters.
450
- *
451
- * Important: Call assignFeatures BEFORE customElements.define(), or at minimum
452
- * before any instances of the element are created. The lazy getters must be on
453
- * the prototype before instances exist to properly capture pre-set properties.
454
- *
455
- * @param ctr - The constructor (class) to assign features to
456
- * @param features - Map of feature keys to their injection configs
457
- * @param featuresRegistry - The registry to store injections in
458
- */
459
- export function assignFeatures(
460
- ctr: Function,
461
- features: FeatureConfigsMap,
462
- featuresRegistry: FeaturesRegistry
463
- ): Promise<void> | undefined {
464
- // Validate that the constructor has static supportedFeatures
465
- const supportedFeatures: SupportedFeaturesMap | undefined = (ctr as any).supportedFeatures;
466
-
467
- if (!supportedFeatures) {
468
- throw new Error(
469
- `assignFeatures: ${ctr.name || 'constructor'} does not define static supportedFeatures`
470
- );
471
- }
472
-
473
- let hasAsync = false;
474
-
475
- async function processFeatures() {
476
- for (const key of Object.keys(features)) {
477
- // 1. Confirm the key is opted-in via supportedFeatures
478
- if (!(key in supportedFeatures!)) {
479
- throw new Error(
480
- `assignFeatures: "${key}" is not declared in ${ctr.name || 'constructor'}.supportedFeatures`
481
- );
482
- }
483
-
484
- // 2. Check that the prototype doesn't already have this property defined
485
- const existingDescriptor = Object.getOwnPropertyDescriptor(ctr.prototype, key);
486
- if (existingDescriptor) {
487
- throw new Error(
488
- `assignFeatures: "${key}" already exists on ${ctr.name || 'constructor'}.prototype`
489
- );
490
- }
491
-
492
- // 3. Check that this key hasn't already been registered for this constructor
493
- if (featuresRegistry.hasKey(ctr, key)) {
494
- throw new Error(
495
- `assignFeatures: "${key}" has already been assigned for ${ctr.name || 'constructor'}`
496
- );
497
- }
498
-
499
- // 4. Register the injection
500
- featuresRegistry.set(ctr, key, features[key]);
501
-
502
- // 5. Install the lazy getter on the prototype
503
- installFeatureGetter(ctr, key, featuresRegistry);
504
-
505
- // 6. Install callback forwarding if configured (merge author + consumer)
506
- const featureConfig = features[key];
507
- const optIn = supportedFeatures![key];
508
- const authorCallbacks = optIn.callbackForwarding || [];
509
- const consumerCallbacks = featureConfig.callbackForwarding || [];
510
- // Union of both (author defaults + consumer additions)
511
- const allCallbacks = [...new Set([...authorCallbacks, ...consumerCallbacks])];
512
- if (allCallbacks.length > 0) {
513
- installCallbackForwarding(ctr, key, allCallbacks);
514
- }
515
-
516
- // 7. Call static onAssigned if the spawn class defines it (sequentially awaited)
517
- const SpawnClass = featureConfig.spawn;
518
- if (SpawnClass && !isAsyncSpawn(SpawnClass) &&
519
- Object.hasOwn(SpawnClass as any, 'onAssigned') &&
520
- typeof (SpawnClass as any).onAssigned === 'function') {
521
- const result = (SpawnClass as any).onAssigned(ctr, featureConfig, key);
522
- if (result && typeof result.then === 'function') {
523
- hasAsync = true;
524
- await result;
525
- }
526
- }
527
- }
528
-
529
- // 8. Install whenFeatureReady method if featuresConfig.lifecycleKeys is configured
530
- const featuresConfig: FeaturesClassConfig | undefined = (ctr as any).featuresConfig;
531
- if (featuresConfig?.lifecycleKeys) {
532
- const methodName = resolveWhenFeatureReadyName(featuresConfig.lifecycleKeys);
533
- if (methodName) {
534
- installWhenFeatureReadyMethod(ctr, methodName);
535
- }
536
- }
537
- }
538
-
539
- // Check if any feature has an async onAssigned (pre-scan)
540
- for (const key of Object.keys(features)) {
541
- const featureConfig = features[key];
542
- const SpawnClass = featureConfig.spawn;
543
- if (SpawnClass && !isAsyncSpawn(SpawnClass) &&
544
- Object.hasOwn(SpawnClass as any, 'onAssigned') &&
545
- typeof (SpawnClass as any).onAssigned === 'function') {
546
- // We can't know if it's async without calling it, so always use the async path
547
- // if any onAssigned exists
548
- return processFeatures();
549
- }
550
- }
551
-
552
- // No onAssigned hooks — run synchronously (inline the logic to avoid the async wrapper)
553
- for (const key of Object.keys(features)) {
554
- if (!(key in supportedFeatures)) {
555
- throw new Error(
556
- `assignFeatures: "${key}" is not declared in ${ctr.name || 'constructor'}.supportedFeatures`
557
- );
558
- }
559
-
560
- const existingDescriptor = Object.getOwnPropertyDescriptor(ctr.prototype, key);
561
- if (existingDescriptor) {
562
- throw new Error(
563
- `assignFeatures: "${key}" already exists on ${ctr.name || 'constructor'}.prototype`
564
- );
565
- }
566
-
567
- if (featuresRegistry.hasKey(ctr, key)) {
568
- throw new Error(
569
- `assignFeatures: "${key}" has already been assigned for ${ctr.name || 'constructor'}`
570
- );
571
- }
572
-
573
- featuresRegistry.set(ctr, key, features[key]);
574
- installFeatureGetter(ctr, key, featuresRegistry);
575
-
576
- const featureConfig = features[key];
577
- const optIn = supportedFeatures[key];
578
- const authorCallbacks = optIn.callbackForwarding || [];
579
- const consumerCallbacks = featureConfig.callbackForwarding || [];
580
- const allCallbacks = [...new Set([...authorCallbacks, ...consumerCallbacks])];
581
- if (allCallbacks.length > 0) {
582
- installCallbackForwarding(ctr, key, allCallbacks);
583
- }
584
- }
585
-
586
- const featuresConfig: FeaturesClassConfig | undefined = (ctr as any).featuresConfig;
587
- if (featuresConfig?.lifecycleKeys) {
588
- const methodName = resolveWhenFeatureReadyName(featuresConfig.lifecycleKeys);
589
- if (methodName) {
590
- installWhenFeatureReadyMethod(ctr, methodName);
591
- }
592
- }
593
-
594
- return undefined;
595
- }
596
-
597
- /**
598
- * Captures own-properties that shadow feature getters and stores them as initVals.
599
- * Call this in the custom element constructor to handle pre-upgrade property values.
600
- *
601
- * When an element exists in the DOM before `define()` is called, properties may have
602
- * been set on it directly. After upgrade, these own-properties shadow the prototype
603
- * getters installed by `assignFeatures`. This helper captures those values and deletes
604
- * the own-properties so the getters can function properly.
605
- *
606
- * @param instance - The custom element instance (typically `this` in the constructor)
607
- *
608
- * @example
609
- * class ClubMember extends HTMLElement {
610
- * static supportedFeatures = { photoTaker: { fallbackSpawn: PhotoTakerImpl } }
611
- * constructor() {
612
- * super();
613
- * captureFeatureInitVals(this);
614
- * }
615
- * }
616
- */
617
- export function captureFeatureInitVals(instance: any): void {
618
- const ctr = instance.constructor;
619
- const supportedFeatures = ctr.supportedFeatures;
620
- if (!supportedFeatures) return;
621
-
622
- for (const key of Object.keys(supportedFeatures)) {
623
- if (Object.hasOwn(instance, key)) {
624
- const value = instance[key];
625
- delete instance[key];
626
-
627
- // Store in the WeakMap so the getter can pick it up
628
- let storage = featureStorage.get(instance);
629
- if (!storage) {
630
- storage = new Map();
631
- featureStorage.set(instance, storage);
632
- }
633
- storage.set(key, { [RAW_INIT_VALS]: value });
634
- }
635
- }
636
- }
637
-
638
- // =============================================================================
639
- // Inter-feature communication: suggestFeatureInfo / getFeatureInfoSuggestions
640
- // =============================================================================
641
-
642
- /**
643
- * A suggestion from one feature to another, containing config fragments to merge.
644
- */
645
- export interface FeatureInfoSuggestion {
646
- /** The feature class that made the suggestion (for debugging/tracing) */
647
- from: Function;
648
- /** Attribute patterns to merge into the target feature's withAttrs */
649
- withAttrs?: any;
650
- /** Custom data to merge into the target feature's customData */
651
- customData?: any;
652
- }
653
-
654
- /**
655
- * Global store for inter-feature suggestions.
656
- * Structure: Map<targetSymbol, Map<targetClass, Array<FeatureInfoSuggestion>>>
657
- * Scoped per target class to prevent leaking between different custom elements.
658
- */
659
- const featureInfoSuggestions = new Map<symbol, Map<Function, FeatureInfoSuggestion[]>>();
660
-
661
- /**
662
- * Suggest configuration to another feature during registration.
663
- *
664
- * Call this in a feature's `static onAssigned` to provide config fragments
665
- * (withAttrs, customData) that another feature should merge into its own config.
666
- *
667
- * The target feature is identified by a Symbol (stable across versions and mocks).
668
- * Suggestions are scoped per target class to prevent leaking between different
669
- * custom elements that use the same features.
670
- *
671
- * @param fromFeatureCtr - The feature class making the suggestion (for tracing)
672
- * @param toFeatureSymbol - Symbol identifying the target feature
673
- * @param featureInfo - Config fragments to suggest (withAttrs, customData)
674
- * @param targetClass - The custom element class being configured
675
- *
676
- * @example
677
- * import { suggestFeatureInfo } from 'assign-gingerly/assignFeatures.js';
678
- * import { ROUNDABOUT_FEATURE } from 'roundabout/symbols.js';
679
- *
680
- * class FaceUp {
681
- * static onAssigned(ctr, featureConfig) {
682
- * suggestFeatureInfo(FaceUp, ROUNDABOUT_FEATURE, {
683
- * customData: { formBindings: { value: 'value' } }
684
- * }, ctr);
685
- * }
686
- * }
687
- */
688
- export function suggestFeatureInfo(
689
- fromFeatureCtr: Function,
690
- toFeatureSymbol: symbol,
691
- featureInfo: { withAttrs?: any; customData?: any },
692
- targetClass: Function
693
- ): void {
694
- let symbolMap = featureInfoSuggestions.get(toFeatureSymbol);
695
- if (!symbolMap) {
696
- symbolMap = new Map();
697
- featureInfoSuggestions.set(toFeatureSymbol, symbolMap);
698
- }
699
-
700
- let suggestions = symbolMap.get(targetClass);
701
- if (!suggestions) {
702
- suggestions = [];
703
- symbolMap.set(targetClass, suggestions);
704
- }
705
-
706
- suggestions.push({
707
- from: fromFeatureCtr,
708
- ...featureInfo
709
- });
710
- }
711
-
712
- /**
713
- * Retrieve suggestions made to a feature by other features.
714
- *
715
- * Call this in a feature's `static onAssigned` to read config fragments
716
- * suggested by other features that were processed earlier.
717
- *
718
- * @param toFeatureSymbol - Symbol identifying this feature (the target)
719
- * @param targetClass - The custom element class being configured
720
- * @returns Array of suggestions (empty if none)
721
- *
722
- * @example
723
- * import { getFeatureInfoSuggestions } from 'assign-gingerly/assignFeatures.js';
724
- * import { ROUNDABOUT_FEATURE } from './symbols.js';
725
- *
726
- * class RoundaboutFeature {
727
- * static onAssigned(ctr, featureConfig) {
728
- * const suggestions = getFeatureInfoSuggestions(ROUNDABOUT_FEATURE, ctr);
729
- * for (const suggestion of suggestions) {
730
- * if (suggestion.customData) {
731
- * featureConfig.customData = { ...featureConfig.customData, ...suggestion.customData };
732
- * }
733
- * }
734
- * }
735
- * }
736
- */
737
- export function getFeatureInfoSuggestions(
738
- toFeatureSymbol: symbol,
739
- targetClass: Function
740
- ): FeatureInfoSuggestion[] {
741
- const symbolMap = featureInfoSuggestions.get(toFeatureSymbol);
742
- if (!symbolMap) return [];
743
- return symbolMap.get(targetClass) || [];
744
- }
745
-
746
- // =============================================================================
747
- // PropertyBag — base class for nested feature containers
748
- // =============================================================================
749
-
750
- /**
751
- * PropertyBag is a base class for creating nested feature containers.
752
- *
753
- * Subclass it to group related features under a single namespace property.
754
- * PropertyBag carries the `customElementRegistry` reference from the host element
755
- * so that nested features can resolve their registries correctly.
756
- *
757
- * PropertyBag must be subclassed — direct instantiation throws an error.
758
- * Subclasses must define `static supportedFeatures` to declare their feature slots.
759
- *
760
- * @example
761
- * class ClubMemberBehaviors extends PropertyBag {
762
- * static supportedFeatures = {
763
- * commandBehavior: { fallbackSpawn: CommandFeatureImpl },
764
- * ariaBehavior: { fallbackSpawn: AriaFeatureImpl }
765
- * }
766
- * }
767
- *
768
- * class ClubMember extends HTMLElement {
769
- * static supportedFeatures = {
770
- * behaviors: { fallbackSpawn: ClubMemberBehaviors }
771
- * }
772
- * }
773
- *
774
- * customElements.assignFeatures(ClubMember, { behaviors: { spawn: ClubMemberBehaviors } });
775
- * customElements.assignFeatures(ClubMemberBehaviors, {
776
- * commandBehavior: { spawn: CommandFeatureImpl }
777
- * });
778
- */
779
- export class PropertyBag {
780
- /** Registry reference carried from the host element */
781
- customElementRegistry: any;
782
-
783
- constructor(hostElement: any, ctx?: FeatureSpawnContext, initVals?: any) {
784
- if (this.constructor === PropertyBag) {
785
- throw new Error(
786
- 'PropertyBag must be subclassed. Define static supportedFeatures on your subclass.'
787
- );
788
- }
789
-
790
- // Carry the registry reference from the host element
791
- this.customElementRegistry = hostElement.customElementRegistry ||
792
- (typeof customElements !== 'undefined' ? customElements : undefined);
793
-
794
- // Apply any initVals
795
- if (initVals && typeof initVals === 'object') {
796
- Object.assign(this, initVals);
797
- }
798
- }
799
- }
800
-
801
- // =============================================================================
802
- // Self-installing: adds featuresRegistry and assignFeatures to CustomElementRegistry
803
- // This code runs as a side effect when this module is imported.
804
- // =============================================================================
805
-
806
- declare global {
807
- interface CustomElementRegistry {
808
- featuresRegistry: FeaturesRegistry;
809
- assignFeatures(ctr: Function, features: FeatureConfigsMap): Promise<void> | undefined;
810
- }
811
- }
812
-
813
- if (typeof CustomElementRegistry !== 'undefined') {
814
- Object.defineProperty(CustomElementRegistry.prototype, 'featuresRegistry', {
815
- get: function () {
816
- const registry = new FeaturesRegistry();
817
- Object.defineProperty(this, 'featuresRegistry', {
818
- value: registry,
819
- writable: true,
820
- enumerable: false,
821
- configurable: true,
822
- });
823
- return registry;
824
- },
825
- enumerable: false,
826
- configurable: true,
827
- });
828
-
829
- Object.defineProperty(CustomElementRegistry.prototype, 'assignFeatures', {
830
- value: function (ctr: Function, features: FeatureConfigsMap): Promise<void> | undefined {
831
- return assignFeatures(ctr, features, this.featuresRegistry);
832
- },
833
- writable: true,
834
- enumerable: false,
835
- configurable: true,
836
- });
837
- }
1
+ /**
2
+ * assignFeatures - Dependency injection for custom element features
3
+ *
4
+ * Allows custom element authors to declare supported feature slots via
5
+ * `static supportedFeatures`, then have implementations injected via
6
+ * `customElementRegistry.assignFeatures(Ctor, features)`.
7
+ *
8
+ * Features are lazily instantiated on first property access via getter-only
9
+ * properties installed on the class prototype.
10
+ */
11
+
12
+ import { parseWithAttrs } from './parseWithAttrs.js';
13
+ import { isAsyncSpawn } from './utils/isAsyncSpawn.js';
14
+ import { findClassPrototypeInPath } from './utils/findClassPrototypeInPath.js';
15
+ import { FeatureSpawnContext, SupportedFeatureConfig, FeatureConfig, SupportedFeaturesMap, FeatureConfigsMap } from './types/assign-gingerly/types.js';
16
+ export type { FeatureSpawnContext, SupportedFeatureConfig, FeatureConfig, SupportedFeaturesMap, FeatureConfigsMap };
17
+
18
+ /**
19
+ * WeakMap storing per-instance feature caches.
20
+ * Outer key: the instance (element or other object).
21
+ * Inner map: feature key -> spawned instance.
22
+ */
23
+ const featureStorage = new WeakMap<object, Map<string, any>>();
24
+
25
+ /**
26
+ * The features registry: maps a constructor to its accumulated feature injections.
27
+ */
28
+ export class FeaturesRegistry {
29
+ #registry = new Map<Function, Map<string, FeatureConfig>>();
30
+
31
+ has(ctr: Function): boolean {
32
+ return this.#registry.has(ctr);
33
+ }
34
+
35
+ get(ctr: Function): Map<string, FeatureConfig> | undefined {
36
+ return this.#registry.get(ctr);
37
+ }
38
+
39
+ set(ctr: Function, key: string, injection: FeatureConfig): void {
40
+ let features = this.#registry.get(ctr);
41
+ if (!features) {
42
+ features = new Map();
43
+ this.#registry.set(ctr, features);
44
+ }
45
+ features.set(key, injection);
46
+ }
47
+
48
+ hasKey(ctr: Function, key: string): boolean {
49
+ const features = this.#registry.get(ctr);
50
+ return features ? features.has(key) : false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Sentinel symbol to mark stored values as raw initVals (not yet spawned).
56
+ */
57
+ const RAW_INIT_VALS = Symbol('rawInitVals');
58
+
59
+ /**
60
+ * Cache for resolved feature spawns.
61
+ * Key: target constructor, Value: Map<featureKey, resolvedConstructor>
62
+ */
63
+ export const resolvedSpawnCache = new WeakMap<Function, Map<string, any>>();
64
+
65
+ function getOrCreateClassCache(ctr: Function): Map<string, any> {
66
+ let classCache = resolvedSpawnCache.get(ctr);
67
+ if (!classCache) {
68
+ classCache = new Map();
69
+ resolvedSpawnCache.set(ctr, classCache);
70
+ }
71
+ return classCache;
72
+ }
73
+
74
+ /**
75
+ * Resolves a spawn reference (constructor, async function, or import-path string).
76
+ */
77
+ async function resolveSpawn(spawnish: any): Promise<Function | undefined> {
78
+ if (!spawnish) return undefined;
79
+ if (typeof spawnish === 'string') {
80
+ return await findClassPrototypeInPath(spawnish);
81
+ }
82
+ if (isAsyncSpawn(spawnish)) {
83
+ return await spawnish();
84
+ }
85
+ return spawnish;
86
+ }
87
+
88
+ /**
89
+ * Returns true if resolving this spawn reference requires awaiting.
90
+ */
91
+ function isAsyncResolution(spawnish: any): boolean {
92
+ if (typeof spawnish === 'string') return true;
93
+ if (isAsyncSpawn(spawnish)) return true;
94
+ return false;
95
+ }
96
+
97
+ /**
98
+ * Resolves all configured spawns for the target constructor and caches them.
99
+ * Returns a Promise if any resolution is async, otherwise undefined.
100
+ */
101
+ function resolveAndCacheSpawns(
102
+ ctr: Function,
103
+ features: FeatureConfigsMap,
104
+ supportedFeatures: SupportedFeaturesMap
105
+ ): Promise<void> | undefined {
106
+ let hasAsync = false;
107
+ const asyncResolutions: Promise<void>[] = [];
108
+
109
+ for (const key of Object.keys(features)) {
110
+ const classCache = getOrCreateClassCache(ctr);
111
+ if (classCache.has(key)) continue;
112
+
113
+ const featureConfig = features[key];
114
+ let spawnish = featureConfig.spawn;
115
+ if (spawnish === undefined) {
116
+ const optIn = supportedFeatures[key];
117
+ spawnish = optIn?.fallbackSpawn;
118
+ }
119
+
120
+ if (!spawnish) {
121
+ classCache.set(key, undefined);
122
+ continue;
123
+ }
124
+
125
+ if (isAsyncResolution(spawnish)) {
126
+ hasAsync = true;
127
+ asyncResolutions.push((async () => {
128
+ const resolved = await resolveSpawn(spawnish);
129
+ classCache.set(key, resolved);
130
+ })());
131
+ } else {
132
+ classCache.set(key, spawnish);
133
+ }
134
+ }
135
+
136
+ if (hasAsync) {
137
+ return Promise.all(asyncResolutions).then(() => undefined);
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ /**
143
+ * Installs a getter/setter pair on the constructor's prototype for the given feature key.
144
+ *
145
+ * - The setter stores raw values (pre-upgrade or early assignment) into the WeakMap
146
+ * tagged with a sentinel so the getter knows they are initVals, not spawned instances.
147
+ * - The getter spawns the feature instance on first access, using any stored raw value
148
+ * as initVals, then caches the spawned instance.
149
+ *
150
+ * If an own-property with the same key exists on the instance (e.g., set before
151
+ * the element upgraded), it is captured as initVals and deleted so the prototype
152
+ * getter/setter is no longer shadowed.
153
+ */
154
+ function installFeatureGetter(
155
+ ctr: Function,
156
+ key: string,
157
+ featuresRegistry: FeaturesRegistry
158
+ ): void {
159
+ Object.defineProperty(ctr.prototype, key, {
160
+ get: function (this: any) {
161
+ // Get or create the per-instance storage
162
+ let storage = featureStorage.get(this);
163
+ if (!storage) {
164
+ storage = new Map();
165
+ featureStorage.set(this, storage);
166
+ }
167
+
168
+ const stored = storage.get(key);
169
+
170
+ // If already spawned (not a raw sentinel, not undefined), return it
171
+ if (stored !== undefined && !(stored && typeof stored === 'object' && RAW_INIT_VALS in stored)) {
172
+ return stored;
173
+ }
174
+
175
+ // Determine initVals: from setter-stored raw value, or from own-property shadow
176
+ let initVals: any = undefined;
177
+ if (stored && typeof stored === 'object' && RAW_INIT_VALS in stored) {
178
+ initVals = stored[RAW_INIT_VALS];
179
+ storage.delete(key);
180
+ } else if (Object.hasOwn(this, key)) {
181
+ initVals = this[key];
182
+ delete this[key]; // Unshadow the prototype accessor
183
+ }
184
+
185
+ // Resolve the registry — use scoped registry if available, fall back to global
186
+ const registry = (this.customElementRegistry || customElements) as any;
187
+ const fr: FeaturesRegistry = registry.featuresRegistry;
188
+
189
+ if (!fr || !fr.has(ctr)) {
190
+ throw new Error(`assignFeatures: featuresRegistry missing entry for constructor`);
191
+ }
192
+
193
+ const features = fr.get(ctr)!;
194
+ const injection = features.get(key);
195
+
196
+ if (!injection) {
197
+ throw new Error(`assignFeatures: no injection found for feature "${key}"`);
198
+ }
199
+
200
+ // Resolve spawn from the cache populated before getters were installed
201
+ const spawns = resolvedSpawnCache.get(ctr);
202
+ const resolvedSpawn = spawns?.get(key);
203
+ if (!resolvedSpawn) {
204
+ throw new Error(`assignFeatures: no spawn implementation found for feature "${key}"`);
205
+ }
206
+
207
+ const supportedFeatures = (ctr as any).supportedFeatures;
208
+ const optIn: SupportedFeatureConfig | undefined = supportedFeatures?.[key];
209
+
210
+ if (!optIn) {
211
+ throw new Error(`assignFeatures: "${key}" not in static supportedFeatures`);
212
+ }
213
+
214
+
215
+ // Build the spawn context
216
+ const shared = optIn.getSharedContext?.(this);
217
+ const ctx: FeatureSpawnContext = {
218
+ key,
219
+ optIn,
220
+ injection,
221
+ featuresRegistry: fr,
222
+ shared
223
+ };
224
+
225
+ // Parse attributes if withAttrs is configured
226
+ let attrInitVals: any = undefined;
227
+ if (injection.withAttrs && this instanceof Element) {
228
+ try {
229
+ attrInitVals = parseWithAttrs(
230
+ this as Element,
231
+ injection.withAttrs,
232
+ true // always unprefixed for features
233
+ );
234
+ } catch (e) {
235
+ console.error('Error parsing feature attributes:', e);
236
+ throw e;
237
+ }
238
+ }
239
+
240
+ // Merge: attributes are base layer, programmatic initVals override
241
+ if (attrInitVals) {
242
+ initVals = initVals
243
+ ? { ...attrInitVals, ...initVals }
244
+ : attrInitVals;
245
+ }
246
+
247
+ // Synchronous path: SpawnClass is a constructor
248
+ const instance = new (resolvedSpawn as any)(this, ctx, initVals);
249
+
250
+ // Validate shape if configured
251
+ if (optIn.validateShape) {
252
+ if (!optIn.validateShape(instance)) {
253
+ throw new Error(
254
+ `assignFeatures: spawned instance for "${key}" failed shape validation`
255
+ );
256
+ }
257
+ }
258
+
259
+ storage.set(key, instance);
260
+ return instance;
261
+ },
262
+ enumerable: true,
263
+ configurable: false
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Valid lifecycle callback names that can be forwarded to features.
269
+ */
270
+ const VALID_CALLBACKS = new Set([
271
+ 'connectedCallback',
272
+ 'disconnectedCallback',
273
+ 'attributeChangedCallback',
274
+ 'adoptedCallback',
275
+ 'formDisabledCallback',
276
+ 'formResetCallback',
277
+ 'formStateRestoreCallback'
278
+ ]);
279
+
280
+ /**
281
+ * WeakMap tracking which callbacks have been patched on which constructors,
282
+ * and which feature keys are registered for each callback.
283
+ * Structure: Map<Function, Map<callbackName, Set<featureKey>>>
284
+ */
285
+ const callbackRegistry = new Map<Function, Map<string, Set<string>>>();
286
+
287
+ /**
288
+ * Installs or updates lifecycle callback forwarding on a constructor's prototype.
289
+ * Patches the callback once per type, accumulating feature keys for each.
290
+ */
291
+ function installCallbackForwarding(
292
+ ctr: Function,
293
+ key: string,
294
+ callbacks: string[]
295
+ ): void {
296
+ let ctrCallbacks = callbackRegistry.get(ctr);
297
+ if (!ctrCallbacks) {
298
+ ctrCallbacks = new Map();
299
+ callbackRegistry.set(ctr, ctrCallbacks);
300
+ }
301
+
302
+ for (const callbackName of callbacks) {
303
+ if (!VALID_CALLBACKS.has(callbackName)) {
304
+ throw new Error(
305
+ `assignFeatures: invalid callbackForwarding "${callbackName}" for feature "${key}". ` +
306
+ `Valid values: ${[...VALID_CALLBACKS].join(', ')}`
307
+ );
308
+ }
309
+
310
+ // Validate that the spawn class has the method (sync spawners only)
311
+ // For async spawners, validation is deferred to runtime
312
+
313
+ let featureKeys = ctrCallbacks.get(callbackName);
314
+ if (!featureKeys) {
315
+ featureKeys = new Set();
316
+ ctrCallbacks.set(callbackName, featureKeys);
317
+
318
+ // Patch the prototype callback (only once per callback type per class)
319
+ const original = ctr.prototype[callbackName];
320
+
321
+ Object.defineProperty(ctr.prototype, callbackName, {
322
+ value: function (this: any, ...args: any[]) {
323
+ // Call original first
324
+ if (original) original.apply(this, args);
325
+
326
+ // Forward to all registered features
327
+ const keys = callbackRegistry.get(ctr)?.get(callbackName);
328
+ if (keys) {
329
+ for (const featureKey of keys) {
330
+ // Access the getter (triggers lazy spawn on first connectedCallback)
331
+ const feature = this[featureKey];
332
+
333
+ // Only forward if it's a real instance
334
+ if (feature && typeof feature === 'object' &&
335
+ typeof feature[callbackName] === 'function') {
336
+ feature[callbackName](...args);
337
+ }
338
+ }
339
+ }
340
+ },
341
+ writable: true,
342
+ enumerable: false,
343
+ configurable: true
344
+ });
345
+ }
346
+
347
+ // Add this feature key to the set for this callback
348
+ featureKeys.add(key);
349
+ }
350
+ }
351
+
352
+ function installOneFeature(
353
+ ctr: Function,
354
+ key: string,
355
+ features: FeatureConfigsMap,
356
+ supportedFeatures: SupportedFeaturesMap,
357
+ featuresRegistry: FeaturesRegistry
358
+ ): Promise<void> | undefined {
359
+ // 1. Confirm the key is opted-in via supportedFeatures
360
+ if (!(key in supportedFeatures)) {
361
+ throw new Error(
362
+ `assignFeatures: "${key}" is not declared in ${ctr.name || 'constructor'}.supportedFeatures`
363
+ );
364
+ }
365
+
366
+ // 2. Check that the prototype doesn't already have this property defined
367
+ const existingDescriptor = Object.getOwnPropertyDescriptor(ctr.prototype, key);
368
+ if (existingDescriptor) {
369
+ throw new Error(
370
+ `assignFeatures: "${key}" already exists on ${ctr.name || 'constructor'}.prototype`
371
+ );
372
+ }
373
+
374
+ // 3. Check that this key hasn't already been registered for this constructor
375
+ if (featuresRegistry.hasKey(ctr, key)) {
376
+ throw new Error(
377
+ `assignFeatures: "${key}" has already been assigned for ${ctr.name || 'constructor'}`
378
+ );
379
+ }
380
+
381
+ // 4. Register the injection
382
+ featuresRegistry.set(ctr, key, features[key]);
383
+
384
+ // 5. Install the lazy getter on the prototype
385
+ installFeatureGetter(ctr, key, featuresRegistry);
386
+
387
+ // 6. Install callback forwarding if configured (merge author + consumer)
388
+ const featureConfig = features[key];
389
+ const optIn = supportedFeatures[key];
390
+ const authorCallbacks = optIn.callbackForwarding || [];
391
+ const consumerCallbacks = featureConfig.callbackForwarding || [];
392
+ // Union of both (author defaults + consumer additions)
393
+ const allCallbacks = [...new Set([...authorCallbacks, ...consumerCallbacks])];
394
+ if (allCallbacks.length > 0) {
395
+ installCallbackForwarding(ctr, key, allCallbacks);
396
+ }
397
+
398
+ // 7. Call static onAssigned if the resolved spawn class defines it
399
+ const classCache = getOrCreateClassCache(ctr);
400
+ const resolvedSpawn = classCache.get(key);
401
+ if (resolvedSpawn && !isAsyncSpawn(resolvedSpawn) &&
402
+ Object.hasOwn(resolvedSpawn, 'onAssigned') &&
403
+ typeof (resolvedSpawn as any).onAssigned === 'function') {
404
+ const result = (resolvedSpawn as any).onAssigned(ctr, featureConfig, key);
405
+ if (result && typeof result.then === 'function') {
406
+ return result;
407
+ }
408
+ }
409
+ return undefined;
410
+ }
411
+
412
+ function installAllFeatures(
413
+ ctr: Function,
414
+ features: FeatureConfigsMap,
415
+ supportedFeatures: SupportedFeaturesMap,
416
+ featuresRegistry: FeaturesRegistry
417
+ ): Promise<void> | undefined {
418
+ let asyncResult: Promise<void> | undefined;
419
+ for (const key of Object.keys(features)) {
420
+ const result = installOneFeature(ctr, key, features, supportedFeatures, featuresRegistry);
421
+ if (result) {
422
+ if (!asyncResult) {
423
+ asyncResult = result.then(() => undefined);
424
+ } else {
425
+ asyncResult = asyncResult.then(() => result.then(() => undefined));
426
+ }
427
+ }
428
+ }
429
+ return asyncResult;
430
+ }
431
+
432
+ /**
433
+ * Core assignFeatures implementation.
434
+ * Validates inputs, resolves all configured spawns (async if needed), caches them,
435
+ * and installs lazy getters on the prototype.
436
+ *
437
+ * Important: Call assignFeatures BEFORE customElements.define(), or at minimum
438
+ * before any instances of the element are created. The lazy getters must be on
439
+ * the prototype before instances exist to properly capture pre-set properties.
440
+ *
441
+ * @param ctr - The constructor (class) to assign features to
442
+ * @param features - Map of feature keys to their injection configs
443
+ * @param featuresRegistry - The registry to store injections in
444
+ */
445
+ export function assignFeatures(
446
+ ctr: Function,
447
+ features: FeatureConfigsMap,
448
+ featuresRegistry: FeaturesRegistry
449
+ ): Promise<void> | undefined {
450
+ // Validate that the constructor has static supportedFeatures
451
+ const supportedFeatures: SupportedFeaturesMap | undefined = (ctr as any).supportedFeatures;
452
+
453
+ if (!supportedFeatures) {
454
+ throw new Error(
455
+ `assignFeatures: ${ctr.name || 'constructor'} does not define static supportedFeatures`
456
+ );
457
+ }
458
+
459
+ // Resolve all configured spawns before installing getters. This is the single
460
+ // source of truth for spawn resolution; other callers (defineWithFeatures, etc.)
461
+ // delegate to assignFeatures.
462
+ const spawnResolution = resolveAndCacheSpawns(ctr, features, supportedFeatures);
463
+
464
+ if (spawnResolution) {
465
+ return spawnResolution.then(() =>
466
+ installAllFeatures(ctr, features, supportedFeatures, featuresRegistry)
467
+ );
468
+ }
469
+
470
+ return installAllFeatures(ctr, features, supportedFeatures, featuresRegistry);
471
+ }
472
+
473
+ /**
474
+ * Captures own-properties that shadow feature getters and stores them as initVals.
475
+ * Call this in the custom element constructor to handle pre-upgrade property values.
476
+ *
477
+ * When an element exists in the DOM before `define()` is called, properties may have
478
+ * been set on it directly. After upgrade, these own-properties shadow the prototype
479
+ * getters installed by `assignFeatures`. This helper captures those values and deletes
480
+ * the own-properties so the getters can function properly.
481
+ *
482
+ * @param instance - The custom element instance (typically `this` in the constructor)
483
+ *
484
+ * @example
485
+ * class ClubMember extends HTMLElement {
486
+ * static supportedFeatures = { photoTaker: { fallbackSpawn: PhotoTakerImpl } }
487
+ * constructor() {
488
+ * super();
489
+ * captureFeatureInitVals(this);
490
+ * }
491
+ * }
492
+ */
493
+ export function captureFeatureInitVals(instance: any): void {
494
+ const ctr = instance.constructor;
495
+ const supportedFeatures = ctr.supportedFeatures;
496
+ if (!supportedFeatures) return;
497
+
498
+ for (const key of Object.keys(supportedFeatures)) {
499
+ if (Object.hasOwn(instance, key)) {
500
+ const value = instance[key];
501
+ delete instance[key];
502
+
503
+ // Store in the WeakMap so the getter can pick it up
504
+ let storage = featureStorage.get(instance);
505
+ if (!storage) {
506
+ storage = new Map();
507
+ featureStorage.set(instance, storage);
508
+ }
509
+ storage.set(key, { [RAW_INIT_VALS]: value });
510
+ }
511
+ }
512
+ }
513
+
514
+ // =============================================================================
515
+ // Inter-feature communication: suggestFeatureInfo / getFeatureInfoSuggestions
516
+ // =============================================================================
517
+
518
+ /**
519
+ * A suggestion from one feature to another, containing config fragments to merge.
520
+ */
521
+ export interface FeatureInfoSuggestion {
522
+ /** The feature class that made the suggestion (for debugging/tracing) */
523
+ from: Function;
524
+ /** Attribute patterns to merge into the target feature's withAttrs */
525
+ withAttrs?: any;
526
+ /** Custom data to merge into the target feature's customData */
527
+ customData?: any;
528
+ }
529
+
530
+ /**
531
+ * Global store for inter-feature suggestions.
532
+ * Structure: Map<targetSymbol, Map<targetClass, Array<FeatureInfoSuggestion>>>
533
+ * Scoped per target class to prevent leaking between different custom elements.
534
+ */
535
+ const featureInfoSuggestions = new Map<symbol, Map<Function, FeatureInfoSuggestion[]>>();
536
+
537
+ /**
538
+ * Suggest configuration to another feature during registration.
539
+ *
540
+ * Call this in a feature's `static onAssigned` to provide config fragments
541
+ * (withAttrs, customData) that another feature should merge into its own config.
542
+ *
543
+ * The target feature is identified by a Symbol (stable across versions and mocks).
544
+ * Suggestions are scoped per target class to prevent leaking between different
545
+ * custom elements that use the same features.
546
+ *
547
+ * @param fromFeatureCtr - The feature class making the suggestion (for tracing)
548
+ * @param toFeatureSymbol - Symbol identifying the target feature
549
+ * @param featureInfo - Config fragments to suggest (withAttrs, customData)
550
+ * @param targetClass - The custom element class being configured
551
+ *
552
+ * @example
553
+ * import { suggestFeatureInfo } from 'assign-gingerly/assignFeatures.js';
554
+ * import { ROUNDABOUT_FEATURE } from 'roundabout/symbols.js';
555
+ *
556
+ * class FaceUp {
557
+ * static onAssigned(ctr, featureConfig) {
558
+ * suggestFeatureInfo(FaceUp, ROUNDABOUT_FEATURE, {
559
+ * customData: { formBindings: { value: 'value' } }
560
+ * }, ctr);
561
+ * }
562
+ * }
563
+ */
564
+ export function suggestFeatureInfo(
565
+ fromFeatureCtr: Function,
566
+ toFeatureSymbol: symbol,
567
+ featureInfo: { withAttrs?: any; customData?: any },
568
+ targetClass: Function
569
+ ): void {
570
+ let symbolMap = featureInfoSuggestions.get(toFeatureSymbol);
571
+ if (!symbolMap) {
572
+ symbolMap = new Map();
573
+ featureInfoSuggestions.set(toFeatureSymbol, symbolMap);
574
+ }
575
+
576
+ let suggestions = symbolMap.get(targetClass);
577
+ if (!suggestions) {
578
+ suggestions = [];
579
+ symbolMap.set(targetClass, suggestions);
580
+ }
581
+
582
+ suggestions.push({
583
+ from: fromFeatureCtr,
584
+ ...featureInfo
585
+ });
586
+ }
587
+
588
+ /**
589
+ * Retrieve suggestions made to a feature by other features.
590
+ *
591
+ * Call this in a feature's `static onAssigned` to read config fragments
592
+ * suggested by other features that were processed earlier.
593
+ *
594
+ * @param toFeatureSymbol - Symbol identifying this feature (the target)
595
+ * @param targetClass - The custom element class being configured
596
+ * @returns Array of suggestions (empty if none)
597
+ *
598
+ * @example
599
+ * import { getFeatureInfoSuggestions } from 'assign-gingerly/assignFeatures.js';
600
+ * import { ROUNDABOUT_FEATURE } from './symbols.js';
601
+ *
602
+ * class RoundaboutFeature {
603
+ * static onAssigned(ctr, featureConfig) {
604
+ * const suggestions = getFeatureInfoSuggestions(ROUNDABOUT_FEATURE, ctr);
605
+ * for (const suggestion of suggestions) {
606
+ * if (suggestion.customData) {
607
+ * featureConfig.customData = { ...featureConfig.customData, ...suggestion.customData };
608
+ * }
609
+ * }
610
+ * }
611
+ * }
612
+ */
613
+ export function getFeatureInfoSuggestions(
614
+ toFeatureSymbol: symbol,
615
+ targetClass: Function
616
+ ): FeatureInfoSuggestion[] {
617
+ const symbolMap = featureInfoSuggestions.get(toFeatureSymbol);
618
+ if (!symbolMap) return [];
619
+ return symbolMap.get(targetClass) || [];
620
+ }
621
+
622
+ // =============================================================================
623
+ // PropertyBag base class for nested feature containers
624
+ // =============================================================================
625
+
626
+ /**
627
+ * PropertyBag is a base class for creating nested feature containers.
628
+ *
629
+ * Subclass it to group related features under a single namespace property.
630
+ * PropertyBag carries the `customElementRegistry` reference from the host element
631
+ * so that nested features can resolve their registries correctly.
632
+ *
633
+ * PropertyBag must be subclassed — direct instantiation throws an error.
634
+ * Subclasses must define `static supportedFeatures` to declare their feature slots.
635
+ *
636
+ * @example
637
+ * class ClubMemberBehaviors extends PropertyBag {
638
+ * static supportedFeatures = {
639
+ * commandBehavior: { fallbackSpawn: CommandFeatureImpl },
640
+ * ariaBehavior: { fallbackSpawn: AriaFeatureImpl }
641
+ * }
642
+ * }
643
+ *
644
+ * class ClubMember extends HTMLElement {
645
+ * static supportedFeatures = {
646
+ * behaviors: { fallbackSpawn: ClubMemberBehaviors }
647
+ * }
648
+ * }
649
+ *
650
+ * customElements.assignFeatures(ClubMember, { behaviors: { spawn: ClubMemberBehaviors } });
651
+ * customElements.assignFeatures(ClubMemberBehaviors, {
652
+ * commandBehavior: { spawn: CommandFeatureImpl }
653
+ * });
654
+ */
655
+ export class PropertyBag {
656
+ /** Registry reference carried from the host element */
657
+ customElementRegistry: any;
658
+
659
+ constructor(hostElement: any, ctx?: FeatureSpawnContext, initVals?: any) {
660
+ if (this.constructor === PropertyBag) {
661
+ throw new Error(
662
+ 'PropertyBag must be subclassed. Define static supportedFeatures on your subclass.'
663
+ );
664
+ }
665
+
666
+ // Carry the registry reference from the host element
667
+ this.customElementRegistry = hostElement.customElementRegistry ||
668
+ (typeof customElements !== 'undefined' ? customElements : undefined);
669
+
670
+ // Apply any initVals
671
+ if (initVals && typeof initVals === 'object') {
672
+ Object.assign(this, initVals);
673
+ }
674
+ }
675
+ }
676
+
677
+ // =============================================================================
678
+ // Self-installing: adds featuresRegistry and assignFeatures to CustomElementRegistry
679
+ // This code runs as a side effect when this module is imported.
680
+ // =============================================================================
681
+
682
+ declare global {
683
+ interface CustomElementRegistry {
684
+ featuresRegistry: FeaturesRegistry;
685
+ assignFeatures(ctr: Function, features: FeatureConfigsMap): Promise<void> | undefined;
686
+ }
687
+ }
688
+
689
+ if (typeof CustomElementRegistry !== 'undefined') {
690
+ Object.defineProperty(CustomElementRegistry.prototype, 'featuresRegistry', {
691
+ get: function () {
692
+ const registry = new FeaturesRegistry();
693
+ Object.defineProperty(this, 'featuresRegistry', {
694
+ value: registry,
695
+ writable: true,
696
+ enumerable: false,
697
+ configurable: true,
698
+ });
699
+ return registry;
700
+ },
701
+ enumerable: false,
702
+ configurable: true,
703
+ });
704
+
705
+ Object.defineProperty(CustomElementRegistry.prototype, 'assignFeatures', {
706
+ value: function (ctr: Function, features: FeatureConfigsMap): Promise<void> | undefined {
707
+ return assignFeatures(ctr, features, this.featuresRegistry);
708
+ },
709
+ writable: true,
710
+ enumerable: false,
711
+ configurable: true,
712
+ });
713
+ }