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 +286 -20
- package/assignGingerly.js +72 -8
- package/assignGingerly.ts +74 -24
- package/package.json +4 -4
- package/playwright.config.ts +8 -8
- 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:
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2390
|
-
const observer = new
|
|
2391
|
-
|
|
2392
|
-
|
|
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
|
-
//
|
|
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,20 @@ 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 - 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
|
-
//
|
|
501
|
-
if (
|
|
502
|
-
|
|
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
|
-
//
|
|
511
|
-
if (
|
|
512
|
-
|
|
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.
|
|
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.
|
|
66
|
+
"@playwright/test": "1.59.1",
|
|
67
67
|
"spa-ssi": "0.0.27",
|
|
68
|
-
"@types/node": "
|
|
69
|
-
"typescript": "
|
|
68
|
+
"@types/node": "25.5.2",
|
|
69
|
+
"typescript": "6.0.2"
|
|
70
70
|
}
|
|
71
71
|
}
|
package/playwright.config.ts
CHANGED
|
@@ -34,15 +34,15 @@ export default defineConfig({
|
|
|
34
34
|
use: { ...devices['Desktop Chrome'] },
|
|
35
35
|
},
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
{
|
|
38
|
+
name: 'firefox',
|
|
39
|
+
use: { ...devices['Desktop Firefox'] },
|
|
40
|
+
},
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
}
|