@drip-apex/sdk 0.1.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
package/src/mutate.ts ADDED
@@ -0,0 +1,469 @@
1
+ import { Mutation, MutationAction } from "./types";
2
+ import { sanitizeHtml } from "./sanitize";
3
+ import { runMuted } from "./observer-mute";
4
+ import { resetReassertSentinel } from "./reassert-sentinel";
5
+ import { isStrictJsPolicy } from "./js";
6
+
7
+ export type ApplyOptions = {
8
+ root?: Document | HTMLElement;
9
+ onApplied?: (mutation: Mutation, element: Element, index: number) => void;
10
+ onMiss?: (mutation: Mutation, index: number) => void;
11
+ };
12
+
13
+ const INSERT_ATTR = "data-drip-inserted";
14
+
15
+ type AttributeSnapshot = {
16
+ kind: "attribute";
17
+ element: Element;
18
+ values: Array<{ name: string; value: string | null }>;
19
+ };
20
+
21
+ type ContentSnapshot = {
22
+ kind: "content";
23
+ element: Element;
24
+ property: "innerHTML" | "textContent";
25
+ value: string | null;
26
+ };
27
+
28
+ type StyleSnapshot = {
29
+ kind: "style";
30
+ element: HTMLElement;
31
+ values: Array<{ name: string; value: string; priority: string }>;
32
+ };
33
+
34
+ type RemoveSnapshot = {
35
+ kind: "remove";
36
+ element: Element;
37
+ parent: Node | null;
38
+ nextSibling: Node | null;
39
+ };
40
+
41
+ type InsertSnapshot = {
42
+ kind: "insert";
43
+ nodes: Node[];
44
+ };
45
+
46
+ type MutationSnapshot =
47
+ | AttributeSnapshot
48
+ | ContentSnapshot
49
+ | StyleSnapshot
50
+ | RemoveSnapshot
51
+ | InsertSnapshot;
52
+
53
+ const mutationSnapshots: MutationSnapshot[] = [];
54
+
55
+ const URL_ATTRIBUTES = new Set(["href", "src", "action", "formaction", "xlink:href"]);
56
+ const SAFE_DATA_URL_MEDIA_TYPES = new Set([
57
+ "image/png",
58
+ "image/jpeg",
59
+ "image/gif",
60
+ "image/webp",
61
+ "image/avif",
62
+ ]);
63
+
64
+ export function isSafeMutationAttribute(name: string, value: string | null): boolean {
65
+ const normalizedName = name.trim().toLowerCase();
66
+ if (!normalizedName || normalizedName.startsWith("on") || normalizedName === "srcdoc") {
67
+ return false;
68
+ }
69
+ if (value === null || !URL_ATTRIBUTES.has(normalizedName)) return true;
70
+ const normalizedValue = value.replace(/[\u0000-\u0020]+/g, "").toLowerCase();
71
+ if (normalizedValue.startsWith("javascript:")) return false;
72
+ if (!normalizedValue.startsWith("data:")) return true;
73
+ const mediaType = normalizedValue.slice(5).split(/[;,]/, 1)[0];
74
+ return SAFE_DATA_URL_MEDIA_TYPES.has(mediaType);
75
+ }
76
+
77
+ function sanitizeMutationHtml(html: string): string {
78
+ const sanitized = sanitizeHtml(html);
79
+ if (!isStrictJsPolicy()) return sanitized;
80
+ const template = document.createElement("template");
81
+ template.innerHTML = sanitized;
82
+ for (const element of Array.from(template.content.querySelectorAll("*"))) {
83
+ for (const attribute of Array.from(element.attributes)) {
84
+ if (!isSafeMutationAttribute(attribute.name, attribute.value)) {
85
+ element.removeAttribute(attribute.name);
86
+ }
87
+ }
88
+ }
89
+ return template.innerHTML;
90
+ }
91
+
92
+ export function normalizeCssPropertyName(name: string): string {
93
+ // Prefer CSS syntax (kebab-case) because it works with:
94
+ // - HTMLElement.style.setProperty(...)
95
+ // - inline style rewriting at the edge worker
96
+ // But accept common JS-style camelCase keys for developer ergonomics.
97
+ if (!name) return name;
98
+ if (name.startsWith("--")) return name; // custom properties
99
+ if (name.includes("-")) return name; // already kebab-case
100
+
101
+ let out = name.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
102
+ // Special-case msTransform -> -ms-transform
103
+ if (out.startsWith("ms-")) out = "-" + out;
104
+ return out;
105
+ }
106
+
107
+ export function getMutationKey(mutation: Mutation, index: number): string {
108
+ return (
109
+ mutation.action +
110
+ ":" +
111
+ index +
112
+ ":" +
113
+ (mutation.selector || "") +
114
+ ":" +
115
+ (mutation.html || mutation.text || "")
116
+ );
117
+ }
118
+
119
+ export function isInsertAction(action: MutationAction): boolean {
120
+ return action === "insertBefore" || action === "insertAfter";
121
+ }
122
+
123
+ function snapshotMutation(
124
+ mutation: Mutation,
125
+ element: Element,
126
+ snapshots: MutationSnapshot[] = mutationSnapshots,
127
+ ): void {
128
+ switch (mutation.action) {
129
+ case "html":
130
+ snapshots.push({
131
+ kind: "content",
132
+ element,
133
+ property: "innerHTML",
134
+ value: element.innerHTML,
135
+ });
136
+ break;
137
+ case "text":
138
+ case "setText":
139
+ snapshots.push({
140
+ kind: "content",
141
+ element,
142
+ property: "textContent",
143
+ value: element.textContent,
144
+ });
145
+ break;
146
+ case "attribute": {
147
+ const names = new Set<string>();
148
+ if (mutation.attribute) names.add(mutation.attribute.name);
149
+ if (mutation.attributes) {
150
+ Object.keys(mutation.attributes).forEach((name) => names.add(name));
151
+ }
152
+ snapshots.push({
153
+ kind: "attribute",
154
+ element,
155
+ values: Array.from(names).map((name) => ({
156
+ name,
157
+ value: element.getAttribute(name),
158
+ })),
159
+ });
160
+ break;
161
+ }
162
+ case "style":
163
+ case "setStyle": {
164
+ const style = (element as HTMLElement).style;
165
+ const names = Object.keys(mutation.styles ?? {}).map(normalizeCssPropertyName);
166
+ snapshots.push({
167
+ kind: "style",
168
+ element: element as HTMLElement,
169
+ values: names.map((name) => ({
170
+ name,
171
+ value: style.getPropertyValue(name),
172
+ priority: style.getPropertyPriority(name),
173
+ })),
174
+ });
175
+ break;
176
+ }
177
+ case "remove":
178
+ snapshots.push({
179
+ kind: "remove",
180
+ element,
181
+ parent: element.parentNode,
182
+ nextSibling: element.nextSibling,
183
+ });
184
+ break;
185
+ }
186
+ }
187
+
188
+ function restoreSnapshot(snapshot: MutationSnapshot): void {
189
+ switch (snapshot.kind) {
190
+ case "content":
191
+ if (snapshot.property === "innerHTML") {
192
+ snapshot.element.innerHTML = snapshot.value ?? "";
193
+ } else {
194
+ snapshot.element.textContent = snapshot.value;
195
+ }
196
+ break;
197
+ case "attribute":
198
+ for (const { name, value } of snapshot.values) {
199
+ if (value === null) snapshot.element.removeAttribute(name);
200
+ else snapshot.element.setAttribute(name, value);
201
+ }
202
+ break;
203
+ case "style":
204
+ for (const { name, value, priority } of snapshot.values) {
205
+ if (value) snapshot.element.style.setProperty(name, value, priority);
206
+ else snapshot.element.style.removeProperty(name);
207
+ }
208
+ break;
209
+ case "remove":
210
+ if (!snapshot.parent || snapshot.element.parentNode) return;
211
+ try {
212
+ snapshot.parent.insertBefore(snapshot.element, snapshot.nextSibling);
213
+ } catch {
214
+ snapshot.parent.appendChild(snapshot.element);
215
+ }
216
+ break;
217
+ case "insert":
218
+ for (const node of snapshot.nodes) node.parentNode?.removeChild(node);
219
+ break;
220
+ }
221
+ }
222
+
223
+ export function applyToElement(mutation: Mutation, element: Element): void {
224
+ switch (mutation.action) {
225
+ case "html":
226
+ if (typeof mutation.html === "string") element.innerHTML = sanitizeMutationHtml(mutation.html);
227
+ break;
228
+ case "text":
229
+ case "setText":
230
+ if (typeof mutation.text === "string") element.textContent = mutation.text;
231
+ break;
232
+ case "attribute":
233
+ if (mutation.attribute) {
234
+ if (!isStrictJsPolicy() ||
235
+ isSafeMutationAttribute(mutation.attribute.name, mutation.attribute.value)) {
236
+ if (mutation.attribute.value === null) {
237
+ element.removeAttribute(mutation.attribute.name);
238
+ } else {
239
+ element.setAttribute(mutation.attribute.name, mutation.attribute.value);
240
+ }
241
+ }
242
+ }
243
+ if (mutation.attributes) {
244
+ for (const [name, value] of Object.entries(mutation.attributes)) {
245
+ if (isStrictJsPolicy() && !isSafeMutationAttribute(name, value)) continue;
246
+ if (value === null) element.removeAttribute(name);
247
+ else element.setAttribute(name, value);
248
+ }
249
+ }
250
+ break;
251
+ case "style":
252
+ case "setStyle":
253
+ if (mutation.styles) {
254
+ for (const [name, value] of Object.entries(mutation.styles)) {
255
+ (element as HTMLElement).style.setProperty(
256
+ normalizeCssPropertyName(name),
257
+ value,
258
+ );
259
+ }
260
+ }
261
+ break;
262
+ case "remove":
263
+ element.remove();
264
+ break;
265
+ case "insertBefore":
266
+ if (typeof mutation.html === "string") {
267
+ element.insertAdjacentHTML("beforebegin", sanitizeMutationHtml(mutation.html));
268
+ }
269
+ break;
270
+ case "insertAfter":
271
+ if (typeof mutation.html === "string") {
272
+ element.insertAdjacentHTML("afterend", sanitizeMutationHtml(mutation.html));
273
+ }
274
+ break;
275
+ }
276
+ }
277
+
278
+ export function isInsertDuplicate(element: Element, key: string): boolean {
279
+ const parent = element.parentElement;
280
+ if (!parent) return false;
281
+ return Array.from(parent.querySelectorAll("[" + INSERT_ATTR + "]")).some(
282
+ (candidate) => candidate.getAttribute(INSERT_ATTR) === key,
283
+ );
284
+ }
285
+
286
+ export function markInserted(el: Element, key: string): void {
287
+ (el as HTMLElement).setAttribute(INSERT_ATTR, key);
288
+ }
289
+
290
+ function applyTrackedMutationWithSnapshots(
291
+ mutation: Mutation,
292
+ element: Element,
293
+ snapshots: MutationSnapshot[],
294
+ key?: string,
295
+ ): Element | null {
296
+ return runMuted(() => {
297
+ if (isInsertAction(mutation.action)) {
298
+ const parent = element.parentNode;
299
+ if (!parent || typeof mutation.html !== "string") return null;
300
+ const previousSibling = element.previousSibling;
301
+ const nextSibling = element.nextSibling;
302
+ applyToElement(mutation, element);
303
+ const insertedNodes: Node[] = [];
304
+ let current = mutation.action === "insertBefore"
305
+ ? (previousSibling ? previousSibling.nextSibling : parent.firstChild)
306
+ : element.nextSibling;
307
+ const boundary = mutation.action === "insertBefore" ? element : nextSibling;
308
+ while (current && current !== boundary) {
309
+ insertedNodes.push(current);
310
+ current = current.nextSibling;
311
+ }
312
+ const insertedElements = insertedNodes.filter(
313
+ (node): node is Element => node instanceof Element,
314
+ );
315
+ if (key) insertedElements.forEach((inserted) => markInserted(inserted, key));
316
+ if (insertedNodes.length) snapshots.push({ kind: "insert", nodes: insertedNodes });
317
+ return insertedElements[0] ?? null;
318
+ }
319
+ snapshotMutation(mutation, element, snapshots);
320
+ applyToElement(mutation, element);
321
+ return null;
322
+ });
323
+ }
324
+
325
+ export function applyTrackedMutation(mutation: Mutation, element: Element, key?: string): Element | null {
326
+ return applyTrackedMutationWithSnapshots(mutation, element, mutationSnapshots, key);
327
+ }
328
+
329
+ // Every pre-apply innerHTML captured for the element's html mutations. The
330
+ // reassert sentinel uses these to attribute subtree changes: a framework
331
+ // hydration revert restores one of these pre-states (the server original,
332
+ // or — for stacked treatments — an earlier treatment's content), while
333
+ // variant-owned widget activity produces content none of them ever had.
334
+ export function matchesOriginalInnerHtmlSnapshot(element: Element, current: string): boolean {
335
+ for (const snapshot of mutationSnapshots) {
336
+ if (
337
+ snapshot.kind === "content" &&
338
+ snapshot.element === element &&
339
+ snapshot.property === "innerHTML" &&
340
+ typeof snapshot.value === "string"
341
+ ) {
342
+ if (snapshot.value === current) return true;
343
+ }
344
+ }
345
+ return false;
346
+ }
347
+
348
+ function applyMutationsWithSnapshots(
349
+ mutations: Mutation[],
350
+ options: ApplyOptions,
351
+ snapshots: MutationSnapshot[],
352
+ ): void {
353
+ const root = options.root || document;
354
+ mutations.forEach((mutation, index) => {
355
+ let elements: NodeListOf<Element>;
356
+ try {
357
+ elements = root.querySelectorAll(mutation.selector);
358
+ } catch {
359
+ options.onMiss?.(mutation, index);
360
+ return;
361
+ }
362
+ if (elements.length === 0) {
363
+ options.onMiss?.(mutation, index);
364
+ return;
365
+ }
366
+ elements.forEach((element) => {
367
+ const key = getMutationKey(mutation, index);
368
+ if (isInsertAction(mutation.action)) {
369
+ if (isInsertDuplicate(element, key)) return;
370
+ applyTrackedMutationWithSnapshots(mutation, element, snapshots, key);
371
+ options.onApplied?.(mutation, element, index);
372
+ return;
373
+ }
374
+ applyTrackedMutationWithSnapshots(mutation, element, snapshots, key);
375
+ options.onApplied?.(mutation, element, index);
376
+ });
377
+ });
378
+ }
379
+
380
+ export function applyMutations(mutations: Mutation[], options: ApplyOptions = {}): void {
381
+ applyMutationsWithSnapshots(mutations, options, mutationSnapshots);
382
+ }
383
+
384
+ export function reassertNonStructuralMutations(mutations: Mutation[], options: ApplyOptions = {}): void {
385
+ const root = options.root || document;
386
+ mutations.forEach((mutation, index) => {
387
+ if (isInsertAction(mutation.action) || mutation.action === "remove") return;
388
+ const elements = root.querySelectorAll(mutation.selector);
389
+ elements.forEach((element) => {
390
+ runMuted(() => applyToElement(mutation, element));
391
+ options.onApplied?.(mutation, element, index);
392
+ });
393
+ });
394
+ }
395
+
396
+ export function resetAppliedMutations(): void {
397
+ resetReassertSentinel();
398
+ runMuted(() => {
399
+ for (let i = mutationSnapshots.length - 1; i >= 0; i -= 1) {
400
+ restoreSnapshot(mutationSnapshots[i]);
401
+ }
402
+ });
403
+ mutationSnapshots.length = 0;
404
+ }
405
+
406
+ export type MutationApplicationContext = {
407
+ apply: (mutations: Mutation[], options?: ApplyOptions) => void;
408
+ rollback: () => void;
409
+ readonly snapshotCount: number;
410
+ };
411
+
412
+ /**
413
+ * Owns only the DOM snapshots created through this context. Rolling it back
414
+ * restores the state that existed immediately before its mutations, without
415
+ * touching shopper assignment mutations tracked by the main SDK runtime.
416
+ */
417
+ export function createMutationApplicationContext(): MutationApplicationContext {
418
+ const snapshots: MutationSnapshot[] = [];
419
+ return {
420
+ apply(mutations, options = {}) {
421
+ applyMutationsWithSnapshots(mutations, options, snapshots);
422
+ },
423
+ rollback() {
424
+ runMuted(() => {
425
+ for (let i = snapshots.length - 1; i >= 0; i -= 1) {
426
+ restoreSnapshot(snapshots[i]);
427
+ }
428
+ });
429
+ snapshots.length = 0;
430
+ },
431
+ get snapshotCount() {
432
+ return snapshots.length;
433
+ },
434
+ };
435
+ }
436
+
437
+ /** @internal — exposed for unit testing only. Tree-shaken from production build. */
438
+ export const __test = {
439
+ normalizeCssPropertyName,
440
+ };
441
+
442
+ export function observeSpaNavigation(onChange: () => void): () => void {
443
+ let lastUrl = location.href;
444
+ const notify = () => {
445
+ if (lastUrl !== location.href) {
446
+ lastUrl = location.href;
447
+ onChange();
448
+ }
449
+ };
450
+ const wrap = (method: "pushState" | "replaceState") => {
451
+ const original = history[method];
452
+ history[method] = function (...args: Parameters<History["pushState"]>) {
453
+ const ret = original.apply(this, args);
454
+ notify();
455
+ return ret;
456
+ };
457
+ return () => {
458
+ history[method] = original;
459
+ };
460
+ };
461
+ const restorePush = wrap("pushState");
462
+ const restoreReplace = wrap("replaceState");
463
+ window.addEventListener("popstate", notify);
464
+ return () => {
465
+ restorePush();
466
+ restoreReplace();
467
+ window.removeEventListener("popstate", notify);
468
+ };
469
+ }
@@ -0,0 +1,88 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { registerMutedObserver, runMuted, unregisterMutedObserver } from "./observer-mute";
4
+
5
+ async function deliverMutations(): Promise<void> {
6
+ await Promise.resolve();
7
+ await Promise.resolve();
8
+ }
9
+
10
+ describe("observer mute", () => {
11
+ const registered: MutationObserver[] = [];
12
+
13
+ afterEach(() => {
14
+ registered.forEach(unregisterMutedObserver);
15
+ registered.length = 0;
16
+ document.body.innerHTML = "";
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ it("discards records produced by muted writes", async () => {
21
+ const callback = vi.fn();
22
+ const observer = new MutationObserver(callback);
23
+ registered.push(observer);
24
+ registerMutedObserver(observer);
25
+ observer.observe(document.body, { childList: true, subtree: true });
26
+
27
+ runMuted(() => {
28
+ document.body.appendChild(document.createElement("div"));
29
+ });
30
+ await deliverMutations();
31
+
32
+ expect(callback).not.toHaveBeenCalled();
33
+ });
34
+
35
+ it("drains only around the outermost nested muted block", () => {
36
+ const observer = new MutationObserver(() => {});
37
+ registered.push(observer);
38
+ registerMutedObserver(observer);
39
+ const takeRecords = vi.spyOn(observer, "takeRecords");
40
+
41
+ runMuted(() => {
42
+ // One pre-drain at outer entry (delivering pre-existing records);
43
+ // nested blocks add none.
44
+ expect(takeRecords).toHaveBeenCalledTimes(1);
45
+ runMuted(() => {
46
+ document.body.textContent = "nested";
47
+ });
48
+ expect(takeRecords).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ expect(takeRecords).toHaveBeenCalledTimes(2);
52
+ });
53
+
54
+ it("delivers records queued before a muted write instead of discarding them", () => {
55
+ const callback = vi.fn();
56
+ const observer = new MutationObserver(callback);
57
+ registered.push(observer);
58
+ registerMutedObserver(observer, callback);
59
+ observer.observe(document.body, { childList: true });
60
+
61
+ // An external storefront change lands in the same task, before the SDK
62
+ // write; its record is still queued when the muted write begins.
63
+ document.body.appendChild(document.createElement("p"));
64
+ runMuted(() => {
65
+ document.body.appendChild(document.createElement("div"));
66
+ });
67
+
68
+ expect(callback).toHaveBeenCalledTimes(1);
69
+ const records = callback.mock.calls[0][0] as MutationRecord[];
70
+ expect(records).toHaveLength(1);
71
+ expect((records[0].addedNodes[0] as Element).tagName).toBe("P");
72
+ });
73
+
74
+ it("does not drain unregistered observers", async () => {
75
+ const callback = vi.fn();
76
+ const observer = new MutationObserver(callback);
77
+ observer.observe(document.body, { childList: true });
78
+
79
+ runMuted(() => {
80
+ document.body.appendChild(document.createElement("span"));
81
+ });
82
+ await deliverMutations();
83
+
84
+ expect(callback).toHaveBeenCalledTimes(1);
85
+ observer.disconnect();
86
+ });
87
+ });
88
+
@@ -0,0 +1,45 @@
1
+ const observers = new Map<MutationObserver, MutationCallback | undefined>();
2
+ let muteDepth = 0;
3
+
4
+ export function registerMutedObserver(
5
+ observer: MutationObserver,
6
+ callback?: MutationCallback,
7
+ ): void {
8
+ observers.set(observer, callback);
9
+ }
10
+
11
+ export function unregisterMutedObserver(observer: MutationObserver): void {
12
+ observers.delete(observer);
13
+ }
14
+
15
+ export function runMuted<T>(fn: () => T): T {
16
+ if (muteDepth === 0) {
17
+ // Deliver records queued BEFORE the muted write to their handlers: a
18
+ // real storefront change in the same task must not be discarded along
19
+ // with the SDK's own write records when the queue drains below.
20
+ for (const [observer, callback] of observers) {
21
+ try {
22
+ const pending = observer.takeRecords();
23
+ if (pending.length && callback) callback(pending, observer);
24
+ } catch {
25
+ // A disconnected or test-double observer must not block SDK writes.
26
+ }
27
+ }
28
+ }
29
+ muteDepth += 1;
30
+ try {
31
+ return fn();
32
+ } finally {
33
+ muteDepth -= 1;
34
+ if (muteDepth === 0) {
35
+ for (const observer of observers.keys()) {
36
+ try {
37
+ observer.takeRecords();
38
+ } catch {
39
+ // A disconnected or test-double observer must not block SDK writes.
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+
@@ -0,0 +1,84 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { __test } from "./index";
3
+ import { clearRuntimeSignal, getSignals } from "./signals";
4
+ import type { PageTargetingRule } from "./types";
5
+
6
+ const signal = "page.trigger.pdp_overlay";
7
+
8
+ function installTrigger(triggerJs: string) {
9
+ const onChange = vi.fn();
10
+ const cleanup = __test.installPageTriggerRule({
11
+ id: "page_1",
12
+ includeRules: [{ type: "simple", pattern: "/products/*", include: true }],
13
+ trigger: {
14
+ mode: "manual-callback",
15
+ apiName: "pdp_overlay",
16
+ signal,
17
+ triggerJs,
18
+ },
19
+ } satisfies PageTargetingRule, onChange);
20
+ return { cleanup, onChange };
21
+ }
22
+
23
+ describe("Page trigger JS", () => {
24
+ afterEach(() => {
25
+ clearRuntimeSignal(signal);
26
+ clearRuntimeSignal("page.trigger.segment_123_pdp_overlay");
27
+ vi.unstubAllGlobals();
28
+ });
29
+
30
+ it("supports activate(), activate(true), and activate(false)", () => {
31
+ let trigger = installTrigger("activate();");
32
+ expect(getSignals()[signal]).toBe(true);
33
+ expect(trigger.onChange).toHaveBeenCalledTimes(1);
34
+ trigger.cleanup();
35
+
36
+ trigger = installTrigger("activate(true);");
37
+ expect(getSignals()[signal]).toBe(true);
38
+ expect(trigger.onChange).toHaveBeenCalledTimes(1);
39
+ trigger.cleanup();
40
+
41
+ trigger = installTrigger("activate(false);");
42
+ expect(getSignals()[signal]).toBe(false);
43
+ expect(trigger.onChange).toHaveBeenCalledTimes(1);
44
+ trigger.cleanup();
45
+ });
46
+
47
+ it("normalizes public API names like server-generated Page trigger signals", () => {
48
+ expect(__test.normalizePageApiName("123 PDP Overlay")).toBe("segment_123_pdp_overlay");
49
+ expect(__test.normalizePageApiName("PDP Overlay")).toBe("pdp_overlay");
50
+ expect(__test.normalizePageApiName(" ")).toBe("");
51
+ expect(__test.normalizePageApiName(null)).toBe("");
52
+ });
53
+
54
+ it("sets the Page trigger signal and dispatches the public API change event", () => {
55
+ const dispatchEvent = vi.fn();
56
+ vi.stubGlobal("document", { dispatchEvent });
57
+ vi.stubGlobal("CustomEvent", class {
58
+ type: string;
59
+ detail: unknown;
60
+
61
+ constructor(type: string, init?: { detail?: unknown }) {
62
+ this.type = type;
63
+ this.detail = init?.detail;
64
+ }
65
+ });
66
+
67
+ __test.triggerPage("123 PDP Overlay");
68
+ expect(getSignals()["page.trigger.segment_123_pdp_overlay"]).toBe(true);
69
+ expect(dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
70
+ type: "drip:signals-changed",
71
+ detail: {
72
+ source: "page_api",
73
+ apiName: "segment_123_pdp_overlay",
74
+ active: true,
75
+ },
76
+ }));
77
+
78
+ __test.triggerPage("123 PDP Overlay", false);
79
+ expect(getSignals()["page.trigger.segment_123_pdp_overlay"]).toBe(false);
80
+
81
+ __test.triggerPage(" ");
82
+ expect(getSignals()["page.trigger."]).toBeUndefined();
83
+ });
84
+ });