assign-gingerly 0.0.39 → 0.0.41

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/README.md CHANGED
@@ -4615,6 +4615,114 @@ class MyBehaviors extends PropertyBag {
4615
4615
  customElements.assignFeatures(MyBehaviors, { anything: { spawn: AnythingImpl } }); // ✓
4616
4616
  ```
4617
4617
 
4618
+ ### Lifecycle callback forwarding with `callbackForwarding`
4619
+
4620
+ Features can receive custom element lifecycle callbacks by declaring `callbackForwarding` in their config. This can be specified by the feature author (in `static supportedFeatures`) and/or by the consumer (in `assignFeatures`). Both are merged — the author declares what the feature intrinsically needs, the consumer can add more:
4621
+
4622
+ ```JavaScript
4623
+ // Author declares what the feature needs
4624
+ class MyElement extends HTMLElement {
4625
+ static supportedFeatures = {
4626
+ reflector: {
4627
+ fallbackSpawn: Reflector,
4628
+ callbackForwarding: ['connectedCallback', 'disconnectedCallback']
4629
+ }
4630
+ }
4631
+ }
4632
+
4633
+ // Consumer can add more (but not remove author's)
4634
+ customElements.assignFeatures(MyElement, {
4635
+ reflector: {
4636
+ spawn: Reflector,
4637
+ callbackForwarding: ['adoptedCallback'] // merged with author's
4638
+ }
4639
+ });
4640
+ ```
4641
+
4642
+ When the custom element's `connectedCallback` fires, the feature's `connectedCallback` is called automatically. This eliminates boilerplate forwarding code and handles feature activation timing naturally.
4643
+
4644
+ **How it works:**
4645
+
4646
+ 1. `assignFeatures` patches the custom element's lifecycle callback on the prototype (once per callback type).
4647
+ 2. The original callback runs first, then all registered features are forwarded.
4648
+ 3. On first `connectedCallback`, the getter is triggered — spawning the feature lazily at the correct lifecycle moment (when the element is in the DOM and computed styles are available).
4649
+ 4. For async features, forwarding is skipped until the real instance is available.
4650
+
4651
+ **Supported callbacks:**
4652
+
4653
+ | Callback | Use case |
4654
+ |----------|----------|
4655
+ | `connectedCallback` | Feature needs DOM context (computed styles, layout, etc.) |
4656
+ | `disconnectedCallback` | Feature needs cleanup (remove listeners, abort fetches) |
4657
+ | `attributeChangedCallback` | Feature reacts to attribute changes (limited to element's `observedAttributes`) |
4658
+ | `adoptedCallback` | Feature reacts to document adoption |
4659
+
4660
+ **Example: Feature that reads computed styles on connect**
4661
+
4662
+ ```JavaScript
4663
+ class Reflector {
4664
+ constructor(host, ctx) {
4665
+ this.host = host;
4666
+ this.internals = ctx.shared.internals;
4667
+ }
4668
+
4669
+ connectedCallback() {
4670
+ // Safe to call getComputedStyle here — element is in the DOM
4671
+ const styles = getComputedStyle(this.host);
4672
+ const exports = styles.getPropertyValue('--custom-state-exports');
4673
+ // ... process exports
4674
+ }
4675
+
4676
+ disconnectedCallback() {
4677
+ // Cleanup
4678
+ }
4679
+ }
4680
+
4681
+ class MyElement extends HTMLElement {
4682
+ #internals;
4683
+ static supportedFeatures = {
4684
+ reflector: {
4685
+ fallbackSpawn: Reflector,
4686
+ getSharedContext(instance) {
4687
+ return { internals: instance.#internals };
4688
+ }
4689
+ }
4690
+ }
4691
+ constructor() {
4692
+ super();
4693
+ this.#internals = this.attachInternals();
4694
+ }
4695
+ }
4696
+
4697
+ customElements.assignFeatures(MyElement, {
4698
+ reflector: {
4699
+ spawn: Reflector,
4700
+ callbackForwarding: ['connectedCallback', 'disconnectedCallback']
4701
+ }
4702
+ });
4703
+ customElements.define('my-element', MyElement);
4704
+ ```
4705
+
4706
+ No manual getter access or `connectedCallback` boilerplate needed — the feature activates at the right time automatically.
4707
+
4708
+ **Multiple features with callbacks:**
4709
+
4710
+ ```JavaScript
4711
+ customElements.assignFeatures(MyElement, {
4712
+ reflector: {
4713
+ spawn: Reflector,
4714
+ callbackForwarding: ['connectedCallback']
4715
+ },
4716
+ logger: {
4717
+ spawn: Logger,
4718
+ callbackForwarding: ['connectedCallback', 'disconnectedCallback']
4719
+ }
4720
+ });
4721
+ // Both features receive connectedCallback; only logger receives disconnectedCallback
4722
+ ```
4723
+
4724
+ **Note on `attributeChangedCallback`:** The feature only receives callbacks for attributes listed in the element's `static observedAttributes`. Features cannot add to this list after `define()` is called.
4725
+
4618
4726
  ### Roadmap (future phases)
4619
4727
 
4620
4728
  - **Nested features**: Support `?.path?.notation` keys directly in `assignFeatures` (without requiring `PropertyBag`).
package/assignFeatures.js CHANGED
@@ -298,6 +298,73 @@ function installFeatureGetter(ctr, key, featuresRegistry) {
298
298
  configurable: false
299
299
  });
300
300
  }
301
+ /**
302
+ * Valid lifecycle callback names that can be forwarded to features.
303
+ */
304
+ const VALID_CALLBACKS = new Set([
305
+ 'connectedCallback',
306
+ 'disconnectedCallback',
307
+ 'attributeChangedCallback',
308
+ 'adoptedCallback'
309
+ ]);
310
+ /**
311
+ * WeakMap tracking which callbacks have been patched on which constructors,
312
+ * and which feature keys are registered for each callback.
313
+ * Structure: Map<Function, Map<callbackName, Set<featureKey>>>
314
+ */
315
+ const callbackRegistry = new Map();
316
+ /**
317
+ * Installs or updates lifecycle callback forwarding on a constructor's prototype.
318
+ * Patches the callback once per type, accumulating feature keys for each.
319
+ */
320
+ function installCallbackForwarding(ctr, key, callbacks) {
321
+ let ctrCallbacks = callbackRegistry.get(ctr);
322
+ if (!ctrCallbacks) {
323
+ ctrCallbacks = new Map();
324
+ callbackRegistry.set(ctr, ctrCallbacks);
325
+ }
326
+ for (const callbackName of callbacks) {
327
+ if (!VALID_CALLBACKS.has(callbackName)) {
328
+ throw new Error(`assignFeatures: invalid callbackForwarding "${callbackName}" for feature "${key}". ` +
329
+ `Valid values: ${[...VALID_CALLBACKS].join(', ')}`);
330
+ }
331
+ // Validate that the spawn class has the method (sync spawners only)
332
+ // For async spawners, validation is deferred to runtime
333
+ let featureKeys = ctrCallbacks.get(callbackName);
334
+ if (!featureKeys) {
335
+ featureKeys = new Set();
336
+ ctrCallbacks.set(callbackName, featureKeys);
337
+ // Patch the prototype callback (only once per callback type per class)
338
+ const original = ctr.prototype[callbackName];
339
+ Object.defineProperty(ctr.prototype, callbackName, {
340
+ value: function (...args) {
341
+ // Call original first
342
+ if (original)
343
+ original.apply(this, args);
344
+ // Forward to all registered features
345
+ const keys = callbackRegistry.get(ctr)?.get(callbackName);
346
+ if (keys) {
347
+ for (const featureKey of keys) {
348
+ // Access the getter (triggers lazy spawn on first connectedCallback)
349
+ const feature = this[featureKey];
350
+ // Only forward if it's a real instance (not a placeholder or error)
351
+ if (feature && typeof feature === 'object' &&
352
+ typeof feature[callbackName] === 'function' &&
353
+ !(FEATURE_ERROR in feature)) {
354
+ feature[callbackName](...args);
355
+ }
356
+ }
357
+ }
358
+ },
359
+ writable: true,
360
+ enumerable: false,
361
+ configurable: true
362
+ });
363
+ }
364
+ // Add this feature key to the set for this callback
365
+ featureKeys.add(key);
366
+ }
367
+ }
301
368
  /**
302
369
  * Core assignFeatures implementation.
303
370
  * Validates inputs, registers injections, and installs lazy getters.
@@ -334,6 +401,16 @@ export function assignFeatures(ctr, features, featuresRegistry) {
334
401
  featuresRegistry.set(ctr, key, features[key]);
335
402
  // 5. Install the lazy getter on the prototype
336
403
  installFeatureGetter(ctr, key, featuresRegistry);
404
+ // 6. Install callback forwarding if configured (merge author + consumer)
405
+ const featureConfig = features[key];
406
+ const optIn = supportedFeatures[key];
407
+ const authorCallbacks = optIn.callbackForwarding || [];
408
+ const consumerCallbacks = featureConfig.callbackForwarding || [];
409
+ // Union of both (author defaults + consumer additions)
410
+ const allCallbacks = [...new Set([...authorCallbacks, ...consumerCallbacks])];
411
+ if (allCallbacks.length > 0) {
412
+ installCallbackForwarding(ctr, key, allCallbacks);
413
+ }
337
414
  }
338
415
  // 6. Install whenFeatureReady method if featuresConfig.lifecycleKeys is configured
339
416
  const featuresConfig = ctr.featuresConfig;
package/assignFeatures.ts CHANGED
@@ -65,6 +65,16 @@ export interface SupportedFeatureConfig {
65
65
  * }
66
66
  */
67
67
  getSharedContext?: (instance: any) => any;
68
+
69
+ /**
70
+ * Lifecycle callbacks that this feature requires.
71
+ * Serves as the default — the consumer can add more via FeatureConfig.callbackForwarding
72
+ * but cannot remove these.
73
+ *
74
+ * Supported: 'connectedCallback', 'disconnectedCallback',
75
+ * 'attributeChangedCallback', 'adoptedCallback'
76
+ */
77
+ callbackForwarding?: string[];
68
78
  }
69
79
 
70
80
  /**
@@ -133,6 +143,22 @@ export interface FeatureConfig {
133
143
  * via ctx.injection.customData in the constructor.
134
144
  */
135
145
  customData?: any;
146
+
147
+ /**
148
+ * Custom element lifecycle callbacks to forward to this feature.
149
+ * The feature class must implement the listed methods.
150
+ *
151
+ * On first `connectedCallback` forwarding, the getter is triggered (spawning
152
+ * the feature if needed). For async features, forwarding is skipped until
153
+ * the real instance is available.
154
+ *
155
+ * Supported values: 'connectedCallback', 'disconnectedCallback',
156
+ * 'attributeChangedCallback', 'adoptedCallback'
157
+ *
158
+ * Note: `attributeChangedCallback` only receives events for attributes
159
+ * listed in the element's `static observedAttributes`.
160
+ */
161
+ callbackForwarding?: string[];
136
162
  }
137
163
 
138
164
  export type SupportedFeaturesMap = Record<string, SupportedFeatureConfig>;
@@ -482,6 +508,89 @@ function installFeatureGetter(
482
508
  });
483
509
  }
484
510
 
511
+ /**
512
+ * Valid lifecycle callback names that can be forwarded to features.
513
+ */
514
+ const VALID_CALLBACKS = new Set([
515
+ 'connectedCallback',
516
+ 'disconnectedCallback',
517
+ 'attributeChangedCallback',
518
+ 'adoptedCallback'
519
+ ]);
520
+
521
+ /**
522
+ * WeakMap tracking which callbacks have been patched on which constructors,
523
+ * and which feature keys are registered for each callback.
524
+ * Structure: Map<Function, Map<callbackName, Set<featureKey>>>
525
+ */
526
+ const callbackRegistry = new Map<Function, Map<string, Set<string>>>();
527
+
528
+ /**
529
+ * Installs or updates lifecycle callback forwarding on a constructor's prototype.
530
+ * Patches the callback once per type, accumulating feature keys for each.
531
+ */
532
+ function installCallbackForwarding(
533
+ ctr: Function,
534
+ key: string,
535
+ callbacks: string[]
536
+ ): void {
537
+ let ctrCallbacks = callbackRegistry.get(ctr);
538
+ if (!ctrCallbacks) {
539
+ ctrCallbacks = new Map();
540
+ callbackRegistry.set(ctr, ctrCallbacks);
541
+ }
542
+
543
+ for (const callbackName of callbacks) {
544
+ if (!VALID_CALLBACKS.has(callbackName)) {
545
+ throw new Error(
546
+ `assignFeatures: invalid callbackForwarding "${callbackName}" for feature "${key}". ` +
547
+ `Valid values: ${[...VALID_CALLBACKS].join(', ')}`
548
+ );
549
+ }
550
+
551
+ // Validate that the spawn class has the method (sync spawners only)
552
+ // For async spawners, validation is deferred to runtime
553
+
554
+ let featureKeys = ctrCallbacks.get(callbackName);
555
+ if (!featureKeys) {
556
+ featureKeys = new Set();
557
+ ctrCallbacks.set(callbackName, featureKeys);
558
+
559
+ // Patch the prototype callback (only once per callback type per class)
560
+ const original = ctr.prototype[callbackName];
561
+
562
+ Object.defineProperty(ctr.prototype, callbackName, {
563
+ value: function (this: any, ...args: any[]) {
564
+ // Call original first
565
+ if (original) original.apply(this, args);
566
+
567
+ // Forward to all registered features
568
+ const keys = callbackRegistry.get(ctr)?.get(callbackName);
569
+ if (keys) {
570
+ for (const featureKey of keys) {
571
+ // Access the getter (triggers lazy spawn on first connectedCallback)
572
+ const feature = this[featureKey];
573
+
574
+ // Only forward if it's a real instance (not a placeholder or error)
575
+ if (feature && typeof feature === 'object' &&
576
+ typeof feature[callbackName] === 'function' &&
577
+ !(FEATURE_ERROR in feature)) {
578
+ feature[callbackName](...args);
579
+ }
580
+ }
581
+ }
582
+ },
583
+ writable: true,
584
+ enumerable: false,
585
+ configurable: true
586
+ });
587
+ }
588
+
589
+ // Add this feature key to the set for this callback
590
+ featureKeys.add(key);
591
+ }
592
+ }
593
+
485
594
  /**
486
595
  * Core assignFeatures implementation.
487
596
  * Validates inputs, registers injections, and installs lazy getters.
@@ -536,6 +645,17 @@ export function assignFeatures(
536
645
 
537
646
  // 5. Install the lazy getter on the prototype
538
647
  installFeatureGetter(ctr, key, featuresRegistry);
648
+
649
+ // 6. Install callback forwarding if configured (merge author + consumer)
650
+ const featureConfig = features[key];
651
+ const optIn = supportedFeatures[key];
652
+ const authorCallbacks = optIn.callbackForwarding || [];
653
+ const consumerCallbacks = featureConfig.callbackForwarding || [];
654
+ // Union of both (author defaults + consumer additions)
655
+ const allCallbacks = [...new Set([...authorCallbacks, ...consumerCallbacks])];
656
+ if (allCallbacks.length > 0) {
657
+ installCallbackForwarding(ctr, key, allCallbacks);
658
+ }
539
659
  }
540
660
 
541
661
  // 6. Install whenFeatureReady method if featuresConfig.lifecycleKeys is configured
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "description": "This package provides a utility function for carefully merging one object into another.",
5
5
  "homepage": "https://github.com/bahrus/assign-gingerly#readme",
6
6
  "bugs": {
@@ -87,9 +87,9 @@
87
87
  "chrome": "npx playwright cr http://localhost:8000"
88
88
  },
89
89
  "devDependencies": {
90
- "@playwright/test": "1.59.1",
90
+ "@playwright/test": "1.60.0",
91
91
  "spa-ssi": "0.0.27",
92
- "@types/node": "25.6.2",
92
+ "@types/node": "25.7.0",
93
93
  "typescript": "6.0.3"
94
94
  }
95
95
  }