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 +255 -19
- package/assignGingerly.js +75 -8
- package/assignGingerly.ts +77 -8
- package/package.json +3 -3
- package/types/assign-gingerly/types.d.ts +22 -17
- package/waitForEvent.js +15 -1
- package/waitForEvent.ts +19 -1
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.
|
|
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
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2390
|
-
const observer = new
|
|
2391
|
-
|
|
2392
|
-
|
|
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
|
-
//
|
|
401
|
-
if (
|
|
402
|
-
|
|
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
|
-
//
|
|
413
|
-
if (
|
|
414
|
-
|
|
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
|
-
//
|
|
501
|
-
if (
|
|
502
|
-
|
|
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
|
-
//
|
|
511
|
-
if (
|
|
512
|
-
|
|
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.
|
|
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": "
|
|
69
|
-
"typescript": "
|
|
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
|
-
|
|
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
|
}
|