@schukai/monster 4.153.1 → 4.154.0

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 (39) 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/time/month-calendar.mjs +115 -0
  24. package/test/cases/data/diff.mjs +86 -0
  25. package/test/cases/data/extend.mjs +52 -0
  26. package/test/cases/data/pathfinder-resolution-baseline.mjs +322 -0
  27. package/test/cases/data/pathfinder.mjs +129 -0
  28. package/test/cases/dom/customelement-rebind-baseline.mjs +323 -0
  29. package/test/cases/dom/customelement-teardown.mjs +408 -0
  30. package/test/cases/dom/customelement.mjs +354 -0
  31. package/test/cases/dom/dimension.mjs +34 -10
  32. package/test/cases/dom/updater-remove.mjs +198 -0
  33. package/test/cases/packaging.mjs +213 -0
  34. package/test/cases/types/base.mjs +50 -0
  35. package/test/cases/types/observerlist.mjs +103 -1
  36. package/test/cases/types/proxyobserver.mjs +114 -0
  37. package/test/cases/types/regex.mjs +4 -0
  38. package/test/cases/types/tokenlist.mjs +64 -1
  39. package/test/cases/util/clone.mjs +115 -0
@@ -36,7 +36,11 @@ import {
36
36
  validateObject,
37
37
  } from "../types/validate.mjs";
38
38
  import { clone } from "../util/clone.mjs";
39
- import { getLinkedObjects, hasObjectLink } from "./attributes.mjs";
39
+ import {
40
+ getLinkedObjects,
41
+ hasObjectLink,
42
+ removeObjectLink,
43
+ } from "./attributes.mjs";
40
44
  import {
41
45
  ATTRIBUTE_DISABLED,
42
46
  ATTRIBUTE_OPTIONS,
@@ -119,6 +123,8 @@ const updateCloneDataSymbol = Symbol("@schukai/monster/dom/@@updateCloneData");
119
123
  const scriptHostElementSymbol = Symbol("scriptHostElement");
120
124
  const optionDefaultsSymbol = Symbol("optionDefaults");
121
125
  const managedShadowRootSymbol = Symbol("managedShadowRoot");
126
+ const assembledOnceSymbol = Symbol("assembledOnce");
127
+ const teardownCallbacksSymbol = Symbol("teardownCallbacks");
122
128
  const visibilityStateSymbol = Symbol("visibilityState");
123
129
  let hostVisibilityStyleSheet = null;
124
130
 
@@ -358,7 +364,7 @@ class CustomElement extends HTMLElement {
358
364
  * @property {Function} templateFormatter.marker.open=null Specifies the opening marker for the templates.
359
365
  * @property {Function} templateFormatter.marker.close=null Specifies the closing marker for the templates.
360
366
  * @property {Boolean} templateFormatter.i18n=false Specifies whether the templates should be formatted with i18n.
361
- * @property {Boolean} eventProcessing=false Specifies whether the control processes events.
367
+ * @property {Boolean} eventProcessing=false Specifies whether the control processes events. A bound input writes its value into the updater snapshot, not into the options. The synchronisation is one way, options to snapshot, so getOption() does not observe user input; read it with getInternalUpdateCloneData().
362
368
  * @property {Object} updater Specifies updater options.
363
369
  * @property {Boolean} updater.batchUpdates=false Batches updater content/attribute updates per diff.
364
370
  * @since 1.8.0
@@ -540,16 +546,9 @@ class CustomElement extends HTMLElement {
540
546
  * @since 1.10.0
541
547
  */
542
548
  getOption(path, defaultValue = undefined) {
543
- let value;
544
-
545
- try {
546
- value = new Pathfinder(
547
- this[internalSymbol].getRealSubject()["options"],
548
- ).getVia(path);
549
- } catch (e) {}
550
-
551
- if (value === undefined) return defaultValue;
552
- return value;
549
+ return new Pathfinder(
550
+ this[internalSymbol].getRealSubject()["options"],
551
+ ).getVia(path, defaultValue);
553
552
  }
554
553
 
555
554
  /**
@@ -699,6 +698,7 @@ class CustomElement extends HTMLElement {
699
698
  [assembleMethodSymbol]() {
700
699
  let elements;
701
700
  let nodeList;
701
+
702
702
  // Extract options from attributes and set them
703
703
  const AttributeOptions = getOptionsFromAttributes.call(this);
704
704
  if (
@@ -736,7 +736,12 @@ class CustomElement extends HTMLElement {
736
736
  try {
737
737
  nodeList = new Set([
738
738
  ...elements,
739
- ...getSlottedElements.call(this, undefined, undefined, getManagedShadowRoot.call(this)),
739
+ ...getSlottedElements.call(
740
+ this,
741
+ undefined,
742
+ undefined,
743
+ getManagedShadowRoot.call(this),
744
+ ),
740
745
  ]);
741
746
  } catch (e) {
742
747
  nodeList = elements;
@@ -764,13 +769,40 @@ class CustomElement extends HTMLElement {
764
769
  );
765
770
  // Attach a mutation observer to observe changes to the attributes of the element
766
771
  attachAttributeChangeMutationObserver.call(this);
772
+ attachChildMutationObserver.call(this);
767
773
 
768
774
  if (isFunction(this[internalSymbol]?.syncDisabledState)) {
769
- this[internalSymbol].syncDisabledState();
775
+ this[internalSymbol].syncDisabledState(true);
770
776
  }
771
777
 
772
778
  syncVisibilityState.call(this);
773
779
 
780
+ this[assembledOnceSymbol] = true;
781
+
782
+ return this;
783
+ }
784
+
785
+ /**
786
+ * Registers a callback that runs when the element is disconnected.
787
+ *
788
+ * The registration is consumed by the first teardown, so a callback that is
789
+ * needed again after a reconnect has to be registered again. A callback that
790
+ * throws does not stop the remaining teardown; the error is reported through
791
+ * the error attribute.
792
+ *
793
+ * @param {function} callback
794
+ * @return {CustomElement}
795
+ * @throws {TypeError} value is not a function
796
+ * @since 4.154.0
797
+ */
798
+ registerTeardown(callback) {
799
+ validateFunction(callback);
800
+
801
+ if (!isArray(this[teardownCallbacksSymbol])) {
802
+ this[teardownCallbacksSymbol] = [];
803
+ }
804
+
805
+ this[teardownCallbacksSymbol].push(callback);
774
806
  return this;
775
807
  }
776
808
 
@@ -778,7 +810,15 @@ class CustomElement extends HTMLElement {
778
810
  * You know what you are doing? This function is only for advanced users.
779
811
  * The result is a clone of the internal data.
780
812
  *
813
+ * This snapshot is the object the updaters are bound to. It is the only
814
+ * place where values typed into a `data-monster-bind` input end up when
815
+ * `eventProcessing` is enabled, because the synchronisation runs one way
816
+ * from the options into the snapshot and never back. A reconnect rebinds to
817
+ * the same snapshot, so that state survives.
818
+ *
781
819
  * @return {*}
820
+ * @see Monster.DOM.CustomElement#getOption
821
+ * @since 3.56.0
782
822
  */
783
823
  getInternalUpdateCloneData() {
784
824
  return clone(this[updateCloneDataSymbol]);
@@ -793,19 +833,31 @@ class CustomElement extends HTMLElement {
793
833
  * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/connectedCallback
794
834
  */
795
835
  connectedCallback() {
796
- // Check if the object has already been initialized
797
- if (!hasObjectLink(this, customElementUpdaterLinkSymbol)) {
798
- try {
799
- initOptionsFromAttributes(
800
- this,
801
- this[internalSymbol].getSubject()?.["options"],
802
- );
803
- } catch (e) {
804
- addErrorAttribute(this, e);
805
- }
806
- // If not, call the assembleMethod to initialize the object
807
- this[assembleMethodSymbol]();
836
+ // Already bound, nothing to do. Moving a connected element inside its own
837
+ // parent fires disconnect and connect, so this runs often.
838
+ if (hasObjectLink(this, customElementUpdaterLinkSymbol)) {
839
+ return;
808
840
  }
841
+
842
+ try {
843
+ initOptionsFromAttributes(
844
+ this,
845
+ this[internalSymbol].getSubject()?.["options"],
846
+ );
847
+ } catch (e) {
848
+ addErrorAttribute(this, e);
849
+ }
850
+
851
+ // The assemble method is a one shot: derived classes override it and
852
+ // rebuild their own state in there, for example state/log.mjs resets its
853
+ // entries array and its entry maps. After a disconnect the rendered DOM
854
+ // and that state are still intact, so a reconnect only relinks.
855
+ if (this[assembledOnceSymbol] === true) {
856
+ rebindAfterReconnect.call(this);
857
+ return;
858
+ }
859
+
860
+ this[assembleMethodSymbol]();
809
861
  }
810
862
 
811
863
  /**
@@ -814,7 +866,16 @@ class CustomElement extends HTMLElement {
814
866
  * @return {void}
815
867
  * @since 1.7.0
816
868
  */
817
- disconnectedCallback() {}
869
+ disconnectedCallback() {
870
+ runTeardownCallbacks.call(this);
871
+ disposeLinkedUpdaters.call(this);
872
+ detachAttributeChangeMutationObserver.call(this);
873
+ detachChildMutationObserver.call(this);
874
+
875
+ if (isFunction(this[internalSymbol]?.disconnectDisabledObservers)) {
876
+ this[internalSymbol].disconnectDisabledObservers();
877
+ }
878
+ }
818
879
 
819
880
  /**
820
881
  * The custom element has been moved into a new document (e.g. someone called document.adoptNode(el)).
@@ -970,6 +1031,168 @@ function initFromCallbackHost() {
970
1031
  callControlCallback.call(this, callBackFunctionName);
971
1032
  }
972
1033
 
1034
+ /**
1035
+ * Relinks a previously assembled element after it was reconnected.
1036
+ *
1037
+ * Deliberately does not touch the shadow root, the rendered markup, the options
1038
+ * snapshot or anything a derived assemble method built. It only recreates what
1039
+ * the teardown released: the updaters and the observers.
1040
+ *
1041
+ * @private
1042
+ * @this CustomElement
1043
+ */
1044
+ function rebindAfterReconnect() {
1045
+ let elements = getManagedShadowRoot.call(this)?.childNodes;
1046
+ if (!(elements instanceof NodeList)) {
1047
+ elements = this.childNodes;
1048
+ }
1049
+
1050
+ let nodeList;
1051
+ try {
1052
+ nodeList = new Set([
1053
+ ...elements,
1054
+ ...getSlottedElements.call(
1055
+ this,
1056
+ undefined,
1057
+ undefined,
1058
+ getManagedShadowRoot.call(this),
1059
+ ),
1060
+ ]);
1061
+ } catch {
1062
+ nodeList = elements;
1063
+ }
1064
+
1065
+ const cfg = {};
1066
+ if (this.getOption("eventProcessing") === true) {
1067
+ cfg.eventProcessing = true;
1068
+ }
1069
+ if (this.getOption("updater.batchUpdates") === true) {
1070
+ cfg.batchUpdates = true;
1071
+ }
1072
+
1073
+ // Binds to the same snapshot object on purpose: it carries the runtime state
1074
+ // the updaters produced, including values written back by event processing,
1075
+ // which the component options never receive.
1076
+ addObjectWithUpdaterToElement.call(
1077
+ this,
1078
+ nodeList,
1079
+ customElementUpdaterLinkSymbol,
1080
+ this[updateCloneDataSymbol],
1081
+ cfg,
1082
+ );
1083
+
1084
+ attachAttributeChangeMutationObserver.call(this);
1085
+ attachChildMutationObserver.call(this);
1086
+
1087
+ if (isFunction(this[internalSymbol]?.syncDisabledState)) {
1088
+ this[internalSymbol].syncDisabledState(true);
1089
+ }
1090
+
1091
+ syncVisibilityState.call(this);
1092
+ }
1093
+
1094
+ /**
1095
+ * @private
1096
+ * @this CustomElement
1097
+ */
1098
+ function runTeardownCallbacks() {
1099
+ const callbacks = this[teardownCallbacksSymbol];
1100
+ if (!isArray(callbacks) || callbacks.length === 0) {
1101
+ return;
1102
+ }
1103
+
1104
+ // taken first, so a callback that re-registers during teardown does not run
1105
+ // in the same pass
1106
+ this[teardownCallbacksSymbol] = [];
1107
+
1108
+ for (const callback of callbacks) {
1109
+ try {
1110
+ callback.call(this);
1111
+ } catch (e) {
1112
+ addErrorAttribute(this, e);
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * @private
1119
+ * @this CustomElement
1120
+ */
1121
+ function disposeLinkedUpdaters() {
1122
+ if (!hasObjectLink(this, customElementUpdaterLinkSymbol)) {
1123
+ return;
1124
+ }
1125
+
1126
+ try {
1127
+ for (const group of getLinkedObjects(
1128
+ this,
1129
+ customElementUpdaterLinkSymbol,
1130
+ )) {
1131
+ if (!(group instanceof Set)) {
1132
+ continue;
1133
+ }
1134
+
1135
+ for (const updater of group) {
1136
+ if (typeof updater?.dispose !== "function") {
1137
+ continue;
1138
+ }
1139
+
1140
+ try {
1141
+ updater.dispose();
1142
+ } catch (e) {
1143
+ addErrorAttribute(this, e);
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ removeObjectLink(this, customElementUpdaterLinkSymbol);
1149
+ } catch (e) {
1150
+ addErrorAttribute(this, e);
1151
+ }
1152
+ }
1153
+
1154
+ /**
1155
+ * The symbol is deleted, not only disconnected: the attach guard treats a
1156
+ * present symbol as an active observer and would otherwise never replace it.
1157
+ *
1158
+ * @private
1159
+ * @this CustomElement
1160
+ */
1161
+ function detachAttributeChangeMutationObserver() {
1162
+ const observer = this[attributeMutationObserverSymbol];
1163
+ if (observer) {
1164
+ try {
1165
+ observer.disconnect();
1166
+ } catch (e) {
1167
+ addErrorAttribute(this, e);
1168
+ }
1169
+ }
1170
+
1171
+ delete this[attributeMutationObserverSymbol];
1172
+ }
1173
+
1174
+ /**
1175
+ * @private
1176
+ * @this CustomElement
1177
+ */
1178
+ function detachChildMutationObserver() {
1179
+ const observer = this[childMutationObserverSymbol];
1180
+ if (observer) {
1181
+ try {
1182
+ observer.disconnect();
1183
+ } catch (e) {
1184
+ addErrorAttribute(this, e);
1185
+ }
1186
+ }
1187
+
1188
+ delete this[childMutationObserverSymbol];
1189
+
1190
+ if (this[childMutationTimerSymbol]) {
1191
+ clearTimeout(this[childMutationTimerSymbol]);
1192
+ this[childMutationTimerSymbol] = null;
1193
+ }
1194
+ }
1195
+
973
1196
  /**
974
1197
  * This method is called when the element is first created.
975
1198
  *
@@ -1098,12 +1321,21 @@ function containChildNode(node) {
1098
1321
  return true;
1099
1322
  }
1100
1323
 
1101
- for (const [, e] of Object.entries(this.childNodes)) {
1102
- if (e.contains(node)) {
1324
+ // Node.contains() already covers the whole light DOM subtree, so the only
1325
+ // thing left to search is the shadow root of nested elements, which no
1326
+ // contains() call can see.
1327
+ for (const child of this.childNodes) {
1328
+ const shadowRoot = child?.shadowRoot;
1329
+ if (
1330
+ shadowRoot instanceof ShadowRoot &&
1331
+ containChildNode.call(shadowRoot, node)
1332
+ ) {
1103
1333
  return true;
1104
1334
  }
1105
1335
 
1106
- containChildNode.call(e, node);
1336
+ if (child.childNodes?.length && containChildNode.call(child, node)) {
1337
+ return true;
1338
+ }
1107
1339
  }
1108
1340
 
1109
1341
  return false;
@@ -1228,6 +1460,8 @@ function initOptionObserver() {
1228
1460
  self.attachObserver(new Observer(syncDisabledState));
1229
1461
 
1230
1462
  self[internalSymbol].syncDisabledState = syncDisabledState;
1463
+ self[internalSymbol].disconnectDisabledObservers =
1464
+ disconnectDisabledObservers;
1231
1465
 
1232
1466
  let updaterSyncRevision = 0;
1233
1467
  self.attachObserver(
@@ -1698,6 +1932,15 @@ function initShadowRoot() {
1698
1932
  let template;
1699
1933
  let html;
1700
1934
 
1935
+ // A rebind keeps the rendered shadow DOM. Calling attachShadow() again would
1936
+ // throw, after which the main template used to be appended into the light
1937
+ // DOM and a second updater set was bound to the same host.
1938
+ const existing = getManagedShadowRoot.call(this);
1939
+ if (existing instanceof ShadowRoot) {
1940
+ this[managedShadowRootSymbol] = existing;
1941
+ return this;
1942
+ }
1943
+
1701
1944
  try {
1702
1945
  template = findDocumentTemplate(this.constructor.getTag());
1703
1946
  } catch (e) {
@@ -651,7 +651,7 @@ function retrieveAndSetValue(element) {
651
651
 
652
652
  let options = element?.selectedOptions;
653
653
  if (options === undefined)
654
- options = element.querySelectorAll(":scope option:checked");
654
+ options = element.querySelectorAll("option:checked");
655
655
  value = Array.from(options).map(({ value }) => value);
656
656
 
657
657
  break;
@@ -847,8 +847,12 @@ function removeElement(_change, renderContext) {
847
847
  }
848
848
 
849
849
  let changed = false;
850
+ // No :scope prefix: querySelectorAll() on an element already returns only its
851
+ // descendants, so the prefix carries no meaning here, while :scope does not
852
+ // resolve for shadow root content under jsdom, which made this a silent no-op
853
+ // for every component that renders into a shadow root.
850
854
  for (const [, element] of this[internalSymbol].element
851
- .querySelectorAll(`:scope [${ATTRIBUTE_UPDATER_REMOVE}]`)
855
+ .querySelectorAll(`[${ATTRIBUTE_UPDATER_REMOVE}]`)
852
856
  .entries()) {
853
857
  teardownManagedSubtree(element);
854
858
  element.parentNode.removeChild(element);
@@ -115,21 +115,22 @@ function checkInstanceSymbol(obj) {
115
115
  }
116
116
 
117
117
  if (proto.hasOwnProperty(instanceSymbol) !== true) {
118
- return checkInstanceSymbol.apply(this, [obj.__proto__]);
118
+ return checkInstanceSymbol.apply(this, [Object.getPrototypeOf(obj)]);
119
119
  }
120
120
 
121
121
  const symbol = proto[instanceSymbol];
122
122
  if (symbol === undefined) {
123
- if (obj.__proto__) {
124
- return checkInstanceSymbol(obj.__proto__);
125
- } else {
126
- return false;
123
+ const parent = Object.getPrototypeOf(obj);
124
+ if (parent) {
125
+ return checkInstanceSymbol.apply(this, [parent]);
127
126
  }
127
+
128
+ return false;
128
129
  }
129
130
 
130
131
  if (symbol === this[instanceSymbol]) {
131
132
  return true;
132
133
  }
133
134
 
134
- return checkInstanceSymbol.apply(this, [obj.__proto__]);
135
+ return checkInstanceSymbol.apply(this, [Object.getPrototypeOf(obj)]);
135
136
  }
@@ -80,13 +80,6 @@ class BaseWithOptions extends Base {
80
80
  * @since 1.10.0
81
81
  */
82
82
  getOption(path, defaultValue) {
83
- let value;
84
-
85
- try {
86
- value = new Pathfinder(this[internalSymbol]).getVia(path);
87
- } catch (e) {}
88
-
89
- if (value === undefined) return defaultValue;
90
- return value;
83
+ return new Pathfinder(this[internalSymbol]).getVia(path, defaultValue);
91
84
  }
92
85
  }
@@ -126,16 +126,10 @@ function equipWithInternal() {
126
126
  * @return {*}
127
127
  */
128
128
  this["getInternal"] = (path, defaultValue) => {
129
- let value;
130
-
131
- try {
132
- value = new Pathfinder(this[internalSymbol].getRealSubject()).getVia(
133
- path,
134
- );
135
- } catch (e) {}
136
-
137
- if (value === undefined) return defaultValue;
138
- return value;
129
+ return new Pathfinder(this[internalSymbol].getRealSubject()).getVia(
130
+ path,
131
+ defaultValue,
132
+ );
139
133
  };
140
134
  }
141
135
 
@@ -55,9 +55,9 @@ class ObserverList extends Base {
55
55
  detach(observer) {
56
56
  validateInstance(observer, Observer);
57
57
 
58
- var i = 0;
59
- var l = this.observers.length;
60
- for (; i < l; i++) {
58
+ // Iterate backwards: splicing while moving forward skips the entry that
59
+ // takes the freed slot, which left adjacent duplicates attached.
60
+ for (let i = this.observers.length - 1; i >= 0; i--) {
61
61
  if (this.observers[i] === observer) {
62
62
  this.observers.splice(i, 1);
63
63
  }
@@ -74,14 +74,8 @@ class ObserverList extends Base {
74
74
  */
75
75
  contains(observer) {
76
76
  validateInstance(observer, Observer);
77
- var i = 0;
78
- var l = this.observers.length;
79
- for (; i < l; i++) {
80
- if (this.observers[i] === observer) {
81
- return true;
82
- }
83
- }
84
- return false;
77
+
78
+ return this.observers.includes(observer);
85
79
  }
86
80
 
87
81
  /**
@@ -90,14 +84,12 @@ class ObserverList extends Base {
90
84
  * @return {Promise}
91
85
  */
92
86
  notify(subject) {
93
- const pomises = [];
87
+ const promises = [];
94
88
 
95
- let i = 0;
96
- const l = this.observers.length;
97
- for (; i < l; i++) {
98
- pomises.push(this.observers[i].update(subject));
89
+ for (const observer of this.observers) {
90
+ promises.push(observer.update(subject));
99
91
  }
100
92
 
101
- return Promise.all(pomises);
93
+ return Promise.all(promises);
102
94
  }
103
95
  }
@@ -218,7 +218,7 @@ function getHandler() {
218
218
  current = proxy.proxyMap.get(current);
219
219
  }
220
220
 
221
- if (current === value) {
221
+ if (isSameValue(current, value)) {
222
222
  return true;
223
223
  }
224
224
 
@@ -232,7 +232,7 @@ function getHandler() {
232
232
  if (proxy.proxyMap.has(next)) {
233
233
  next = proxy.proxyMap.get(next);
234
234
  }
235
- if (next !== current) {
235
+ if (!isSameValue(next, current)) {
236
236
  proxy.observers.notify(proxy);
237
237
  }
238
238
  }
@@ -274,6 +274,23 @@ function getHandler() {
274
274
  return handler;
275
275
  }
276
276
 
277
+ /**
278
+ * Two NaN values are the same value, so assigning NaN over NaN is not a change.
279
+ * Object.is() is not used here because it would additionally treat 0 and -0 as
280
+ * a change and notify for assignments that do not alter the subject.
281
+ * @param {*} a
282
+ * @param {*} b
283
+ * @return {boolean}
284
+ * @private
285
+ */
286
+ function isSameValue(a, b) {
287
+ if (a === b) {
288
+ return true;
289
+ }
290
+
291
+ return Number.isNaN(a) && Number.isNaN(b);
292
+ }
293
+
277
294
  /**
278
295
  * Native objects with internal slots cannot be used through a transparent Proxy
279
296
  * because their prototype methods require the original receiver.
@@ -84,13 +84,18 @@ class TokenList extends Base {
84
84
  */
85
85
  contains(value) {
86
86
  if (isString(value)) {
87
- value = value.trim();
88
- let counter = 0;
89
- value.split(" ").forEach((token) => {
90
- if (this.tokens.has(token.trim()) === false) return false;
91
- counter++;
92
- });
93
- return counter > 0 ? true : false;
87
+ // Repeated or surrounding whitespace produces empty segments, which are
88
+ // not tokens. A value without any token contains nothing.
89
+ const tokens = value
90
+ .split(" ")
91
+ .map((token) => token.trim())
92
+ .filter((token) => token !== "");
93
+
94
+ if (tokens.length === 0) {
95
+ return false;
96
+ }
97
+
98
+ return tokens.every((token) => this.tokens.has(token));
94
99
  }
95
100
 
96
101
  if (isIterable(value)) {
@@ -100,7 +105,7 @@ class TokenList extends Base {
100
105
  if (this.tokens.has(token.trim()) === false) return false;
101
106
  counter++;
102
107
  }
103
- return counter > 0 ? true : false;
108
+ return counter > 0;
104
109
  }
105
110
 
106
111
  return false;