@schukai/monster 4.153.1 → 4.154.1

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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/source/components/form/message-state-button.mjs +1 -1
  3. package/source/components/form/select-link.mjs +12 -1
  4. package/source/components/state/log.mjs +3 -7
  5. package/source/components/state/thread.mjs +3 -7
  6. package/source/components/time/day.mjs +0 -19
  7. package/source/components/time/month-calendar.mjs +5 -1
  8. package/source/data/buildmap.mjs +3 -3
  9. package/source/data/datasource.mjs +3 -10
  10. package/source/data/diff.mjs +13 -12
  11. package/source/data/extend.mjs +23 -1
  12. package/source/data/pathfinder.mjs +186 -19
  13. package/source/dom/customelement.mjs +273 -30
  14. package/source/dom/updater.mjs +6 -2
  15. package/source/types/base.mjs +7 -6
  16. package/source/types/basewithoptions.mjs +1 -8
  17. package/source/types/internal.mjs +4 -10
  18. package/source/types/observerlist.mjs +9 -17
  19. package/source/types/proxyobserver.mjs +19 -2
  20. package/source/types/tokenlist.mjs +13 -8
  21. package/source/util/clone.mjs +44 -14
  22. package/test/cases/browser-bundle.mjs +75 -0
  23. package/test/cases/components/datatable/drag-scroll.mjs +41 -9
  24. package/test/cases/components/navigation/site-navigation.mjs +10 -1
  25. package/test/cases/components/time/month-calendar.mjs +115 -0
  26. package/test/cases/data/diff.mjs +86 -0
  27. package/test/cases/data/extend.mjs +52 -0
  28. package/test/cases/data/pathfinder-resolution-baseline.mjs +322 -0
  29. package/test/cases/data/pathfinder.mjs +129 -0
  30. package/test/cases/dom/customelement-rebind-baseline.mjs +323 -0
  31. package/test/cases/dom/customelement-teardown.mjs +408 -0
  32. package/test/cases/dom/customelement.mjs +354 -0
  33. package/test/cases/dom/dimension.mjs +34 -10
  34. package/test/cases/dom/updater-remove.mjs +198 -0
  35. package/test/cases/packaging.mjs +213 -0
  36. package/test/cases/types/base.mjs +50 -0
  37. package/test/cases/types/observerlist.mjs +103 -1
  38. package/test/cases/types/proxyobserver.mjs +114 -0
  39. package/test/cases/types/regex.mjs +4 -0
  40. package/test/cases/types/tokenlist.mjs +64 -1
  41. package/test/cases/util/clone.mjs +115 -0
@@ -42,6 +42,16 @@ export { clone };
42
42
  * @throws {Error} unable to clone obj! its type isn't supported.
43
43
  */
44
44
  function clone(obj) {
45
+ return cloneValue(obj, new WeakMap());
46
+ }
47
+
48
+ /**
49
+ * @private
50
+ * @param {*} obj
51
+ * @param {WeakMap} seen already cloned objects, keyed by source
52
+ * @return {*}
53
+ */
54
+ function cloneValue(obj, seen) {
45
55
  // typeof null results in 'object'. https://2ality.com/2013/10/typeof-null.html
46
56
  if (null === obj) {
47
57
  return obj;
@@ -59,9 +69,14 @@ function clone(obj) {
59
69
 
60
70
  // Handle Array
61
71
  if (isArray(obj)) {
72
+ if (seen.has(obj)) {
73
+ return seen.get(obj);
74
+ }
75
+
62
76
  const copy = [];
77
+ seen.set(obj, copy);
63
78
  for (let i = 0, len = obj.length; i < len; i++) {
64
- copy[i] = clone(obj[i]);
79
+ copy[i] = cloneValue(obj[i], seen);
65
80
  }
66
81
 
67
82
  return copy;
@@ -87,18 +102,28 @@ function clone(obj) {
87
102
 
88
103
  // Handle Map
89
104
  if (obj instanceof Map) {
105
+ if (seen.has(obj)) {
106
+ return seen.get(obj);
107
+ }
108
+
90
109
  const copy = new Map();
110
+ seen.set(obj, copy);
91
111
  for (const [key, value] of obj.entries()) {
92
- copy.set(clone(key), clone(value));
112
+ copy.set(cloneValue(key, seen), cloneValue(value, seen));
93
113
  }
94
114
  return copy;
95
115
  }
96
116
 
97
117
  // Handle Set
98
118
  if (obj instanceof Set) {
119
+ if (seen.has(obj)) {
120
+ return seen.get(obj);
121
+ }
122
+
99
123
  const copy = new Set();
124
+ seen.set(obj, copy);
100
125
  for (const value of obj.values()) {
101
- copy.add(clone(value));
126
+ copy.add(cloneValue(value, seen));
102
127
  }
103
128
  return copy;
104
129
  }
@@ -119,7 +144,7 @@ function clone(obj) {
119
144
  if (typeof navigator !== "undefined" && obj === navigator) return obj;
120
145
  if (typeof JSON !== "undefined" && obj === JSON) return obj;
121
146
  if (isProxy(obj)) return obj; // Handle Proxy-Object
122
- return cloneObject(obj);
147
+ return cloneObject(obj, seen);
123
148
  }
124
149
 
125
150
  throw new Error("unable to clone obj! its type isn't supported.");
@@ -128,12 +153,17 @@ function clone(obj) {
128
153
  /**
129
154
  *
130
155
  * @param {object} obj
156
+ * @param {WeakMap} seen already cloned objects, keyed by source
131
157
  * @return {object}
132
158
  * @private
133
159
  */
134
- function cloneObject(obj) {
160
+ function cloneObject(obj, seen) {
135
161
  validateObject(obj);
136
162
 
163
+ if (seen.has(obj)) {
164
+ return seen.get(obj);
165
+ }
166
+
137
167
  const fkt = obj?.["constructor"];
138
168
  const sourcePrototype = Object.getPrototypeOf(obj);
139
169
 
@@ -145,19 +175,19 @@ function cloneObject(obj) {
145
175
  Object.prototype.hasOwnProperty.call(prototype, "getClone") &&
146
176
  typeOf(obj.getClone) === "function"
147
177
  ) {
178
+ // deliberately before the memo is written: the object decides for
179
+ // itself what its copy is, and that result must not be replaced by
180
+ // a half built copy when the object is reached again inside a cycle.
148
181
  return obj.getClone();
149
182
  }
150
183
  }
151
184
  }
152
185
 
153
- let copy = sourcePrototype === null ? Object.create(null) : {};
154
- if (
155
- sourcePrototype !== null &&
156
- typeof fkt === "function" &&
157
- typeof fkt.call === "function"
158
- ) {
159
- copy = new fkt();
160
- }
186
+ // The constructor is not invoked. Calling it would run arbitrary code and
187
+ // fail for every class whose constructor requires arguments. Classes that
188
+ // need constructor work expose getClone() instead.
189
+ const copy = Object.create(sourcePrototype);
190
+ seen.set(obj, copy);
161
191
 
162
192
  for (const key in obj) {
163
193
  if (!Object.prototype.hasOwnProperty.call(obj, key)) {
@@ -169,7 +199,7 @@ function cloneObject(obj) {
169
199
  continue;
170
200
  }
171
201
 
172
- copy[key] = clone(obj[key]);
202
+ copy[key] = cloneValue(obj[key], seen);
173
203
  }
174
204
 
175
205
  return copy;
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+
3
+ import { expect } from "chai";
4
+ import { buildCurrentTests } from "../web/current-bundle.mjs";
5
+
6
+ /**
7
+ * Guards the browser test bundle for issue #577.
8
+ *
9
+ * The browser tier bundles the cases as bare `import "<file>"` statements. A
10
+ * `sideEffects` list in package.json is a restriction: every path it does not
11
+ * match counts as side effect free, and esbuild then drops those bare imports.
12
+ * With `./test/**` missing from the list the bundle collapsed from about 9 MB
13
+ * to 15 bytes, and all three browser jobs reported `0 passing, 0 failing,
14
+ * 0 pending` rather than failing loudly.
15
+ *
16
+ * This file runs esbuild over the repository, so it is listed in nodeOnlyCases
17
+ * in test/web/current-bundle.mjs and does not run in the browser bundle itself.
18
+ */
19
+ describe("Browser test bundle issue #577", function () {
20
+ this.timeout(60000);
21
+
22
+ let bundle;
23
+
24
+ before(async function () {
25
+ bundle = await buildCurrentTests();
26
+ });
27
+
28
+ it("should collect the case files", function () {
29
+ expect(bundle.files).to.be.an("array");
30
+ expect(
31
+ bundle.files.length,
32
+ "far fewer case files than the repository contains",
33
+ ).to.be.above(150);
34
+ });
35
+
36
+ it("should produce a bundle that actually contains the cases", function () {
37
+ // an emptied bundle is a few bytes, a complete one is megabytes
38
+ expect(
39
+ bundle.code.length,
40
+ "the bundled browser suite is far too small to hold the test cases",
41
+ ).to.be.above(1_000_000);
42
+ });
43
+
44
+ it("should keep the suites of the bundled files", function () {
45
+ // every case file registers suites at module scope, so dropping the bare
46
+ // imports removes these calls even though the file list stays complete
47
+ const suites = [...bundle.code.matchAll(/\bdescribe\(/g)].length;
48
+
49
+ expect(suites, "the bundle registers almost no suites").to.be.above(300);
50
+ });
51
+
52
+ it("should contain content from several distinct case files", function () {
53
+ for (const marker of [
54
+ "Pathfinder resolution baseline",
55
+ "CustomElement rebind baseline",
56
+ "Updater remove attribute",
57
+ "MonthCalendar date configuration",
58
+ "contains all tokens",
59
+ ]) {
60
+ expect(
61
+ bundle.code.includes(marker),
62
+ `the bundle does not contain "${marker}", so cases are missing`,
63
+ ).to.be.true;
64
+ }
65
+ });
66
+
67
+ it("should not bundle the node only cases", function () {
68
+ for (const nodeOnly of ["packaging.mjs", "browser-bundle.mjs"]) {
69
+ expect(
70
+ bundle.files.some((file) => file.endsWith(`test/cases/${nodeOnly}`)),
71
+ `${nodeOnly} must stay out of the browser bundle`,
72
+ ).to.be.false;
73
+ }
74
+ });
75
+ });
@@ -6,6 +6,25 @@ import { ResizeObserverMock } from "../../../util/resize-observer.mjs";
6
6
  const expect = chai.expect;
7
7
  chai.use(chaiDom);
8
8
 
9
+ /**
10
+ * Waits for a value to appear instead of for a fixed delay.
11
+ *
12
+ * The components under test attach their ResizeObserver inside a
13
+ * requestAnimationFrame, so the precondition is a frame, not a number of
14
+ * milliseconds. A headless browser without a compositor schedules frames less
15
+ * regularly, which is why a fixed 30ms wait failed in CI webkit while passing
16
+ * everywhere else. The deadline keeps a genuine absence failing.
17
+ */
18
+ async function waitForValue(produce, timeout = 2000, interval = 10) {
19
+ const deadline = Date.now() + timeout;
20
+ for (;;) {
21
+ const value = produce();
22
+ if (value !== undefined && value !== null) return value;
23
+ if (Date.now() >= deadline) return value;
24
+ await new Promise((resolve) => setTimeout(resolve, interval));
25
+ }
26
+ }
27
+
9
28
  // Supply geometry before the first layout can populate the measurement cache.
10
29
  function installColumnBarGeometry(parentWidth) {
11
30
  const original = HTMLElement.prototype.getBoundingClientRect;
@@ -434,10 +453,13 @@ describe("Datatable drag scroll", function () {
434
453
  );
435
454
  expect(control).to.exist;
436
455
  expect(control.classList.contains("small")).to.equal(false);
437
- expect(TrackingResizeObserver.instances).to.have.length.greaterThan(0);
438
- const tableResizeObserver = TrackingResizeObserver.instances.find(
439
- (observer) => observer.elements.includes(wrapper),
456
+ const tableResizeObserver = await waitForValue(() =>
457
+ TrackingResizeObserver.instances.find((observer) =>
458
+ observer.elements.includes(wrapper),
459
+ ),
440
460
  );
461
+
462
+ expect(TrackingResizeObserver.instances).to.have.length.greaterThan(0);
441
463
  expect(tableResizeObserver).to.exist;
442
464
 
443
465
  let scheduledCallback = null;
@@ -456,6 +478,10 @@ describe("Datatable drag scroll", function () {
456
478
  scheduledCallback();
457
479
 
458
480
  expect(control.classList.contains("small")).to.equal(true);
481
+
482
+ // The responsive layout stays in the document otherwise, and the
483
+ // browser keeps rendering it for the rest of the run.
484
+ wrapper.remove();
459
485
  } finally {
460
486
  window.ResizeObserver = OriginalResizeObserver;
461
487
  globalThis.ResizeObserver = originalGlobalResizeObserver;
@@ -546,8 +572,10 @@ describe("Datatable drag scroll", function () {
546
572
  dotsContainer.querySelector(".dots-overflow-indicator"),
547
573
  ).to.equal(null);
548
574
 
549
- const columnBarResizeObserver = TrackingResizeObserver.instances.find(
550
- (observer) => observer.elements.includes(control),
575
+ const columnBarResizeObserver = await waitForValue(() =>
576
+ TrackingResizeObserver.instances.find((observer) =>
577
+ observer.elements.includes(control),
578
+ ),
551
579
  );
552
580
  expect(columnBarResizeObserver).to.exist;
553
581
 
@@ -660,11 +688,15 @@ describe("Datatable drag scroll", function () {
660
688
  const first = await createColumnBar();
661
689
  const second = await createColumnBar();
662
690
 
663
- const firstResizeObserver = TrackingResizeObserver.instances.find(
664
- (observer) => observer.elements.includes(first.control),
691
+ const firstResizeObserver = await waitForValue(() =>
692
+ TrackingResizeObserver.instances.find((observer) =>
693
+ observer.elements.includes(first.control),
694
+ ),
665
695
  );
666
- const secondResizeObserver = TrackingResizeObserver.instances.find(
667
- (observer) => observer.elements.includes(second.control),
696
+ const secondResizeObserver = await waitForValue(() =>
697
+ TrackingResizeObserver.instances.find((observer) =>
698
+ observer.elements.includes(second.control),
699
+ ),
668
700
  );
669
701
 
670
702
  expect(firstResizeObserver).to.exist;
@@ -77,7 +77,16 @@ describe("SiteNavigation", function () {
77
77
  resizeObserver.triggerResize([]);
78
78
  resizeObserver.triggerResize([]);
79
79
 
80
- await new Promise(resolve => setTimeout(resolve, 260));
80
+ // The component debounces resizes with a 200ms DeadMansSwitch and then
81
+ // requests exactly one frame. Waiting 260ms left 60ms of margin over
82
+ // that debounce, which is not enough under software rendering: the run
83
+ // then measured zero scheduled frames. Waiting for the first request
84
+ // keeps the coalescing assertion strict without assuming wall clock.
85
+ const deadline = Date.now() + 5000;
86
+ while (scheduledCallbacks.length === 0 && Date.now() < deadline) {
87
+ await new Promise((resolve) => setTimeout(resolve, 10));
88
+ }
89
+
81
90
  expect(scheduledCallbacks.length).to.equal(1);
82
91
  scheduledCallbacks.shift()();
83
92
  } finally {
@@ -70,6 +70,121 @@ describe("MonthCalendar date configuration", () => {
70
70
  expect(currentDays(calendar)[0].month).to.equal(new Date().getMonth() + 1);
71
71
  expect(calendar.getOption("calendarDays")).to.have.length(42);
72
72
  });
73
+ describe("document language issue #578", () => {
74
+ // The oracle is Intl itself with an explicit tag, so none of these
75
+ // assertions depend on the locale of the machine running the suite.
76
+ const MONDAY = new Date(1970, 0, 5);
77
+ const weekdayIn = (tag, format) =>
78
+ new Intl.DateTimeFormat(tag, { weekday: format }).format(MONDAY);
79
+
80
+ let originalLang;
81
+
82
+ beforeEach(() => {
83
+ originalLang = document.documentElement.getAttribute("lang");
84
+ });
85
+
86
+ afterEach(() => {
87
+ if (originalLang === null) {
88
+ document.documentElement.removeAttribute("lang");
89
+ } else {
90
+ document.documentElement.setAttribute("lang", originalLang);
91
+ }
92
+ });
93
+
94
+ async function calendarFor(lang, format = "long") {
95
+ document.documentElement.setAttribute("lang", lang);
96
+ const calendar = document.createElement("monster-month-calendar");
97
+ calendar.setOption("startDate", "2026-10-01");
98
+ document.getElementById("mocks").append(calendar);
99
+ await updates();
100
+ calendar.setOption("locale.weekdayFormat", format);
101
+ await updates();
102
+ return calendar;
103
+ }
104
+
105
+ // net: the weekday header is always seven entries, indexed Monday first,
106
+ // and every label is a non empty string
107
+ it("produces seven indexed weekdays", async () => {
108
+ const calendar = await calendarFor("en");
109
+ const weekdays = calendar.getOption("calendarWeekdays");
110
+
111
+ expect(weekdays).to.have.length(7);
112
+ expect(weekdays.map((entry) => entry.index)).to.deep.equal([
113
+ 0, 1, 2, 3, 4, 5, 6,
114
+ ]);
115
+ for (const entry of weekdays) {
116
+ expect(entry.label).to.be.a("string").and.to.have.length.above(0);
117
+ }
118
+ });
119
+
120
+ it("rejects an invalid weekday format and falls back to short", async () => {
121
+ const calendar = await calendarFor("en", "bogus");
122
+ const weekdays = calendar.getOption("calendarWeekdays");
123
+
124
+ expect(calendar.hasAttribute(ATTRIBUTE_ERRORMESSAGE)).to.be.true;
125
+ // the short form is shorter than the long one, whatever the language
126
+ expect(weekdays[0].label.length).to.be.below(
127
+ weekdayIn("en", "long").length,
128
+ );
129
+ });
130
+
131
+ // net: this path reads locale.language, a string, and already works
132
+ it("translates the more-appointments label from the document language", async () => {
133
+ const calendar = await calendarFor("de");
134
+ const cell = calendar.shadowRoot.querySelector(
135
+ '[data-monster-day="2026-10-01"]',
136
+ );
137
+
138
+ expect(
139
+ cell.querySelector('[data-monster-role="more-appointments"]')
140
+ .textContent,
141
+ ).to.contain("Weitere Termine");
142
+ });
143
+
144
+ // regression: Intl received the Locale instance instead of its tag, so the
145
+ // document language was ignored and the host system locale decided. At
146
+ // most one of these languages can match the machine locale, so the set is
147
+ // red before the fix in any environment.
148
+ for (const lang of ["ja", "de", "en", "es"]) {
149
+ it(`formats weekday names for lang="${lang}"`, async () => {
150
+ const calendar = await calendarFor(lang);
151
+
152
+ expect(calendar.getOption("calendarWeekdays")[0].label).to.equal(
153
+ weekdayIn(lang, "long"),
154
+ );
155
+ });
156
+ }
157
+
158
+ it("changes weekday names when the document language changes", async () => {
159
+ const japanese = await calendarFor("ja");
160
+ const japaneseLabel = japanese.getOption("calendarWeekdays")[0].label;
161
+
162
+ const spanish = await calendarFor("es");
163
+ const spanishLabel = spanish.getOption("calendarWeekdays")[0].label;
164
+
165
+ expect(japaneseLabel).to.equal(weekdayIn("ja", "long"));
166
+ expect(spanishLabel).to.equal(weekdayIn("es", "long"));
167
+ expect(japaneseLabel).to.not.equal(spanishLabel);
168
+ });
169
+
170
+ it("falls back to the short format in the document language", async () => {
171
+ const calendar = await calendarFor("ja", "bogus");
172
+
173
+ expect(calendar.hasAttribute(ATTRIBUTE_ERRORMESSAGE)).to.be.true;
174
+ expect(calendar.getOption("calendarWeekdays")[0].label).to.equal(
175
+ weekdayIn("ja", "short"),
176
+ );
177
+ });
178
+
179
+ it("honors the document language for the short format as well", async () => {
180
+ const calendar = await calendarFor("ja", "short");
181
+
182
+ expect(calendar.getOption("calendarWeekdays")[0].label).to.equal(
183
+ weekdayIn("ja", "short"),
184
+ );
185
+ });
186
+ });
187
+
73
188
  it("keeps day interactions and weekday updates working after changing months", async () => {
74
189
  const calendar = document.createElement("monster-month-calendar");
75
190
  document.getElementById("mocks").append(calendar);
@@ -285,4 +285,90 @@ describe('Diff', function () {
285
285
 
286
286
  });
287
287
 
288
+ describe('numeric identity issue #571', function () {
289
+
290
+ // net: these describe behaviour that is already correct today
291
+ it('should report an update when NaN is replaced by a number', function () {
292
+ let d = diff({a: NaN}, {a: 1});
293
+ expect(d.length).is.equal(1);
294
+ expect(d[0].operator).is.equal('update');
295
+ expect(d[0].path).is.eql(['a']);
296
+ });
297
+
298
+ it('should report an update when a number is replaced by NaN', function () {
299
+ let d = diff({a: 1}, {a: NaN});
300
+ expect(d.length).is.equal(1);
301
+ expect(d[0].operator).is.equal('update');
302
+ expect(d[0].path).is.eql(['a']);
303
+ });
304
+
305
+ it('should keep treating 0 and -0 as equal', function () {
306
+ expect(JSON.stringify(diff({a: 0}, {a: -0}))).is.equal('[]');
307
+ expect(JSON.stringify(diff({a: -0}, {a: 0}))).is.equal('[]');
308
+ });
309
+
310
+ // regression: two equal NaN values are not a change
311
+ it('should not report a change for two NaN values at the same path', function () {
312
+ expect(JSON.stringify(diff({a: NaN}, {a: NaN}))).is.equal('[]');
313
+ });
314
+
315
+ it('should not report a change for nested NaN values', function () {
316
+ let first = {a: {b: NaN}, c: {d: {e: NaN}}};
317
+ let second = {a: {b: NaN}, c: {d: {e: NaN}}};
318
+ expect(JSON.stringify(diff(first, second))).is.equal('[]');
319
+ });
320
+
321
+ it('should not report a change for NaN inside an unchanged array', function () {
322
+ expect(JSON.stringify(diff([NaN, 1], [NaN, 1]))).is.equal('[]');
323
+ });
324
+
325
+ it('should not report a change for a NaN value reached through a wildcard shaped tree', function () {
326
+ let first = {rows: [{value: NaN}, {value: 2}]};
327
+ let second = {rows: [{value: NaN}, {value: 2}]};
328
+ expect(JSON.stringify(diff(first, second))).is.equal('[]');
329
+ });
330
+ })
331
+
332
+ describe('array key handling issue #574', function () {
333
+
334
+ // net: array indices are reported as string path segments. getKeys() has a
335
+ // dead numeric-index branch; replacing it would turn ["0"] into [0] and
336
+ // change every array diff path, so these pin the contract first.
337
+ it('should report deletions with string index paths when an array shrinks', function () {
338
+ let d = diff({a: [1, 2, 3]}, {a: [1]});
339
+
340
+ expect(JSON.stringify(d)).is.equal('[{"operator":"delete","path":["a","1"],"first":{"value":2,"type":"number"}},{"operator":"delete","path":["a","2"],"first":{"value":3,"type":"number"}}]');
341
+ });
342
+
343
+ it('should report additions with string index paths when an array grows', function () {
344
+ let d = diff({a: [1]}, {a: [1, 2]});
345
+
346
+ expect(JSON.stringify(d)).is.equal('[{"operator":"add","path":["a","1"],"second":{"value":2,"type":"number"}}]');
347
+ });
348
+
349
+ it('should report an update with a string index path when an element changes', function () {
350
+ let d = diff({a: [1, 2]}, {a: [1, 9]});
351
+
352
+ expect(JSON.stringify(d)).is.equal('[{"operator":"update","path":["a","1"],"first":{"value":2,"type":"number"},"second":{"value":9,"type":"number"}}]');
353
+ });
354
+
355
+ it('should report no change for identical arrays', function () {
356
+ expect(JSON.stringify(diff({a: [1, 2, 3]}, {a: [1, 2, 3]}))).is.equal('[]');
357
+ });
358
+
359
+ it('should diff nested arrays of objects with string index paths', function () {
360
+ let d = diff({rows: [{id: 1}, {id: 2}]}, {rows: [{id: 1}, {id: 3}]});
361
+
362
+ expect(JSON.stringify(d)).is.equal('[{"operator":"update","path":["rows","1","id"],"first":{"value":2,"type":"number"},"second":{"value":3,"type":"number"}}]');
363
+ });
364
+
365
+ it('should treat a sparse array hole as absent rather than as undefined', function () {
366
+ let sparse = [1];
367
+ sparse[2] = 3;
368
+
369
+ expect(JSON.stringify(diff({a: sparse}, {a: sparse}))).is.equal('[]');
370
+ expect(Object.hasOwn(sparse, '1')).to.be.false;
371
+ });
372
+ })
373
+
288
374
  })
@@ -183,6 +183,58 @@ describe('extend function', () => {
183
183
  }
184
184
  });
185
185
  });
186
+
187
+ // net: behaviour that is already correct and must stay correct
188
+ it('should keep merging nested objects without losing existing keys issue #573', () => {
189
+ const target = { a: { b: 1, keep: 'yes' } };
190
+ const source = { a: { c: 2 } };
191
+
192
+ expect(extend(target, source)).to.deep.equal({ a: { b: 1, c: 2, keep: 'yes' } });
193
+ });
194
+
195
+ it('should keep throwing on a type mismatch issue #573', () => {
196
+ expect(() => extend({ a: { b: 1 } }, { a: [1, 2] })).to.throw();
197
+ expect(() => extend({ a: [1, 2] }, { a: { b: 1 } })).to.throw();
198
+ });
199
+
200
+ it('should keep throwing for unsupported arguments issue #573', () => {
201
+ expect(() => extend({ a: 1 }, 'not an object')).to.throw();
202
+ expect(() => extend(null, { a: 1 })).to.throw();
203
+ });
204
+
205
+ // regression: a cyclic source must terminate instead of exhausting the stack
206
+ it('should extend from a self referencing source issue #573', () => {
207
+ const source = { name: 'root' };
208
+ source.self = source;
209
+
210
+ const result = extend({}, source);
211
+
212
+ expect(result.name).to.equal('root');
213
+ expect(result.self).to.be.an('object');
214
+ expect(result.self.name).to.equal('root');
215
+ });
216
+
217
+ it('should extend from a mutual reference between two sources issue #573', () => {
218
+ const first = { name: 'first' };
219
+ const second = { name: 'second', first };
220
+ first.second = second;
221
+
222
+ const result = extend({}, first);
223
+
224
+ expect(result.name).to.equal('first');
225
+ expect(result.second.name).to.equal('second');
226
+ expect(result.second.first).to.be.an('object');
227
+ });
228
+
229
+ it('should extend from a cycle that runs through an array issue #573', () => {
230
+ const source = { items: [] };
231
+ source.items.push(source);
232
+
233
+ const result = extend({}, source);
234
+
235
+ expect(result.items).to.be.an('array');
236
+ expect(result.items.length).to.equal(1);
237
+ });
186
238
  });
187
239
 
188
240