assign-gingerly 0.0.38 → 0.0.39
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 +897 -11
- package/assignFeatures.js +457 -0
- package/assignFeatures.ts +683 -0
- package/assignGingerly.js +96 -3
- package/assignGingerly.ts +122 -3
- package/evaluatePathWithAsyncMethods.js +90 -0
- package/evaluatePathWithAsyncMethods.ts +102 -0
- package/index.js +3 -1
- package/index.ts +3 -1
- package/installForwarding.js +61 -0
- package/installForwarding.ts +71 -0
- package/package.json +14 -2
- package/resolveValues.js +62 -2
- package/resolveValues.ts +71 -2
- package/types/assign-gingerly/types.d.ts +139 -0
package/README.md
CHANGED
|
@@ -33,13 +33,9 @@ On top of that, this polyfill package builds on the newly minted Custom Element
|
|
|
33
33
|
|
|
34
34
|
2. [itemscopeRegistry for Itemscope Managers](#itemscoperegistry) to automatically associate a function prototype or class instance with the itemscope attribute of an HTMLElement.
|
|
35
35
|
|
|
36
|
-
3.
|
|
36
|
+
3. [featuresRegistry for Custom Element Features](#custom-element-features) to support dependency injection of composable feature classes or function prototypes onto custom element prototypes via lazy getters.
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
So in our view this package helps fill the void left by not supporting the "is" attribute for built-in elements (but is not a complete solution, just a critical building block). Mount-observer, mount-observer-script-element, and custom enhancements builds on top of the critical role that assign-gingerly plays.
|
|
41
|
-
|
|
42
|
-
5. Iterator upgrade support [TODO] -- limited to ish?
|
|
38
|
+
So in our view this package helps fill the void left by not supporting the "is" attribute for built-in elements (but is not a complete solution, just a critical building block). Mount-observer and custom enhancements builds on top of the critical role that assign-gingerly plays.
|
|
43
39
|
|
|
44
40
|
Anyway, let's start out detailing the more innocent features of this package / polyfill.
|
|
45
41
|
|
|
@@ -54,11 +50,11 @@ assign-gingerly adds support for:
|
|
|
54
50
|
1. Carefully merging in nested properties.
|
|
55
51
|
2. Dependency injection based on a mapping protocol.
|
|
56
52
|
|
|
57
|
-
|
|
53
|
+
The second fundamental utility function is:
|
|
58
54
|
|
|
59
55
|
## assignTentatively
|
|
60
56
|
|
|
61
|
-
assignTentatively provides a far more limited subset of functionality compared to assignGingerly. The tradeoff is that assignTentatively can do something important assignGingerly cannot do -- be "reversed". This can be quite useful for some scenarios. Think of how css "turns on" visual effects while conditions are met, then reverts to how things were before the conditions were met when
|
|
57
|
+
assignTentatively provides a far more limited subset of functionality compared to assignGingerly. The tradeoff is that assignTentatively can do something important assignGingerly cannot do -- be "reversed". This can be quite useful for some scenarios. Think of how css "turns on" visual effects while conditions are met, then reverts to how things were before the conditions were met when the conditions are no longer met, as if nothing happened. Another example is allowing user edits to be rolled back as they repeatedly hit "ctrl+z".
|
|
62
58
|
|
|
63
59
|
## Example 1 - assignGingerly as a "superset" of Object.assign:
|
|
64
60
|
|
|
@@ -110,7 +106,7 @@ console.log(obj);
|
|
|
110
106
|
|
|
111
107
|
When the right hand side of an expression is an object, assignGingerly behavior depends on the context:
|
|
112
108
|
- For **nested paths** (starting with `?.`): recursively merges into nested objects, creating them if needed
|
|
113
|
-
- For **plain keys**: performs simple assignment (like `Object.assign`), unless the target property is readonly
|
|
109
|
+
- For **plain keys**: performs simple assignment (like `Object.assign`), unless the target property is readonly, an accessor, or the current value's class defines [`static assignTo`](#custom-assignment-with-static-assignto-protocol) (see Examples 3a, 3b, and the assignTo section below)
|
|
114
110
|
|
|
115
111
|
Of course, just as Object.assign led to object spread notation, assignGingerly could lead to some sort of deep structural JavaScript syntax, but that is outside the scope of this polyfill package.
|
|
116
112
|
|
|
@@ -227,7 +223,7 @@ assignGingerly(config, {
|
|
|
227
223
|
console.log(config.settings.theme); // 'dark'
|
|
228
224
|
```
|
|
229
225
|
|
|
230
|
-
## Example 3b - Class Instances Are Replaced
|
|
226
|
+
## Example 3b - Class Instances Are Normally Replaced
|
|
231
227
|
|
|
232
228
|
Unlike readonly/accessor properties, class instances on writable properties are **replaced** by simple assignment, just like plain objects. This allows you to swap one object for another without unexpected merging:
|
|
233
229
|
|
|
@@ -257,10 +253,31 @@ console.log(obj.clone === element); // true - replaced, not merged
|
|
|
257
253
|
|
|
258
254
|
In real-world use cases, you often need to replace one object with another of a completely different type. For example, replacing a cloned DocumentFragment with the actual web component element. Automatic merging would corrupt the target by mixing properties from incompatible types.
|
|
259
255
|
|
|
256
|
+
**Exception: classes with `static assignTo`**
|
|
257
|
+
|
|
258
|
+
If the current value is an instance of a class that defines [`static assignTo`](#custom-assignment-with-static-assignto-protocol), that method is called instead of replacing. This allows classes to opt into custom assignment behavior (e.g., reactive models, validated records, iterable collections with private lists):
|
|
259
|
+
|
|
260
|
+
```TypeScript
|
|
261
|
+
class TodoList {
|
|
262
|
+
#items = [];
|
|
263
|
+
*[Symbol.iterator]() { yield* this.#items; }
|
|
264
|
+
static assignTo(instance, rhs) {
|
|
265
|
+
if (Array.isArray(rhs)) instance.#items = [...rhs];
|
|
266
|
+
else Object.assign(instance, rhs);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const app = { todos: new TodoList() };
|
|
271
|
+
assignGingerly(app, { todos: ['Buy milk', 'Walk dog'] });
|
|
272
|
+
// TodoList.assignTo is called — replaces internal list, not the instance
|
|
273
|
+
console.log([...app.todos]); // ['Buy milk', 'Walk dog']
|
|
274
|
+
console.log(app.todos instanceof TodoList); // true — instance preserved
|
|
275
|
+
```
|
|
276
|
+
|
|
260
277
|
**Readonly/accessor properties are still merged:**
|
|
261
278
|
|
|
262
279
|
The distinction is clear:
|
|
263
|
-
- **Writable data properties**:
|
|
280
|
+
- **Writable data properties**: replaced (unless class defines `static assignTo`)
|
|
264
281
|
- **Readonly data properties** (`writable: false`): merged into
|
|
265
282
|
- **Getter-only accessor properties** (no setter): merged into
|
|
266
283
|
- **Getter+setter accessor properties** (e.g., `style`): setter runs with the value as-is
|
|
@@ -3325,6 +3342,232 @@ assignFrom(target, {
|
|
|
3325
3342
|
|
|
3326
3343
|
For full documentation, see [docs/assignFrom.md](docs/assignFrom.md).
|
|
3327
3344
|
|
|
3345
|
+
## Custom Assignment with `static assignTo` Protocol
|
|
3346
|
+
|
|
3347
|
+
Classes can opt into custom assignment behavior by defining a `static assignTo` method. When `assignGingerly` encounters a property whose current value is an instance of such a class, it delegates the assignment to `assignTo` instead of performing the default merge/replace logic.
|
|
3348
|
+
|
|
3349
|
+
```JavaScript
|
|
3350
|
+
class ReactiveModel {
|
|
3351
|
+
#data = {};
|
|
3352
|
+
#listeners = [];
|
|
3353
|
+
|
|
3354
|
+
static assignTo(instance, rhs, parent, key) {
|
|
3355
|
+
// Custom merge: trigger reactive notifications
|
|
3356
|
+
for (const [k, v] of Object.entries(rhs)) {
|
|
3357
|
+
instance.#data[k] = v;
|
|
3358
|
+
instance.#listeners.forEach(fn => fn(k, v));
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
get(prop) { return this.#data[prop]; }
|
|
3363
|
+
onChange(fn) { this.#listeners.push(fn); }
|
|
3364
|
+
}
|
|
3365
|
+
```
|
|
3366
|
+
|
|
3367
|
+
### How it works
|
|
3368
|
+
|
|
3369
|
+
When `assignGingerly` is about to assign a value to a property, it checks if the property's current value is an object whose constructor defines `static assignTo`. If so, it calls `assignTo` instead of the default behavior:
|
|
3370
|
+
|
|
3371
|
+
```JavaScript
|
|
3372
|
+
const obj = { model: new ReactiveModel() };
|
|
3373
|
+
|
|
3374
|
+
assignGingerly(obj, { model: { name: 'Alice', age: 30 } });
|
|
3375
|
+
// Instead of replacing or merging, calls:
|
|
3376
|
+
// ReactiveModel.assignTo(obj.model, { name: 'Alice', age: 30 }, obj, 'model')
|
|
3377
|
+
```
|
|
3378
|
+
|
|
3379
|
+
### Parameters
|
|
3380
|
+
|
|
3381
|
+
```TypeScript
|
|
3382
|
+
class MyClass {
|
|
3383
|
+
static assignTo(
|
|
3384
|
+
instance: MyClass, // The current value (instance of this class)
|
|
3385
|
+
rhs: any, // The value being assigned (from the RHS of assignGingerly)
|
|
3386
|
+
parent: any, // The parent object containing the property
|
|
3387
|
+
key: string|symbol // The property key being assigned to
|
|
3388
|
+
) { ... }
|
|
3389
|
+
}
|
|
3390
|
+
```
|
|
3391
|
+
|
|
3392
|
+
The `parent` and `key` parameters allow `assignTo` to replace the instance entirely if needed (e.g., for immutable patterns):
|
|
3393
|
+
|
|
3394
|
+
```JavaScript
|
|
3395
|
+
class ImmutableState {
|
|
3396
|
+
#data;
|
|
3397
|
+
constructor(data) { this.#data = Object.freeze({ ...data }); }
|
|
3398
|
+
|
|
3399
|
+
static assignTo(instance, rhs, parent, key) {
|
|
3400
|
+
// Replace with a new immutable instance
|
|
3401
|
+
parent[key] = new ImmutableState({ ...instance.#data, ...rhs });
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
```
|
|
3405
|
+
|
|
3406
|
+
### Use case: Iterable classes with private lists
|
|
3407
|
+
|
|
3408
|
+
A class that is iterable over a private list can use `assignTo` to handle array assignment (replacing the list) vs object assignment (merging properties):
|
|
3409
|
+
|
|
3410
|
+
```JavaScript
|
|
3411
|
+
class TodoList {
|
|
3412
|
+
#items = [];
|
|
3413
|
+
|
|
3414
|
+
*[Symbol.iterator]() { yield* this.#items; }
|
|
3415
|
+
|
|
3416
|
+
static assignTo(instance, rhs) {
|
|
3417
|
+
if (Array.isArray(rhs)) {
|
|
3418
|
+
// Replace the private list
|
|
3419
|
+
instance.#items = [...rhs];
|
|
3420
|
+
} else if (typeof rhs === 'object' && rhs !== null) {
|
|
3421
|
+
// Merge properties normally
|
|
3422
|
+
Object.assign(instance, rhs);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
const app = { todos: new TodoList() };
|
|
3428
|
+
|
|
3429
|
+
// Assign an array — replaces the internal list
|
|
3430
|
+
assignGingerly(app, { todos: ['Buy milk', 'Walk dog'] });
|
|
3431
|
+
console.log([...app.todos]); // ['Buy milk', 'Walk dog']
|
|
3432
|
+
|
|
3433
|
+
// Assign an object — merges properties
|
|
3434
|
+
assignGingerly(app, { todos: { title: 'My Todos' } });
|
|
3435
|
+
console.log(app.todos.title); // 'My Todos'
|
|
3436
|
+
console.log([...app.todos]); // ['Buy milk', 'Walk dog'] (list unchanged)
|
|
3437
|
+
```
|
|
3438
|
+
|
|
3439
|
+
### Use case: Validation on assignment
|
|
3440
|
+
|
|
3441
|
+
```JavaScript
|
|
3442
|
+
class TypedRecord {
|
|
3443
|
+
static schema = { name: 'string', age: 'number' };
|
|
3444
|
+
|
|
3445
|
+
static assignTo(instance, rhs) {
|
|
3446
|
+
for (const [k, v] of Object.entries(rhs)) {
|
|
3447
|
+
const expected = TypedRecord.schema[k];
|
|
3448
|
+
if (expected && typeof v !== expected) {
|
|
3449
|
+
throw new TypeError(`${k} must be ${expected}, got ${typeof v}`);
|
|
3450
|
+
}
|
|
3451
|
+
instance[k] = v;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
```
|
|
3456
|
+
|
|
3457
|
+
### Private field access
|
|
3458
|
+
|
|
3459
|
+
Because `assignTo` is a static method defined in the class body, it has full access to `#private` fields:
|
|
3460
|
+
|
|
3461
|
+
```JavaScript
|
|
3462
|
+
class SecureStore {
|
|
3463
|
+
#secrets = {};
|
|
3464
|
+
|
|
3465
|
+
static assignTo(instance, rhs) {
|
|
3466
|
+
// Can read/write private fields
|
|
3467
|
+
for (const [k, v] of Object.entries(rhs)) {
|
|
3468
|
+
instance.#secrets[k] = encrypt(v);
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
```
|
|
3473
|
+
|
|
3474
|
+
### Safety
|
|
3475
|
+
|
|
3476
|
+
Only classes that explicitly define their own `assignTo` are affected. The check uses `Object.hasOwn(constructor, 'assignTo')` to prevent accidental inheritance — plain objects, arrays, and classes without `assignTo` use the default assignGingerly behavior.
|
|
3477
|
+
|
|
3478
|
+
## Property Forwarding with `installForwarding`
|
|
3479
|
+
|
|
3480
|
+
`installForwarding` installs getter/setter pairs on a class prototype that delegate to nested paths on the instance. This is useful for exposing deeply nested properties at the top level of an object — particularly for custom elements that delegate behavior to compositional feature classes.
|
|
3481
|
+
|
|
3482
|
+
```JavaScript
|
|
3483
|
+
import { installForwarding } from 'assign-gingerly/installForwarding.js';
|
|
3484
|
+
```
|
|
3485
|
+
|
|
3486
|
+
### Basic usage
|
|
3487
|
+
|
|
3488
|
+
```JavaScript
|
|
3489
|
+
class ClubMember extends HTMLElement {
|
|
3490
|
+
static propLinks = {
|
|
3491
|
+
'command': '?.behaviors?.commandBehavior?.command',
|
|
3492
|
+
'commandForElement': '?.behaviors?.commandBehavior?.commandForElement'
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
installForwarding(ClubMember);
|
|
3497
|
+
|
|
3498
|
+
const el = document.createElement('club-member');
|
|
3499
|
+
el.command = 'toggle';
|
|
3500
|
+
// Equivalent to: assignGingerly(el, { '?.behaviors?.commandBehavior?.command': 'toggle' })
|
|
3501
|
+
|
|
3502
|
+
console.log(el.command);
|
|
3503
|
+
// Equivalent to: resolveValue('?.behaviors?.commandBehavior?.command', el)
|
|
3504
|
+
```
|
|
3505
|
+
|
|
3506
|
+
### How it works
|
|
3507
|
+
|
|
3508
|
+
1. Reads `static propLinks` from the constructor — a map of top-level property names to `?.`-delimited path strings.
|
|
3509
|
+
2. For each entry, installs a getter/setter pair on the prototype:
|
|
3510
|
+
- **Getter**: uses `resolveValue` to walk the path with optional chaining semantics. Returns `undefined` if any segment is nullish.
|
|
3511
|
+
- **Setter**: uses `assignGingerly` to assign the value at the path, creating intermediate objects as needed.
|
|
3512
|
+
3. Validates that forwarded property names don't already exist on the prototype (throws if they do).
|
|
3513
|
+
|
|
3514
|
+
### With methods and aliases
|
|
3515
|
+
|
|
3516
|
+
Because the getter uses `resolveValue` and the setter uses `assignGingerly`, you get full access to `withMethods` and `aka` via the options parameter:
|
|
3517
|
+
|
|
3518
|
+
```JavaScript
|
|
3519
|
+
class MyComponent extends HTMLElement {
|
|
3520
|
+
static propLinks = {
|
|
3521
|
+
'username': '?.q?.#user-input?.value'
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
installForwarding(MyComponent, {
|
|
3526
|
+
withMethods: ['querySelector'],
|
|
3527
|
+
aka: { 'q': 'querySelector' }
|
|
3528
|
+
});
|
|
3529
|
+
|
|
3530
|
+
// el.username now resolves:
|
|
3531
|
+
// el.querySelector('#user-input').value
|
|
3532
|
+
```
|
|
3533
|
+
|
|
3534
|
+
### Use with Custom Element Features
|
|
3535
|
+
|
|
3536
|
+
A common pattern is forwarding top-level properties to feature instances:
|
|
3537
|
+
|
|
3538
|
+
```JavaScript
|
|
3539
|
+
class CustomButton extends HTMLElement {
|
|
3540
|
+
static supportedFeatures = {
|
|
3541
|
+
commandBehavior: { fallbackSpawn: CommandFeatureImpl }
|
|
3542
|
+
};
|
|
3543
|
+
static propLinks = {
|
|
3544
|
+
'command': '?.commandBehavior?.command',
|
|
3545
|
+
'commandForElement': '?.commandBehavior?.commandForElement'
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
customElements.assignFeatures(CustomButton, {
|
|
3550
|
+
commandBehavior: { spawn: CommandFeatureImpl }
|
|
3551
|
+
});
|
|
3552
|
+
installForwarding(CustomButton);
|
|
3553
|
+
|
|
3554
|
+
// Now el.command delegates to el.commandBehavior.command
|
|
3555
|
+
// The feature getter triggers lazy instantiation automatically
|
|
3556
|
+
```
|
|
3557
|
+
|
|
3558
|
+
### Error conditions
|
|
3559
|
+
|
|
3560
|
+
| Condition | Error |
|
|
3561
|
+
|-----------|-------|
|
|
3562
|
+
| Property already exists on prototype | `"already exists on Constructor.prototype"` |
|
|
3563
|
+
| Path doesn't start with `?.` | `"path must start with '?.'"` |
|
|
3564
|
+
|
|
3565
|
+
### Performance
|
|
3566
|
+
|
|
3567
|
+
- Paths are cached after first parse — repeated getter/setter calls don't re-split strings.
|
|
3568
|
+
- The getter uses `resolveValue` (a lightweight single-path resolver with caching).
|
|
3569
|
+
- The setter uses `assignGingerly` which also benefits from path caching.
|
|
3570
|
+
|
|
3328
3571
|
## Itemscope Managers (Chrome 146+)
|
|
3329
3572
|
|
|
3330
3573
|
Itemscope Managers provide a way to manage DOM fragments and their associated data/view models for elements with the `itemscope` attribute. This feature enables frameworks and libraries to manage light children of web components, DOM fragments from looping constructs, and scenarios where custom element wrapping is not feasible.
|
|
@@ -3733,3 +3976,646 @@ ItemScope Managers follow these design principles:
|
|
|
3733
3976
|
|
|
3734
3977
|
This design ensures backward compatibility while providing powerful new capabilities for managing DOM fragments.
|
|
3735
3978
|
|
|
3979
|
+
|
|
3980
|
+
## Custom Element Features
|
|
3981
|
+
|
|
3982
|
+
Custom Element Features provide dependency injection for custom elements (and other objects). A custom element author declares which feature "slots" their class supports, and consumers inject implementations into those slots. Features are lazily instantiated on first property access.
|
|
3983
|
+
|
|
3984
|
+
To use features, import the module directly — it is independent of `object-extension.js`:
|
|
3985
|
+
|
|
3986
|
+
```JavaScript
|
|
3987
|
+
import 'assign-gingerly/assignFeatures.js';
|
|
3988
|
+
```
|
|
3989
|
+
|
|
3990
|
+
This self-installs `featuresRegistry` and `assignFeatures()` on `CustomElementRegistry.prototype`. It does not require or pull in the enhancement/itemscope registries.
|
|
3991
|
+
|
|
3992
|
+
This is useful for:
|
|
3993
|
+
|
|
3994
|
+
- **Decomposing large components** into smaller, testable units (e.g., a photo-taking feature, a badge-making feature).
|
|
3995
|
+
- **Mocking in tests** — swap real implementations for test doubles without subclassing.
|
|
3996
|
+
- **Reusing behaviors** across different custom elements without mixins.
|
|
3997
|
+
- **Lazy loading** — feature code isn't executed until the property is actually accessed.
|
|
3998
|
+
|
|
3999
|
+
### How it works
|
|
4000
|
+
|
|
4001
|
+
1. The custom element declares `static supportedFeatures` — an opt-in map of feature keys and their configuration.
|
|
4002
|
+
2. A consumer calls `customElements.assignFeatures(Constructor, injections)` to register implementations.
|
|
4003
|
+
3. Lazy getter-only properties are installed on the constructor's prototype.
|
|
4004
|
+
4. On first access, the getter spawns the feature instance, validates it (optionally), caches it, and returns it.
|
|
4005
|
+
5. Because the property is getter-only (no setter), `assignGingerly` automatically merges into the spawned instance when assigning object values to that property.
|
|
4006
|
+
|
|
4007
|
+
### Basic example
|
|
4008
|
+
|
|
4009
|
+
```JavaScript
|
|
4010
|
+
import 'assign-gingerly/assignFeatures.js';
|
|
4011
|
+
|
|
4012
|
+
// 1. Define a feature implementation
|
|
4013
|
+
class PhotoTakerImpl {
|
|
4014
|
+
constructor(hostElement) {
|
|
4015
|
+
this.host = hostElement;
|
|
4016
|
+
}
|
|
4017
|
+
takePicture() {
|
|
4018
|
+
return `📸 taken by ${this.host.localName}`;
|
|
4019
|
+
}
|
|
4020
|
+
someProp = 'default';
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
// 2. Define the custom element with supported feature slots
|
|
4024
|
+
class ClubMember extends HTMLElement {
|
|
4025
|
+
static supportedFeatures = {
|
|
4026
|
+
photoTaker: {
|
|
4027
|
+
// Used if no spawn is provided in assignFeatures
|
|
4028
|
+
fallbackSpawn: PhotoTakerImpl,
|
|
4029
|
+
// Optional runtime check on the spawned instance
|
|
4030
|
+
validateShape(instance) {
|
|
4031
|
+
return typeof instance.takePicture === 'function';
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
// 3. Inject features before define (getters must be on prototype before instances exist)
|
|
4038
|
+
customElements.assignFeatures(ClubMember, {
|
|
4039
|
+
photoTaker: {
|
|
4040
|
+
spawn: PhotoTakerImpl
|
|
4041
|
+
}
|
|
4042
|
+
});
|
|
4043
|
+
|
|
4044
|
+
customElements.define('club-member', ClubMember);
|
|
4045
|
+
|
|
4046
|
+
// 4. Use it — lazy instantiation on first access
|
|
4047
|
+
const el = document.createElement('club-member');
|
|
4048
|
+
console.log(el.photoTaker.takePicture()); // '📸 taken by club-member'
|
|
4049
|
+
|
|
4050
|
+
// 5. assignGingerly merges into the feature instance automatically
|
|
4051
|
+
el.assignGingerly({
|
|
4052
|
+
photoTaker: { someProp: 'hello' }
|
|
4053
|
+
});
|
|
4054
|
+
console.log(el.photoTaker.someProp); // 'hello'
|
|
4055
|
+
```
|
|
4056
|
+
|
|
4057
|
+
### Using fallbackSpawn (no explicit injection needed)
|
|
4058
|
+
|
|
4059
|
+
If `fallbackSpawn` is provided in `supportedFeatures`, you can call `assignFeatures` with an empty spawn — or even just `{}` — and the fallback will be used:
|
|
4060
|
+
|
|
4061
|
+
```JavaScript
|
|
4062
|
+
class ClubMember extends HTMLElement {
|
|
4063
|
+
static supportedFeatures = {
|
|
4064
|
+
photoTaker: {
|
|
4065
|
+
fallbackSpawn: PhotoTakerImpl
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
customElements.define('club-member', ClubMember);
|
|
4071
|
+
|
|
4072
|
+
// No spawn provided — will use fallbackSpawn
|
|
4073
|
+
customElements.assignFeatures(ClubMember, {
|
|
4074
|
+
photoTaker: {}
|
|
4075
|
+
});
|
|
4076
|
+
|
|
4077
|
+
const el = document.createElement('club-member');
|
|
4078
|
+
console.log(el.photoTaker.takePicture()); // works via fallbackSpawn
|
|
4079
|
+
```
|
|
4080
|
+
|
|
4081
|
+
### Testing with mocks
|
|
4082
|
+
|
|
4083
|
+
```JavaScript
|
|
4084
|
+
class PhotoTakerMock {
|
|
4085
|
+
constructor(hostElement) {
|
|
4086
|
+
this.host = hostElement;
|
|
4087
|
+
this.calls = [];
|
|
4088
|
+
}
|
|
4089
|
+
takePicture() {
|
|
4090
|
+
this.calls.push('takePicture');
|
|
4091
|
+
return 'mock click';
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
// In test setup:
|
|
4096
|
+
customElements.assignFeatures(ClubMember, {
|
|
4097
|
+
photoTaker: { spawn: PhotoTakerMock }
|
|
4098
|
+
});
|
|
4099
|
+
|
|
4100
|
+
const el = document.createElement('club-member');
|
|
4101
|
+
el.photoTaker.takePicture();
|
|
4102
|
+
console.log(el.photoTaker.calls); // ['takePicture']
|
|
4103
|
+
```
|
|
4104
|
+
|
|
4105
|
+
### Multiple features
|
|
4106
|
+
|
|
4107
|
+
```JavaScript
|
|
4108
|
+
class BadgeMakerImpl {
|
|
4109
|
+
constructor(hostElement) {
|
|
4110
|
+
this.host = hostElement;
|
|
4111
|
+
}
|
|
4112
|
+
makeBadge(name) {
|
|
4113
|
+
return `🎫 ${name}`;
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
class ClubMember extends HTMLElement {
|
|
4118
|
+
static supportedFeatures = {
|
|
4119
|
+
photoTaker: { fallbackSpawn: PhotoTakerImpl },
|
|
4120
|
+
badgeMaker: { fallbackSpawn: BadgeMakerImpl }
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
customElements.define('club-member', ClubMember);
|
|
4125
|
+
|
|
4126
|
+
// Can assign all at once
|
|
4127
|
+
customElements.assignFeatures(ClubMember, {
|
|
4128
|
+
photoTaker: { spawn: PhotoTakerImpl },
|
|
4129
|
+
badgeMaker: { spawn: BadgeMakerImpl }
|
|
4130
|
+
});
|
|
4131
|
+
|
|
4132
|
+
// Or incrementally (different keys each call)
|
|
4133
|
+
// customElements.assignFeatures(ClubMember, { photoTaker: { spawn: PhotoTakerImpl } });
|
|
4134
|
+
// customElements.assignFeatures(ClubMember, { badgeMaker: { spawn: BadgeMakerImpl } });
|
|
4135
|
+
```
|
|
4136
|
+
|
|
4137
|
+
### Validation
|
|
4138
|
+
|
|
4139
|
+
The `validateShape` callback runs after instantiation. If it returns `false`, an error is thrown:
|
|
4140
|
+
|
|
4141
|
+
```JavaScript
|
|
4142
|
+
class ClubMember extends HTMLElement {
|
|
4143
|
+
static supportedFeatures = {
|
|
4144
|
+
photoTaker: {
|
|
4145
|
+
fallbackSpawn: PhotoTakerImpl,
|
|
4146
|
+
validateShape(instance) {
|
|
4147
|
+
if (typeof instance.takePicture !== 'function') return false;
|
|
4148
|
+
if (typeof instance.someProp !== 'string') return false;
|
|
4149
|
+
return true;
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
```
|
|
4155
|
+
|
|
4156
|
+
### Error conditions
|
|
4157
|
+
|
|
4158
|
+
`assignFeatures` throws in these cases:
|
|
4159
|
+
|
|
4160
|
+
| Condition | Error |
|
|
4161
|
+
|-----------|-------|
|
|
4162
|
+
| Constructor has no `static supportedFeatures` | `"does not define static supportedFeatures"` |
|
|
4163
|
+
| Key not declared in `supportedFeatures` | `"is not declared in Constructor.supportedFeatures"` |
|
|
4164
|
+
| Property already exists on prototype | `"already exists on Constructor.prototype"` |
|
|
4165
|
+
| Same key assigned twice for same constructor | `"has already been assigned for Constructor"` |
|
|
4166
|
+
| No `spawn` provided and no `fallbackSpawn` | `"no spawn implementation found"` (at access time) |
|
|
4167
|
+
| `validateShape` returns false | `"failed shape validation"` (at access time) |
|
|
4168
|
+
|
|
4169
|
+
### Integration with assignGingerly
|
|
4170
|
+
|
|
4171
|
+
Because `assignFeatures` installs **getter-only** properties (no setter), assignGingerly's existing readonly property detection kicks in automatically:
|
|
4172
|
+
|
|
4173
|
+
```JavaScript
|
|
4174
|
+
// assignGingerly detects photoTaker is getter-only
|
|
4175
|
+
// → reads the getter (spawning the instance if needed)
|
|
4176
|
+
// → recursively merges the RHS object into the instance
|
|
4177
|
+
el.assignGingerly({
|
|
4178
|
+
photoTaker: { someProp: 'updated' }
|
|
4179
|
+
});
|
|
4180
|
+
```
|
|
4181
|
+
|
|
4182
|
+
This means no special handling is needed in assignGingerly for features — it "just works."
|
|
4183
|
+
|
|
4184
|
+
### Scoped registry support
|
|
4185
|
+
|
|
4186
|
+
The lazy getter uses `(this.customElementRegistry || customElements)` to resolve the features registry. This means:
|
|
4187
|
+
|
|
4188
|
+
- On Chrome 146+ (and future browsers with scoped registries), the element's scoped `customElementRegistry` is used.
|
|
4189
|
+
- On older browsers, it falls back to the global `customElements`.
|
|
4190
|
+
|
|
4191
|
+
### Not limited to custom elements
|
|
4192
|
+
|
|
4193
|
+
While designed with custom elements in mind, `assignFeatures` works with any constructor whose instances will have a `customElementRegistry` property (or where the global `customElements` fallback is acceptable). This includes element enhancement classes that set `this.customElementRegistry` from the element they enhance.
|
|
4194
|
+
|
|
4195
|
+
### API reference
|
|
4196
|
+
|
|
4197
|
+
```TypeScript
|
|
4198
|
+
// On CustomElementRegistry.prototype:
|
|
4199
|
+
customElements.assignFeatures(
|
|
4200
|
+
ctr: Function, // The class constructor
|
|
4201
|
+
features: FeatureConfigsMap // Map of feature keys to FeatureConfig
|
|
4202
|
+
): void;
|
|
4203
|
+
|
|
4204
|
+
// FeatureConfig — passed to assignFeatures for each feature key:
|
|
4205
|
+
interface FeatureConfig {
|
|
4206
|
+
// Synchronous constructor or async function returning one
|
|
4207
|
+
spawn?:
|
|
4208
|
+
| { new(hostElement: any, ctx: FeatureSpawnContext, initVals?: any): any }
|
|
4209
|
+
| (() => Promise<{ new(hostElement: any, ctx: FeatureSpawnContext, initVals?: any): any }>);
|
|
4210
|
+
|
|
4211
|
+
// Attribute patterns for parsing element attributes into initVals
|
|
4212
|
+
withAttrs?: AttrPatterns<any>;
|
|
4213
|
+
|
|
4214
|
+
// Pass-through field for custom configuration (accessible via ctx.injection.customData)
|
|
4215
|
+
customData?: any;
|
|
4216
|
+
}
|
|
4217
|
+
|
|
4218
|
+
// SupportedFeatureConfig — declared on the class via static supportedFeatures:
|
|
4219
|
+
interface SupportedFeatureConfig {
|
|
4220
|
+
fallbackSpawn?: /* same type as FeatureConfig.spawn */;
|
|
4221
|
+
validateShape?: (instance: any) => boolean;
|
|
4222
|
+
lifecycleKeys?: true | { whenFeatureReady?: string };
|
|
4223
|
+
getSharedContext?: (instance: any) => any;
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
// Context passed to feature constructors:
|
|
4227
|
+
interface FeatureSpawnContext {
|
|
4228
|
+
key: string; // The feature key (e.g., 'photoTaker')
|
|
4229
|
+
optIn: SupportedFeatureConfig; // The config from static supportedFeatures
|
|
4230
|
+
injection: FeatureConfig; // The config from assignFeatures()
|
|
4231
|
+
featuresRegistry: FeaturesRegistry; // The registry reference
|
|
4232
|
+
shared?: any; // From getSharedContext (if defined)
|
|
4233
|
+
}
|
|
4234
|
+
```
|
|
4235
|
+
|
|
4236
|
+
### Constructor signature
|
|
4237
|
+
|
|
4238
|
+
Feature classes receive three arguments:
|
|
4239
|
+
|
|
4240
|
+
```JavaScript
|
|
4241
|
+
class MyFeature {
|
|
4242
|
+
constructor(hostElement, ctx, initVals) {
|
|
4243
|
+
// hostElement: the element instance that owns this feature
|
|
4244
|
+
// ctx: { key, optIn, injection, featuresRegistry, shared }
|
|
4245
|
+
// initVals: any pre-set value captured before the feature was spawned (or undefined)
|
|
4246
|
+
if (initVals) {
|
|
4247
|
+
Object.assign(this, initVals);
|
|
4248
|
+
}
|
|
4249
|
+
// Access shared context (e.g., ElementInternals)
|
|
4250
|
+
if (ctx.shared) {
|
|
4251
|
+
this.internals = ctx.shared.internals;
|
|
4252
|
+
}
|
|
4253
|
+
// Access custom data from the FeatureConfig
|
|
4254
|
+
if (ctx.injection.customData) {
|
|
4255
|
+
this.config = ctx.injection.customData;
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
```
|
|
4260
|
+
|
|
4261
|
+
### Pre-upgrade property capture with `captureFeatureInitVals`
|
|
4262
|
+
|
|
4263
|
+
When a custom element exists in the DOM before `customElements.define()` is called, properties may be set on it directly (e.g., by a framework or server-rendered HTML hydration). After the element upgrades, these own-properties shadow the prototype getters installed by `assignFeatures`, preventing the lazy spawn mechanism from working.
|
|
4264
|
+
|
|
4265
|
+
`captureFeatureInitVals` solves this by capturing and deleting those own-properties in the constructor, storing them so the getter can pass them as `initVals` when the feature is first accessed.
|
|
4266
|
+
|
|
4267
|
+
**Usage:**
|
|
4268
|
+
|
|
4269
|
+
```JavaScript
|
|
4270
|
+
import { captureFeatureInitVals } from 'assign-gingerly/assignFeatures.js';
|
|
4271
|
+
|
|
4272
|
+
class ClubMember extends HTMLElement {
|
|
4273
|
+
static supportedFeatures = {
|
|
4274
|
+
photoTaker: { fallbackSpawn: PhotoTakerImpl }
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
constructor() {
|
|
4278
|
+
super();
|
|
4279
|
+
captureFeatureInitVals(this);
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
```
|
|
4283
|
+
|
|
4284
|
+
**What it does:**
|
|
4285
|
+
|
|
4286
|
+
1. Iterates over the keys in `static supportedFeatures`.
|
|
4287
|
+
2. For each key, checks if the instance has an own-property with that name (`Object.hasOwn`).
|
|
4288
|
+
3. If found: captures the value, deletes the own-property, and stores the value internally so the getter can retrieve it as `initVals` when the feature is first accessed.
|
|
4289
|
+
|
|
4290
|
+
**When to use it:**
|
|
4291
|
+
|
|
4292
|
+
- Always include it in the constructor if your element might exist in the DOM before `define()` is called (which is common with server-rendered HTML or lazy-loaded component definitions).
|
|
4293
|
+
- It's safe to call even when no own-properties exist — it simply does nothing.
|
|
4294
|
+
- It's a one-liner with no performance cost when there are no pre-set properties.
|
|
4295
|
+
|
|
4296
|
+
**The full pre-upgrade flow:**
|
|
4297
|
+
|
|
4298
|
+
```JavaScript
|
|
4299
|
+
// 1. Element exists in DOM before define (unknown element)
|
|
4300
|
+
const el = document.createElement('club-member');
|
|
4301
|
+
document.body.appendChild(el);
|
|
4302
|
+
|
|
4303
|
+
// 2. Framework or hydration sets properties
|
|
4304
|
+
el.photoTaker = { someProp: 'hello', count: 42 };
|
|
4305
|
+
|
|
4306
|
+
// 3. Later, the component definition loads
|
|
4307
|
+
customElements.assignFeatures(ClubMember, {
|
|
4308
|
+
photoTaker: { spawn: PhotoTakerImpl }
|
|
4309
|
+
});
|
|
4310
|
+
customElements.define('club-member', ClubMember);
|
|
4311
|
+
// → constructor runs, captureFeatureInitVals captures el.photoTaker value
|
|
4312
|
+
|
|
4313
|
+
// 4. First access spawns with initVals
|
|
4314
|
+
console.log(el.photoTaker.someProp); // 'hello'
|
|
4315
|
+
console.log(el.photoTaker.count); // 42
|
|
4316
|
+
```
|
|
4317
|
+
|
|
4318
|
+
**Important ordering:** Call `assignFeatures` before `customElements.define()`. The getters must be on the prototype before any instances are created or upgraded.
|
|
4319
|
+
|
|
4320
|
+
### Async spawn (lazy-loading features)
|
|
4321
|
+
|
|
4322
|
+
Feature implementations can be loaded asynchronously. Instead of providing a constructor directly, provide a function that returns a Promise resolving to a constructor:
|
|
4323
|
+
|
|
4324
|
+
```JavaScript
|
|
4325
|
+
customElements.assignFeatures(ClubMember, {
|
|
4326
|
+
photoTaker: {
|
|
4327
|
+
spawn: () => import('./photo-taker.js').then(m => m.PhotoTakerImpl)
|
|
4328
|
+
}
|
|
4329
|
+
});
|
|
4330
|
+
```
|
|
4331
|
+
|
|
4332
|
+
**How it works:**
|
|
4333
|
+
|
|
4334
|
+
1. On first access, the getter detects that `spawn` is an async function (arrow function or `async function`).
|
|
4335
|
+
2. It creates a `{}` placeholder object, stores it, and returns it immediately.
|
|
4336
|
+
3. In the background, the async function is called and awaited.
|
|
4337
|
+
4. When the Promise resolves, the real class is instantiated with the placeholder as `initVals` (so any properties merged into the placeholder are passed to the constructor).
|
|
4338
|
+
5. The placeholder is replaced in storage with the real instance.
|
|
4339
|
+
6. Subsequent getter accesses return the real instance.
|
|
4340
|
+
|
|
4341
|
+
**During the loading window:**
|
|
4342
|
+
|
|
4343
|
+
```JavaScript
|
|
4344
|
+
const el = document.createElement('club-member');
|
|
4345
|
+
|
|
4346
|
+
// First access — returns placeholder {}
|
|
4347
|
+
assignGingerly(el, { photoTaker: { someProp: 'hello' } });
|
|
4348
|
+
// Merges into the placeholder: { someProp: 'hello' }
|
|
4349
|
+
|
|
4350
|
+
// Later, after async resolution:
|
|
4351
|
+
console.log(el.photoTaker.someProp); // 'hello' — now on the real instance
|
|
4352
|
+
console.log(el.photoTaker instanceof PhotoTakerImpl); // true
|
|
4353
|
+
```
|
|
4354
|
+
|
|
4355
|
+
**Error handling:**
|
|
4356
|
+
|
|
4357
|
+
If the async import fails, the error is stored. The next getter access throws with the original error attached:
|
|
4358
|
+
|
|
4359
|
+
```JavaScript
|
|
4360
|
+
try {
|
|
4361
|
+
el.photoTaker;
|
|
4362
|
+
} catch (e) {
|
|
4363
|
+
console.log(e.message); // 'assignFeatures: async spawn for "photoTaker" failed: ...'
|
|
4364
|
+
console.log(e.placeholder); // the accumulated placeholder object
|
|
4365
|
+
console.log(e.cause); // the original import/network error
|
|
4366
|
+
}
|
|
4367
|
+
```
|
|
4368
|
+
|
|
4369
|
+
**Detection heuristic:** A function is treated as an async spawner if it's an `AsyncFunction` or has no `.prototype` (arrow functions). Classes and `function` declarations (which have `.prototype`) are treated as synchronous constructors.
|
|
4370
|
+
|
|
4371
|
+
### `whenFeatureReady` lifecycle method
|
|
4372
|
+
|
|
4373
|
+
For code that needs to wait for an async feature to be fully instantiated, configure `lifecycleKeys` on the supported feature:
|
|
4374
|
+
|
|
4375
|
+
```JavaScript
|
|
4376
|
+
class ClubMember extends HTMLElement {
|
|
4377
|
+
static supportedFeatures = {
|
|
4378
|
+
photoTaker: {
|
|
4379
|
+
fallbackSpawn: PhotoTakerImpl,
|
|
4380
|
+
lifecycleKeys: true // installs 'whenFeatureReady' method
|
|
4381
|
+
}
|
|
4382
|
+
};
|
|
4383
|
+
static featuresConfig = {
|
|
4384
|
+
lifecycleKeys: true
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
customElements.assignFeatures(ClubMember, {
|
|
4389
|
+
photoTaker: { spawn: () => import('./photo-taker.js').then(m => m.PhotoTakerImpl) }
|
|
4390
|
+
});
|
|
4391
|
+
|
|
4392
|
+
const el = document.createElement('club-member');
|
|
4393
|
+
|
|
4394
|
+
// Wait for the async feature to be ready
|
|
4395
|
+
const photoTaker = await el.whenFeatureReady('photoTaker');
|
|
4396
|
+
console.log(photoTaker instanceof PhotoTakerImpl); // true
|
|
4397
|
+
```
|
|
4398
|
+
|
|
4399
|
+
**Configuration:**
|
|
4400
|
+
|
|
4401
|
+
- `lifecycleKeys: true` — installs a method named `'whenFeatureReady'` on the prototype.
|
|
4402
|
+
- `lifecycleKeys: { whenFeatureReady: 'awaitFeature' }` — custom method name (in case `whenFeatureReady` conflicts with an existing method).
|
|
4403
|
+
|
|
4404
|
+
**Behavior:**
|
|
4405
|
+
|
|
4406
|
+
- For **synchronous** features: returns `Promise.resolve(instance)` immediately.
|
|
4407
|
+
- For **async** features: returns a Promise that resolves when the async spawn completes and the real instance is stored.
|
|
4408
|
+
- The method triggers the getter (starting async resolution if it hasn't started yet).
|
|
4409
|
+
|
|
4410
|
+
### Attribute parsing with `withAttrs`
|
|
4411
|
+
|
|
4412
|
+
Features can declare attribute patterns to parse element attributes into `initVals`:
|
|
4413
|
+
|
|
4414
|
+
```JavaScript
|
|
4415
|
+
customElements.assignFeatures(ClubMember, {
|
|
4416
|
+
photoTaker: {
|
|
4417
|
+
spawn: PhotoTakerImpl,
|
|
4418
|
+
withAttrs: {
|
|
4419
|
+
base: 'photo',
|
|
4420
|
+
resolution: '${base}-resolution',
|
|
4421
|
+
format: '${base}-format'
|
|
4422
|
+
}
|
|
4423
|
+
}
|
|
4424
|
+
});
|
|
4425
|
+
```
|
|
4426
|
+
|
|
4427
|
+
```HTML
|
|
4428
|
+
<club-member photo-resolution="4k" photo-format="png"></club-member>
|
|
4429
|
+
```
|
|
4430
|
+
|
|
4431
|
+
This parses into `initVals = { resolution: '4k', format: 'png' }`. By default, non-underscore keys are assumed to be strings with `mapsTo` equal to the key name. The `_key` form is only needed to override defaults (e.g., parse as Number, map to a different property name, use a custom parser):
|
|
4432
|
+
|
|
4433
|
+
```JavaScript
|
|
4434
|
+
withAttrs: {
|
|
4435
|
+
base: 'photo',
|
|
4436
|
+
resolution: '${base}-resolution',
|
|
4437
|
+
// Override: parse as Number instead of String
|
|
4438
|
+
_resolution: { instanceOf: 'Number', mapsTo: 'resolutionPx' },
|
|
4439
|
+
format: '${base}-format'
|
|
4440
|
+
// No _format needed — defaults to String, mapsTo: 'format'
|
|
4441
|
+
}
|
|
4442
|
+
```
|
|
4443
|
+
|
|
4444
|
+
**Merge priority (lowest to highest):**
|
|
4445
|
+
1. Attribute-parsed values (`withAttrs`)
|
|
4446
|
+
2. Programmatic `initVals` (from `captureFeatureInitVals` or placeholder accumulation)
|
|
4447
|
+
|
|
4448
|
+
Attributes are always unprefixed for features (no `enh-` prefix). The same `parseWithAttrs` function used by enhancements is reused here.
|
|
4449
|
+
|
|
4450
|
+
### Shared context with `getSharedContext`
|
|
4451
|
+
|
|
4452
|
+
Features often need access to private data from the host element (e.g., `ElementInternals`). The `getSharedContext` callback on `supportedFeatures` provides this:
|
|
4453
|
+
|
|
4454
|
+
```JavaScript
|
|
4455
|
+
class MyButton extends HTMLElement {
|
|
4456
|
+
#internals;
|
|
4457
|
+
#privateState = { clickCount: 0 };
|
|
4458
|
+
|
|
4459
|
+
static supportedFeatures = {
|
|
4460
|
+
commandBehavior: {
|
|
4461
|
+
fallbackSpawn: CommandFeatureImpl,
|
|
4462
|
+
getSharedContext(instance) {
|
|
4463
|
+
// This callback is in the class scope — it can access #private fields
|
|
4464
|
+
return {
|
|
4465
|
+
internals: instance.#internals,
|
|
4466
|
+
state: instance.#privateState
|
|
4467
|
+
};
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
constructor() {
|
|
4473
|
+
super();
|
|
4474
|
+
this.#internals = this.attachInternals();
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
|
|
4478
|
+
class CommandFeatureImpl {
|
|
4479
|
+
constructor(host, ctx, initVals) {
|
|
4480
|
+
// ctx.shared contains what getSharedContext returned
|
|
4481
|
+
this.internals = ctx.shared.internals;
|
|
4482
|
+
this.state = ctx.shared.state;
|
|
4483
|
+
}
|
|
4484
|
+
}
|
|
4485
|
+
```
|
|
4486
|
+
|
|
4487
|
+
**Key points:**
|
|
4488
|
+
- `getSharedContext` is called at spawn time (both sync and async paths).
|
|
4489
|
+
- It's per-feature — different features can receive different slices of private state.
|
|
4490
|
+
- It's opt-in — if not defined, `ctx.shared` is `undefined`.
|
|
4491
|
+
- Because it's defined in the class body, it has access to `#private` fields of instances of that class.
|
|
4492
|
+
|
|
4493
|
+
### Custom data
|
|
4494
|
+
|
|
4495
|
+
The `customData` field on `FeatureConfig` is a pass-through for arbitrary configuration:
|
|
4496
|
+
|
|
4497
|
+
```JavaScript
|
|
4498
|
+
customElements.assignFeatures(ClubMember, {
|
|
4499
|
+
photoTaker: {
|
|
4500
|
+
spawn: PhotoTakerImpl,
|
|
4501
|
+
customData: {
|
|
4502
|
+
maxResolution: '8k',
|
|
4503
|
+
allowedFormats: ['png', 'jpg', 'webp']
|
|
4504
|
+
}
|
|
4505
|
+
}
|
|
4506
|
+
});
|
|
4507
|
+
|
|
4508
|
+
class PhotoTakerImpl {
|
|
4509
|
+
constructor(host, ctx, initVals) {
|
|
4510
|
+
// Access custom data
|
|
4511
|
+
const { maxResolution, allowedFormats } = ctx.injection.customData;
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
```
|
|
4515
|
+
|
|
4516
|
+
### `withAsyncMethods` in assignGingerly
|
|
4517
|
+
|
|
4518
|
+
assignGingerly supports async method calls in path expressions via the `withAsyncMethods` option. This is particularly useful with `whenFeatureReady`:
|
|
4519
|
+
|
|
4520
|
+
```JavaScript
|
|
4521
|
+
import assignGingerly from 'assign-gingerly/assignGingerly.js';
|
|
4522
|
+
|
|
4523
|
+
assignGingerly(el, {
|
|
4524
|
+
'?.whenFeatureReady?.photoTaker?.someProp': 'hello'
|
|
4525
|
+
}, { withAsyncMethods: ['whenFeatureReady'] });
|
|
4526
|
+
// Equivalent to: (await el.whenFeatureReady('photoTaker')).someProp = 'hello'
|
|
4527
|
+
```
|
|
4528
|
+
|
|
4529
|
+
**How it works:**
|
|
4530
|
+
|
|
4531
|
+
- `assignGingerly` remains synchronous — async paths are **fire-and-forget**.
|
|
4532
|
+
- When a path segment matches a name in `withAsyncMethods`, the method is called and its return value is awaited before continuing the chain.
|
|
4533
|
+
- The async path evaluator (`evaluatePathWithAsyncMethods`) is dynamically imported only when needed, so there's no cost to the synchronous path.
|
|
4534
|
+
- `withAsyncMethods` works together with `withMethods` — you can mix sync and async methods in the same path.
|
|
4535
|
+
|
|
4536
|
+
```JavaScript
|
|
4537
|
+
assignGingerly(el, {
|
|
4538
|
+
'?.whenFeatureReady?.photoTaker?.classList?.add': 'active'
|
|
4539
|
+
}, {
|
|
4540
|
+
withAsyncMethods: ['whenFeatureReady'],
|
|
4541
|
+
withMethods: ['add']
|
|
4542
|
+
});
|
|
4543
|
+
// (await el.whenFeatureReady('photoTaker')).classList.add('active')
|
|
4544
|
+
```
|
|
4545
|
+
|
|
4546
|
+
**Note:** Interaction with `@each` and `@eachTime` is not yet supported for async methods.
|
|
4547
|
+
|
|
4548
|
+
### Nested features with `PropertyBag`
|
|
4549
|
+
|
|
4550
|
+
`PropertyBag` is a base class for creating nested feature containers. It groups related features under a single namespace property, enabling hierarchical composition:
|
|
4551
|
+
|
|
4552
|
+
```JavaScript
|
|
4553
|
+
import { PropertyBag, assignFeatures } from 'assign-gingerly/assignFeatures.js';
|
|
4554
|
+
import { installForwarding } from 'assign-gingerly/installForwarding.js';
|
|
4555
|
+
|
|
4556
|
+
// 1. Define a feature container by subclassing PropertyBag
|
|
4557
|
+
class ClubMemberBehaviors extends PropertyBag {
|
|
4558
|
+
static supportedFeatures = {
|
|
4559
|
+
commandBehavior: { fallbackSpawn: CommandFeatureImpl },
|
|
4560
|
+
ariaBehavior: { fallbackSpawn: AriaFeatureImpl }
|
|
4561
|
+
}
|
|
4562
|
+
}
|
|
4563
|
+
|
|
4564
|
+
// 2. Define the custom element with the container as a feature
|
|
4565
|
+
class ClubMember extends HTMLElement {
|
|
4566
|
+
static supportedFeatures = {
|
|
4567
|
+
behaviors: { fallbackSpawn: ClubMemberBehaviors }
|
|
4568
|
+
}
|
|
4569
|
+
// Forward nested properties to the top level
|
|
4570
|
+
static propLinks = {
|
|
4571
|
+
'command': '?.behaviors?.commandBehavior?.command',
|
|
4572
|
+
'commandForElement': '?.behaviors?.commandBehavior?.commandForElement'
|
|
4573
|
+
}
|
|
4574
|
+
}
|
|
4575
|
+
|
|
4576
|
+
// 3. Register features at both levels
|
|
4577
|
+
customElements.assignFeatures(ClubMember, {
|
|
4578
|
+
behaviors: { spawn: ClubMemberBehaviors }
|
|
4579
|
+
});
|
|
4580
|
+
customElements.assignFeatures(ClubMemberBehaviors, {
|
|
4581
|
+
commandBehavior: { spawn: CommandFeatureImpl },
|
|
4582
|
+
ariaBehavior: { spawn: AriaFeatureImpl }
|
|
4583
|
+
});
|
|
4584
|
+
installForwarding(ClubMember);
|
|
4585
|
+
customElements.define('club-member', ClubMember);
|
|
4586
|
+
|
|
4587
|
+
// 4. Use it
|
|
4588
|
+
const el = document.createElement('club-member');
|
|
4589
|
+
el.command = 'toggle'; // forwards to el.behaviors.commandBehavior.command
|
|
4590
|
+
el.behaviors.ariaBehavior.setRole('button'); // access nested features directly
|
|
4591
|
+
```
|
|
4592
|
+
|
|
4593
|
+
**How `PropertyBag` works:**
|
|
4594
|
+
|
|
4595
|
+
- Carries `customElementRegistry` from the host element so nested features can resolve their registries.
|
|
4596
|
+
- Applies `initVals` via `Object.assign` (supports pre-upgrade property capture).
|
|
4597
|
+
- Must be subclassed — direct instantiation throws an error.
|
|
4598
|
+
- Subclasses must define `static supportedFeatures` (enforced by `assignFeatures` validation).
|
|
4599
|
+
|
|
4600
|
+
**Why subclass instead of using `PropertyBag` directly?**
|
|
4601
|
+
|
|
4602
|
+
Each subclass declares its own `static supportedFeatures`, which:
|
|
4603
|
+
- Provides opt-in safety (only declared feature keys are allowed).
|
|
4604
|
+
- Enables TypeScript type checking on the feature slots.
|
|
4605
|
+
- Documents the expected shape of the container.
|
|
4606
|
+
|
|
4607
|
+
```JavaScript
|
|
4608
|
+
// This throws — PropertyBag has no supportedFeatures
|
|
4609
|
+
customElements.assignFeatures(PropertyBag, { anything: {} }); // Error!
|
|
4610
|
+
|
|
4611
|
+
// This works — subclass declares what's allowed
|
|
4612
|
+
class MyBehaviors extends PropertyBag {
|
|
4613
|
+
static supportedFeatures = { anything: { fallbackSpawn: AnythingImpl } }
|
|
4614
|
+
}
|
|
4615
|
+
customElements.assignFeatures(MyBehaviors, { anything: { spawn: AnythingImpl } }); // ✓
|
|
4616
|
+
```
|
|
4617
|
+
|
|
4618
|
+
### Roadmap (future phases)
|
|
4619
|
+
|
|
4620
|
+
- **Nested features**: Support `?.path?.notation` keys directly in `assignFeatures` (without requiring `PropertyBag`).
|
|
4621
|
+
- **`@each` + async interaction**: Combine async methods with iteration.
|