@schukai/monster 4.148.8 → 4.148.9

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.8"}
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.9"}
@@ -122,6 +122,20 @@ const managedShadowRootSymbol = Symbol("managedShadowRoot");
122
122
  const visibilityStateSymbol = Symbol("visibilityState");
123
123
  let hostVisibilityStyleSheet = null;
124
124
 
125
+ class RevisionObserver extends Observer {
126
+ #onNotification;
127
+
128
+ constructor(callback, onNotification) {
129
+ super(callback);
130
+ this.#onNotification = onNotification;
131
+ }
132
+
133
+ update(subject) {
134
+ this.#onNotification();
135
+ return super.update(subject);
136
+ }
137
+ }
138
+
125
139
  /**
126
140
  * The `CustomElement` class provides a way to define a new HTML element using the power of Custom Elements.
127
141
  *
@@ -1207,20 +1221,42 @@ function initOptionObserver() {
1207
1221
 
1208
1222
  self[internalSymbol].syncDisabledState = syncDisabledState;
1209
1223
 
1224
+ let updaterSyncRevision = 0;
1210
1225
  self.attachObserver(
1211
- new Observer(function () {
1212
- if (!hasObjectLink(self, customElementUpdaterLinkSymbol)) {
1213
- return;
1214
- }
1215
- const updaters = getLinkedObjects(self, customElementUpdaterLinkSymbol);
1226
+ new RevisionObserver(
1227
+ async function () {
1228
+ let processedRevision;
1229
+ do {
1230
+ processedRevision = updaterSyncRevision;
1231
+ if (hasObjectLink(self, customElementUpdaterLinkSymbol)) {
1232
+ const source = clone(
1233
+ self[internalSymbol].getRealSubject()["options"],
1234
+ );
1235
+ const notifications = [];
1236
+ const updaters = getLinkedObjects(
1237
+ self,
1238
+ customElementUpdaterLinkSymbol,
1239
+ );
1240
+
1241
+ for (const list of updaters) {
1242
+ for (const updater of list) {
1243
+ syncUpdaterSubject(updater.getSubject(), source);
1244
+ const subject = updater?.[internalSymbol]?.subject;
1245
+ if (subject instanceof ProxyObserver) {
1246
+ notifications.push(subject.notifyObservers());
1247
+ }
1248
+ }
1249
+ }
1250
+ await Promise.all(notifications);
1251
+ }
1216
1252
 
1217
- for (const list of updaters) {
1218
- for (const updater of list) {
1219
- const d = clone(self[internalSymbol].getRealSubject()["options"]);
1220
- syncUpdaterSubject(updater.getSubject(), d);
1221
- }
1222
- }
1223
- }),
1253
+ await Promise.resolve();
1254
+ } while (processedRevision !== updaterSyncRevision);
1255
+ },
1256
+ () => {
1257
+ updaterSyncRevision++;
1258
+ },
1259
+ ),
1224
1260
  );
1225
1261
 
1226
1262
  self[attributeObserverSymbol][ATTRIBUTE_DISABLED] = () => {
@@ -1258,39 +1294,68 @@ function syncUpdaterSubject(target, source) {
1258
1294
  }
1259
1295
 
1260
1296
  for (const [key, value] of Object.entries(source)) {
1261
- if (
1262
- isElement(value) ||
1263
- (typeof Document !== "undefined" && value instanceof Document) ||
1264
- (typeof DocumentFragment !== "undefined" &&
1265
- value instanceof DocumentFragment)
1266
- ) {
1267
- target[key] = value;
1268
- continue;
1269
- }
1297
+ syncUpdaterValue(target, key, value);
1298
+ }
1299
+ }
1270
1300
 
1271
- if (isArray(value)) {
1272
- if (!isArray(target?.[key])) {
1273
- target[key] = [];
1274
- }
1275
- target[key].length = 0;
1276
- target[key].push(...clone(value));
1277
- continue;
1301
+ /**
1302
+ * Synchronizes an updater value while preserving proxied array and plain-object
1303
+ * identities. Equal primitive entries therefore do not notify observers again.
1304
+ * @private
1305
+ * @param {object|array} target
1306
+ * @param {string|number} key
1307
+ * @param {*} value
1308
+ * @return {void}
1309
+ */
1310
+ function syncUpdaterValue(target, key, value) {
1311
+ if (
1312
+ isElement(value) ||
1313
+ (typeof Document !== "undefined" && value instanceof Document) ||
1314
+ (typeof DocumentFragment !== "undefined" &&
1315
+ value instanceof DocumentFragment)
1316
+ ) {
1317
+ target[key] = value;
1318
+ return;
1319
+ }
1320
+
1321
+ if (isArray(value)) {
1322
+ if (!isArray(target?.[key])) {
1323
+ target[key] = clone(value);
1324
+ return;
1278
1325
  }
1326
+ syncUpdaterArray(target[key], value);
1327
+ return;
1328
+ }
1279
1329
 
1280
- if (isObject(value)) {
1281
- const proto = Object.getPrototypeOf(value);
1282
- if (proto && proto !== Object.prototype) {
1283
- target[key] = value;
1284
- continue;
1285
- }
1286
- if (!isObject(target?.[key]) || isArray(target?.[key])) {
1287
- target[key] = {};
1288
- }
1289
- syncUpdaterSubject(target[key], value);
1290
- continue;
1330
+ if (isObject(value)) {
1331
+ const proto = Object.getPrototypeOf(value);
1332
+ if (proto && proto !== Object.prototype) {
1333
+ target[key] = value;
1334
+ return;
1291
1335
  }
1336
+ if (!isObject(target?.[key]) || isArray(target?.[key])) {
1337
+ target[key] = {};
1338
+ }
1339
+ syncUpdaterSubject(target[key], value);
1340
+ return;
1341
+ }
1292
1342
 
1293
- target[key] = value;
1343
+ target[key] = value;
1344
+ }
1345
+
1346
+ /**
1347
+ * @private
1348
+ * @param {array} target
1349
+ * @param {array} source
1350
+ * @return {void}
1351
+ */
1352
+ function syncUpdaterArray(target, source) {
1353
+ for (let index = 0; index < source.length; index++) {
1354
+ syncUpdaterValue(target, index, source[index]);
1355
+ }
1356
+
1357
+ if (target.length !== source.length) {
1358
+ target.length = source.length;
1294
1359
  }
1295
1360
  }
1296
1361
 
@@ -91,6 +91,8 @@ const subjectObserverSymbol = Symbol("subjectObserver");
91
91
  const subjectRevisionSymbol = Symbol("subjectRevision");
92
92
  const patchNodeKeySymbol = Symbol("patchNodeKey");
93
93
  const queuedSnapshotSymbol = Symbol("queuedSnapshot");
94
+ const fullRefreshRequestedSymbol = Symbol("fullRefreshRequested");
95
+ const replacementValueCacheSymbol = Symbol("replacementValueCache");
94
96
 
95
97
  /**
96
98
  * Tracks every notification request, including requests that Observer deduplicates
@@ -188,6 +190,8 @@ class Updater extends Base {
188
190
  this[disposedSymbol] = false;
189
191
  this[subjectRevisionSymbol] = 0;
190
192
  this[queuedSnapshotSymbol] = clone(this[internalSymbol].last);
193
+ this[fullRefreshRequestedSymbol] = false;
194
+ this[replacementValueCacheSymbol] = new WeakMap();
191
195
  this[controlEventTimersSymbol] = new Map();
192
196
  this[registeredEventTypesSymbol] = new Set();
193
197
 
@@ -201,7 +205,11 @@ class Updater extends Base {
201
205
  }
202
206
 
203
207
  const real = this[internalSymbol].subject.getRealSubject();
204
- const diffResult = diff(this[queuedSnapshotSymbol], real);
208
+ const previous = this[fullRefreshRequestedSymbol]
209
+ ? { __init__: true }
210
+ : this[queuedSnapshotSymbol];
211
+ this[fullRefreshRequestedSymbol] = false;
212
+ const diffResult = diff(previous, real);
205
213
  if (diffResult.length > 0) {
206
214
  const snapshot = clone(real);
207
215
  this[queuedSnapshotSymbol] = snapshot;
@@ -239,11 +247,15 @@ class Updater extends Base {
239
247
  }
240
248
 
241
249
  const { diffResult, snapshot } = this[pendingDiffsSymbol].shift();
250
+ const renderContext = createRenderContext();
242
251
  if (this[internalSymbol].features.batchUpdates === true) {
243
252
  const updatePaths = new Map();
244
253
  for (const change of Object.values(diffResult)) {
245
- removeElement.call(this, change);
246
- insertElement.call(this, change);
254
+ const removed = removeElement.call(this, change, renderContext);
255
+ const inserted = insertElement.call(this, change, renderContext);
256
+ if (removed || inserted) {
257
+ invalidateRenderCandidates(renderContext);
258
+ }
247
259
 
248
260
  const path = isArray(change?.["path"]) ? change["path"] : null;
249
261
  if (!path) {
@@ -260,13 +272,13 @@ class Updater extends Base {
260
272
  }
261
273
 
262
274
  for (const path of updatePaths.values()) {
263
- updateContent.call(this, { path });
264
- updateAttributes.call(this, { path });
265
- updateProperties.call(this, { path });
275
+ updateContent.call(this, { path }, renderContext);
276
+ updateAttributes.call(this, { path }, renderContext);
277
+ updateProperties.call(this, { path }, renderContext);
266
278
  }
267
279
  } else {
268
280
  for (const change of Object.values(diffResult)) {
269
- await this[applyChangeSymbol](change);
281
+ await this[applyChangeSymbol](change, renderContext);
270
282
  }
271
283
  }
272
284
  this[internalSymbol].last = clone(snapshot);
@@ -279,17 +291,20 @@ class Updater extends Base {
279
291
  }
280
292
 
281
293
  /** @private **/
282
- async [applyChangeSymbol](change) {
294
+ async [applyChangeSymbol](change, renderContext) {
283
295
  if (this[disposedSymbol] === true) {
284
- return Promise.resolve();
296
+ return;
285
297
  }
286
298
 
287
- removeElement.call(this, change);
288
- insertElement.call(this, change);
289
- updateContent.call(this, change);
299
+ const removed = removeElement.call(this, change, renderContext);
300
+ const inserted = insertElement.call(this, change, renderContext);
301
+ if (removed || inserted) {
302
+ invalidateRenderCandidates(renderContext);
303
+ }
304
+ updateContent.call(this, change, renderContext);
290
305
  await Promise.resolve();
291
- updateAttributes.call(this, change);
292
- updateProperties.call(this, change);
306
+ updateAttributes.call(this, change, renderContext);
307
+ updateProperties.call(this, change, renderContext);
293
308
  }
294
309
 
295
310
  /**
@@ -391,6 +406,7 @@ class Updater extends Base {
391
406
  }
392
407
 
393
408
  this[disposedSymbol] = true;
409
+ this[fullRefreshRequestedSymbol] = false;
394
410
  this.disableEventProcessing();
395
411
  this[pendingDiffsSymbol].length = 0;
396
412
 
@@ -423,10 +439,10 @@ class Updater extends Base {
423
439
  return Promise.resolve();
424
440
  }
425
441
 
426
- // the key __init__has no further meaning and is only
427
- // used to create the diff for empty objects.
428
- this[internalSymbol].last = { __init__: true };
429
- this[queuedSnapshotSymbol] = clone(this[internalSymbol].last);
442
+ // The key __init__ has no further meaning and is only used to create
443
+ // a full diff. A flag keeps repeated run requests from resetting an
444
+ // active queue more than once.
445
+ this[fullRefreshRequestedSymbol] = true;
430
446
  return this[internalSymbol].subject.notifyObservers();
431
447
  }
432
448
 
@@ -803,15 +819,25 @@ function retrieveFromBindings() {
803
819
  * @license AGPLv3
804
820
  * @since 1.8.0
805
821
  * @param {object} change
806
- * @return {void}
822
+ * @return {boolean}
807
823
  */
808
- function removeElement(change) {
824
+ function removeElement(_change, renderContext) {
825
+ if (renderContext?.removeProcessed === true) {
826
+ return false;
827
+ }
828
+ if (renderContext) {
829
+ renderContext.removeProcessed = true;
830
+ }
831
+
832
+ let changed = false;
809
833
  for (const [, element] of this[internalSymbol].element
810
834
  .querySelectorAll(`:scope [${ATTRIBUTE_UPDATER_REMOVE}]`)
811
835
  .entries()) {
812
836
  teardownManagedSubtree(element);
813
837
  element.parentNode.removeChild(element);
838
+ changed = true;
814
839
  }
840
+ return changed;
815
841
  }
816
842
 
817
843
  /**
@@ -819,27 +845,36 @@ function removeElement(change) {
819
845
  * @license AGPLv3
820
846
  * @since 1.8.0
821
847
  * @param {object} change
822
- * @return {void}
848
+ * @return {boolean}
823
849
  * @throws {Error} the value is not iterable
824
850
  * @throws {Error} pipes are not allowed when cloning a node.
825
851
  * @throws {Error} no template was found with the specified key.
826
852
  * @throws {Error} the maximum depth for the recursion is reached.
827
853
  * @this Updater
828
854
  */
829
- function insertElement(change) {
855
+ function insertElement(change, renderContext) {
830
856
  const subject = this[internalSymbol].subject.getRealSubject();
831
857
 
832
858
  const mem = new WeakSet();
833
859
  let wd = 0;
860
+ let changed = false;
834
861
 
835
862
  const container = this[internalSymbol].element;
863
+ if (renderContext && renderContext.insertBindingsPresent === undefined) {
864
+ const query = `[${ATTRIBUTE_UPDATER_INSERT}]`;
865
+ renderContext.insertBindingsPresent =
866
+ container.matches(query) || container.querySelector(query) !== null;
867
+ }
868
+ if (renderContext?.insertBindingsPresent === false) {
869
+ return false;
870
+ }
836
871
 
837
872
  while (true) {
838
873
  let found = false;
839
874
  wd++;
840
875
 
841
876
  const p = clone(change?.["path"]);
842
- if (!isArray(p)) return;
877
+ if (!isArray(p)) return changed;
843
878
 
844
879
  while (p.length > 0) {
845
880
  const current = p.join(".");
@@ -920,6 +955,7 @@ function insertElement(change) {
920
955
  }
921
956
 
922
957
  appendNewDocumentFragment(containerElement, key, ref, currentPath);
958
+ changed = true;
923
959
  }
924
960
 
925
961
  const nodes = containerElement.querySelectorAll(
@@ -935,6 +971,7 @@ function insertElement(change) {
935
971
  try {
936
972
  teardownManagedSubtree(node);
937
973
  containerElement.removeChild(node);
974
+ changed = true;
938
975
  } catch (e) {
939
976
  addErrorAttribute(containerElement, e);
940
977
  }
@@ -950,6 +987,8 @@ function insertElement(change) {
950
987
  throw new Error("the maximum depth for the recursion is reached.");
951
988
  }
952
989
  }
990
+
991
+ return changed;
953
992
  }
954
993
 
955
994
  /**
@@ -1064,25 +1103,147 @@ function applyRecursive(node, key, path) {
1064
1103
 
1065
1104
  /**
1066
1105
  * @private
1067
- * @license AGPLv3
1068
- * @since 1.8.0
1106
+ * Creates state shared by every change in one queued subject snapshot.
1107
+ * @private
1108
+ * @return {object}
1109
+ */
1110
+ function createRenderContext() {
1111
+ return {
1112
+ replace: new WeakSet(),
1113
+ patch: new WeakSet(),
1114
+ attributes: new WeakSet(),
1115
+ properties: new WeakSet(),
1116
+ removeProcessed: false,
1117
+ insertBindingsPresent: undefined,
1118
+ candidates: {
1119
+ replace: new WeakMap(),
1120
+ patch: new WeakMap(),
1121
+ attributes: new WeakMap(),
1122
+ properties: new WeakMap(),
1123
+ slots: new WeakMap(),
1124
+ },
1125
+ };
1126
+ }
1127
+
1128
+ /**
1129
+ * Invalidates DOM-derived candidates after structural or rendered DOM changes.
1130
+ * @private
1131
+ * @param {object} renderContext
1132
+ * @return {void}
1133
+ */
1134
+ function invalidateRenderCandidates(renderContext) {
1135
+ renderContext.removeProcessed = false;
1136
+ renderContext.insertBindingsPresent = undefined;
1137
+ renderContext.candidates.replace = new WeakMap();
1138
+ renderContext.candidates.patch = new WeakMap();
1139
+ renderContext.candidates.attributes = new WeakMap();
1140
+ renderContext.candidates.properties = new WeakMap();
1141
+ renderContext.candidates.slots = new WeakMap();
1142
+ }
1143
+
1144
+ /**
1145
+ * @private
1146
+ * @param {HTMLElement} container
1147
+ * @param {string} attribute
1148
+ * @param {WeakMap} cache
1149
+ * @return {Set<HTMLElement>}
1150
+ */
1151
+ function getBindingCandidates(container, attribute, cache) {
1152
+ if (!(container instanceof HTMLElement)) {
1153
+ return new Set();
1154
+ }
1155
+
1156
+ if (cache.has(container)) {
1157
+ return cache.get(container);
1158
+ }
1159
+
1160
+ const candidates = new Set(container.querySelectorAll(`[${attribute}]`));
1161
+ if (container.hasAttribute(attribute)) {
1162
+ candidates.add(container);
1163
+ }
1164
+ cache.set(container, candidates);
1165
+ return candidates;
1166
+ }
1167
+
1168
+ /**
1169
+ * @private
1170
+ * @param {HTMLElement} container
1171
+ * @param {WeakMap} cache
1172
+ * @return {NodeList}
1173
+ */
1174
+ function getSlots(container, cache) {
1175
+ if (cache.has(container)) {
1176
+ return cache.get(container);
1177
+ }
1178
+
1179
+ const slots = container.querySelectorAll("slot");
1180
+ cache.set(container, slots);
1181
+ return slots;
1182
+ }
1183
+
1184
+ /**
1185
+ * @private
1069
1186
  * @param {object} change
1187
+ * @param {object} renderContext
1070
1188
  * @return {void}
1071
1189
  * @this Updater
1072
1190
  */
1073
- function updateContent(change) {
1191
+ function updateContent(change, renderContext = createRenderContext()) {
1074
1192
  const subject = this[internalSymbol].subject.getRealSubject();
1075
1193
 
1076
1194
  const p = clone(change?.["path"]);
1077
- runUpdateContent.call(this, this[internalSymbol].element, p, subject);
1078
- runUpdatePatch.call(this, this[internalSymbol].element, p, subject);
1079
-
1080
- const slots = this[internalSymbol].element.querySelectorAll("slot");
1195
+ const replacementChanged = runUpdateContent.call(
1196
+ this,
1197
+ this[internalSymbol].element,
1198
+ p,
1199
+ subject,
1200
+ renderContext.replace,
1201
+ renderContext.candidates.replace,
1202
+ );
1203
+ if (replacementChanged) {
1204
+ invalidateRenderCandidates(renderContext);
1205
+ }
1206
+ const patchProcessed = runUpdatePatch.call(
1207
+ this,
1208
+ this[internalSymbol].element,
1209
+ p,
1210
+ subject,
1211
+ renderContext.patch,
1212
+ renderContext.candidates.patch,
1213
+ );
1214
+ if (patchProcessed) {
1215
+ invalidateRenderCandidates(renderContext);
1216
+ }
1217
+
1218
+ const slots = getSlots(
1219
+ this[internalSymbol].element,
1220
+ renderContext.candidates.slots,
1221
+ );
1081
1222
  if (slots.length > 0) {
1082
1223
  for (const [, slot] of Object.entries(slots)) {
1083
1224
  for (const [, element] of Object.entries(slot.assignedNodes())) {
1084
- runUpdateContent.call(this, element, p, subject);
1085
- runUpdatePatch.call(this, element, p, subject);
1225
+ const slottedReplacementChanged = runUpdateContent.call(
1226
+ this,
1227
+ element,
1228
+ p,
1229
+ subject,
1230
+ renderContext.replace,
1231
+ renderContext.candidates.replace,
1232
+ );
1233
+ if (slottedReplacementChanged) {
1234
+ invalidateRenderCandidates(renderContext);
1235
+ }
1236
+ const slottedPatchProcessed = runUpdatePatch.call(
1237
+ this,
1238
+ element,
1239
+ p,
1240
+ subject,
1241
+ renderContext.patch,
1242
+ renderContext.candidates.patch,
1243
+ );
1244
+ if (slottedPatchProcessed) {
1245
+ invalidateRenderCandidates(renderContext);
1246
+ }
1086
1247
  }
1087
1248
  }
1088
1249
  }
@@ -1097,35 +1258,47 @@ function updateContent(change) {
1097
1258
  * @param {object} subject
1098
1259
  * @return {void}
1099
1260
  */
1100
- function runUpdateContent(container, parts, subject) {
1261
+ function runUpdateContent(
1262
+ container,
1263
+ parts,
1264
+ subject,
1265
+ mem = new WeakSet(),
1266
+ candidateCache = new WeakMap(),
1267
+ ) {
1101
1268
  if (!isArray(parts)) return;
1102
1269
  if (!(container instanceof HTMLElement)) return;
1103
1270
  parts = clone(parts);
1104
-
1105
- const mem = new WeakSet();
1271
+ let changed = false;
1106
1272
 
1107
1273
  while (parts.length > 0) {
1108
1274
  const current = parts.join(".");
1109
1275
  parts.pop();
1110
1276
 
1111
1277
  // Unfortunately, static data is always changed as well, since it is not possible to react to changes here.
1112
- const query = `[${ATTRIBUTE_UPDATER_REPLACE}^="path:${current}"], [${ATTRIBUTE_UPDATER_REPLACE}^="static:"], [${ATTRIBUTE_UPDATER_REPLACE}^="i18n:"]`;
1113
- const e = container.querySelectorAll(`${query}`);
1114
-
1115
- const iterator = new Set([...e]);
1116
-
1117
- if (container.matches(query)) {
1118
- iterator.add(container);
1119
- }
1278
+ const iterator = getBindingCandidates(
1279
+ container,
1280
+ ATTRIBUTE_UPDATER_REPLACE,
1281
+ candidateCache,
1282
+ );
1120
1283
 
1121
1284
  /**
1122
1285
  * @type {HTMLElement}
1123
1286
  */
1124
1287
  for (const [element] of iterator.entries()) {
1125
1288
  if (mem.has(element)) continue;
1126
- mem.add(element);
1127
1289
 
1128
1290
  const attributes = element.getAttribute(ATTRIBUTE_UPDATER_REPLACE);
1291
+ if (!isString(attributes)) {
1292
+ continue;
1293
+ }
1294
+ if (
1295
+ !attributes.startsWith(`path:${current}`) &&
1296
+ !attributes.startsWith("static:") &&
1297
+ !attributes.startsWith("i18n:")
1298
+ ) {
1299
+ continue;
1300
+ }
1301
+ mem.add(element);
1129
1302
  const cmd = trimSpaces(attributes);
1130
1303
 
1131
1304
  const pipe = new Pipe(cmd);
@@ -1142,49 +1315,101 @@ function runUpdateContent(container, parts, subject) {
1142
1315
  continue;
1143
1316
  }
1144
1317
 
1145
- if (value instanceof HTMLElement) {
1146
- teardownChildNodes(element);
1147
- while (element.firstChild) {
1148
- element.removeChild(element.firstChild);
1149
- }
1318
+ changed = applyReplacementValue.call(this, element, value) || changed;
1319
+ }
1320
+ }
1150
1321
 
1151
- try {
1152
- element.appendChild(value);
1153
- } catch (e) {
1154
- addErrorAttribute(element, e);
1155
- }
1156
- } else {
1157
- teardownChildNodes(element);
1158
- element.innerHTML = value;
1159
- }
1322
+ return changed;
1323
+ }
1324
+
1325
+ function applyReplacementValue(element, value) {
1326
+ if (!(element instanceof HTMLElement)) {
1327
+ return false;
1328
+ }
1329
+
1330
+ if (value instanceof HTMLElement) {
1331
+ if (element.childNodes.length === 1 && element.firstChild === value) {
1332
+ return false;
1160
1333
  }
1334
+
1335
+ teardownChildNodes(element);
1336
+ while (element.firstChild) {
1337
+ element.removeChild(element.firstChild);
1338
+ }
1339
+
1340
+ try {
1341
+ element.appendChild(value);
1342
+ this[replacementValueCacheSymbol].delete(element);
1343
+ } catch (e) {
1344
+ addErrorAttribute(element, e);
1345
+ }
1346
+ return true;
1161
1347
  }
1348
+
1349
+ const cacheable =
1350
+ value === null ||
1351
+ value === undefined ||
1352
+ (typeof value !== "object" && typeof value !== "function");
1353
+ const cached = this[replacementValueCacheSymbol].get(element);
1354
+ if (
1355
+ cacheable &&
1356
+ cached &&
1357
+ Object.is(cached.value, value) &&
1358
+ element.innerHTML === cached.html
1359
+ ) {
1360
+ return false;
1361
+ }
1362
+
1363
+ teardownChildNodes(element);
1364
+ element.innerHTML = value;
1365
+ if (cacheable) {
1366
+ this[replacementValueCacheSymbol].set(element, {
1367
+ value,
1368
+ html: element.innerHTML,
1369
+ });
1370
+ } else {
1371
+ this[replacementValueCacheSymbol].delete(element);
1372
+ }
1373
+ return true;
1162
1374
  }
1163
1375
 
1164
- function runUpdatePatch(container, parts, subject) {
1376
+ function runUpdatePatch(
1377
+ container,
1378
+ parts,
1379
+ subject,
1380
+ mem = new WeakSet(),
1381
+ candidateCache = new WeakMap(),
1382
+ ) {
1165
1383
  if (!isArray(parts)) return;
1166
1384
  if (!(container instanceof HTMLElement)) return;
1167
1385
  parts = clone(parts);
1168
-
1169
- const mem = new WeakSet();
1386
+ let processed = false;
1170
1387
 
1171
1388
  while (parts.length > 0) {
1172
1389
  const current = parts.join(".");
1173
1390
  parts.pop();
1174
1391
 
1175
- const query = `[${ATTRIBUTE_UPDATER_PATCH}^="path:${current}"], [${ATTRIBUTE_UPDATER_PATCH}^="static:"], [${ATTRIBUTE_UPDATER_PATCH}^="i18n:"]`;
1176
- const e = container.querySelectorAll(`${query}`);
1177
-
1178
- const iterator = new Set([...e]);
1179
- if (container.matches(query)) {
1180
- iterator.add(container);
1181
- }
1392
+ const iterator = getBindingCandidates(
1393
+ container,
1394
+ ATTRIBUTE_UPDATER_PATCH,
1395
+ candidateCache,
1396
+ );
1182
1397
 
1183
1398
  for (const [element] of iterator.entries()) {
1184
1399
  if (mem.has(element)) continue;
1185
- mem.add(element);
1186
1400
 
1187
1401
  const attributes = element.getAttribute(ATTRIBUTE_UPDATER_PATCH);
1402
+ if (!isString(attributes)) {
1403
+ continue;
1404
+ }
1405
+ if (
1406
+ !attributes.startsWith(`path:${current}`) &&
1407
+ !attributes.startsWith("static:") &&
1408
+ !attributes.startsWith("i18n:")
1409
+ ) {
1410
+ continue;
1411
+ }
1412
+ mem.add(element);
1188
1413
  const cmd = trimSpaces(attributes);
1189
1414
 
1190
1415
  const pipe = new Pipe(cmd);
@@ -1202,8 +1427,11 @@ function runUpdatePatch(container, parts, subject) {
1202
1427
  }
1203
1428
 
1204
1429
  applyPatchValue.call(this, element, value);
1430
+ processed = true;
1205
1431
  }
1206
1432
  }
1433
+
1434
+ return processed;
1207
1435
  }
1208
1436
 
1209
1437
  function applyPatchValue(element, value) {
@@ -1253,6 +1481,9 @@ function applyPatchValue(element, value) {
1253
1481
  }
1254
1482
 
1255
1483
  const nextValue = value === null || value === undefined ? "" : String(value);
1484
+ if (element.children.length === 0 && element.textContent === nextValue) {
1485
+ return;
1486
+ }
1256
1487
 
1257
1488
  if (element.children.length > 0) {
1258
1489
  teardownChildNodes(element);
@@ -1518,10 +1749,20 @@ function hasObjectLinkSafe(element, symbol) {
1518
1749
  * @param {object} change
1519
1750
  * @return {void}
1520
1751
  */
1521
- function updateAttributes(change) {
1752
+ function updateAttributes(change, renderContext = createRenderContext()) {
1522
1753
  const subject = this[internalSymbol].subject.getRealSubject();
1523
1754
  const p = clone(change?.["path"]);
1524
- runUpdateAttributes.call(this, this[internalSymbol].element, p, subject);
1755
+ const processed = runUpdateAttributes.call(
1756
+ this,
1757
+ this[internalSymbol].element,
1758
+ p,
1759
+ subject,
1760
+ renderContext.attributes,
1761
+ renderContext.candidates.attributes,
1762
+ );
1763
+ if (processed) {
1764
+ invalidateRenderCandidates(renderContext);
1765
+ }
1525
1766
  }
1526
1767
 
1527
1768
  /**
@@ -1530,10 +1771,20 @@ function updateAttributes(change) {
1530
1771
  * @param {object} change
1531
1772
  * @return {void}
1532
1773
  */
1533
- function updateProperties(change) {
1774
+ function updateProperties(change, renderContext = createRenderContext()) {
1534
1775
  const subject = this[internalSymbol].subject.getRealSubject();
1535
1776
  const p = clone(change?.["path"]);
1536
- runUpdateProperties.call(this, this[internalSymbol].element, p, subject);
1777
+ const processed = runUpdateProperties.call(
1778
+ this,
1779
+ this[internalSymbol].element,
1780
+ p,
1781
+ subject,
1782
+ renderContext.properties,
1783
+ renderContext.candidates.properties,
1784
+ );
1785
+ if (processed) {
1786
+ invalidateRenderCandidates(renderContext);
1787
+ }
1537
1788
  }
1538
1789
 
1539
1790
  /**
@@ -1544,40 +1795,44 @@ function updateProperties(change) {
1544
1795
  * @return {void}
1545
1796
  * @this Updater
1546
1797
  */
1547
- function runUpdateAttributes(container, parts, subject) {
1798
+ function runUpdateAttributes(
1799
+ container,
1800
+ parts,
1801
+ subject,
1802
+ mem = new WeakSet(),
1803
+ candidateCache = new WeakMap(),
1804
+ ) {
1548
1805
  if (!isArray(parts)) return;
1549
1806
  parts = clone(parts);
1550
-
1551
- const mem = new WeakSet();
1807
+ let processed = false;
1552
1808
 
1553
1809
  while (parts.length > 0) {
1554
1810
  const current = parts.join(".");
1555
1811
  parts.pop();
1556
1812
 
1557
- let iterator = new Set();
1558
-
1559
- const query = `[${ATTRIBUTE_UPDATER_SELECT_THIS}][${ATTRIBUTE_UPDATER_ATTRIBUTES}], [${ATTRIBUTE_UPDATER_ATTRIBUTES}*="path:${current}"], [${ATTRIBUTE_UPDATER_ATTRIBUTES}^="static:"], [${ATTRIBUTE_UPDATER_ATTRIBUTES}^="i18n:"]`;
1560
-
1561
- const e = container.querySelectorAll(query);
1562
-
1563
- if (e.length > 0) {
1564
- iterator = new Set([...e]);
1565
- }
1566
-
1567
- if (container.matches(query)) {
1568
- iterator.add(container);
1569
- }
1813
+ const iterator = getBindingCandidates(
1814
+ container,
1815
+ ATTRIBUTE_UPDATER_ATTRIBUTES,
1816
+ candidateCache,
1817
+ );
1570
1818
 
1571
1819
  for (const [element] of iterator.entries()) {
1572
1820
  if (mem.has(element)) continue;
1573
- mem.add(element);
1574
1821
 
1575
- // this case occurs when the ATTRIBUTE_UPDATER_SELECT_THIS attribute is set
1576
- if (!element.hasAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)) {
1822
+ const attributes = element.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);
1823
+ if (!isString(attributes)) {
1577
1824
  continue;
1578
1825
  }
1579
-
1580
- const attributes = element.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);
1826
+ if (
1827
+ !element.hasAttribute(ATTRIBUTE_UPDATER_SELECT_THIS) &&
1828
+ !attributes.includes(`path:${current}`) &&
1829
+ !attributes.startsWith("static:") &&
1830
+ !attributes.startsWith("i18n:")
1831
+ ) {
1832
+ continue;
1833
+ }
1834
+ mem.add(element);
1835
+ processed = true;
1581
1836
  element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
1582
1837
 
1583
1838
  for (let [, def] of Object.entries(attributes.split(","))) {
@@ -1610,6 +1865,8 @@ function runUpdateAttributes(container, parts, subject) {
1610
1865
  }
1611
1866
  }
1612
1867
  }
1868
+
1869
+ return processed;
1613
1870
  }
1614
1871
 
1615
1872
  /**
@@ -1620,40 +1877,44 @@ function runUpdateAttributes(container, parts, subject) {
1620
1877
  * @return {void}
1621
1878
  * @this Updater
1622
1879
  */
1623
- function runUpdateProperties(container, parts, subject) {
1880
+ function runUpdateProperties(
1881
+ container,
1882
+ parts,
1883
+ subject,
1884
+ mem = new WeakSet(),
1885
+ candidateCache = new WeakMap(),
1886
+ ) {
1624
1887
  if (!isArray(parts)) return;
1625
1888
  parts = clone(parts);
1626
-
1627
- const mem = new WeakSet();
1889
+ let processed = false;
1628
1890
 
1629
1891
  while (parts.length > 0) {
1630
1892
  const current = parts.join(".");
1631
1893
  parts.pop();
1632
1894
 
1633
- let iterator = new Set();
1634
-
1635
- const query = `[${ATTRIBUTE_UPDATER_SELECT_THIS}][${ATTRIBUTE_UPDATER_PROPERTIES}], [${ATTRIBUTE_UPDATER_PROPERTIES}*="path:${current}"], [${ATTRIBUTE_UPDATER_PROPERTIES}^="static:"], [${ATTRIBUTE_UPDATER_PROPERTIES}^="i18n:"]`;
1636
-
1637
- const e = container.querySelectorAll(query);
1638
-
1639
- if (e.length > 0) {
1640
- iterator = new Set([...e]);
1641
- }
1642
-
1643
- if (container.matches(query)) {
1644
- iterator.add(container);
1645
- }
1895
+ const iterator = getBindingCandidates(
1896
+ container,
1897
+ ATTRIBUTE_UPDATER_PROPERTIES,
1898
+ candidateCache,
1899
+ );
1646
1900
 
1647
1901
  for (const [element] of iterator.entries()) {
1648
1902
  if (mem.has(element)) continue;
1649
- mem.add(element);
1650
1903
 
1651
- // this case occurs when the ATTRIBUTE_UPDATER_SELECT_THIS attribute is set
1652
- if (!element.hasAttribute(ATTRIBUTE_UPDATER_PROPERTIES)) {
1904
+ const properties = element.getAttribute(ATTRIBUTE_UPDATER_PROPERTIES);
1905
+ if (!isString(properties)) {
1653
1906
  continue;
1654
1907
  }
1655
-
1656
- const properties = element.getAttribute(ATTRIBUTE_UPDATER_PROPERTIES);
1908
+ if (
1909
+ !element.hasAttribute(ATTRIBUTE_UPDATER_SELECT_THIS) &&
1910
+ !properties.includes(`path:${current}`) &&
1911
+ !properties.startsWith("static:") &&
1912
+ !properties.startsWith("i18n:")
1913
+ ) {
1914
+ continue;
1915
+ }
1916
+ mem.add(element);
1917
+ processed = true;
1657
1918
  element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
1658
1919
 
1659
1920
  for (let [, def] of Object.entries(properties.split(","))) {
@@ -1680,6 +1941,8 @@ function runUpdateProperties(container, parts, subject) {
1680
1941
  }
1681
1942
  }
1682
1943
  }
1944
+
1945
+ return processed;
1683
1946
  }
1684
1947
 
1685
1948
  /**
@@ -2026,12 +2289,11 @@ function addObjectWithUpdaterToElement(elements, symbol, object, config = {}) {
2026
2289
  u.setCallback(name, callback);
2027
2290
  }
2028
2291
  }
2029
-
2292
+ if (config.batchUpdates === true) {
2293
+ u.setBatchUpdates(true);
2294
+ }
2030
2295
  result.push(
2031
2296
  u.run().then(() => {
2032
- if (config.batchUpdates === true) {
2033
- u.setBatchUpdates(true);
2034
- }
2035
2297
  if (config.eventProcessing === true) {
2036
2298
  u.enableEventProcessing();
2037
2299
  }
@@ -4,6 +4,7 @@ import * as chai from 'chai';
4
4
  import {internalSymbol} from "../../../source/constants.mjs";
5
5
  import {customElementUpdaterLinkSymbol} from "../../../source/dom/constants.mjs";
6
6
  import {getDocument} from "../../../source/dom/util.mjs";
7
+ import {Observer} from "../../../source/types/observer.mjs";
7
8
  import {ProxyObserver} from "../../../source/types/proxyobserver.mjs";
8
9
  import {chaiDom} from "../../util/chai-dom.mjs";
9
10
  import {initJSDOM} from "../../util/jsdom.mjs";
@@ -28,7 +29,7 @@ const updaterSymbolSymbol = Symbol.for(updaterSymbolKey);
28
29
 
29
30
  describe('DOM', function () {
30
31
 
31
- let CustomElement, registerCustomElement, TestComponent, document, TestComponent2, TestStateComponent, TestMutationComponent, HiddenStateClass, assignUpdaterToElement,
32
+ let CustomElement, registerCustomElement, updaterTransformerMethodsSymbol, TestComponent, document, TestComponent2, TestStateComponent, TestMutationComponent, HiddenStateClass, assignUpdaterToElement,
32
33
  addObjectWithUpdaterToElement;
33
34
 
34
35
  describe("assignUpdaterToElement", function () {
@@ -154,6 +155,7 @@ describe('DOM', function () {
154
155
  try {
155
156
  CustomElement = m['CustomElement'];
156
157
  registerCustomElement = m['registerCustomElement'];
158
+ updaterTransformerMethodsSymbol = m['updaterTransformerMethodsSymbol'];
157
159
  TestComponent = class extends CustomElement {
158
160
  static getTag() {
159
161
  return "monster-testclass"
@@ -510,6 +512,81 @@ describe('DOM', function () {
510
512
  }, 20);
511
513
  }, 10);
512
514
  });
515
+
516
+ it('should synchronize option arrays without unchanged proxy churn issue #517', async function () {
517
+ const htmlTAG = 'monster-testclass-array-sync-517';
518
+
519
+ if (!customElements.get(htmlTAG)) {
520
+ class ArraySyncComponent extends CustomElement {
521
+ static getTag() {
522
+ return htmlTAG;
523
+ }
524
+
525
+ get defaults() {
526
+ return Object.assign({}, super.defaults, {
527
+ items: Array.from({length: 100}, (_, index) => `item-${index}`),
528
+ unrelated: 0,
529
+ templates: {
530
+ main: '<span id="array-sync-value" data-monster-replace="path:items | call:joinItems"></span><span id="array-sync-value-second" data-monster-replace="path:items | call:joinItems"></span>',
531
+ },
532
+ });
533
+ }
534
+
535
+ [updaterTransformerMethodsSymbol]() {
536
+ return {
537
+ joinItems: (items) => items.join(','),
538
+ };
539
+ }
540
+ }
541
+
542
+ registerCustomElement(ArraySyncComponent);
543
+ }
544
+
545
+ const element = document.createElement(htmlTAG);
546
+ document.getElementById('test1').appendChild(element);
547
+ await new Promise((resolve) => setTimeout(resolve, 20));
548
+
549
+ const updaterGroups = element[customElementUpdaterLinkSymbol];
550
+ let linkedUpdater = null;
551
+ for (const group of updaterGroups) {
552
+ for (const updater of group) {
553
+ linkedUpdater = updater;
554
+ break;
555
+ }
556
+ if (linkedUpdater) break;
557
+ }
558
+ expect(linkedUpdater).to.exist;
559
+
560
+ let notificationCount = 0;
561
+ class CountingObserver extends Observer {
562
+ update(subject) {
563
+ notificationCount++;
564
+ return super.update(subject);
565
+ }
566
+ }
567
+ const observer = new CountingObserver(() => {});
568
+ linkedUpdater[internalSymbol].subject.attachObserver(observer);
569
+
570
+ try {
571
+ element.setOption('unrelated', 1);
572
+ await new Promise((resolve) => setTimeout(resolve, 30));
573
+ expect(notificationCount).to.be.at.most(3);
574
+
575
+ notificationCount = 0;
576
+ element.setOption('items.50', 'changed');
577
+ await new Promise((resolve) => setTimeout(resolve, 30));
578
+
579
+ expect(notificationCount).to.be.at.most(3);
580
+ expect(
581
+ element.shadowRoot.querySelector('#array-sync-value').textContent,
582
+ ).to.contain('changed');
583
+ expect(
584
+ element.shadowRoot.querySelector('#array-sync-value-second').textContent,
585
+ ).to.contain('changed');
586
+ } finally {
587
+ linkedUpdater[internalSymbol].subject.detachObserver(observer);
588
+ }
589
+ });
513
590
  })
514
591
 
515
592
  describe('setOptions()', function () {
@@ -860,6 +937,64 @@ describe('DOM', function () {
860
937
  })
861
938
 
862
939
  describe('mutation observer lifecycle', function () {
940
+ it('should coalesce child mutations without a refresh loop issue #517', async function () {
941
+ const htmlTAG = 'monster-testclass-mutation-loop-517';
942
+
943
+ if (!customElements.get(htmlTAG)) {
944
+ class MutationLoopComponent extends CustomElement {
945
+ static getTag() {
946
+ return htmlTAG;
947
+ }
948
+
949
+ get defaults() {
950
+ return Object.assign({}, super.defaults, {
951
+ shadowMode: false,
952
+ features: {
953
+ mutationObserver: true,
954
+ },
955
+ templates: {
956
+ main: '<span data-monster-replace="static:stable"></span>',
957
+ },
958
+ });
959
+ }
960
+ }
961
+
962
+ registerCustomElement(MutationLoopComponent);
963
+ }
964
+
965
+ const element = document.createElement(htmlTAG);
966
+ document.getElementById('test1').appendChild(element);
967
+ await new Promise((resolve) => setTimeout(resolve, 120));
968
+
969
+ const updaterGroups = element[customElementUpdaterLinkSymbol];
970
+ let linkedUpdater = null;
971
+ for (const group of updaterGroups) {
972
+ for (const updater of group) {
973
+ linkedUpdater = updater;
974
+ break;
975
+ }
976
+ if (linkedUpdater) break;
977
+ }
978
+ expect(linkedUpdater).to.exist;
979
+
980
+ let runCount = 0;
981
+ const originalRun = linkedUpdater.run.bind(linkedUpdater);
982
+ linkedUpdater.run = function () {
983
+ runCount++;
984
+ return originalRun();
985
+ };
986
+
987
+ element.appendChild(document.createElement('i'));
988
+ element.appendChild(document.createElement('b'));
989
+ element.appendChild(document.createElement('em'));
990
+ await new Promise((resolve) => setTimeout(resolve, 160));
991
+
992
+ expect(runCount).to.equal(1);
993
+ expect(element.querySelector('[data-monster-replace]').textContent).to.equal(
994
+ 'stable',
995
+ );
996
+ });
997
+
863
998
  it('should not rerun linked updaters after disconnect', function (done) {
864
999
  let mocks = document.getElementById('mocks');
865
1000
  mocks.innerHTML = `<monster-testclass-mutation id="mutation-case"></monster-testclass-mutation>`;
@@ -170,6 +170,7 @@ let htmlPatchVsReplace = `
170
170
 
171
171
  describe("DOM", function () {
172
172
  let Updater = null;
173
+ let addObjectWithUpdaterToElement = null;
173
174
 
174
175
  before(function (done) {
175
176
  const options = {};
@@ -177,6 +178,7 @@ describe("DOM", function () {
177
178
  import("../../../source/dom/updater.mjs")
178
179
  .then((m) => {
179
180
  Updater = m.Updater;
181
+ addObjectWithUpdaterToElement = m.addObjectWithUpdaterToElement;
180
182
 
181
183
  if (!customElements.get("monster-test-property")) {
182
184
  class MonsterTestProperty extends HTMLElement {
@@ -1907,6 +1909,50 @@ describe("DOM", function () {
1907
1909
  });
1908
1910
 
1909
1911
  describe("Updater reactive updates", function () {
1912
+ it("should enable configured batching before the initial run issue #517", async function () {
1913
+ const originalRun = Updater.prototype.run;
1914
+ const originalSetBatchUpdates = Updater.prototype.setBatchUpdates;
1915
+ const batchedUpdaters = new WeakSet();
1916
+ const batchStateAtRun = [];
1917
+ const symbol = Symbol("initial-batch-test");
1918
+ const element = document.createElement("div");
1919
+ element.setAttribute("data-monster-replace", "path:value");
1920
+ document.getElementById("mocks").appendChild(element);
1921
+
1922
+ Updater.prototype.setBatchUpdates = function (enabled) {
1923
+ if (enabled === true) {
1924
+ batchedUpdaters.add(this);
1925
+ }
1926
+ return originalSetBatchUpdates.call(this, enabled);
1927
+ };
1928
+ Updater.prototype.run = function () {
1929
+ batchStateAtRun.push(batchedUpdaters.has(this));
1930
+ return originalRun.call(this);
1931
+ };
1932
+
1933
+ let updaters = [];
1934
+ try {
1935
+ updaters = await Promise.all(
1936
+ addObjectWithUpdaterToElement.call(
1937
+ element,
1938
+ element,
1939
+ symbol,
1940
+ { value: "initial" },
1941
+ { batchUpdates: true },
1942
+ ),
1943
+ );
1944
+
1945
+ expect(batchStateAtRun).to.deep.equal([true]);
1946
+ expect(element.textContent).to.equal("initial");
1947
+ } finally {
1948
+ Updater.prototype.run = originalRun;
1949
+ Updater.prototype.setBatchUpdates = originalSetBatchUpdates;
1950
+ for (const updater of updaters) {
1951
+ updater.dispose();
1952
+ }
1953
+ }
1954
+ });
1955
+
1910
1956
  [false, true].forEach((batchUpdates) => {
1911
1957
  it(`should preserve microtask changes with batchUpdates=${batchUpdates} issue #496`, async function () {
1912
1958
  let mocks = document.getElementById("mocks");
@@ -1938,6 +1984,134 @@ describe("DOM", function () {
1938
1984
  });
1939
1985
  });
1940
1986
 
1987
+ [false, true].forEach((batchUpdates) => {
1988
+ it(`should render each binding once per snapshot with batchUpdates=${batchUpdates} issue #517`, async function () {
1989
+ const mocks = document.getElementById("mocks");
1990
+ const staticBindings = Array.from(
1991
+ { length: 20 },
1992
+ (_, index) =>
1993
+ `<span data-index="${index}" data-monster-replace="static:marker | call:trackStatic"></span>`,
1994
+ ).join("");
1995
+ mocks.innerHTML = `
1996
+ <span id="snapshot-items" data-monster-replace="path:items | call:lastItem"></span>
1997
+ ${staticBindings}
1998
+ <span id="snapshot-attribute" data-monster-attributes="title path:items | call:trackAttribute"></span>
1999
+ <input id="snapshot-property" data-monster-properties="value path:items | call:trackProperty">
2000
+ `;
2001
+
2002
+ const updater = new Updater(mocks, { items: [] });
2003
+ updater.setBatchUpdates(batchUpdates);
2004
+ let staticRenderCount = 0;
2005
+ let attributeRenderCount = 0;
2006
+ let propertyRenderCount = 0;
2007
+ updater.setCallback("trackStatic", (value) => {
2008
+ staticRenderCount++;
2009
+ return value;
2010
+ });
2011
+ updater.setCallback("lastItem", (value) => value.at(-1) ?? "");
2012
+ updater.setCallback("trackAttribute", (value) => {
2013
+ attributeRenderCount++;
2014
+ return value;
2015
+ });
2016
+ updater.setCallback("trackProperty", (value) => {
2017
+ propertyRenderCount++;
2018
+ return value;
2019
+ });
2020
+
2021
+ let innerHTMLDescriptor = null;
2022
+ let replacementWrites = 0;
2023
+ try {
2024
+ await updater.run();
2025
+ staticRenderCount = 0;
2026
+ attributeRenderCount = 0;
2027
+ propertyRenderCount = 0;
2028
+
2029
+ innerHTMLDescriptor = Object.getOwnPropertyDescriptor(
2030
+ Element.prototype,
2031
+ "innerHTML",
2032
+ );
2033
+ Object.defineProperty(Element.prototype, "innerHTML", {
2034
+ configurable: innerHTMLDescriptor.configurable,
2035
+ enumerable: innerHTMLDescriptor.enumerable,
2036
+ get: innerHTMLDescriptor.get,
2037
+ set(value) {
2038
+ if (
2039
+ mocks.contains(this) &&
2040
+ this.hasAttribute("data-monster-replace")
2041
+ ) {
2042
+ replacementWrites++;
2043
+ }
2044
+ return innerHTMLDescriptor.set.call(this, value);
2045
+ },
2046
+ });
2047
+
2048
+ const items = updater.getSubject().items;
2049
+ for (let index = 0; index < 500; index++) {
2050
+ items.push(`new-${index}`);
2051
+ }
2052
+
2053
+ await new Promise((resolve) => setTimeout(resolve, 30));
2054
+
2055
+ expect(document.getElementById("snapshot-items").textContent).to.equal(
2056
+ "new-499",
2057
+ );
2058
+ expect(staticRenderCount).to.equal(20);
2059
+ expect(attributeRenderCount).to.equal(1);
2060
+ expect(propertyRenderCount).to.equal(1);
2061
+ expect(replacementWrites).to.equal(1);
2062
+ } finally {
2063
+ updater.dispose();
2064
+ if (innerHTMLDescriptor) {
2065
+ Object.defineProperty(
2066
+ Element.prototype,
2067
+ "innerHTML",
2068
+ innerHTMLDescriptor,
2069
+ );
2070
+ }
2071
+ }
2072
+ });
2073
+ });
2074
+
2075
+ it("should not rewrite unchanged replacement content issue #517", async function () {
2076
+ const mocks = document.getElementById("mocks");
2077
+ mocks.innerHTML = `
2078
+ <span id="unchanged-replacement" data-monster-replace="static:same"></span>
2079
+ `;
2080
+
2081
+ const element = document.getElementById("unchanged-replacement");
2082
+ const descriptor = Object.getOwnPropertyDescriptor(
2083
+ Element.prototype,
2084
+ "innerHTML",
2085
+ );
2086
+ let writes = 0;
2087
+
2088
+ Object.defineProperty(Element.prototype, "innerHTML", {
2089
+ configurable: descriptor.configurable,
2090
+ enumerable: descriptor.enumerable,
2091
+ get: descriptor.get,
2092
+ set(value) {
2093
+ if (this === element) {
2094
+ writes++;
2095
+ }
2096
+ return descriptor.set.call(this, value);
2097
+ },
2098
+ });
2099
+
2100
+ const updater = new Updater(mocks, { trigger: 0 });
2101
+ try {
2102
+ await updater.run();
2103
+ writes = 0;
2104
+ updater.getSubject().trigger = 1;
2105
+ await new Promise((resolve) => setTimeout(resolve, 20));
2106
+
2107
+ expect(element.textContent).to.equal("same");
2108
+ expect(writes).to.equal(0);
2109
+ } finally {
2110
+ updater.dispose();
2111
+ Object.defineProperty(Element.prototype, "innerHTML", descriptor);
2112
+ }
2113
+ });
2114
+
1941
2115
  it("should update the DOM when the subject changes", function (done) {
1942
2116
  let mocks = document.getElementById("mocks");
1943
2117
  mocks.innerHTML = html1;