assign-gingerly 0.0.32 → 0.0.34
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 +47 -125
- package/assignGingerly.js +99 -64
- package/assignGingerly.ts +90 -66
- package/eachTime.js +5 -5
- package/eachTime.ts +5 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ console.log(obj);
|
|
|
110
110
|
|
|
111
111
|
When the right hand side of an expression is an object, assignGingerly behavior depends on the context:
|
|
112
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
|
|
113
|
+
- For **plain keys**: performs simple assignment (like `Object.assign`), unless the target property is readonly or an accessor (see Examples 3a and 3b below)
|
|
114
114
|
|
|
115
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
116
|
|
|
@@ -144,38 +144,40 @@ console.log(obj.config); // { theme: 'dark' } - intermediate object created
|
|
|
144
144
|
|
|
145
145
|
## Example 3a - Automatic Readonly Property Detection
|
|
146
146
|
|
|
147
|
-
assignGingerly automatically detects readonly properties and merges into them instead of attempting to replace them. This makes working with DOM properties like `
|
|
147
|
+
assignGingerly automatically detects readonly properties and merges into them instead of attempting to replace them. This makes working with DOM properties like `dataset` ergonomic:
|
|
148
148
|
|
|
149
149
|
```TypeScript
|
|
150
|
-
// Instead of this verbose syntax:
|
|
151
150
|
const div = document.createElement('div');
|
|
152
151
|
assignGingerly(div, {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// You can now use this cleaner syntax:
|
|
158
|
-
assignGingerly(div, {
|
|
159
|
-
style: {
|
|
160
|
-
height: '15px',
|
|
161
|
-
width: '20px'
|
|
152
|
+
dataset: {
|
|
153
|
+
userId: '123',
|
|
154
|
+
userName: 'Alice'
|
|
162
155
|
}
|
|
163
156
|
});
|
|
164
|
-
console.log(div.
|
|
165
|
-
console.log(div.
|
|
157
|
+
console.log(div.dataset.userId); // '123'
|
|
158
|
+
console.log(div.dataset.userName); // 'Alice'
|
|
166
159
|
```
|
|
167
160
|
|
|
168
161
|
**How it works:**
|
|
169
162
|
|
|
170
163
|
When assignGingerly encounters an object value being assigned to an existing property, it checks if that property is readonly:
|
|
171
164
|
- **Data properties** with `writable: false`
|
|
172
|
-
- **Accessor properties** with a getter but no setter
|
|
165
|
+
- **Accessor properties** with a getter but no setter (e.g., `dataset`, `shadowRoot`)
|
|
173
166
|
|
|
174
167
|
If the property is readonly and its current value is an object, assignGingerly automatically merges into it recursively.
|
|
175
168
|
|
|
176
|
-
**
|
|
177
|
-
|
|
178
|
-
|
|
169
|
+
**Note on `element.style`:** The `style` property has both a getter and a setter, so it is *not* treated as readonly. Use nested path syntax instead:
|
|
170
|
+
|
|
171
|
+
```TypeScript
|
|
172
|
+
// Use nested path syntax for style
|
|
173
|
+
assignGingerly(div, {
|
|
174
|
+
'?.style?.height': '15px',
|
|
175
|
+
'?.style?.width': '20px'
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Examples of readonly properties that trigger merging:**
|
|
180
|
+
- `HTMLElement.dataset` - getter only, no setter
|
|
179
181
|
- Custom objects with `Object.defineProperty(obj, 'prop', { value: {}, writable: false })`
|
|
180
182
|
- Accessor properties with getter only: `Object.defineProperty(obj, 'prop', { get() { return {}; } })`
|
|
181
183
|
|
|
@@ -225,134 +227,54 @@ assignGingerly(config, {
|
|
|
225
227
|
console.log(config.settings.theme); // 'dark'
|
|
226
228
|
```
|
|
227
229
|
|
|
228
|
-
## Example 3b -
|
|
230
|
+
## Example 3b - Class Instances Are Replaced
|
|
229
231
|
|
|
230
|
-
|
|
232
|
+
Unlike readonly/accessor properties, class instances on writable properties are **replaced** by simple assignment, just like plain objects. This allows you to swap one object for another without unexpected merging:
|
|
231
233
|
|
|
232
234
|
```TypeScript
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
}
|
|
235
|
+
class FakeDocumentFragment {
|
|
236
|
+
constructor() {
|
|
237
|
+
this.nodeType = 11;
|
|
238
|
+
this.childNodes = [];
|
|
243
239
|
}
|
|
244
|
-
prop1 = null;
|
|
245
|
-
prop2 = null;
|
|
246
240
|
}
|
|
247
241
|
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
myEnh: new MyEnhancement(element, {}, {})
|
|
242
|
+
const obj = {
|
|
243
|
+
clone: new FakeDocumentFragment()
|
|
251
244
|
};
|
|
252
245
|
|
|
253
|
-
const
|
|
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:
|
|
246
|
+
const element = document.createElement('div');
|
|
294
247
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
'?.enh?.mellowYellow?.madAboutFourteen': true
|
|
248
|
+
// Replace the DocumentFragment with the actual element
|
|
249
|
+
assignGingerly(obj, {
|
|
250
|
+
clone: element
|
|
299
251
|
});
|
|
300
252
|
|
|
301
|
-
//
|
|
302
|
-
assignGingerly(element, {
|
|
303
|
-
enh: {
|
|
304
|
-
mellowYellow: {
|
|
305
|
-
madAboutFourteen: true
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
});
|
|
253
|
+
console.log(obj.clone === element); // true - replaced, not merged
|
|
309
254
|
```
|
|
310
255
|
|
|
311
|
-
**
|
|
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
|
-
};
|
|
256
|
+
**Why replacement instead of merging?**
|
|
326
257
|
|
|
327
|
-
|
|
328
|
-
timestamp: {
|
|
329
|
-
customProp: 'metadata'
|
|
330
|
-
}
|
|
331
|
-
});
|
|
258
|
+
In real-world use cases, you often need to replace one object with another of a completely different type. For example, replacing a cloned DocumentFragment with the actual web component element. Automatic merging would corrupt the target by mixing properties from incompatible types.
|
|
332
259
|
|
|
333
|
-
|
|
334
|
-
console.log(obj.timestamp.customProp); // 'metadata'
|
|
335
|
-
```
|
|
336
|
-
|
|
337
|
-
**Combined with readonly detection:**
|
|
260
|
+
**Readonly/accessor properties are still merged:**
|
|
338
261
|
|
|
339
|
-
|
|
262
|
+
The distinction is clear:
|
|
263
|
+
- **Writable data properties**: always replaced (whether holding a plain object or class instance)
|
|
264
|
+
- **Readonly data properties** (`writable: false`): merged into
|
|
265
|
+
- **Getter-only accessor properties** (no setter): merged into
|
|
266
|
+
- **Getter+setter accessor properties** (e.g., `style`): setter runs with the value as-is
|
|
340
267
|
|
|
341
268
|
```TypeScript
|
|
342
269
|
const div = document.createElement('div');
|
|
343
|
-
div.enh = {
|
|
344
|
-
myEnh: new MyEnhancement(div, {}, {})
|
|
345
|
-
};
|
|
346
270
|
|
|
347
271
|
assignGingerly(div, {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
myEnh: { prop: 'value' } // Class instance - merged
|
|
351
|
-
},
|
|
352
|
-
dataset: { userId: '123' } // Readonly - merged
|
|
272
|
+
dataset: { userId: '123' }, // Getter-only - merged
|
|
273
|
+
'?.style?.height': '100px' // Use nested path for style
|
|
353
274
|
});
|
|
354
275
|
|
|
355
|
-
//
|
|
276
|
+
console.log(div.dataset.userId); // '123'
|
|
277
|
+
console.log(div.style.height); // '100px'
|
|
356
278
|
```
|
|
357
279
|
|
|
358
280
|
## Example 3c - Method Calls with withMethods
|
|
@@ -812,7 +734,7 @@ console.log(obj);
|
|
|
812
734
|
// }
|
|
813
735
|
```
|
|
814
736
|
|
|
815
|
-
The `+=` command syntax is `<path> +=` where the path
|
|
737
|
+
The `+=` command syntax is `<path> +=` where the path uses the `?.` nested notation for nested properties, or a plain key for direct properties. The right-hand side value is added to the existing value using `+=`. If the path doesn't exist, it's created and set directly to the value. If the expression is a string, string concatenation is used. If the expression can't be "added to", it allows JavaScript to throw its natural error.
|
|
816
738
|
|
|
817
739
|
## Example 5 - Toggling boolean values and negating
|
|
818
740
|
|
|
@@ -842,7 +764,7 @@ console.log(obj);
|
|
|
842
764
|
// }
|
|
843
765
|
```
|
|
844
766
|
|
|
845
|
-
The `=!` command syntax is `<path> =!` where the path
|
|
767
|
+
The `=!` command syntax is `<path> =!` where the path uses the `?.` nested notation for nested properties, or a plain key for direct properties.
|
|
846
768
|
|
|
847
769
|
For existing values, the toggle is performed using JavaScript's logical NOT operator (`!value`), regardless of what type it is.
|
|
848
770
|
|
package/assignGingerly.js
CHANGED
|
@@ -201,11 +201,13 @@ function parseDeleteCommand(key) {
|
|
|
201
201
|
}
|
|
202
202
|
/**
|
|
203
203
|
* Helper function to parse a path string with ?. notation
|
|
204
|
+
* Always splits on '?.' delimiter, preserving dots that are part of values
|
|
205
|
+
* (e.g., CSS class selectors like '.username')
|
|
206
|
+
* Paths must use ?. notation — plain dot notation is not supported.
|
|
204
207
|
*/
|
|
205
208
|
function parsePath(path) {
|
|
206
209
|
return path
|
|
207
|
-
.split('
|
|
208
|
-
.map(part => part.replace(/\?/g, ''))
|
|
210
|
+
.split('?.')
|
|
209
211
|
.filter(part => part.length > 0);
|
|
210
212
|
}
|
|
211
213
|
/**
|
|
@@ -228,11 +230,14 @@ function ensureNestedPath(obj, pathParts) {
|
|
|
228
230
|
return current;
|
|
229
231
|
}
|
|
230
232
|
/**
|
|
231
|
-
* Helper function to check if a property
|
|
232
|
-
* A property is
|
|
233
|
+
* Helper function to check if a property should be merged into rather than replaced.
|
|
234
|
+
* A property is non-replaceable if:
|
|
233
235
|
* - It's a data property with writable: false, OR
|
|
234
236
|
* - It's an accessor property with a getter but no setter
|
|
235
237
|
*
|
|
238
|
+
* Properties with both a getter and setter (e.g., element.style) are treated as
|
|
239
|
+
* replaceable — the setter runs with whatever value is provided (garbage in, garbage out).
|
|
240
|
+
*
|
|
236
241
|
* Exported for use by eachTime.ts
|
|
237
242
|
*/
|
|
238
243
|
export function isReadonlyProperty(obj, propName) {
|
|
@@ -253,8 +258,8 @@ export function isReadonlyProperty(obj, propName) {
|
|
|
253
258
|
if ('value' in descriptor) {
|
|
254
259
|
return descriptor.writable === false;
|
|
255
260
|
}
|
|
256
|
-
// If it's an accessor property
|
|
257
|
-
if ('get' in descriptor) {
|
|
261
|
+
// If it's an accessor property with a getter but no setter, it's readonly
|
|
262
|
+
if ('get' in descriptor && descriptor.get !== undefined) {
|
|
258
263
|
return descriptor.set === undefined;
|
|
259
264
|
}
|
|
260
265
|
return false;
|
|
@@ -439,10 +444,10 @@ function applyToEach(iterable, remainingPath, value, withMethods, aliasMap, opti
|
|
|
439
444
|
const lastKey = result.lastKey;
|
|
440
445
|
const parent = result.target;
|
|
441
446
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
442
|
-
if (lastKey in parent &&
|
|
447
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
443
448
|
const currentValue = parent[lastKey];
|
|
444
449
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
445
|
-
throw new Error(`Cannot merge object into
|
|
450
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
446
451
|
}
|
|
447
452
|
assignGingerly(currentValue, value, options);
|
|
448
453
|
}
|
|
@@ -569,16 +574,25 @@ export function assignGingerly(target, source, options) {
|
|
|
569
574
|
if (isIncCommand(key)) {
|
|
570
575
|
const path = parseIncCommand(key);
|
|
571
576
|
if (path) {
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
577
|
+
if (isNestedPath(path)) {
|
|
578
|
+
const pathParts = parsePath(path);
|
|
579
|
+
const lastKey = pathParts[pathParts.length - 1];
|
|
580
|
+
const parent = ensureNestedPath(target, pathParts);
|
|
581
|
+
if (!(lastKey in parent)) {
|
|
582
|
+
parent[lastKey] = value;
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
parent[lastKey] += value;
|
|
586
|
+
}
|
|
578
587
|
}
|
|
579
588
|
else {
|
|
580
|
-
//
|
|
581
|
-
|
|
589
|
+
// Plain key - direct operation on target
|
|
590
|
+
if (!(path in target)) {
|
|
591
|
+
target[path] = value;
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
target[path] += value;
|
|
595
|
+
}
|
|
582
596
|
}
|
|
583
597
|
}
|
|
584
598
|
continue;
|
|
@@ -588,10 +602,18 @@ export function assignGingerly(target, source, options) {
|
|
|
588
602
|
const lhsPath = parseToggleCommand(key);
|
|
589
603
|
if (lhsPath) {
|
|
590
604
|
const rhsPath = value;
|
|
591
|
-
//
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
605
|
+
// Resolve LHS
|
|
606
|
+
let lhsParent;
|
|
607
|
+
let lhsLastKey;
|
|
608
|
+
if (isNestedPath(lhsPath)) {
|
|
609
|
+
const lhsPathParts = parsePath(lhsPath);
|
|
610
|
+
lhsLastKey = lhsPathParts[lhsPathParts.length - 1];
|
|
611
|
+
lhsParent = ensureNestedPath(target, lhsPathParts);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
lhsLastKey = lhsPath;
|
|
615
|
+
lhsParent = target;
|
|
616
|
+
}
|
|
595
617
|
// Determine what to negate
|
|
596
618
|
let valueToNegate;
|
|
597
619
|
if (rhsPath === '.') {
|
|
@@ -600,26 +622,30 @@ export function assignGingerly(target, source, options) {
|
|
|
600
622
|
valueToNegate = lhsParent[lhsLastKey];
|
|
601
623
|
}
|
|
602
624
|
else {
|
|
603
|
-
// LHS doesn't exist, treat as undefined -> !undefined = true
|
|
604
625
|
valueToNegate = undefined;
|
|
605
626
|
}
|
|
606
627
|
}
|
|
607
628
|
else {
|
|
608
629
|
// RHS path: navigate to get the value (don't create paths)
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
current
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
630
|
+
if (isNestedPath(rhsPath)) {
|
|
631
|
+
const rhsPathParts = parsePath(rhsPath);
|
|
632
|
+
let current = target;
|
|
633
|
+
let exists = true;
|
|
634
|
+
for (const part of rhsPathParts) {
|
|
635
|
+
if (current && typeof current === 'object' && part in current) {
|
|
636
|
+
current = current[part];
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
exists = false;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
619
642
|
}
|
|
643
|
+
valueToNegate = exists ? current : true;
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
// Plain key RHS
|
|
647
|
+
valueToNegate = (rhsPath in target) ? target[rhsPath] : true;
|
|
620
648
|
}
|
|
621
|
-
// If RHS doesn't exist, treat as truthy (will become false)
|
|
622
|
-
valueToNegate = exists ? current : true;
|
|
623
649
|
}
|
|
624
650
|
// Apply negation to LHS
|
|
625
651
|
lhsParent[lhsLastKey] = !valueToNegate;
|
|
@@ -630,28 +656,37 @@ export function assignGingerly(target, source, options) {
|
|
|
630
656
|
if (isDeleteCommand(key)) {
|
|
631
657
|
const path = parseDeleteCommand(key);
|
|
632
658
|
if (path !== null) {
|
|
633
|
-
const pathParts = parsePath(path);
|
|
634
659
|
// Determine the parent object
|
|
635
660
|
let parent = target;
|
|
636
661
|
let canDelete = true;
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
662
|
+
if (isNestedPath(path)) {
|
|
663
|
+
const pathParts = parsePath(path);
|
|
664
|
+
if (pathParts.length === 0) {
|
|
665
|
+
parent = target;
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
for (const part of pathParts) {
|
|
669
|
+
if (parent && typeof parent === 'object' && part in parent) {
|
|
670
|
+
parent = parent[part];
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
canDelete = false;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
650
676
|
}
|
|
651
677
|
}
|
|
652
678
|
}
|
|
679
|
+
else if (path.length > 0) {
|
|
680
|
+
// Plain key - navigate one level
|
|
681
|
+
if (parent && typeof parent === 'object' && path in parent) {
|
|
682
|
+
parent = parent[path];
|
|
683
|
+
}
|
|
684
|
+
else {
|
|
685
|
+
canDelete = false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// else: empty path = delete from root
|
|
653
689
|
if (canDelete && typeof parent === 'object' && parent !== null) {
|
|
654
|
-
// RHS can be a string (single property) or array (multiple properties)
|
|
655
690
|
const propertiesToDelete = Array.isArray(value) ? value : [value];
|
|
656
691
|
for (const prop of propertiesToDelete) {
|
|
657
692
|
if (prop in parent) {
|
|
@@ -729,16 +764,16 @@ export function assignGingerly(target, source, options) {
|
|
|
729
764
|
const lastKey = result.lastKey;
|
|
730
765
|
const parent = result.target;
|
|
731
766
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
732
|
-
// Check if property exists and is readonly
|
|
733
|
-
if (lastKey in parent &&
|
|
767
|
+
// Check if property exists and is readonly
|
|
768
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
734
769
|
const currentValue = parent[lastKey];
|
|
735
770
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
736
|
-
throw new Error(`Cannot merge object into
|
|
771
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
737
772
|
}
|
|
738
773
|
assignGingerly(currentValue, value, options);
|
|
739
774
|
}
|
|
740
775
|
else {
|
|
741
|
-
// Property is writable
|
|
776
|
+
// Property is writable - replace it
|
|
742
777
|
parent[lastKey] = value;
|
|
743
778
|
}
|
|
744
779
|
}
|
|
@@ -751,18 +786,18 @@ export function assignGingerly(target, source, options) {
|
|
|
751
786
|
const lastKey = pathParts[pathParts.length - 1];
|
|
752
787
|
const parent = ensureNestedPath(target, pathParts);
|
|
753
788
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
754
|
-
// Check if property exists and is readonly
|
|
755
|
-
if (lastKey in parent &&
|
|
756
|
-
// Property is readonly
|
|
789
|
+
// Check if property exists and is readonly
|
|
790
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
791
|
+
// Property is readonly - check if current value is an object
|
|
757
792
|
const currentValue = parent[lastKey];
|
|
758
793
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
759
|
-
throw new Error(`Cannot merge object into
|
|
794
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
760
795
|
}
|
|
761
|
-
// Recursively apply assignGingerly to the readonly object
|
|
796
|
+
// Recursively apply assignGingerly to the readonly object
|
|
762
797
|
assignGingerly(currentValue, value, options);
|
|
763
798
|
}
|
|
764
799
|
else {
|
|
765
|
-
// Property is writable
|
|
800
|
+
// Property is writable - replace it
|
|
766
801
|
parent[lastKey] = value;
|
|
767
802
|
}
|
|
768
803
|
}
|
|
@@ -789,18 +824,18 @@ export function assignGingerly(target, source, options) {
|
|
|
789
824
|
}
|
|
790
825
|
// Normal assignment
|
|
791
826
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
792
|
-
// Check if property exists and is readonly
|
|
793
|
-
if (key in target &&
|
|
794
|
-
// Property is readonly
|
|
827
|
+
// Check if property exists and is readonly
|
|
828
|
+
if (key in target && isReadonlyProperty(target, key)) {
|
|
829
|
+
// Property is readonly - check if current value is an object
|
|
795
830
|
const currentValue = target[key];
|
|
796
831
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
797
|
-
throw new Error(`Cannot merge object into
|
|
832
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(key)}'`);
|
|
798
833
|
}
|
|
799
|
-
// Recursively apply assignGingerly to the readonly object
|
|
834
|
+
// Recursively apply assignGingerly to the readonly object
|
|
800
835
|
assignGingerly(currentValue, value, options);
|
|
801
836
|
}
|
|
802
837
|
else {
|
|
803
|
-
// Property is writable
|
|
838
|
+
// Property is writable - replace it
|
|
804
839
|
target[key] = value;
|
|
805
840
|
}
|
|
806
841
|
}
|
package/assignGingerly.ts
CHANGED
|
@@ -326,11 +326,13 @@ function parseDeleteCommand(key: string): string | null {
|
|
|
326
326
|
|
|
327
327
|
/**
|
|
328
328
|
* Helper function to parse a path string with ?. notation
|
|
329
|
+
* Always splits on '?.' delimiter, preserving dots that are part of values
|
|
330
|
+
* (e.g., CSS class selectors like '.username')
|
|
331
|
+
* Paths must use ?. notation — plain dot notation is not supported.
|
|
329
332
|
*/
|
|
330
333
|
function parsePath(path: string): string[] {
|
|
331
334
|
return path
|
|
332
|
-
.split('
|
|
333
|
-
.map(part => part.replace(/\?/g, ''))
|
|
335
|
+
.split('?.')
|
|
334
336
|
.filter(part => part.length > 0);
|
|
335
337
|
}
|
|
336
338
|
|
|
@@ -356,11 +358,14 @@ function ensureNestedPath(obj: any, pathParts: string[]): any {
|
|
|
356
358
|
}
|
|
357
359
|
|
|
358
360
|
/**
|
|
359
|
-
* Helper function to check if a property
|
|
360
|
-
* A property is
|
|
361
|
+
* Helper function to check if a property should be merged into rather than replaced.
|
|
362
|
+
* A property is non-replaceable if:
|
|
361
363
|
* - It's a data property with writable: false, OR
|
|
362
364
|
* - It's an accessor property with a getter but no setter
|
|
363
365
|
*
|
|
366
|
+
* Properties with both a getter and setter (e.g., element.style) are treated as
|
|
367
|
+
* replaceable — the setter runs with whatever value is provided (garbage in, garbage out).
|
|
368
|
+
*
|
|
364
369
|
* Exported for use by eachTime.ts
|
|
365
370
|
*/
|
|
366
371
|
export function isReadonlyProperty(obj: any, propName: string | symbol): boolean {
|
|
@@ -383,8 +388,8 @@ export function isReadonlyProperty(obj: any, propName: string | symbol): boolean
|
|
|
383
388
|
return descriptor.writable === false;
|
|
384
389
|
}
|
|
385
390
|
|
|
386
|
-
// If it's an accessor property
|
|
387
|
-
if ('get' in descriptor) {
|
|
391
|
+
// If it's an accessor property with a getter but no setter, it's readonly
|
|
392
|
+
if ('get' in descriptor && descriptor.get !== undefined) {
|
|
388
393
|
return descriptor.set === undefined;
|
|
389
394
|
}
|
|
390
395
|
|
|
@@ -589,10 +594,10 @@ function applyToEach(
|
|
|
589
594
|
const parent = result.target;
|
|
590
595
|
|
|
591
596
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
592
|
-
if (lastKey in parent &&
|
|
597
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
593
598
|
const currentValue = parent[lastKey];
|
|
594
599
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
595
|
-
throw new Error(`Cannot merge object into
|
|
600
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
596
601
|
}
|
|
597
602
|
assignGingerly(currentValue, value, options);
|
|
598
603
|
} else {
|
|
@@ -733,16 +738,22 @@ export function assignGingerly(
|
|
|
733
738
|
if (isIncCommand(key)) {
|
|
734
739
|
const path = parseIncCommand(key);
|
|
735
740
|
if (path) {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
741
|
+
if (isNestedPath(path)) {
|
|
742
|
+
const pathParts = parsePath(path);
|
|
743
|
+
const lastKey = pathParts[pathParts.length - 1];
|
|
744
|
+
const parent = ensureNestedPath(target, pathParts);
|
|
745
|
+
if (!(lastKey in parent)) {
|
|
746
|
+
parent[lastKey] = value;
|
|
747
|
+
} else {
|
|
748
|
+
parent[lastKey] += value;
|
|
749
|
+
}
|
|
743
750
|
} else {
|
|
744
|
-
//
|
|
745
|
-
|
|
751
|
+
// Plain key - direct operation on target
|
|
752
|
+
if (!(path in target)) {
|
|
753
|
+
target[path] = value;
|
|
754
|
+
} else {
|
|
755
|
+
target[path] += value;
|
|
756
|
+
}
|
|
746
757
|
}
|
|
747
758
|
}
|
|
748
759
|
continue;
|
|
@@ -754,10 +765,17 @@ export function assignGingerly(
|
|
|
754
765
|
if (lhsPath) {
|
|
755
766
|
const rhsPath = value;
|
|
756
767
|
|
|
757
|
-
//
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
768
|
+
// Resolve LHS
|
|
769
|
+
let lhsParent: any;
|
|
770
|
+
let lhsLastKey: string;
|
|
771
|
+
if (isNestedPath(lhsPath)) {
|
|
772
|
+
const lhsPathParts = parsePath(lhsPath);
|
|
773
|
+
lhsLastKey = lhsPathParts[lhsPathParts.length - 1];
|
|
774
|
+
lhsParent = ensureNestedPath(target, lhsPathParts);
|
|
775
|
+
} else {
|
|
776
|
+
lhsLastKey = lhsPath;
|
|
777
|
+
lhsParent = target;
|
|
778
|
+
}
|
|
761
779
|
|
|
762
780
|
// Determine what to negate
|
|
763
781
|
let valueToNegate;
|
|
@@ -766,26 +784,27 @@ export function assignGingerly(
|
|
|
766
784
|
if (lhsLastKey in lhsParent) {
|
|
767
785
|
valueToNegate = lhsParent[lhsLastKey];
|
|
768
786
|
} else {
|
|
769
|
-
// LHS doesn't exist, treat as undefined -> !undefined = true
|
|
770
787
|
valueToNegate = undefined;
|
|
771
788
|
}
|
|
772
789
|
} else {
|
|
773
790
|
// RHS path: navigate to get the value (don't create paths)
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
791
|
+
if (isNestedPath(rhsPath)) {
|
|
792
|
+
const rhsPathParts = parsePath(rhsPath);
|
|
793
|
+
let current = target;
|
|
794
|
+
let exists = true;
|
|
795
|
+
for (const part of rhsPathParts) {
|
|
796
|
+
if (current && typeof current === 'object' && part in current) {
|
|
797
|
+
current = current[part];
|
|
798
|
+
} else {
|
|
799
|
+
exists = false;
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
784
802
|
}
|
|
803
|
+
valueToNegate = exists ? current : true;
|
|
804
|
+
} else {
|
|
805
|
+
// Plain key RHS
|
|
806
|
+
valueToNegate = (rhsPath in target) ? target[rhsPath] : true;
|
|
785
807
|
}
|
|
786
|
-
|
|
787
|
-
// If RHS doesn't exist, treat as truthy (will become false)
|
|
788
|
-
valueToNegate = exists ? current : true;
|
|
789
808
|
}
|
|
790
809
|
|
|
791
810
|
// Apply negation to LHS
|
|
@@ -798,31 +817,36 @@ export function assignGingerly(
|
|
|
798
817
|
if (isDeleteCommand(key)) {
|
|
799
818
|
const path = parseDeleteCommand(key);
|
|
800
819
|
if (path !== null) {
|
|
801
|
-
const pathParts = parsePath(path);
|
|
802
|
-
|
|
803
820
|
// Determine the parent object
|
|
804
821
|
let parent = target;
|
|
805
822
|
let canDelete = true;
|
|
806
823
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
824
|
+
if (isNestedPath(path)) {
|
|
825
|
+
const pathParts = parsePath(path);
|
|
826
|
+
if (pathParts.length === 0) {
|
|
827
|
+
parent = target;
|
|
828
|
+
} else {
|
|
829
|
+
for (const part of pathParts) {
|
|
830
|
+
if (parent && typeof parent === 'object' && part in parent) {
|
|
831
|
+
parent = parent[part];
|
|
832
|
+
} else {
|
|
833
|
+
canDelete = false;
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
818
836
|
}
|
|
819
837
|
}
|
|
838
|
+
} else if (path.length > 0) {
|
|
839
|
+
// Plain key - navigate one level
|
|
840
|
+
if (parent && typeof parent === 'object' && path in parent) {
|
|
841
|
+
parent = parent[path];
|
|
842
|
+
} else {
|
|
843
|
+
canDelete = false;
|
|
844
|
+
}
|
|
820
845
|
}
|
|
846
|
+
// else: empty path = delete from root
|
|
821
847
|
|
|
822
848
|
if (canDelete && typeof parent === 'object' && parent !== null) {
|
|
823
|
-
// RHS can be a string (single property) or array (multiple properties)
|
|
824
849
|
const propertiesToDelete = Array.isArray(value) ? value : [value];
|
|
825
|
-
|
|
826
850
|
for (const prop of propertiesToDelete) {
|
|
827
851
|
if (prop in parent) {
|
|
828
852
|
delete parent[prop];
|
|
@@ -918,15 +942,15 @@ export function assignGingerly(
|
|
|
918
942
|
const parent = result.target;
|
|
919
943
|
|
|
920
944
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
921
|
-
// Check if property exists and is readonly
|
|
922
|
-
if (lastKey in parent &&
|
|
945
|
+
// Check if property exists and is readonly
|
|
946
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
923
947
|
const currentValue = parent[lastKey];
|
|
924
948
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
925
|
-
throw new Error(`Cannot merge object into
|
|
949
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
926
950
|
}
|
|
927
951
|
assignGingerly(currentValue, value, options);
|
|
928
952
|
} else {
|
|
929
|
-
// Property is writable
|
|
953
|
+
// Property is writable - replace it
|
|
930
954
|
parent[lastKey] = value;
|
|
931
955
|
}
|
|
932
956
|
} else {
|
|
@@ -938,17 +962,17 @@ export function assignGingerly(
|
|
|
938
962
|
const parent = ensureNestedPath(target, pathParts);
|
|
939
963
|
|
|
940
964
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
941
|
-
// Check if property exists and is readonly
|
|
942
|
-
if (lastKey in parent &&
|
|
943
|
-
// Property is readonly
|
|
965
|
+
// Check if property exists and is readonly
|
|
966
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
967
|
+
// Property is readonly - check if current value is an object
|
|
944
968
|
const currentValue = parent[lastKey];
|
|
945
969
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
946
|
-
throw new Error(`Cannot merge object into
|
|
970
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
947
971
|
}
|
|
948
|
-
// Recursively apply assignGingerly to the readonly object
|
|
972
|
+
// Recursively apply assignGingerly to the readonly object
|
|
949
973
|
assignGingerly(currentValue, value, options);
|
|
950
974
|
} else {
|
|
951
|
-
// Property is writable
|
|
975
|
+
// Property is writable - replace it
|
|
952
976
|
parent[lastKey] = value;
|
|
953
977
|
}
|
|
954
978
|
} else {
|
|
@@ -974,17 +998,17 @@ export function assignGingerly(
|
|
|
974
998
|
|
|
975
999
|
// Normal assignment
|
|
976
1000
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
977
|
-
// Check if property exists and is readonly
|
|
978
|
-
if (key in target &&
|
|
979
|
-
// Property is readonly
|
|
1001
|
+
// Check if property exists and is readonly
|
|
1002
|
+
if (key in target && isReadonlyProperty(target, key)) {
|
|
1003
|
+
// Property is readonly - check if current value is an object
|
|
980
1004
|
const currentValue = target[key];
|
|
981
1005
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
982
|
-
throw new Error(`Cannot merge object into
|
|
1006
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(key)}'`);
|
|
983
1007
|
}
|
|
984
|
-
// Recursively apply assignGingerly to the readonly object
|
|
1008
|
+
// Recursively apply assignGingerly to the readonly object
|
|
985
1009
|
assignGingerly(currentValue, value, options);
|
|
986
1010
|
} else {
|
|
987
|
-
// Property is writable
|
|
1011
|
+
// Property is writable - replace it
|
|
988
1012
|
target[key] = value;
|
|
989
1013
|
}
|
|
990
1014
|
} else {
|
package/eachTime.js
CHANGED
|
@@ -58,7 +58,7 @@ export async function handleEachTime(target, pathParts, forEachIndex, value, wit
|
|
|
58
58
|
(async () => {
|
|
59
59
|
try {
|
|
60
60
|
// Import needed functions from assignGingerly
|
|
61
|
-
const { evaluatePathWithMethods, assignGingerly, isReadonlyProperty
|
|
61
|
+
const { evaluatePathWithMethods, assignGingerly, isReadonlyProperty } = await import('./assignGingerly.js');
|
|
62
62
|
if (pathAfterForEach.length > 0) {
|
|
63
63
|
const result = evaluatePathWithMethods(mountedElement, pathAfterForEach, value, withMethods || new Set());
|
|
64
64
|
if (result.isMethod) {
|
|
@@ -78,17 +78,17 @@ export async function handleEachTime(target, pathParts, forEachIndex, value, wit
|
|
|
78
78
|
const lastKey = result.lastKey;
|
|
79
79
|
const parent = result.target;
|
|
80
80
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
81
|
-
// Check if property exists and is readonly
|
|
82
|
-
if (lastKey in parent &&
|
|
81
|
+
// Check if property exists and is readonly
|
|
82
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
83
83
|
const currentValue = parent[lastKey];
|
|
84
84
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
85
|
-
throw new Error(`Cannot merge object into
|
|
85
|
+
throw new Error(`Cannot merge object into readonly primitive property '${String(lastKey)}'`);
|
|
86
86
|
}
|
|
87
87
|
// Recursively apply assignGingerly
|
|
88
88
|
assignGingerly(currentValue, value, options);
|
|
89
89
|
}
|
|
90
90
|
else {
|
|
91
|
-
// Property is writable
|
|
91
|
+
// Property is writable - replace it
|
|
92
92
|
parent[lastKey] = value;
|
|
93
93
|
}
|
|
94
94
|
}
|
package/eachTime.ts
CHANGED
|
@@ -77,8 +77,7 @@ export async function handleEachTime(
|
|
|
77
77
|
const {
|
|
78
78
|
evaluatePathWithMethods,
|
|
79
79
|
assignGingerly,
|
|
80
|
-
isReadonlyProperty
|
|
81
|
-
isClassInstance
|
|
80
|
+
isReadonlyProperty
|
|
82
81
|
} = await import('./assignGingerly.js');
|
|
83
82
|
|
|
84
83
|
if (pathAfterForEach.length > 0) {
|
|
@@ -105,18 +104,18 @@ export async function handleEachTime(
|
|
|
105
104
|
const parent = result.target;
|
|
106
105
|
|
|
107
106
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
108
|
-
// Check if property exists and is readonly
|
|
109
|
-
if (lastKey in parent &&
|
|
107
|
+
// Check if property exists and is readonly
|
|
108
|
+
if (lastKey in parent && isReadonlyProperty(parent, lastKey)) {
|
|
110
109
|
const currentValue = parent[lastKey];
|
|
111
110
|
if (typeof currentValue !== 'object' || currentValue === null) {
|
|
112
111
|
throw new Error(
|
|
113
|
-
`Cannot merge object into
|
|
112
|
+
`Cannot merge object into readonly primitive property '${String(lastKey)}'`
|
|
114
113
|
);
|
|
115
114
|
}
|
|
116
115
|
// Recursively apply assignGingerly
|
|
117
116
|
assignGingerly(currentValue, value, options);
|
|
118
117
|
} else {
|
|
119
|
-
// Property is writable
|
|
118
|
+
// Property is writable - replace it
|
|
120
119
|
parent[lastKey] = value;
|
|
121
120
|
}
|
|
122
121
|
} else {
|
package/package.json
CHANGED