assign-gingerly 0.0.38 → 0.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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. Default Support For Not Replacing one object with another if it is a subclass. [TODO]
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
- 4. Custom Element Features [TODO]
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
- and
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 needed without as if nothing happened. Another example is allowing user edits to be rolled back as they repeatedly hit "ctrl+z".
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 or an accessor (see Examples 3a and 3b below)
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**: always replaced (whether holding a plain object or class instance)
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,743 @@ 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
+ ### 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
+
4715
+ ### Roadmap (future phases)
4716
+
4717
+ - **Nested features**: Support `?.path?.notation` keys directly in `assignFeatures` (without requiring `PropertyBag`).
4718
+ - **`@each` + async interaction**: Combine async methods with iteration.