@schukai/monster 4.148.1 → 4.148.2

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/package.json CHANGED
@@ -1 +1 @@
1
- {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.148.1"}
1
+ {"author":"Volker Schukai","dependencies":{"@floating-ui/dom":"^1.7.6"},"description":"Monster is a simple library for creating fast, robust and lightweight websites.","homepage":"https://monsterjs.org/","keywords":["framework","web","dom","css","sass","mobile-first","app","front-end","templates","schukai","core","shopcloud","alvine","monster","buildmap","stack","observer","observable","uuid","node","nodelist","css-in-js","logger","log","theme"],"license":"AGPL 3.0","main":"source/monster.mjs","module":"source/monster.mjs","name":"@schukai/monster","repository":{"type":"git","url":"https://gitlab.schukai.com/oss/libraries/javascript/monster.git"},"type":"module","version":"4.148.2"}
@@ -43,6 +43,17 @@ const DELIMITER = ".";
43
43
  */
44
44
  const WILDCARD = "*";
45
45
 
46
+ /**
47
+ * Path segments that must never be traversed by mutation operations.
48
+ * @private
49
+ * @type {Set<string>}
50
+ */
51
+ const UNSAFE_MUTATION_PATH_SEGMENTS = new Set([
52
+ "__proto__",
53
+ "constructor",
54
+ "prototype",
55
+ ]);
56
+
46
57
  /**
47
58
  * Pathfinder is a class to find a path to an object.
48
59
  *
@@ -245,7 +256,12 @@ function getValueViaPath(subject, path, check) {
245
256
  }
246
257
 
247
258
  let parts;
248
- if (isString(path)) {
259
+ if (isArray(path)) {
260
+ if (path.length === 0) {
261
+ return subject;
262
+ }
263
+ parts = [...path];
264
+ } else {
249
265
  if (path === "") {
250
266
  return subject;
251
267
  }
@@ -263,10 +279,12 @@ function getValueViaPath(subject, path, check) {
263
279
  let anchor;
264
280
  if (subject instanceof Map || subject instanceof WeakMap) {
265
281
  anchor = subject.get(current);
266
- } else if (subject instanceof Set || subject instanceof WeakSet) {
282
+ } else if (subject instanceof Set) {
267
283
  current = parseInt(current);
268
284
  validateInteger(current);
269
285
  anchor = [...subject]?.[current];
286
+ } else if (subject instanceof WeakSet) {
287
+ throw Error("unsupported action for this data type (WeakSet)");
270
288
  } else if (typeof WeakRef === "function" && subject instanceof WeakRef) {
271
289
  throw Error("unsupported action for this data type (WeakRef)");
272
290
  } else if (isArray(subject)) {
@@ -318,6 +336,7 @@ function setValueViaPath(subject, path, value) {
318
336
  if (!(isArray(path) || isString(path))) {
319
337
  throw new Error("type error: a path must be a string or an array");
320
338
  }
339
+ validateMutationPath(path);
321
340
 
322
341
  let parts;
323
342
  if (isArray(path)) {
@@ -325,7 +344,7 @@ function setValueViaPath(subject, path, value) {
325
344
  return;
326
345
  }
327
346
 
328
- parts = path;
347
+ parts = [...path];
329
348
  } else {
330
349
  parts = path.split(DELIMITER);
331
350
  }
@@ -370,8 +389,10 @@ function setValueViaPath(subject, path, value) {
370
389
 
371
390
  if (anchor instanceof Map || anchor instanceof WeakMap) {
372
391
  anchor.set(last, value);
373
- } else if (anchor instanceof Set || anchor instanceof WeakSet) {
374
- anchor.append(value);
392
+ } else if (anchor instanceof Set) {
393
+ anchor.add(value);
394
+ } else if (anchor instanceof WeakSet) {
395
+ throw Error("unsupported action for this data type in setValueViaPath");
375
396
  } else if (typeof WeakRef === "function" && anchor instanceof WeakRef) {
376
397
  throw Error("unsupported action for this data type in setValueViaPath");
377
398
  } else if (isArray(anchor)) {
@@ -421,6 +442,7 @@ function deleteValueViaPath(subject, path) {
421
442
  "type error: a path must be a string or an array in deleteValueViaPath",
422
443
  );
423
444
  }
445
+ validateMutationPath(path);
424
446
 
425
447
  let parts;
426
448
  if (isArray(path)) {
@@ -428,7 +450,7 @@ function deleteValueViaPath(subject, path) {
428
450
  return;
429
451
  }
430
452
 
431
- parts = path;
453
+ parts = [...path];
432
454
  } else {
433
455
  parts = path.split(DELIMITER);
434
456
  }
@@ -455,3 +477,19 @@ function deleteValueViaPath(subject, path) {
455
477
  delete anchor[last];
456
478
  }
457
479
  }
480
+
481
+ /**
482
+ * Reject paths that could mutate an object's prototype chain.
483
+ * @param {string|array} path
484
+ * @return {void}
485
+ * @throws {Error} unsafe mutation path segment
486
+ * @private
487
+ */
488
+ function validateMutationPath(path) {
489
+ const parts = isArray(path) ? path : path.split(DELIMITER);
490
+ for (const part of parts) {
491
+ if (UNSAFE_MUTATION_PATH_SEGMENTS.has(`${part}`)) {
492
+ throw new Error(`unsafe mutation path segment: ${part}`);
493
+ }
494
+ }
495
+ }
@@ -69,7 +69,8 @@ export { Updater, addObjectWithUpdaterToElement };
69
69
  * @private
70
70
  * @type {symbol}
71
71
  */
72
- const timerElementEventHandlerSymbol = Symbol("timerElementEventHandler");
72
+ const controlEventTimersSymbol = Symbol("controlEventTimers");
73
+ const registeredEventTypesSymbol = Symbol("registeredEventTypes");
73
74
 
74
75
  /**
75
76
  * @private
@@ -87,9 +88,29 @@ const applyChangeSymbol = Symbol("applyChange");
87
88
  const updaterRootSymbol = Symbol.for("@schukai/monster/dom/@@updater-root");
88
89
  const disposedSymbol = Symbol("disposed");
89
90
  const subjectObserverSymbol = Symbol("subjectObserver");
91
+ const subjectRevisionSymbol = Symbol("subjectRevision");
90
92
  const patchNodeKeySymbol = Symbol("patchNodeKey");
91
93
  const queuedSnapshotSymbol = Symbol("queuedSnapshot");
92
94
 
95
+ /**
96
+ * Tracks every notification request, including requests that Observer deduplicates
97
+ * while its asynchronous callback is already running.
98
+ * @private
99
+ */
100
+ class UpdaterSubjectObserver extends Observer {
101
+ #onNotification;
102
+
103
+ constructor(callback, onNotification) {
104
+ super(callback);
105
+ this.#onNotification = onNotification;
106
+ }
107
+
108
+ update(subject) {
109
+ this.#onNotification();
110
+ return super.update(subject);
111
+ }
112
+ }
113
+
93
114
  /**
94
115
  * The updater class connects an object with the DOM. In this way, structures and contents in the DOM can be
95
116
  * programmatically adapted via attributes.
@@ -165,23 +186,34 @@ class Updater extends Base {
165
186
  this[pendingDiffsSymbol] = [];
166
187
  this[processingSymbol] = false;
167
188
  this[disposedSymbol] = false;
189
+ this[subjectRevisionSymbol] = 0;
168
190
  this[queuedSnapshotSymbol] = clone(this[internalSymbol].last);
191
+ this[controlEventTimersSymbol] = new Map();
192
+ this[registeredEventTypesSymbol] = new Set();
193
+
194
+ this[subjectObserverSymbol] = new UpdaterSubjectObserver(
195
+ async () => {
196
+ let processedRevision;
197
+ do {
198
+ processedRevision = this[subjectRevisionSymbol];
199
+ if (this[disposedSymbol] === true) {
200
+ return;
201
+ }
169
202
 
170
- this[subjectObserverSymbol] = new Observer(() => {
171
- if (this[disposedSymbol] === true) {
172
- return Promise.resolve();
173
- }
174
-
175
- const real = this[internalSymbol].subject.getRealSubject();
176
- const diffResult = diff(this[queuedSnapshotSymbol], real);
177
- if (diffResult.length === 0) {
178
- return Promise.resolve();
179
- }
180
- const snapshot = clone(real);
181
- this[queuedSnapshotSymbol] = snapshot;
182
- this[pendingDiffsSymbol].push({ diffResult, snapshot });
183
- return this[processQueueSymbol]();
184
- });
203
+ const real = this[internalSymbol].subject.getRealSubject();
204
+ const diffResult = diff(this[queuedSnapshotSymbol], real);
205
+ if (diffResult.length > 0) {
206
+ const snapshot = clone(real);
207
+ this[queuedSnapshotSymbol] = snapshot;
208
+ this[pendingDiffsSymbol].push({ diffResult, snapshot });
209
+ await this[processQueueSymbol]();
210
+ }
211
+ } while (processedRevision !== this[subjectRevisionSymbol]);
212
+ },
213
+ () => {
214
+ this[subjectRevisionSymbol]++;
215
+ },
216
+ );
185
217
  this[internalSymbol].subject.attachObserver(this[subjectObserverSymbol]);
186
218
  }
187
219
 
@@ -317,6 +349,7 @@ class Updater extends Base {
317
349
  passive: true,
318
350
  },
319
351
  );
352
+ this[registeredEventTypesSymbol].add(type);
320
353
  }
321
354
 
322
355
  return this;
@@ -329,12 +362,23 @@ class Updater extends Base {
329
362
  * @return {Updater}
330
363
  */
331
364
  disableEventProcessing() {
332
- for (const type of this[internalSymbol].eventTypes) {
365
+ for (const type of this[registeredEventTypesSymbol]) {
333
366
  this[internalSymbol].element.removeEventListener(
334
367
  type,
335
368
  getControlEventHandler.call(this),
369
+ { capture: true },
336
370
  );
337
371
  }
372
+ this[registeredEventTypesSymbol].clear();
373
+
374
+ for (const timer of this[controlEventTimersSymbol].values()) {
375
+ try {
376
+ timer.defuse();
377
+ } catch (e) {
378
+ // A timer that already ran no longer has pending work to cancel.
379
+ }
380
+ }
381
+ this[controlEventTimersSymbol].clear();
338
382
 
339
383
  delete this[internalSymbol].element[updaterRootSymbol];
340
384
 
@@ -354,8 +398,6 @@ class Updater extends Base {
354
398
  this[internalSymbol].subject.detachObserver(this[subjectObserverSymbol]);
355
399
  }
356
400
 
357
- delete this[timerElementEventHandlerSymbol];
358
-
359
401
  return this;
360
402
  }
361
403
 
@@ -495,21 +537,24 @@ function getControlEventHandler() {
495
537
  return;
496
538
  }
497
539
 
498
- if (this[timerElementEventHandlerSymbol] instanceof DeadMansSwitch) {
540
+ const timers = this[controlEventTimersSymbol];
541
+ let timer = timers.get(element);
542
+ if (timer instanceof DeadMansSwitch) {
499
543
  try {
500
- this[timerElementEventHandlerSymbol].touch();
544
+ timer.touch();
501
545
  return;
502
546
  } catch (e) {
503
- delete this[timerElementEventHandlerSymbol];
547
+ timers.delete(element);
504
548
  }
505
549
  }
506
550
 
507
- this[timerElementEventHandlerSymbol] = new DeadMansSwitch(50, () => {
551
+ timer = new DeadMansSwitch(50, () => {
508
552
  if (
509
553
  this[disposedSymbol] === true ||
510
554
  !this[internalSymbol].element?.isConnected ||
511
555
  !element?.isConnected
512
556
  ) {
557
+ timers.delete(element);
513
558
  return;
514
559
  }
515
560
 
@@ -517,8 +562,11 @@ function getControlEventHandler() {
517
562
  retrieveAndSetValue.call(this, element);
518
563
  } catch (e) {
519
564
  addErrorAttribute(element, e);
565
+ } finally {
566
+ timers.delete(element);
520
567
  }
521
568
  });
569
+ timers.set(element, timer);
522
570
  };
523
571
 
524
572
  return this[symbol];
@@ -1091,6 +1139,7 @@ function runUpdateContent(container, parts, subject) {
1091
1139
  value = pipe.run(subject);
1092
1140
  } catch (e) {
1093
1141
  element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
1142
+ continue;
1094
1143
  }
1095
1144
 
1096
1145
  if (value instanceof HTMLElement) {
@@ -1529,6 +1578,7 @@ function runUpdateAttributes(container, parts, subject) {
1529
1578
  }
1530
1579
 
1531
1580
  const attributes = element.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);
1581
+ element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
1532
1582
 
1533
1583
  for (let [, def] of Object.entries(attributes.split(","))) {
1534
1584
  def = trimSpaces(def);
@@ -1544,10 +1594,10 @@ function runUpdateAttributes(container, parts, subject) {
1544
1594
 
1545
1595
  let value;
1546
1596
  try {
1547
- element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
1548
1597
  value = pipe.run(subject);
1549
1598
  } catch (e) {
1550
1599
  addErrorAttribute(element, e);
1600
+ continue;
1551
1601
  }
1552
1602
 
1553
1603
  if (value === undefined) {
@@ -1604,6 +1654,7 @@ function runUpdateProperties(container, parts, subject) {
1604
1654
  }
1605
1655
 
1606
1656
  const properties = element.getAttribute(ATTRIBUTE_UPDATER_PROPERTIES);
1657
+ element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
1607
1658
 
1608
1659
  for (let [, def] of Object.entries(properties.split(","))) {
1609
1660
  def = trimSpaces(def);
@@ -1619,10 +1670,10 @@ function runUpdateProperties(container, parts, subject) {
1619
1670
 
1620
1671
  let value;
1621
1672
  try {
1622
- element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
1623
1673
  value = pipe.run(subject);
1624
1674
  } catch (e) {
1625
1675
  addErrorAttribute(element, e);
1676
+ continue;
1626
1677
  }
1627
1678
 
1628
1679
  handleInputControlPropertyUpdate.call(this, element, name, value);
@@ -173,6 +173,10 @@ function getHandler() {
173
173
  return value;
174
174
  }
175
175
 
176
+ if (isNativeObjectWithInternalSlots(value)) {
177
+ return value;
178
+ }
179
+
176
180
  // set value as proxy if object or array
177
181
  if (isArray(value) || isObject(value)) {
178
182
  if (proxy.objectMap.has(value)) {
@@ -264,3 +268,14 @@ function getHandler() {
264
268
 
265
269
  return handler;
266
270
  }
271
+
272
+ /**
273
+ * Native objects with internal slots cannot be used through a transparent Proxy
274
+ * because their prototype methods require the original receiver.
275
+ * @param {*} value
276
+ * @return {boolean}
277
+ * @private
278
+ */
279
+ function isNativeObjectWithInternalSlots(value) {
280
+ return value instanceof Date || value instanceof Map || value instanceof Set;
281
+ }
@@ -135,13 +135,14 @@ function cloneObject(obj) {
135
135
  validateObject(obj);
136
136
 
137
137
  const fkt = obj?.["constructor"];
138
+ const sourcePrototype = Object.getPrototypeOf(obj);
138
139
 
139
140
  /** Object has clone method */
140
141
  if (typeOf(fkt) === "function") {
141
142
  const prototype = fkt?.prototype;
142
143
  if (typeof prototype === "object") {
143
144
  if (
144
- prototype.hasOwnProperty("getClone") &&
145
+ Object.prototype.hasOwnProperty.call(prototype, "getClone") &&
145
146
  typeOf(obj.getClone) === "function"
146
147
  ) {
147
148
  return obj.getClone();
@@ -149,16 +150,17 @@ function cloneObject(obj) {
149
150
  }
150
151
  }
151
152
 
152
- let copy = {};
153
+ let copy = sourcePrototype === null ? Object.create(null) : {};
153
154
  if (
154
- typeof obj.constructor === "function" &&
155
- typeof obj.constructor.call === "function"
155
+ sourcePrototype !== null &&
156
+ typeof fkt === "function" &&
157
+ typeof fkt.call === "function"
156
158
  ) {
157
- copy = new obj.constructor();
159
+ copy = new fkt();
158
160
  }
159
161
 
160
162
  for (const key in obj) {
161
- if (!obj.hasOwnProperty(key)) {
163
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) {
162
164
  continue;
163
165
  }
164
166
 
@@ -5,6 +5,111 @@ import {Pathfinder} from "../../../source/data/pathfinder.mjs";
5
5
 
6
6
  describe('Pathfinder', function () {
7
7
 
8
+ describe('mutation path security issue #492', function () {
9
+ const probe = 'monsterPathfinderSecurityProbe';
10
+
11
+ afterEach(function () {
12
+ delete Object.prototype[probe];
13
+ });
14
+
15
+ [
16
+ `__proto__.${probe}`,
17
+ `constructor.prototype.${probe}`,
18
+ ['__proto__', probe],
19
+ ['constructor', 'prototype', probe],
20
+ ].forEach(function (path) {
21
+ it(`setVia(${JSON.stringify(path)}) should reject unsafe mutation paths`, function () {
22
+ try {
23
+ expect(() => new Pathfinder({}).setVia(path, 'polluted')).to.throw(Error);
24
+ expect(Object.prototype).to.not.have.own.property(probe);
25
+ } finally {
26
+ delete Object.prototype[probe];
27
+ }
28
+ });
29
+ });
30
+
31
+ it('deleteVia() should not cross a prototype boundary', function () {
32
+ Object.defineProperty(Object.prototype, probe, {
33
+ configurable: true,
34
+ value: 'preserved',
35
+ writable: true,
36
+ });
37
+
38
+ try {
39
+ expect(() => new Pathfinder({}).deleteVia(`__proto__.${probe}`)).to.throw(Error);
40
+ expect(Object.prototype[probe]).to.equal('preserved');
41
+ } finally {
42
+ delete Object.prototype[probe];
43
+ }
44
+ });
45
+
46
+ it('setVia() should keep safe paths working', function () {
47
+ const subject = {};
48
+ new Pathfinder(subject).setVia('safe.nested.value', true);
49
+ expect(subject).to.deep.equal({safe: {nested: {value: true}}});
50
+ });
51
+ });
52
+
53
+ describe('array and collection path contracts issue #493', function () {
54
+ it('getVia() should accept an array path without mutating it', function () {
55
+ const path = ['a', 'b'];
56
+ const subject = {a: {b: 4}};
57
+
58
+ expect(new Pathfinder(subject).getVia(path)).to.equal(4);
59
+ expect(path).to.deep.equal(['a', 'b']);
60
+ });
61
+
62
+ it('getVia([]) should return the root subject', function () {
63
+ const subject = {a: true};
64
+ expect(new Pathfinder(subject).getVia([])).to.equal(subject);
65
+ });
66
+
67
+ it('setVia() should not mutate an array path', function () {
68
+ const path = ['a', 'b'];
69
+ const subject = {};
70
+
71
+ new Pathfinder(subject).setVia(path, 4);
72
+
73
+ expect(subject).to.deep.equal({a: {b: 4}});
74
+ expect(path).to.deep.equal(['a', 'b']);
75
+ });
76
+
77
+ it('deleteVia() should not mutate an array path', function () {
78
+ const path = ['a', 'b'];
79
+ const subject = {a: {b: 4, c: 5}};
80
+
81
+ new Pathfinder(subject).deleteVia(path);
82
+
83
+ expect(subject).to.deep.equal({a: {c: 5}});
84
+ expect(path).to.deep.equal(['a', 'b']);
85
+ });
86
+
87
+ it('empty mutation paths should be no-ops', function () {
88
+ const setPath = [];
89
+ const deletePath = [];
90
+ const subject = {a: true};
91
+ const pathfinder = new Pathfinder(subject);
92
+
93
+ pathfinder.setVia(setPath, false).deleteVia(deletePath);
94
+
95
+ expect(subject).to.deep.equal({a: true});
96
+ expect(setPath).to.deep.equal([]);
97
+ expect(deletePath).to.deep.equal([]);
98
+ });
99
+
100
+ it('setVia() should add values to a Set', function () {
101
+ const values = new Set(['first']);
102
+ new Pathfinder({values}).setVia('values.next', 'second');
103
+ expect([...values]).to.deep.equal(['first', 'second']);
104
+ });
105
+
106
+ it('setVia() should reject WeakSet writes explicitly', function () {
107
+ const values = new WeakSet();
108
+ expect(() => new Pathfinder({values}).setVia('values.next', {}))
109
+ .to.throw(Error, 'unsupported action for this data type in setValueViaPath');
110
+ });
111
+ });
112
+
8
113
  let convertMapResult = function (r) {
9
114
  if (r instanceof Map) {
10
115
  r = Object.fromEntries(r);
@@ -419,4 +524,3 @@ describe('Pathfinder', function () {
419
524
 
420
525
  });
421
526
  });
422
-
@@ -383,6 +383,21 @@ describe("DOM", function () {
383
383
  expect(u.getSubject().a).to.be.equal(1);
384
384
  });
385
385
 
386
+ it("run() should accept dictionary payload keys issue #494", async function () {
387
+ element.innerHTML = `<span data-monster-replace="path:nested.value"></span>`;
388
+ const u = new Updater(element, {
389
+ hasOwnProperty: "payload",
390
+ nested: { value: "rendered" },
391
+ });
392
+
393
+ try {
394
+ await u.run();
395
+ expect(element.textContent).to.equal("rendered");
396
+ } finally {
397
+ u.dispose();
398
+ }
399
+ });
400
+
386
401
  it("enableEventProcessing() should be chainable", function () {
387
402
  const u = new Updater(element);
388
403
  expect(u.enableEventProcessing()).to.be.instanceof(Updater);
@@ -1892,6 +1907,37 @@ describe("DOM", function () {
1892
1907
  });
1893
1908
 
1894
1909
  describe("Updater reactive updates", function () {
1910
+ [false, true].forEach((batchUpdates) => {
1911
+ it(`should preserve microtask changes with batchUpdates=${batchUpdates} issue #496`, async function () {
1912
+ let mocks = document.getElementById("mocks");
1913
+ mocks.innerHTML = `
1914
+ <span id="microtask-a" data-monster-replace="path:a"></span>
1915
+ <span id="microtask-b" data-monster-replace="path:b"></span>
1916
+ `;
1917
+
1918
+ const updater = new Updater(mocks, { a: "A1", b: "B1" });
1919
+ updater.setBatchUpdates(batchUpdates);
1920
+
1921
+ try {
1922
+ await updater.run();
1923
+ const subject = updater.getSubject();
1924
+ subject.a = "A2";
1925
+ queueMicrotask(() => {
1926
+ subject.b = "B2";
1927
+ });
1928
+
1929
+ await new Promise((resolve) => setTimeout(resolve, 20));
1930
+
1931
+ expect(subject.a).to.equal("A2");
1932
+ expect(subject.b).to.equal("B2");
1933
+ expect(document.getElementById("microtask-a").textContent).to.equal("A2");
1934
+ expect(document.getElementById("microtask-b").textContent).to.equal("B2");
1935
+ } finally {
1936
+ updater.dispose();
1937
+ }
1938
+ });
1939
+ });
1940
+
1895
1941
  it("should update the DOM when the subject changes", function (done) {
1896
1942
  let mocks = document.getElementById("mocks");
1897
1943
  mocks.innerHTML = html1;
@@ -2017,4 +2063,251 @@ describe("DOM", function () {
2017
2063
  .catch((e) => done(e));
2018
2064
  });
2019
2065
  });
2066
+
2067
+ describe("Updater event lifecycle issue #497", function () {
2068
+ const waitForBinding = () => new Promise((resolve) => setTimeout(resolve, 80));
2069
+
2070
+ it("should debounce different bound controls independently", async function () {
2071
+ const mocks = document.getElementById("mocks");
2072
+ mocks.innerHTML = `
2073
+ <div id="event-root">
2074
+ <input id="event-first" data-monster-bind="path:first">
2075
+ <input id="event-second" data-monster-bind="path:second">
2076
+ </div>
2077
+ `;
2078
+ const updater = new Updater(document.getElementById("event-root"), {});
2079
+ updater.enableEventProcessing();
2080
+
2081
+ try {
2082
+ const first = document.getElementById("event-first");
2083
+ const second = document.getElementById("event-second");
2084
+ first.value = "A";
2085
+ first.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
2086
+ second.value = "B";
2087
+ second.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
2088
+
2089
+ await waitForBinding();
2090
+
2091
+ expect(updater.getSubject().first).to.equal("A");
2092
+ expect(updater.getSubject().second).to.equal("B");
2093
+ } finally {
2094
+ updater.dispose();
2095
+ }
2096
+ });
2097
+
2098
+ it("should coalesce a soft keyboard event burst to the latest value", async function () {
2099
+ const mocks = document.getElementById("mocks");
2100
+ mocks.innerHTML = `
2101
+ <div id="keyboard-root">
2102
+ <input id="keyboard-input" data-monster-bind="path:value">
2103
+ </div>
2104
+ `;
2105
+ const updater = new Updater(document.getElementById("keyboard-root"), {});
2106
+ updater.enableEventProcessing();
2107
+
2108
+ try {
2109
+ const input = document.getElementById("keyboard-input");
2110
+ input.value = "intermediate";
2111
+ input.dispatchEvent(new InputEvent("input", {
2112
+ bubbles: true,
2113
+ composed: true,
2114
+ data: "i",
2115
+ isComposing: true,
2116
+ }));
2117
+ input.value = "final";
2118
+ input.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, composed: true }));
2119
+ input.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
2120
+
2121
+ await waitForBinding();
2122
+
2123
+ expect(updater.getSubject().value).to.equal("final");
2124
+ } finally {
2125
+ updater.dispose();
2126
+ }
2127
+ });
2128
+
2129
+ it("disableEventProcessing() should cancel pending binding work", async function () {
2130
+ const mocks = document.getElementById("mocks");
2131
+ mocks.innerHTML = `
2132
+ <div id="disable-root">
2133
+ <input id="disable-input" data-monster-bind="path:value">
2134
+ </div>
2135
+ `;
2136
+ const updater = new Updater(document.getElementById("disable-root"), {});
2137
+ updater.enableEventProcessing();
2138
+
2139
+ try {
2140
+ const input = document.getElementById("disable-input");
2141
+ input.value = "ignored";
2142
+ input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
2143
+ updater.disableEventProcessing();
2144
+
2145
+ await waitForBinding();
2146
+
2147
+ expect(updater.getSubject()).to.not.have.property("value");
2148
+ } finally {
2149
+ updater.dispose();
2150
+ }
2151
+ });
2152
+
2153
+ it("dispose() should cancel pending binding work", async function () {
2154
+ const mocks = document.getElementById("mocks");
2155
+ mocks.innerHTML = `
2156
+ <div id="dispose-root">
2157
+ <input id="dispose-input" data-monster-bind="path:value">
2158
+ </div>
2159
+ `;
2160
+ const updater = new Updater(document.getElementById("dispose-root"), {});
2161
+ const subject = updater.getSubject();
2162
+ updater.enableEventProcessing();
2163
+
2164
+ const input = document.getElementById("dispose-input");
2165
+ input.value = "ignored";
2166
+ input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
2167
+ updater.dispose();
2168
+
2169
+ await waitForBinding();
2170
+
2171
+ expect(subject).to.not.have.property("value");
2172
+ });
2173
+
2174
+ it("should remove previously registered event types before re-enabling", async function () {
2175
+ const mocks = document.getElementById("mocks");
2176
+ mocks.innerHTML = `
2177
+ <div id="types-root">
2178
+ <input id="types-input" data-monster-bind="path:value">
2179
+ </div>
2180
+ `;
2181
+ const updater = new Updater(document.getElementById("types-root"), {});
2182
+ updater.enableEventProcessing();
2183
+ updater.setEventTypes(["blur"]);
2184
+ updater.enableEventProcessing();
2185
+
2186
+ try {
2187
+ const input = document.getElementById("types-input");
2188
+ input.value = "stale-input-listener";
2189
+ input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
2190
+ await waitForBinding();
2191
+ expect(updater.getSubject()).to.not.have.property("value");
2192
+
2193
+ input.value = "blur-value";
2194
+ input.dispatchEvent(new Event("blur", { bubbles: false, composed: true }));
2195
+ await waitForBinding();
2196
+ expect(updater.getSubject().value).to.equal("blur-value");
2197
+ } finally {
2198
+ updater.dispose();
2199
+ }
2200
+ });
2201
+ });
2202
+
2203
+ describe("Updater pipe failure recovery issue #498", function () {
2204
+ const waitForUpdate = () => new Promise((resolve) => setTimeout(resolve, 20));
2205
+
2206
+ it("should preserve and recover replace content", async function () {
2207
+ const mocks = document.getElementById("mocks");
2208
+ mocks.innerHTML = `<span id="failure-replace" data-monster-replace="path:value | call:unstable"></span>`;
2209
+ const updater = new Updater(mocks, { value: "first" });
2210
+ let failing = false;
2211
+ updater.setCallback("unstable", (value) => {
2212
+ if (failing) throw new Error("probe failure");
2213
+ return value;
2214
+ });
2215
+
2216
+ try {
2217
+ await updater.run();
2218
+ const target = document.getElementById("failure-replace");
2219
+ expect(target.textContent).to.equal("first");
2220
+
2221
+ failing = true;
2222
+ updater.getSubject().value = "second";
2223
+ await waitForUpdate();
2224
+ expect(target.textContent).to.equal("first");
2225
+ expect(target.getAttribute("data-monster-error")).to.equal("probe failure");
2226
+
2227
+ failing = false;
2228
+ updater.getSubject().value = "third";
2229
+ await waitForUpdate();
2230
+ expect(target.textContent).to.equal("third");
2231
+ expect(target.hasAttribute("data-monster-error")).to.be.false;
2232
+ } finally {
2233
+ updater.dispose();
2234
+ }
2235
+ });
2236
+
2237
+ it("should preserve attributes while valid sibling bindings continue", async function () {
2238
+ const mocks = document.getElementById("mocks");
2239
+ mocks.innerHTML = `
2240
+ <span
2241
+ id="failure-attribute"
2242
+ data-monster-attributes="title path:value | call:unstable, data-valid path:sibling"
2243
+ ></span>
2244
+ `;
2245
+ const updater = new Updater(mocks, { value: "first", sibling: "one" });
2246
+ let failing = false;
2247
+ updater.setCallback("unstable", (value) => {
2248
+ if (failing) throw new Error("probe failure");
2249
+ return value;
2250
+ });
2251
+
2252
+ try {
2253
+ await updater.run();
2254
+ const target = document.getElementById("failure-attribute");
2255
+ expect(target.getAttribute("title")).to.equal("first");
2256
+
2257
+ failing = true;
2258
+ updater.getSubject().value = "second";
2259
+ updater.getSubject().sibling = "two";
2260
+ await waitForUpdate();
2261
+ expect(target.getAttribute("title")).to.equal("first");
2262
+ expect(target.getAttribute("data-valid")).to.equal("two");
2263
+ expect(target.getAttribute("data-monster-error")).to.equal("probe failure");
2264
+
2265
+ failing = false;
2266
+ updater.getSubject().value = "third";
2267
+ await waitForUpdate();
2268
+ expect(target.getAttribute("title")).to.equal("third");
2269
+ expect(target.hasAttribute("data-monster-error")).to.be.false;
2270
+ } finally {
2271
+ updater.dispose();
2272
+ }
2273
+ });
2274
+
2275
+ it("should preserve properties while valid sibling bindings continue", async function () {
2276
+ const mocks = document.getElementById("mocks");
2277
+ mocks.innerHTML = `
2278
+ <input
2279
+ id="failure-property"
2280
+ data-monster-properties="value path:value | call:unstable, disabled path:disabled"
2281
+ >
2282
+ `;
2283
+ const updater = new Updater(mocks, { value: "first", disabled: false });
2284
+ let failing = false;
2285
+ updater.setCallback("unstable", (value) => {
2286
+ if (failing) throw new Error("probe failure");
2287
+ return value;
2288
+ });
2289
+
2290
+ try {
2291
+ await updater.run();
2292
+ const target = document.getElementById("failure-property");
2293
+ expect(target.value).to.equal("first");
2294
+
2295
+ failing = true;
2296
+ updater.getSubject().value = "second";
2297
+ updater.getSubject().disabled = true;
2298
+ await waitForUpdate();
2299
+ expect(target.value).to.equal("first");
2300
+ expect(target.disabled).to.be.true;
2301
+ expect(target.getAttribute("data-monster-error")).to.equal("probe failure");
2302
+
2303
+ failing = false;
2304
+ updater.getSubject().value = "third";
2305
+ await waitForUpdate();
2306
+ expect(target.value).to.equal("third");
2307
+ expect(target.hasAttribute("data-monster-error")).to.be.false;
2308
+ } finally {
2309
+ updater.dispose();
2310
+ }
2311
+ });
2312
+ });
2020
2313
  });
@@ -292,4 +292,56 @@ describe('ProxyObserver', function () {
292
292
  });
293
293
  });
294
294
 
295
+ describe('native object compatibility issue #495', function () {
296
+ it('should preserve Date, Map, and Set identity and native methods', function () {
297
+ const date = new Date('2026-01-01T00:00:00Z');
298
+ const map = new Map([['key', 'value']]);
299
+ const set = new Set(['value']);
300
+ const proxy = new ProxyObserver({date, map, set});
301
+ const subject = proxy.getSubject();
302
+
303
+ expect(subject.date).to.equal(date);
304
+ expect(subject.date.getTime()).to.equal(date.getTime());
305
+ expect(subject.map).to.equal(map);
306
+ expect(subject.map.get('key')).to.equal('value');
307
+ expect(subject.set).to.equal(set);
308
+ expect(subject.set.has('value')).to.be.true;
309
+ });
310
+
311
+ it('should not notify for internal Map and Set mutations', async function () {
312
+ const proxy = new ProxyObserver({map: new Map(), set: new Set()});
313
+ let notifications = 0;
314
+ proxy.attachObserver(new Observer(function () {
315
+ notifications++;
316
+ }));
317
+
318
+ proxy.getSubject().map.set('key', 'value');
319
+ proxy.getSubject().set.add('value');
320
+ await new Promise((resolve) => setTimeout(resolve, 0));
321
+
322
+ expect(notifications).to.equal(0);
323
+ });
324
+
325
+ it('should notify when a complete native object property is replaced', async function () {
326
+ const proxy = new ProxyObserver({map: new Map()});
327
+ const replacement = new Map([['key', 'value']]);
328
+ let notifications = 0;
329
+ let resolveNotification;
330
+ const notified = new Promise((resolve) => {
331
+ resolveNotification = resolve;
332
+ });
333
+ proxy.attachObserver(new Observer(function () {
334
+ notifications++;
335
+ resolveNotification();
336
+ }));
337
+
338
+ proxy.getSubject().map = replacement;
339
+ await notified;
340
+
341
+ expect(proxy.getRealSubject().map).to.equal(replacement);
342
+ expect(proxy.getSubject().map).to.equal(replacement);
343
+ expect(notifications).to.equal(1);
344
+ });
345
+ });
346
+
295
347
  })
@@ -107,6 +107,30 @@ describe('Clone', function () {
107
107
  let b = clone(a);
108
108
  expect(a.x).is.equal(b.x)
109
109
  });
110
+
111
+ it('should clone objects with an own hasOwnProperty key issue #494', function () {
112
+ const source = {
113
+ hasOwnProperty: 'payload',
114
+ nested: {value: 1},
115
+ };
116
+
117
+ const result = clone(source);
118
+
119
+ expect(result).to.deep.equal(source);
120
+ expect(result).not.to.equal(source);
121
+ expect(result.nested).not.to.equal(source.nested);
122
+ });
123
+
124
+ it('should deeply clone null-prototype dictionaries issue #494', function () {
125
+ const source = Object.create(null);
126
+ source.nested = {value: 1};
127
+
128
+ const result = clone(source);
129
+
130
+ expect(Object.getPrototypeOf(result)).to.equal(null);
131
+ expect(result.nested).to.deep.equal({value: 1});
132
+ expect(result.nested).not.to.equal(source.nested);
133
+ });
110
134
  })
111
135
  describe('.clone(function)', function () {
112
136