assign-gingerly 0.0.38 → 0.0.40

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