assign-gingerly 0.0.39 → 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.
package/README.md CHANGED
@@ -4615,6 +4615,103 @@ 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:
4621
+
4622
+ ```JavaScript
4623
+ customElements.assignFeatures(MyElement, {
4624
+ reflector: {
4625
+ spawn: Reflector,
4626
+ callbackForwarding: ['connectedCallback', 'disconnectedCallback']
4627
+ }
4628
+ });
4629
+ ```
4630
+
4631
+ 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.
4632
+
4633
+ **How it works:**
4634
+
4635
+ 1. `assignFeatures` patches the custom element's lifecycle callback on the prototype (once per callback type).
4636
+ 2. The original callback runs first, then all registered features are forwarded.
4637
+ 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).
4638
+ 4. For async features, forwarding is skipped until the real instance is available.
4639
+
4640
+ **Supported callbacks:**
4641
+
4642
+ | Callback | Use case |
4643
+ |----------|----------|
4644
+ | `connectedCallback` | Feature needs DOM context (computed styles, layout, etc.) |
4645
+ | `disconnectedCallback` | Feature needs cleanup (remove listeners, abort fetches) |
4646
+ | `attributeChangedCallback` | Feature reacts to attribute changes (limited to element's `observedAttributes`) |
4647
+ | `adoptedCallback` | Feature reacts to document adoption |
4648
+
4649
+ **Example: Feature that reads computed styles on connect**
4650
+
4651
+ ```JavaScript
4652
+ class Reflector {
4653
+ constructor(host, ctx) {
4654
+ this.host = host;
4655
+ this.internals = ctx.shared.internals;
4656
+ }
4657
+
4658
+ connectedCallback() {
4659
+ // Safe to call getComputedStyle here — element is in the DOM
4660
+ const styles = getComputedStyle(this.host);
4661
+ const exports = styles.getPropertyValue('--custom-state-exports');
4662
+ // ... process exports
4663
+ }
4664
+
4665
+ disconnectedCallback() {
4666
+ // Cleanup
4667
+ }
4668
+ }
4669
+
4670
+ class MyElement extends HTMLElement {
4671
+ #internals;
4672
+ static supportedFeatures = {
4673
+ reflector: {
4674
+ fallbackSpawn: Reflector,
4675
+ getSharedContext(instance) {
4676
+ return { internals: instance.#internals };
4677
+ }
4678
+ }
4679
+ }
4680
+ constructor() {
4681
+ super();
4682
+ this.#internals = this.attachInternals();
4683
+ }
4684
+ }
4685
+
4686
+ customElements.assignFeatures(MyElement, {
4687
+ reflector: {
4688
+ spawn: Reflector,
4689
+ callbackForwarding: ['connectedCallback', 'disconnectedCallback']
4690
+ }
4691
+ });
4692
+ customElements.define('my-element', MyElement);
4693
+ ```
4694
+
4695
+ No manual getter access or `connectedCallback` boilerplate needed — the feature activates at the right time automatically.
4696
+
4697
+ **Multiple features with callbacks:**
4698
+
4699
+ ```JavaScript
4700
+ customElements.assignFeatures(MyElement, {
4701
+ reflector: {
4702
+ spawn: Reflector,
4703
+ callbackForwarding: ['connectedCallback']
4704
+ },
4705
+ logger: {
4706
+ spawn: Logger,
4707
+ callbackForwarding: ['connectedCallback', 'disconnectedCallback']
4708
+ }
4709
+ });
4710
+ // Both features receive connectedCallback; only logger receives disconnectedCallback
4711
+ ```
4712
+
4713
+ **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.
4714
+
4618
4715
  ### Roadmap (future phases)
4619
4716
 
4620
4717
  - **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,11 @@ 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
405
+ const featureConfig = features[key];
406
+ if (featureConfig.callbackForwarding && featureConfig.callbackForwarding.length > 0) {
407
+ installCallbackForwarding(ctr, key, featureConfig.callbackForwarding);
408
+ }
337
409
  }
338
410
  // 6. Install whenFeatureReady method if featuresConfig.lifecycleKeys is configured
339
411
  const featuresConfig = ctr.featuresConfig;
package/assignFeatures.ts CHANGED
@@ -133,6 +133,22 @@ export interface FeatureConfig {
133
133
  * via ctx.injection.customData in the constructor.
134
134
  */
135
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[];
136
152
  }
137
153
 
138
154
  export type SupportedFeaturesMap = Record<string, SupportedFeatureConfig>;
@@ -482,6 +498,89 @@ function installFeatureGetter(
482
498
  });
483
499
  }
484
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
+
485
584
  /**
486
585
  * Core assignFeatures implementation.
487
586
  * Validates inputs, registers injections, and installs lazy getters.
@@ -536,6 +635,12 @@ export function assignFeatures(
536
635
 
537
636
  // 5. Install the lazy getter on the prototype
538
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
+ }
539
644
  }
540
645
 
541
646
  // 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.40",
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
  }