assign-gingerly 0.0.23 → 0.0.25

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:
@@ -104,9 +108,252 @@ console.log(obj);
104
108
  // }
105
109
  ```
106
110
 
107
- 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).
111
+ When the right hand side of an expression is an object, assignGingerly behavior depends on the context:
112
+ - 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 a class instance (see Examples 3a and 3b below)
114
+
115
+ 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
+
117
+ ## Example 3-plain - Plain Key Object Assignment
118
+
119
+ For plain keys (without `?.` prefix), assignGingerly performs simple assignment, just like `Object.assign`:
120
+
121
+ ```TypeScript
122
+ const obj = {};
123
+ const template = document.createElement('template');
124
+ template.innerHTML = '<div>Hello</div>';
125
+
126
+ assignGingerly(obj, {
127
+ template: template,
128
+ config: { theme: 'dark', lang: 'en' }
129
+ });
130
+
131
+ console.log(obj.template === template); // true - direct assignment
132
+ console.log(obj.config); // { theme: 'dark', lang: 'en' } - direct assignment
133
+ ```
134
+
135
+ This is different from nested paths, which create intermediate objects:
136
+
137
+ ```TypeScript
138
+ const obj = {};
139
+ assignGingerly(obj, {
140
+ '?.config?.theme': 'dark'
141
+ });
142
+ console.log(obj.config); // { theme: 'dark' } - intermediate object created
143
+ ```
144
+
145
+ ## Example 3a - Automatic Readonly Property Detection
146
+
147
+ 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:
148
+
149
+ ```TypeScript
150
+ // Instead of this verbose syntax:
151
+ const div = document.createElement('div');
152
+ assignGingerly(div, {
153
+ '?.style?.height': '15px',
154
+ '?.style?.width': '20px'
155
+ });
156
+
157
+ // You can now use this cleaner syntax:
158
+ assignGingerly(div, {
159
+ style: {
160
+ height: '15px',
161
+ width: '20px'
162
+ }
163
+ });
164
+ console.log(div.style.height); // '15px'
165
+ console.log(div.style.width); // '20px'
166
+ ```
167
+
168
+ **How it works:**
169
+
170
+ When assignGingerly encounters an object value being assigned to an existing property, it checks if that property is readonly:
171
+ - **Data properties** with `writable: false`
172
+ - **Accessor properties** with a getter but no setter
173
+
174
+ If the property is readonly and its current value is an object, assignGingerly automatically merges into it recursively.
175
+
176
+ **Examples of readonly properties:**
177
+ - `HTMLElement.style` - The CSSStyleDeclaration object
178
+ - `HTMLElement.dataset` - The DOMStringMap object
179
+ - Custom objects with `Object.defineProperty(obj, 'prop', { value: {}, writable: false })`
180
+ - Accessor properties with getter only: `Object.defineProperty(obj, 'prop', { get() { return {}; } })`
181
+
182
+ **Error handling:**
183
+
184
+ If you try to merge an object into a readonly property whose current value is a primitive, assignGingerly throws a descriptive error:
185
+
186
+ ```TypeScript
187
+ const obj = {};
188
+ Object.defineProperty(obj, 'readonlyString', {
189
+ value: 'immutable',
190
+ writable: false
191
+ });
192
+
193
+ assignGingerly(obj, {
194
+ readonlyString: { nested: 'value' }
195
+ });
196
+ // Error: Cannot merge object into readonly primitive property 'readonlyString'
197
+ ```
198
+
199
+ **Additional examples:**
200
+
201
+ ```TypeScript
202
+ // Dataset property
203
+ const div = document.createElement('div');
204
+ assignGingerly(div, {
205
+ dataset: {
206
+ userId: '123',
207
+ userName: 'Alice'
208
+ }
209
+ });
210
+ console.log(div.dataset.userId); // '123'
211
+ console.log(div.dataset.userName); // 'Alice'
212
+
213
+ // Custom readonly property
214
+ const config = {};
215
+ Object.defineProperty(config, 'settings', {
216
+ value: {},
217
+ writable: false
218
+ });
219
+ assignGingerly(config, {
220
+ settings: {
221
+ theme: 'dark',
222
+ lang: 'en'
223
+ }
224
+ });
225
+ console.log(config.settings.theme); // 'dark'
226
+ ```
227
+
228
+ ## Example 3b - Automatic Class Instance Preservation
229
+
230
+ In addition to readonly property detection, assignGingerly automatically preserves class instances when merging. This is particularly useful when working with enhancement instances:
231
+
232
+ ```TypeScript
233
+ import 'assign-gingerly/object-extension.js';
234
+
235
+ // Define an enhancement class
236
+ class MyEnhancement {
237
+ constructor(element, ctx, initVals) {
238
+ this.element = element;
239
+ this.instanceId = Math.random(); // Track instance identity
240
+ if (initVals) {
241
+ Object.assign(this, initVals);
242
+ }
243
+ }
244
+ prop1 = null;
245
+ prop2 = null;
246
+ }
247
+
248
+ const element = document.createElement('div');
249
+ element.enh = {
250
+ myEnh: new MyEnhancement(element, {}, {})
251
+ };
252
+
253
+ const originalId = element.enh.myEnh.instanceId;
254
+
255
+ // Clean syntax - no need for ?.myEnh?.prop1 notation
256
+ assignGingerly(element, {
257
+ enh: {
258
+ myEnh: {
259
+ prop1: 'value1',
260
+ prop2: 'value2'
261
+ }
262
+ }
263
+ });
264
+
265
+ console.log(element.enh.myEnh.instanceId === originalId); // true - instance preserved!
266
+ console.log(element.enh.myEnh.prop1); // 'value1'
267
+ console.log(element.enh.myEnh.prop2); // 'value2'
268
+ ```
269
+
270
+ **How it works:**
271
+
272
+ 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):
273
+
274
+ - **Class instances** are detected by checking if their prototype is something other than `Object.prototype` or `null`
275
+ - **Plain objects** `{}` have `Object.prototype` as their prototype
276
+ - **Class instances** have their class's prototype
277
+
278
+ If the existing value is a class instance, assignGingerly merges into it instead of replacing it.
279
+
280
+ **What counts as a class instance:**
281
+ - Custom class instances: `new MyClass()`
282
+ - Built-in class instances: `new Date()`, `new Map()`, `new Set()`, etc.
283
+ - Enhancement instances on the `enh` property
284
+ - Any object whose prototype is not `Object.prototype` or `null`
285
+
286
+ **What doesn't count:**
287
+ - Plain objects: `{}`, `{ a: 1 }`
288
+ - Arrays: `[]`, `[1, 2, 3]` (arrays are replaced, not merged)
289
+ - Primitives: strings, numbers, booleans
290
+
291
+ **Benefits:**
292
+
293
+ This feature enables clean, framework-friendly syntax for updating enhancements:
294
+
295
+ ```TypeScript
296
+ // Before: Verbose nested path syntax
297
+ assignGingerly(element, {
298
+ '?.enh?.mellowYellow?.madAboutFourteen': true
299
+ });
300
+
301
+ // After: Clean object syntax
302
+ assignGingerly(element, {
303
+ enh: {
304
+ mellowYellow: {
305
+ madAboutFourteen: true
306
+ }
307
+ }
308
+ });
309
+ ```
310
+
311
+ **Additional examples:**
312
+
313
+ ```TypeScript
314
+ // Multiple enhancements at once
315
+ assignGingerly(element, {
316
+ enh: {
317
+ enhancement1: { prop: 'value1' },
318
+ enhancement2: { prop: 'value2' }
319
+ }
320
+ });
321
+
322
+ // Works with built-in classes too
323
+ const obj = {
324
+ timestamp: new Date('2024-01-01')
325
+ };
326
+
327
+ assignGingerly(obj, {
328
+ timestamp: {
329
+ customProp: 'metadata'
330
+ }
331
+ });
332
+
333
+ console.log(obj.timestamp instanceof Date); // true - Date instance preserved
334
+ console.log(obj.timestamp.customProp); // 'metadata'
335
+ ```
336
+
337
+ **Combined with readonly detection:**
108
338
 
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.
339
+ Both readonly properties and class instances are preserved:
340
+
341
+ ```TypeScript
342
+ const div = document.createElement('div');
343
+ div.enh = {
344
+ myEnh: new MyEnhancement(div, {}, {})
345
+ };
346
+
347
+ assignGingerly(div, {
348
+ style: { height: '100px' }, // Readonly - merged
349
+ enh: {
350
+ myEnh: { prop: 'value' } // Class instance - merged
351
+ },
352
+ dataset: { userId: '123' } // Readonly - merged
353
+ });
354
+
355
+ // All instances and readonly objects preserved
356
+ ```
110
357
 
111
358
  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
359
 
@@ -171,7 +418,7 @@ For existing values, the toggle is performed using JavaScript's logical NOT oper
171
418
 
172
419
  ## Example 6 - Deleting properties with -= command
173
420
 
174
- The `-=` command allows you to delete properties from objects:
421
+ The `-=` command allows us to delete properties from objects:
175
422
 
176
423
  ```TypeScript
177
424
  const obj = {
@@ -348,8 +595,13 @@ EnhancementRegistry.push([
348
595
  const result = assignGingerly({}, {
349
596
  [isHappy]: true,
350
597
  [isMellow]: true,
351
- '?.style?.height': '40px',
352
- '?.enh?.mellowYellow?.madAboutFourteen': true
598
+ style:{
599
+ height: '40px',
600
+ },
601
+ enh: {
602
+ '?.mellowYellow?.madAboutFourteen': true
603
+ }
604
+
353
605
  }, {
354
606
  registry: EnhancementRegistry
355
607
  });
@@ -466,8 +718,12 @@ console.log(target.set[symbol1].prop2); // 'value2'
466
718
  const result = assignGingerly({}, {
467
719
  "[Symbol.for('TFWsx0YH5E6eSfhE7zfLxA')]": true,
468
720
  "[Symbol.for('BqnnTPWRHkWdVGWcGQoAiw')]": true,
469
- '?.style.height': '40px',
470
- '?.enh?.mellowYellow?.madAboutFourteen': true
721
+ style: {
722
+ height: '40px'
723
+ }
724
+ enh: {
725
+ mellowYellow?.madAboutFourteen': true
726
+ }
471
727
  }, {
472
728
  registry: EnhancementRegistry
473
729
  });
@@ -519,7 +775,7 @@ The prototype extensions are non-enumerable and won't appear in `Object.keys()`
519
775
 
520
776
  This package polyfill adds an "enhancementRegistry" registry on the CustomElementRegistry prototype.
521
777
 
522
- In this way, we achieve dependency injection in harmony with scoped custom elements DOM registry scope islands.
778
+ In this way, we achieve dependency injection in harmony with scoped custom elements DOM registry scopes.
523
779
 
524
780
  > [!NOTE]
525
781
  > 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 +853,7 @@ Building on the Custom Element Registry integration, this package provides a pow
597
853
 
598
854
  ### Basic Usage
599
855
 
600
- The `enh.set` proxy allows you to assign properties to enhancements using a clean, chainable syntax:
856
+ The `enh.set` proxy allows us to assign properties to enhancements using a clean, chainable syntax:
601
857
 
602
858
  ```TypeScript
603
859
  import 'assign-gingerly/object-extension.js';
@@ -693,7 +949,7 @@ Note that the class need not extend any base class or leverage any mixins. In f
693
949
  <details>
694
950
  <summary>Passing Custom Context</summary>
695
951
 
696
- You can pass custom context when calling `enh.get()` or `enh.whenResolved()`:
952
+ You can pass custom context when calling `enh.get()` or `enh.whenResolved()` (discussed in detail below):
697
953
 
698
954
  ```TypeScript
699
955
  // Pass custom context to the spawned instance
@@ -1419,7 +1675,8 @@ console.log(instance.count); // 42 (parsed from attribute)
1419
1675
  console.log(instance.theme); // 'dark' (parsed from attribute)
1420
1676
  ```
1421
1677
 
1422
- **Example without enhKey:**
1678
+ <details>
1679
+ <summary>Example without enhKey</summary>
1423
1680
 
1424
1681
  ```TypeScript
1425
1682
  // withAttrs works even without enhKey
@@ -1453,16 +1710,21 @@ const instance = element.enh.get(config);
1453
1710
  console.log(instance.value); // 'test123' (parsed from attribute)
1454
1711
  ```
1455
1712
 
1456
- **How it works:**
1713
+ </details>
1714
+
1715
+ <details>
1716
+ <summary>How it works</summary>
1717
+
1457
1718
  1. When an enhancement is spawned via `enh.get()`, `enh.set`, or `assignGingerly()`
1458
1719
  2. If the registry item has a `withAttrs` property defined
1459
1720
  3. `parseWithAttrs(element, registryItem.withAttrs)` is automatically called
1460
1721
  4. The parsed attributes are passed to the enhancement constructor as `initVals`
1461
1722
  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
1723
 
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
-
1724
+ </details>
1465
1725
 
1726
+ > ![NOTE]
1727
+ > `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
1728
 
1467
1729
  ### The `enh-` Prefix for Attribute Isolation
1468
1730
 
@@ -1919,7 +2181,7 @@ const result = parseWithAttrs(element, {
1919
2181
 
1920
2182
  ### Default Values with valIfNull
1921
2183
 
1922
- The `valIfNull` property allows you to specify default values when attributes are missing:
2184
+ The `valIfNull` property allows us to specify default values when attributes are missing:
1923
2185
 
1924
2186
  ```TypeScript
1925
2187
  // HTML: <div></div> (no attributes)
@@ -2386,13 +2648,17 @@ buildCSSQuery(config, ' div , span , p ');
2386
2648
  1. **Mount Observer Integration**: Find elements that need enhancement
2387
2649
  ```TypeScript
2388
2650
  // 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));
2651
+ const matching = buildCSSQuery(enhancementConfig);
2652
+ const observer = new MountObserver({
2653
+ matching,
2654
+ do: (mountedElement) => {
2655
+ enhance(mountedElement);
2656
+ }
2393
2657
  });
2394
2658
  ```
2395
2659
 
2660
+ See [Mount-Observer](https://github.com/bahrus/mount-observer).
2661
+
2396
2662
  2. **Specific Element Types**: Enhance only certain element types
2397
2663
  ```TypeScript
2398
2664
  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,20 @@ 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 - simple assignment
479
+ target[key] = value;
415
480
  }
416
- assignGingerly(target[key], value, options);
417
481
  }
418
482
  else {
419
483
  target[key] = value;
package/assignGingerly.ts CHANGED
@@ -29,22 +29,6 @@ export interface ItemscopeManagerConfig<T = any> {
29
29
  };
30
30
  }
31
31
 
32
- // Polyfill for WeakMap.prototype.getOrInsert
33
-
34
- // if (typeof WeakMap.prototype.getOrInsertComputed !== 'function') {
35
- // WeakMap.prototype.getOrInsertComputed = function(key, insert) {
36
- // if (this.has(key)) return this.get(key);
37
- // const value = insert();
38
- // this.set(key, value);
39
- // return value;
40
- // };
41
- // }
42
-
43
- // /**
44
- // * @deprecated Use EnhancementConfig instead
45
- // */
46
- // export type IBaseRegistryItem<T = any> = EnhancementConfig<T>;
47
-
48
32
  /**
49
33
  * Interface for the options passed to assignGingerly
50
34
  */
@@ -309,6 +293,53 @@ function ensureNestedPath(obj: any, pathParts: string[]): any {
309
293
  return current;
310
294
  }
311
295
 
296
+ /**
297
+ * Helper function to check if a property is readonly
298
+ * A property is readonly if:
299
+ * - It's a data property with writable: false, OR
300
+ * - It's an accessor property with a getter but no setter
301
+ */
302
+ function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
303
+ let descriptor = Object.getOwnPropertyDescriptor(obj, propName);
304
+
305
+ if (!descriptor) {
306
+ // Check prototype chain
307
+ let proto = Object.getPrototypeOf(obj);
308
+ while (proto) {
309
+ descriptor = Object.getOwnPropertyDescriptor(proto, propName);
310
+ if (descriptor) break;
311
+ proto = Object.getPrototypeOf(proto);
312
+ }
313
+ }
314
+
315
+ if (!descriptor) return false;
316
+
317
+ // If it's a data property, check writable flag
318
+ if ('value' in descriptor) {
319
+ return descriptor.writable === false;
320
+ }
321
+
322
+ // If it's an accessor property, check if it has only a getter (no setter)
323
+ if ('get' in descriptor) {
324
+ return descriptor.set === undefined;
325
+ }
326
+
327
+ return false;
328
+ }
329
+
330
+ /**
331
+ * Helper function to check if a value is a class instance (not a plain object)
332
+ * Returns true for instances of classes, false for plain objects, arrays, and primitives
333
+ */
334
+ function isClassInstance(value: any): boolean {
335
+ if (!value || typeof value !== 'object') return false;
336
+ if (Array.isArray(value)) return false;
337
+
338
+ const proto = Object.getPrototypeOf(value);
339
+ // Plain objects have Object.prototype or null as prototype
340
+ return proto !== Object.prototype && proto !== null;
341
+ }
342
+
312
343
  /**
313
344
  * Main assignGingerly function
314
345
  */
@@ -497,21 +528,40 @@ export function assignGingerly(
497
528
  const parent = ensureNestedPath(target, pathParts);
498
529
 
499
530
  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] = {};
531
+ // Check if property exists and is readonly OR is a class instance
532
+ if (lastKey in parent && (isReadonlyProperty(parent, lastKey) || isClassInstance(parent[lastKey]))) {
533
+ // Property is readonly or a class instance - check if current value is an object
534
+ const currentValue = parent[lastKey];
535
+ if (typeof currentValue !== 'object' || currentValue === null) {
536
+ throw new Error(`Cannot merge object into ${isReadonlyProperty(parent, lastKey) ? 'readonly ' : ''}primitive property '${String(lastKey)}'`);
537
+ }
538
+ // Recursively apply assignGingerly to the readonly object or class instance
539
+ assignGingerly(currentValue, value, options);
540
+ } else {
541
+ // Property is writable and not a class instance - normal recursive merge
542
+ if (!(lastKey in parent) || typeof parent[lastKey] !== 'object') {
543
+ parent[lastKey] = {};
544
+ }
545
+ assignGingerly(parent[lastKey], value, options);
503
546
  }
504
- assignGingerly(parent[lastKey], value, options);
505
547
  } else {
506
548
  parent[lastKey] = value;
507
549
  }
508
550
  } else {
509
551
  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] = {};
552
+ // Check if property exists and is readonly OR is a class instance
553
+ if (key in target && (isReadonlyProperty(target, key) || isClassInstance(target[key]))) {
554
+ // Property is readonly or a class instance - check if current value is an object
555
+ const currentValue = target[key];
556
+ if (typeof currentValue !== 'object' || currentValue === null) {
557
+ throw new Error(`Cannot merge object into ${isReadonlyProperty(target, key) ? 'readonly ' : ''}primitive property '${String(key)}'`);
558
+ }
559
+ // Recursively apply assignGingerly to the readonly object or class instance
560
+ assignGingerly(currentValue, value, options);
561
+ } else {
562
+ // Property is writable and not a class instance - simple assignment
563
+ target[key] = value;
513
564
  }
514
- assignGingerly(target[key], value, options);
515
565
  } else {
516
566
  target[key] = value;
517
567
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "assign-gingerly",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
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": {
@@ -63,9 +63,9 @@
63
63
  "chrome": "npx playwright cr http://localhost:8000"
64
64
  },
65
65
  "devDependencies": {
66
- "@playwright/test": "1.59.0-alpha-2026-02-28",
66
+ "@playwright/test": "1.59.1",
67
67
  "spa-ssi": "0.0.27",
68
- "@types/node": "^25.3.3",
69
- "typescript": "^5.9.3"
68
+ "@types/node": "25.5.2",
69
+ "typescript": "6.0.2"
70
70
  }
71
71
  }
@@ -34,15 +34,15 @@ export default defineConfig({
34
34
  use: { ...devices['Desktop Chrome'] },
35
35
  },
36
36
 
37
- // {
38
- // name: 'firefox',
39
- // use: { ...devices['Desktop Firefox'] },
40
- // },
37
+ {
38
+ name: 'firefox',
39
+ use: { ...devices['Desktop Firefox'] },
40
+ },
41
41
 
42
- // {
43
- // name: 'webkit',
44
- // use: { ...devices['Desktop Safari'] },
45
- // },
42
+ {
43
+ name: 'webkit',
44
+ use: { ...devices['Desktop Safari'] },
45
+ },
46
46
  ],
47
47
 
48
48
  /* Run your local dev server before starting the tests */
@@ -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
  }