assign-gingerly 0.0.23 → 0.0.24

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,10 +33,14 @@ 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. Custom Element Features [TODO]
36
+ 3. Default Support For Not Replacing one object with another if it is a subclass. [TODO]
37
+
38
+ 4. Custom Element Features [TODO]
37
39
 
38
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.
39
41
 
42
+ 5. Iterator upgrade support [TODO] -- limited to ish?
43
+
40
44
  Anyway, let's start out detailing the more innocent features of this package / polyfill.
41
45
 
42
46
  The two utility functions are:
@@ -106,7 +110,220 @@ console.log(obj);
106
110
 
107
111
  When the right hand side of an expression is an object, assignGingerly is recursively applied (passing the third argument in if applicable, which will be discussed below).
108
112
 
109
- 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.
113
+ 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.
114
+
115
+ ## Example 3a - Automatic Readonly Property Detection
116
+
117
+ assignGingerly automatically detects readonly properties and merges into them instead of attempting to replace them. This makes working with DOM properties like `style` and `dataset` much more ergonomic:
118
+
119
+ ```TypeScript
120
+ // Instead of this verbose syntax:
121
+ const div = document.createElement('div');
122
+ assignGingerly(div, {
123
+ '?.style?.height': '15px',
124
+ '?.style?.width': '20px'
125
+ });
126
+
127
+ // You can now use this cleaner syntax:
128
+ assignGingerly(div, {
129
+ style: {
130
+ height: '15px',
131
+ width: '20px'
132
+ }
133
+ });
134
+ console.log(div.style.height); // '15px'
135
+ console.log(div.style.width); // '20px'
136
+ ```
137
+
138
+ **How it works:**
139
+
140
+ When assignGingerly encounters an object value being assigned to an existing property, it checks if that property is readonly:
141
+ - **Data properties** with `writable: false`
142
+ - **Accessor properties** with a getter but no setter
143
+
144
+ If the property is readonly and its current value is an object, assignGingerly automatically merges into it recursively.
145
+
146
+ **Examples of readonly properties:**
147
+ - `HTMLElement.style` - The CSSStyleDeclaration object
148
+ - `HTMLElement.dataset` - The DOMStringMap object
149
+ - Custom objects with `Object.defineProperty(obj, 'prop', { value: {}, writable: false })`
150
+ - Accessor properties with getter only: `Object.defineProperty(obj, 'prop', { get() { return {}; } })`
151
+
152
+ **Error handling:**
153
+
154
+ If you try to merge an object into a readonly property whose current value is a primitive, assignGingerly throws a descriptive error:
155
+
156
+ ```TypeScript
157
+ const obj = {};
158
+ Object.defineProperty(obj, 'readonlyString', {
159
+ value: 'immutable',
160
+ writable: false
161
+ });
162
+
163
+ assignGingerly(obj, {
164
+ readonlyString: { nested: 'value' }
165
+ });
166
+ // Error: Cannot merge object into readonly primitive property 'readonlyString'
167
+ ```
168
+
169
+ **Additional examples:**
170
+
171
+ ```TypeScript
172
+ // Dataset property
173
+ const div = document.createElement('div');
174
+ assignGingerly(div, {
175
+ dataset: {
176
+ userId: '123',
177
+ userName: 'Alice'
178
+ }
179
+ });
180
+ console.log(div.dataset.userId); // '123'
181
+ console.log(div.dataset.userName); // 'Alice'
182
+
183
+ // Custom readonly property
184
+ const config = {};
185
+ Object.defineProperty(config, 'settings', {
186
+ value: {},
187
+ writable: false
188
+ });
189
+ assignGingerly(config, {
190
+ settings: {
191
+ theme: 'dark',
192
+ lang: 'en'
193
+ }
194
+ });
195
+ console.log(config.settings.theme); // 'dark'
196
+ ```
197
+
198
+ ## Example 3b - Automatic Class Instance Preservation
199
+
200
+ In addition to readonly property detection, assignGingerly automatically preserves class instances when merging. This is particularly useful when working with enhancement instances:
201
+
202
+ ```TypeScript
203
+ import 'assign-gingerly/object-extension.js';
204
+
205
+ // Define an enhancement class
206
+ class MyEnhancement {
207
+ constructor(element, ctx, initVals) {
208
+ this.element = element;
209
+ this.instanceId = Math.random(); // Track instance identity
210
+ if (initVals) {
211
+ Object.assign(this, initVals);
212
+ }
213
+ }
214
+ prop1 = null;
215
+ prop2 = null;
216
+ }
217
+
218
+ const element = document.createElement('div');
219
+ element.enh = {
220
+ myEnh: new MyEnhancement(element, {}, {})
221
+ };
222
+
223
+ const originalId = element.enh.myEnh.instanceId;
224
+
225
+ // Clean syntax - no need for ?.myEnh?.prop1 notation
226
+ assignGingerly(element, {
227
+ enh: {
228
+ myEnh: {
229
+ prop1: 'value1',
230
+ prop2: 'value2'
231
+ }
232
+ }
233
+ });
234
+
235
+ console.log(element.enh.myEnh.instanceId === originalId); // true - instance preserved!
236
+ console.log(element.enh.myEnh.prop1); // 'value1'
237
+ console.log(element.enh.myEnh.prop2); // 'value2'
238
+ ```
239
+
240
+ **How it works:**
241
+
242
+ When assignGingerly encounters an object value being assigned to an existing property, it checks if the current value is a class instance (not a plain object):
243
+
244
+ - **Class instances** are detected by checking if their prototype is something other than `Object.prototype` or `null`
245
+ - **Plain objects** `{}` have `Object.prototype` as their prototype
246
+ - **Class instances** have their class's prototype
247
+
248
+ If the existing value is a class instance, assignGingerly merges into it instead of replacing it.
249
+
250
+ **What counts as a class instance:**
251
+ - Custom class instances: `new MyClass()`
252
+ - Built-in class instances: `new Date()`, `new Map()`, `new Set()`, etc.
253
+ - Enhancement instances on the `enh` property
254
+ - Any object whose prototype is not `Object.prototype` or `null`
255
+
256
+ **What doesn't count:**
257
+ - Plain objects: `{}`, `{ a: 1 }`
258
+ - Arrays: `[]`, `[1, 2, 3]` (arrays are replaced, not merged)
259
+ - Primitives: strings, numbers, booleans
260
+
261
+ **Benefits:**
262
+
263
+ This feature enables clean, framework-friendly syntax for updating enhancements:
264
+
265
+ ```TypeScript
266
+ // Before: Verbose nested path syntax
267
+ assignGingerly(element, {
268
+ '?.enh?.mellowYellow?.madAboutFourteen': true
269
+ });
270
+
271
+ // After: Clean object syntax
272
+ assignGingerly(element, {
273
+ enh: {
274
+ mellowYellow: {
275
+ madAboutFourteen: true
276
+ }
277
+ }
278
+ });
279
+ ```
280
+
281
+ **Additional examples:**
282
+
283
+ ```TypeScript
284
+ // Multiple enhancements at once
285
+ assignGingerly(element, {
286
+ enh: {
287
+ enhancement1: { prop: 'value1' },
288
+ enhancement2: { prop: 'value2' }
289
+ }
290
+ });
291
+
292
+ // Works with built-in classes too
293
+ const obj = {
294
+ timestamp: new Date('2024-01-01')
295
+ };
296
+
297
+ assignGingerly(obj, {
298
+ timestamp: {
299
+ customProp: 'metadata'
300
+ }
301
+ });
302
+
303
+ console.log(obj.timestamp instanceof Date); // true - Date instance preserved
304
+ console.log(obj.timestamp.customProp); // 'metadata'
305
+ ```
306
+
307
+ **Combined with readonly detection:**
308
+
309
+ Both readonly properties and class instances are preserved:
310
+
311
+ ```TypeScript
312
+ const div = document.createElement('div');
313
+ div.enh = {
314
+ myEnh: new MyEnhancement(div, {}, {})
315
+ };
316
+
317
+ assignGingerly(div, {
318
+ style: { height: '100px' }, // Readonly - merged
319
+ enh: {
320
+ myEnh: { prop: 'value' } // Class instance - merged
321
+ },
322
+ dataset: { userId: '123' } // Readonly - merged
323
+ });
324
+
325
+ // All instances and readonly objects preserved
326
+ ```
110
327
 
111
328
  While we are in the business of passing values of object A into object B, we might as well add some extremely common behavior that allows updating properties of object B based on the current values of object B -- things like incrementing, toggling, and deleting. Deleting is critical for assignTentatively, but is included with both functions.
112
329
 
@@ -171,7 +388,7 @@ For existing values, the toggle is performed using JavaScript's logical NOT oper
171
388
 
172
389
  ## Example 6 - Deleting properties with -= command
173
390
 
174
- The `-=` command allows you to delete properties from objects:
391
+ The `-=` command allows us to delete properties from objects:
175
392
 
176
393
  ```TypeScript
177
394
  const obj = {
@@ -348,8 +565,13 @@ EnhancementRegistry.push([
348
565
  const result = assignGingerly({}, {
349
566
  [isHappy]: true,
350
567
  [isMellow]: true,
351
- '?.style?.height': '40px',
352
- '?.enh?.mellowYellow?.madAboutFourteen': true
568
+ style:{
569
+ height: '40px',
570
+ },
571
+ enh: {
572
+ '?.mellowYellow?.madAboutFourteen': true
573
+ }
574
+
353
575
  }, {
354
576
  registry: EnhancementRegistry
355
577
  });
@@ -466,8 +688,12 @@ console.log(target.set[symbol1].prop2); // 'value2'
466
688
  const result = assignGingerly({}, {
467
689
  "[Symbol.for('TFWsx0YH5E6eSfhE7zfLxA')]": true,
468
690
  "[Symbol.for('BqnnTPWRHkWdVGWcGQoAiw')]": true,
469
- '?.style.height': '40px',
470
- '?.enh?.mellowYellow?.madAboutFourteen': true
691
+ style: {
692
+ height: '40px'
693
+ }
694
+ enh: {
695
+ mellowYellow?.madAboutFourteen': true
696
+ }
471
697
  }, {
472
698
  registry: EnhancementRegistry
473
699
  });
@@ -519,7 +745,7 @@ The prototype extensions are non-enumerable and won't appear in `Object.keys()`
519
745
 
520
746
  This package polyfill adds an "enhancementRegistry" registry on the CustomElementRegistry prototype.
521
747
 
522
- In this way, we achieve dependency injection in harmony with scoped custom elements DOM registry scope islands.
748
+ In this way, we achieve dependency injection in harmony with scoped custom elements DOM registry scopes.
523
749
 
524
750
  > [!NOTE]
525
751
  > Safari/WebKit played a critical role in pushing scoped custom element registries forward, and announced with little fanfare or documentation that [Safari 26 supports it](https://developer.apple.com/documentation/safari-release-notes/safari-26-release-notes). However, the Playwright test machinery's cross platform Safari test browser doesn't yet support it. For now, only Chrome 146+ has been tested / vetted for this functionality.
@@ -597,7 +823,7 @@ Building on the Custom Element Registry integration, this package provides a pow
597
823
 
598
824
  ### Basic Usage
599
825
 
600
- The `enh.set` proxy allows you to assign properties to enhancements using a clean, chainable syntax:
826
+ The `enh.set` proxy allows us to assign properties to enhancements using a clean, chainable syntax:
601
827
 
602
828
  ```TypeScript
603
829
  import 'assign-gingerly/object-extension.js';
@@ -693,7 +919,7 @@ Note that the class need not extend any base class or leverage any mixins. In f
693
919
  <details>
694
920
  <summary>Passing Custom Context</summary>
695
921
 
696
- You can pass custom context when calling `enh.get()` or `enh.whenResolved()`:
922
+ You can pass custom context when calling `enh.get()` or `enh.whenResolved()` (discussed in detail below):
697
923
 
698
924
  ```TypeScript
699
925
  // Pass custom context to the spawned instance
@@ -1419,7 +1645,8 @@ console.log(instance.count); // 42 (parsed from attribute)
1419
1645
  console.log(instance.theme); // 'dark' (parsed from attribute)
1420
1646
  ```
1421
1647
 
1422
- **Example without enhKey:**
1648
+ <details>
1649
+ <summary>Example without enhKey</summary>
1423
1650
 
1424
1651
  ```TypeScript
1425
1652
  // withAttrs works even without enhKey
@@ -1453,16 +1680,21 @@ const instance = element.enh.get(config);
1453
1680
  console.log(instance.value); // 'test123' (parsed from attribute)
1454
1681
  ```
1455
1682
 
1456
- **How it works:**
1683
+ </details>
1684
+
1685
+ <details>
1686
+ <summary>How it works</summary>
1687
+
1457
1688
  1. When an enhancement is spawned via `enh.get()`, `enh.set`, or `assignGingerly()`
1458
1689
  2. If the registry item has a `withAttrs` property defined
1459
1690
  3. `parseWithAttrs(element, registryItem.withAttrs)` is automatically called
1460
1691
  4. The parsed attributes are passed to the enhancement constructor as `initVals`
1461
1692
  5. If the registry item also has an `enhKey`, the parsed attributes are merged with any existing values from `element.enh[enhKey]` (existing values take precedence)
1462
1693
 
1463
- **Note**: `withAttrs` works with or without `enhKey`. When there's no `enhKey`, the parsed attributes are passed directly to the constructor. When there is an `enhKey`, they're merged with any pre-existing values on the enh container.
1464
-
1694
+ </details>
1465
1695
 
1696
+ > ![NOTE]
1697
+ > `withAttrs` works with or without `enhKey`. When there's no `enhKey`, the parsed attributes are passed directly to the constructor. When there is an `enhKey`, they're merged with any pre-existing values on the enh container.
1466
1698
 
1467
1699
  ### The `enh-` Prefix for Attribute Isolation
1468
1700
 
@@ -1919,7 +2151,7 @@ const result = parseWithAttrs(element, {
1919
2151
 
1920
2152
  ### Default Values with valIfNull
1921
2153
 
1922
- The `valIfNull` property allows you to specify default values when attributes are missing:
2154
+ The `valIfNull` property allows us to specify default values when attributes are missing:
1923
2155
 
1924
2156
  ```TypeScript
1925
2157
  // HTML: <div></div> (no attributes)
@@ -2386,13 +2618,17 @@ buildCSSQuery(config, ' div , span , p ');
2386
2618
  1. **Mount Observer Integration**: Find elements that need enhancement
2387
2619
  ```TypeScript
2388
2620
  // Match any element with the attributes
2389
- const query = buildCSSQuery(enhancementConfig);
2390
- const observer = new MutationObserver(() => {
2391
- const elements = document.querySelectorAll(query);
2392
- elements.forEach(el => enhance(el));
2621
+ const matching = buildCSSQuery(enhancementConfig);
2622
+ const observer = new MountObserver({
2623
+ matching,
2624
+ do: (mountedElement) => {
2625
+ enhance(mountedElement);
2626
+ }
2393
2627
  });
2394
2628
  ```
2395
2629
 
2630
+ See [Mount-Observer](https://github.com/bahrus/mount-observer).
2631
+
2396
2632
  2. **Specific Element Types**: Enhance only certain element types
2397
2633
  ```TypeScript
2398
2634
  const query = buildCSSQuery(config, 'template, script');
package/assignGingerly.js CHANGED
@@ -227,6 +227,49 @@ function ensureNestedPath(obj, pathParts) {
227
227
  }
228
228
  return current;
229
229
  }
230
+ /**
231
+ * Helper function to check if a property is readonly
232
+ * A property is readonly if:
233
+ * - It's a data property with writable: false, OR
234
+ * - It's an accessor property with a getter but no setter
235
+ */
236
+ function isReadonlyProperty(obj, propName) {
237
+ let descriptor = Object.getOwnPropertyDescriptor(obj, propName);
238
+ if (!descriptor) {
239
+ // Check prototype chain
240
+ let proto = Object.getPrototypeOf(obj);
241
+ while (proto) {
242
+ descriptor = Object.getOwnPropertyDescriptor(proto, propName);
243
+ if (descriptor)
244
+ break;
245
+ proto = Object.getPrototypeOf(proto);
246
+ }
247
+ }
248
+ if (!descriptor)
249
+ return false;
250
+ // If it's a data property, check writable flag
251
+ if ('value' in descriptor) {
252
+ return descriptor.writable === false;
253
+ }
254
+ // If it's an accessor property, check if it has only a getter (no setter)
255
+ if ('get' in descriptor) {
256
+ return descriptor.set === undefined;
257
+ }
258
+ return false;
259
+ }
260
+ /**
261
+ * Helper function to check if a value is a class instance (not a plain object)
262
+ * Returns true for instances of classes, false for plain objects, arrays, and primitives
263
+ */
264
+ function isClassInstance(value) {
265
+ if (!value || typeof value !== 'object')
266
+ return false;
267
+ if (Array.isArray(value))
268
+ return false;
269
+ const proto = Object.getPrototypeOf(value);
270
+ // Plain objects have Object.prototype or null as prototype
271
+ return proto !== Object.prototype && proto !== null;
272
+ }
230
273
  /**
231
274
  * Main assignGingerly function
232
275
  */
@@ -397,11 +440,23 @@ export function assignGingerly(target, source, options) {
397
440
  const lastKey = pathParts[pathParts.length - 1];
398
441
  const parent = ensureNestedPath(target, pathParts);
399
442
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
400
- // Recursively apply assignGingerly for nested objects
401
- if (!(lastKey in parent) || typeof parent[lastKey] !== 'object') {
402
- parent[lastKey] = {};
443
+ // Check if property exists and is readonly OR is a class instance
444
+ if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
445
+ // Property is readonly or a class instance - check if current value is an object
446
+ const currentValue = parent[lastKey];
447
+ if (typeof currentValue !== 'object' || currentValue === null) {
448
+ throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
449
+ }
450
+ // Recursively apply assignGingerly to the readonly object or class instance
451
+ assignGingerly(currentValue, value, options);
452
+ }
453
+ else {
454
+ // Property is writable and not a class instance - normal recursive merge
455
+ if (!(lastKey in parent) || typeof parent[lastKey] !== 'object') {
456
+ parent[lastKey] = {};
457
+ }
458
+ assignGingerly(parent[lastKey], value, options);
403
459
  }
404
- assignGingerly(parent[lastKey], value, options);
405
460
  }
406
461
  else {
407
462
  parent[lastKey] = value;
@@ -409,11 +464,23 @@ export function assignGingerly(target, source, options) {
409
464
  }
410
465
  else {
411
466
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
412
- // Recursively apply assignGingerly for nested objects
413
- if (!(key in target) || typeof target[key] !== 'object') {
414
- target[key] = {};
467
+ // Check if property exists and is readonly OR is a class instance
468
+ if (key in target && (isReadonlyProperty(target, key) || isClassInstance(target[key]))) {
469
+ // Property is readonly or a class instance - check if current value is an object
470
+ const currentValue = target[key];
471
+ if (typeof currentValue !== 'object' || currentValue === null) {
472
+ throw new Error(`Cannot merge object into ${isReadonlyProperty(target, key) ? 'readonly ' : ''}primitive property '${String(key)}'`);
473
+ }
474
+ // Recursively apply assignGingerly to the readonly object or class instance
475
+ assignGingerly(currentValue, value, options);
476
+ }
477
+ else {
478
+ // Property is writable and not a class instance - normal recursive merge
479
+ if (!(key in target) || typeof target[key] !== 'object') {
480
+ target[key] = {};
481
+ }
482
+ assignGingerly(target[key], value, options);
415
483
  }
416
- assignGingerly(target[key], value, options);
417
484
  }
418
485
  else {
419
486
  target[key] = value;
package/assignGingerly.ts CHANGED
@@ -309,6 +309,53 @@ function ensureNestedPath(obj: any, pathParts: string[]): any {
309
309
  return current;
310
310
  }
311
311
 
312
+ /**
313
+ * Helper function to check if a property is readonly
314
+ * A property is readonly if:
315
+ * - It's a data property with writable: false, OR
316
+ * - It's an accessor property with a getter but no setter
317
+ */
318
+ function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
319
+ let descriptor = Object.getOwnPropertyDescriptor(obj, propName);
320
+
321
+ if (!descriptor) {
322
+ // Check prototype chain
323
+ let proto = Object.getPrototypeOf(obj);
324
+ while (proto) {
325
+ descriptor = Object.getOwnPropertyDescriptor(proto, propName);
326
+ if (descriptor) break;
327
+ proto = Object.getPrototypeOf(proto);
328
+ }
329
+ }
330
+
331
+ if (!descriptor) return false;
332
+
333
+ // If it's a data property, check writable flag
334
+ if ('value' in descriptor) {
335
+ return descriptor.writable === false;
336
+ }
337
+
338
+ // If it's an accessor property, check if it has only a getter (no setter)
339
+ if ('get' in descriptor) {
340
+ return descriptor.set === undefined;
341
+ }
342
+
343
+ return false;
344
+ }
345
+
346
+ /**
347
+ * Helper function to check if a value is a class instance (not a plain object)
348
+ * Returns true for instances of classes, false for plain objects, arrays, and primitives
349
+ */
350
+ function isClassInstance(value: any): boolean {
351
+ if (!value || typeof value !== 'object') return false;
352
+ if (Array.isArray(value)) return false;
353
+
354
+ const proto = Object.getPrototypeOf(value);
355
+ // Plain objects have Object.prototype or null as prototype
356
+ return proto !== Object.prototype && proto !== null;
357
+ }
358
+
312
359
  /**
313
360
  * Main assignGingerly function
314
361
  */
@@ -497,21 +544,43 @@ export function assignGingerly(
497
544
  const parent = ensureNestedPath(target, pathParts);
498
545
 
499
546
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
500
- // Recursively apply assignGingerly for nested objects
501
- if (!(lastKey in parent) || typeof parent[lastKey] !== 'object') {
502
- parent[lastKey] = {};
547
+ // Check if property exists and is readonly OR is a class instance
548
+ if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
549
+ // Property is readonly or a class instance - check if current value is an object
550
+ const currentValue = parent[lastKey];
551
+ if (typeof currentValue !== 'object' || currentValue === null) {
552
+ throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
553
+ }
554
+ // Recursively apply assignGingerly to the readonly object or class instance
555
+ assignGingerly(currentValue, value, options);
556
+ } else {
557
+ // Property is writable and not a class instance - normal recursive merge
558
+ if (!(lastKey in parent) || typeof parent[lastKey] !== 'object') {
559
+ parent[lastKey] = {};
560
+ }
561
+ assignGingerly(parent[lastKey], value, options);
503
562
  }
504
- assignGingerly(parent[lastKey], value, options);
505
563
  } else {
506
564
  parent[lastKey] = value;
507
565
  }
508
566
  } else {
509
567
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
510
- // Recursively apply assignGingerly for nested objects
511
- if (!(key in target) || typeof target[key] !== 'object') {
512
- target[key] = {};
568
+ // Check if property exists and is readonly OR is a class instance
569
+ if (key in target && (isReadonlyProperty(target, key) || isClassInstance(target[key]))) {
570
+ // Property is readonly or a class instance - check if current value is an object
571
+ const currentValue = target[key];
572
+ if (typeof currentValue !== 'object' || currentValue === null) {
573
+ throw new Error(`Cannot merge object into ${isReadonlyProperty(target, key) ? 'readonly ' : ''}primitive property '${String(key)}'`);
574
+ }
575
+ // Recursively apply assignGingerly to the readonly object or class instance
576
+ assignGingerly(currentValue, value, options);
577
+ } else {
578
+ // Property is writable and not a class instance - normal recursive merge
579
+ if (!(key in target) || typeof target[key] !== 'object') {
580
+ target[key] = {};
581
+ }
582
+ assignGingerly(target[key], value, options);
513
583
  }
514
- assignGingerly(target[key], value, options);
515
584
  } else {
516
585
  target[key] = value;
517
586
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "description": "This package provides a utility function for carefully merging one object into another.",
5
5
  "homepage": "https://github.com/bahrus/assign-gingerly#readme",
6
6
  "bugs": {
@@ -65,7 +65,7 @@
65
65
  "devDependencies": {
66
66
  "@playwright/test": "1.59.0-alpha-2026-02-28",
67
67
  "spa-ssi": "0.0.27",
68
- "@types/node": "^25.3.3",
69
- "typescript": "^5.9.3"
68
+ "@types/node": "25.5.0",
69
+ "typescript": "6.0.2"
70
70
  }
71
71
  }
@@ -30,35 +30,40 @@ export type Spawner<T = any, Obj = Element> = {
30
30
  canSpawn?: (obj: any, ctx?: SpawnContext<T>) => boolean;
31
31
  }
32
32
 
33
+ export interface EnhancementConfigBase<T = any> {
34
+ //Allow unprefixed attributes for custom elements and SVG when element tag name matches pattern
35
+ allowUnprefixed?: string | RegExp;
36
+
37
+ //keys of type symbol are used for dependency injection
38
+ //and are used by assign-gingerly
39
+ symlinks?: { [key: symbol]: keyof T };
40
+
41
+ lifecycleKeys?:
42
+ | true // Use standard names: "dispose" method, "resolved" property/event
43
+ | {
44
+ dispose?: string | symbol,
45
+ resolved?: string | symbol
46
+ }
47
+ //used by mount-observer, not by assign-gingerly
48
+ //impossible to polyfill, but will always be disposed
49
+ //when oElement's reference count goes to zero
50
+ disposeOn?: DisposeEvent | DisposeEvent[]
51
+ }
52
+
33
53
  /**
34
54
  * Configuration for enhancing elements with class instances
35
55
  * Defines how to spawn and initialize enhancement classes
36
56
  */
37
- export interface EnhancementConfig<T = any, Obj = Element> {
57
+ export interface EnhancementConfig<T = any, Obj = Element> extends EnhancementConfigBase<T> {
38
58
 
39
59
  spawn: Spawner<T, Obj>;
40
60
 
41
61
  //Applicable to passing in the initVals during the spawn lifecycle event
42
62
  withAttrs?: AttrPatterns<T>;
43
63
 
44
- //Allow unprefixed attributes for custom elements and SVG when element tag name matches pattern
45
- allowUnprefixed?: string | RegExp;
46
-
47
- //keys of type symbol are used for dependency injection
48
- //and are used by assign-gingerly
49
- symlinks?: { [key: symbol]: keyof T };
64
+
50
65
  //only applicable when spawning from a DOM Element reference
51
66
  enhKey?: EnhKey;
52
- lifecycleKeys?:
53
- | true // Use standard names: "dispose" method, "resolved" property/event
54
- | {
55
- dispose?: string | symbol,
56
- resolved?: string | symbol
57
- }
58
- //used by mount-observer, not by assign-gingerly
59
- //impossible to polyfill, but will always be disposed
60
- //when oElement's reference count goes to zero
61
- disposeOn?: DisposeEvent | DisposeEvent[]
62
67
 
63
68
  }
64
69
 
package/waitForEvent.js CHANGED
@@ -3,17 +3,31 @@
3
3
  * @param et - The EventTarget to listen on
4
4
  * @param eventName - The event name to wait for (resolves the promise)
5
5
  * @param failureEventName - Optional event name that rejects the promise
6
+ * @param timeout - Optional timeout in milliseconds (rejects if exceeded)
6
7
  * @returns Promise that resolves with the event
7
8
  */
8
- export function waitForEvent(et, eventName, failureEventName) {
9
+ export function waitForEvent(et, eventName, failureEventName, timeout) {
9
10
  return new Promise((resolve, reject) => {
11
+ let timeoutId;
12
+ const cleanup = () => {
13
+ if (timeoutId !== undefined) {
14
+ clearTimeout(timeoutId);
15
+ }
16
+ };
10
17
  et.addEventListener(eventName, (e) => {
18
+ cleanup();
11
19
  resolve(e);
12
20
  }, { once: true });
13
21
  if (failureEventName !== undefined) {
14
22
  et.addEventListener(failureEventName, (e) => {
23
+ cleanup();
15
24
  reject(e);
16
25
  }, { once: true });
17
26
  }
27
+ if (timeout !== undefined && timeout > 0) {
28
+ timeoutId = setTimeout(() => {
29
+ reject(new Error(`Timeout waiting for event '${eventName}' after ${timeout}ms`));
30
+ }, timeout);
31
+ }
18
32
  });
19
33
  }
package/waitForEvent.ts CHANGED
@@ -3,17 +3,28 @@
3
3
  * @param et - The EventTarget to listen on
4
4
  * @param eventName - The event name to wait for (resolves the promise)
5
5
  * @param failureEventName - Optional event name that rejects the promise
6
+ * @param timeout - Optional timeout in milliseconds (rejects if exceeded)
6
7
  * @returns Promise that resolves with the event
7
8
  */
8
9
  export function waitForEvent<TEvent extends Event = Event>(
9
10
  et: EventTarget,
10
11
  eventName: string,
11
- failureEventName?: string
12
+ failureEventName?: string,
13
+ timeout?: number
12
14
  ): Promise<TEvent> {
13
15
  return new Promise((resolve, reject) => {
16
+ let timeoutId: number | undefined;
17
+
18
+ const cleanup = () => {
19
+ if (timeoutId !== undefined) {
20
+ clearTimeout(timeoutId);
21
+ }
22
+ };
23
+
14
24
  et.addEventListener(
15
25
  eventName,
16
26
  (e) => {
27
+ cleanup();
17
28
  resolve(e as TEvent);
18
29
  },
19
30
  { once: true }
@@ -23,10 +34,17 @@ export function waitForEvent<TEvent extends Event = Event>(
23
34
  et.addEventListener(
24
35
  failureEventName,
25
36
  (e) => {
37
+ cleanup();
26
38
  reject(e as TEvent);
27
39
  },
28
40
  { once: true }
29
41
  );
30
42
  }
43
+
44
+ if (timeout !== undefined && timeout > 0) {
45
+ timeoutId = setTimeout(() => {
46
+ reject(new Error(`Timeout waiting for event '${eventName}' after ${timeout}ms`));
47
+ }, timeout) as unknown as number;
48
+ }
31
49
  });
32
50
  }