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 +108 -0
- package/assignFeatures.js +77 -0
- package/assignFeatures.ts +120 -0
- package/package.json +3 -3
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.
|
|
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.
|
|
90
|
+
"@playwright/test": "1.60.0",
|
|
91
91
|
"spa-ssi": "0.0.27",
|
|
92
|
-
"@types/node": "25.
|
|
92
|
+
"@types/node": "25.7.0",
|
|
93
93
|
"typescript": "6.0.3"
|
|
94
94
|
}
|
|
95
95
|
}
|