@drip-apex/sdk 0.1.0 → 0.2.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 (245) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19262 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19246 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16621 -13539
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +27 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2070 -0
  128. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  129. package/src/consent-evaluation.ts +138 -0
  130. package/src/css.test.ts +30 -0
  131. package/src/css.ts +120 -6
  132. package/src/cwv.test.ts +60 -77
  133. package/src/debug-loader.test.ts +214 -0
  134. package/src/debug-loader.ts +41 -0
  135. package/src/decide-assignments.test.ts +196 -0
  136. package/src/decide-assignments.ts +204 -0
  137. package/src/delivery-timing.test.ts +104 -0
  138. package/src/delivery-timing.ts +89 -0
  139. package/src/early-interaction-capture.test.ts +451 -0
  140. package/src/early-interaction-capture.ts +230 -0
  141. package/src/early-reassert.test.ts +207 -0
  142. package/src/early-reassert.ts +152 -0
  143. package/src/edge-adoption.test.ts +435 -0
  144. package/src/edge-runtime.ts +30 -0
  145. package/src/editor/api.test.ts +109 -0
  146. package/src/editor/api.ts +34 -3
  147. package/src/editor/controller.test.ts +297 -14
  148. package/src/editor/controller.ts +69 -5
  149. package/src/editor/index.test.ts +2 -0
  150. package/src/editor/index.ts +1 -0
  151. package/src/editor/publication-result.ts +58 -0
  152. package/src/editor/types.ts +10 -1
  153. package/src/editor/view-kit.ts +2 -1
  154. package/src/editor/view.test.ts +108 -0
  155. package/src/editor/view.ts +49 -7
  156. package/src/endpoint.test.ts +15 -0
  157. package/src/endpoint.ts +10 -0
  158. package/src/exclusion-groups.ts +8 -18
  159. package/src/flags.test.ts +88 -1
  160. package/src/flags.ts +29 -29
  161. package/src/global-runtime-reconciler.test.ts +271 -0
  162. package/src/goal-eligibility.test.ts +29 -0
  163. package/src/goal-eligibility.ts +17 -0
  164. package/src/goals.test.ts +144 -1
  165. package/src/goals.ts +97 -18
  166. package/src/guards.test.ts +99 -1
  167. package/src/guards.ts +34 -15
  168. package/src/handover-cache-order.test.ts +6 -3
  169. package/src/handover.test.ts +154 -8
  170. package/src/handover.ts +122 -12
  171. package/src/hash.ts +29 -54
  172. package/src/holdout.test.ts +26 -0
  173. package/src/hydration.test.ts +73 -2
  174. package/src/hydration.ts +18 -9
  175. package/src/index.ts +130 -7287
  176. package/src/init-analytics.test.ts +1694 -0
  177. package/src/init-anti-flicker.test.ts +1211 -0
  178. package/src/init-cart-attributes.test.ts +2003 -0
  179. package/src/init-config-resolution.test.ts +999 -0
  180. package/src/init-consent.test.ts +971 -0
  181. package/src/init-empty.test.ts +11 -5406
  182. package/src/init-integrations.test.ts +846 -0
  183. package/src/init-page-activation.test.ts +5064 -0
  184. package/src/init-qa-loading.test.ts +740 -0
  185. package/src/init-redirects.test.ts +2156 -0
  186. package/src/init-test-helpers.ts +287 -0
  187. package/src/inline-bootstrap.test.ts +184 -0
  188. package/src/inline-bootstrap.ts +73 -0
  189. package/src/js.test.ts +51 -1
  190. package/src/js.ts +86 -2
  191. package/src/money.test.ts +55 -1
  192. package/src/money.ts +104 -2
  193. package/src/money.web-parity.contract.test.ts +85 -0
  194. package/src/mutate.test.ts +474 -10
  195. package/src/mutate.ts +739 -230
  196. package/src/page-trigger.test.ts +218 -8
  197. package/src/performance.ts +10 -0
  198. package/src/persistence-decoders.ts +18 -0
  199. package/src/public-api.ts +1828 -0
  200. package/src/qa-assistant-state.test.ts +249 -3
  201. package/src/qa-assistant-state.ts +146 -6
  202. package/src/qa-assistant-view.test.ts +220 -2
  203. package/src/qa-assistant-view.ts +102 -5
  204. package/src/react/index.test.tsx +37 -0
  205. package/src/react/index.tsx +19 -3
  206. package/src/reassert-sentinel.test.ts +155 -1
  207. package/src/reassert-sentinel.ts +168 -8
  208. package/src/redirect.test.ts +541 -0
  209. package/src/redirect.ts +572 -0
  210. package/src/replay-recorder-entry.ts +115 -0
  211. package/src/replay-signal-state.ts +100 -0
  212. package/src/replay-signals.test.ts +249 -0
  213. package/src/replay-signals.ts +151 -0
  214. package/src/replay.test.ts +764 -0
  215. package/src/replay.ts +514 -0
  216. package/src/router.test.ts +61 -2
  217. package/src/router.ts +50 -12
  218. package/src/runtime-state.ts +507 -0
  219. package/src/runtime.test.ts +174 -7
  220. package/src/runtime.ts +88 -14
  221. package/src/sanitize.test.ts +34 -0
  222. package/src/sanitize.ts +23 -12
  223. package/src/shadow.test.ts +187 -0
  224. package/src/shadow.ts +99 -0
  225. package/src/shopify-cart.test.ts +733 -23
  226. package/src/shopify-cart.ts +402 -49
  227. package/src/storefront-vitals.test.ts +217 -0
  228. package/src/storefront-vitals.ts +206 -0
  229. package/src/surface-signals.ts +6 -0
  230. package/src/targeting-conformance.test.ts +9 -0
  231. package/src/targeting-diagnostics.ts +38 -8
  232. package/src/targeting-parity-corpus.json +559 -0
  233. package/src/targeting-parity-corpus.test.ts +18 -0
  234. package/src/targeting.detailed.test.ts +105 -0
  235. package/src/targeting.test.ts +211 -0
  236. package/src/targeting.ts +88 -207
  237. package/src/track.test.ts +147 -0
  238. package/src/track.ts +125 -26
  239. package/src/types.ts +120 -13
  240. package/src/wait.test.ts +23 -0
  241. package/src/wait.ts +129 -13
  242. package/src/weekday-delivery.test.ts +56 -0
  243. package/src/weekday-schedule-parity-corpus.json +92 -0
  244. package/src/weekday-schedule.test.ts +69 -0
  245. package/src/weekday-schedule.ts +20 -0
package/src/mutate.ts CHANGED
@@ -1,92 +1,84 @@
1
1
  import { Mutation, MutationAction } from "./types";
2
- import { sanitizeHtml } from "./sanitize";
2
+ import { isSafeMutationAttribute, sanitizeHtml } from "./sanitize";
3
3
  import { runMuted } from "./observer-mute";
4
- import { resetReassertSentinel } from "./reassert-sentinel";
5
- import { isStrictJsPolicy } from "./js";
4
+ import {
5
+ addReassertSentinelOwners,
6
+ replayReassertSentinelEntry,
7
+ resetReassertSentinel,
8
+ resetReassertSentinelForOwner,
9
+ } from "./reassert-sentinel";
10
+ import { extractCurrencyTokens, type CurrencyToken } from "./money";
11
+ import { queryAllDeep } from "./shadow";
6
12
 
7
13
  export type ApplyOptions = {
8
14
  root?: Document | HTMLElement;
9
- onApplied?: (mutation: Mutation, element: Element, index: number) => void;
15
+ artifactId?: string;
16
+ epoch?: number;
17
+ ownerKeys?: readonly string[];
18
+ onApplied?: (mutation: Mutation, element: Element, index: number, journalSeq?: number) => void;
10
19
  onMiss?: (mutation: Mutation, index: number) => void;
20
+ preservePrices?: boolean;
21
+ priceTestExempt?: boolean;
22
+ onPricePreservationViolation?: (input: {
23
+ mutation: Mutation;
24
+ selector: string;
25
+ originalPriceTokens: CurrencyToken[];
26
+ }) => void;
11
27
  };
12
28
 
13
29
  const INSERT_ATTR = "data-drip-inserted";
14
30
 
15
- type AttributeSnapshot = {
16
- kind: "attribute";
17
- element: Element;
18
- values: Array<{ name: string; value: string | null }>;
19
- };
31
+ type ElementReference = { deref: () => Element | undefined };
20
32
 
21
- type ContentSnapshot = {
22
- kind: "content";
23
- element: Element;
24
- property: "innerHTML" | "textContent";
25
- value: string | null;
33
+ type ElementBaseline = {
34
+ attributeValues: Map<string, string | null>;
35
+ contentTouched: boolean;
36
+ innerHTML?: string;
37
+ textContent?: string | null;
38
+ styleAttributeTouched: boolean;
39
+ styleAttributeValue: string | null;
40
+ styleValues: Map<string, { value: string; priority: string }>;
41
+ parent: Node | null;
42
+ nextSibling: Node | null;
26
43
  };
27
44
 
28
- type StyleSnapshot = {
29
- kind: "style";
30
- element: HTMLElement;
31
- values: Array<{ name: string; value: string; priority: string }>;
45
+ export type MutationJournalEntry = {
46
+ seq: number;
47
+ artifactId: string;
48
+ owners: Set<string>;
49
+ target: { selector: string; index: number; el: ElementReference };
50
+ mutation: Mutation;
51
+ epoch: number;
52
+ kind: "mutation" | "insert" | "adopt-insert";
53
+ insertKey?: string;
54
+ nodes?: Node[];
55
+ state: "applied" | "invalidated";
32
56
  };
33
57
 
34
- type RemoveSnapshot = {
35
- kind: "remove";
36
- element: Element;
37
- parent: Node | null;
38
- nextSibling: Node | null;
58
+ type MutationJournal = {
59
+ entries: MutationJournalEntry[];
60
+ baselines: WeakMap<Element, ElementBaseline>;
61
+ fallbackArtifactId: string;
39
62
  };
40
63
 
41
- type InsertSnapshot = {
42
- kind: "insert";
43
- nodes: Node[];
64
+ let journalSequence = 0;
65
+ let applicationContextSequence = 0;
66
+ // Sequence order is the treatment order. A rollback restores one baseline,
67
+ // removes dead entries, and replays every surviving entry in this order.
68
+ const mutationJournal: MutationJournal = {
69
+ entries: [],
70
+ baselines: new WeakMap(),
71
+ fallbackArtifactId: "runtime",
44
72
  };
45
73
 
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
- }
74
+ // Reassertion bypasses the normal application chokepoint, so remember mutation
75
+ // objects rejected by the price guard and never replay them later.
76
+ const priceRejectedMutations = new WeakSet<Mutation>();
77
+
78
+ export { isSafeMutationAttribute } from "./sanitize";
76
79
 
77
80
  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;
81
+ return sanitizeHtml(html);
90
82
  }
91
83
 
92
84
  export function normalizeCssPropertyName(name: string): string {
@@ -120,106 +112,206 @@ export function isInsertAction(action: MutationAction): boolean {
120
112
  return action === "insertBefore" || action === "insertAfter";
121
113
  }
122
114
 
123
- function snapshotMutation(
124
- mutation: Mutation,
115
+ function elementReference(element: Element): ElementReference {
116
+ const WeakRefClass = (globalThis as typeof globalThis & {
117
+ WeakRef?: new (value: Element) => ElementReference;
118
+ }).WeakRef;
119
+ return WeakRefClass ? new WeakRefClass(element) : { deref: () => element };
120
+ }
121
+
122
+ function mutationAttributeNames(mutation: Mutation): string[] {
123
+ const names = new Set<string>();
124
+ if (mutation.attribute) names.add(mutation.attribute.name);
125
+ Object.keys(mutation.attributes ?? {}).forEach((name) => names.add(name));
126
+ return Array.from(names);
127
+ }
128
+
129
+ function canonicalAttributeName(element: Element, name: string): string {
130
+ const namespace = element.namespaceURI;
131
+ return namespace === null || namespace === "http://www.w3.org/1999/xhtml"
132
+ ? name.trim().toLowerCase()
133
+ : name;
134
+ }
135
+
136
+ function isContentMutation(mutation: Mutation): boolean {
137
+ return mutation.action === "html" || mutation.action === "text" || mutation.action === "setText";
138
+ }
139
+
140
+ function mutationStyleNames(mutation: Mutation): string[] {
141
+ if (mutation.action !== "style" && mutation.action !== "setStyle") return [];
142
+ return Object.keys(mutation.styles ?? {}).map(normalizeCssPropertyName);
143
+ }
144
+
145
+ function captureElementBaseline(
146
+ journal: MutationJournal,
125
147
  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));
148
+ mutation: Mutation,
149
+ ): ElementBaseline {
150
+ let baseline = journal.baselines.get(element);
151
+ if (!baseline) {
152
+ baseline = {
153
+ attributeValues: new Map(),
154
+ contentTouched: false,
155
+ styleAttributeTouched: false,
156
+ styleAttributeValue: null,
157
+ styleValues: new Map(),
158
+ parent: element.parentNode,
159
+ nextSibling: element.nextSibling,
160
+ };
161
+ journal.baselines.set(element, baseline);
162
+ }
163
+
164
+ const attributeNames = mutationAttributeNames(mutation)
165
+ .map((name) => canonicalAttributeName(element, name));
166
+ const styleNames = mutationStyleNames(mutation);
167
+ let capturedStyleAliasNow = false;
168
+ if ((!baseline.styleAttributeTouched) &&
169
+ (styleNames.length > 0 || attributeNames.includes("style"))) {
170
+ baseline.styleAttributeValue = element.getAttribute("style");
171
+ baseline.styleAttributeTouched = true;
172
+ capturedStyleAliasNow = true;
173
+ }
174
+ for (const name of attributeNames) {
175
+ if (!baseline.attributeValues.has(name)) {
176
+ baseline.attributeValues.set(
177
+ name,
178
+ name === "style" && baseline.styleAttributeTouched
179
+ ? baseline.styleAttributeValue
180
+ : element.getAttribute(name),
181
+ );
182
+ }
183
+ }
184
+ if (styleNames.length > 0) {
185
+ let originalStyle: CSSStyleDeclaration;
186
+ if (capturedStyleAliasNow) {
187
+ // This CSSOM is still pristine: the current style mutation has not run.
188
+ // It also keeps non-DOM Element mocks useful when their attribute and
189
+ // CSSStyleDeclaration views are not wired together like a browser's.
190
+ originalStyle = (element as HTMLElement).style;
191
+ } else if (baseline.styleAttributeTouched) {
192
+ const originalStyleElement = document.createElement("div");
193
+ const originalStyleAttribute = baseline.styleAttributeValue;
194
+ if (originalStyleAttribute !== null && originalStyleAttribute !== undefined) {
195
+ originalStyleElement.setAttribute("style", originalStyleAttribute);
151
196
  }
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;
197
+ originalStyle = originalStyleElement.style;
198
+ } else {
199
+ originalStyle = (element as HTMLElement).style;
161
200
  }
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;
201
+ for (const name of styleNames) {
202
+ if (!baseline.styleValues.has(name)) {
203
+ baseline.styleValues.set(name, {
204
+ value: originalStyle.getPropertyValue(name),
205
+ priority: originalStyle.getPropertyPriority(name),
206
+ });
207
+ }
176
208
  }
177
- case "remove":
178
- snapshots.push({
179
- kind: "remove",
180
- element,
181
- parent: element.parentNode,
182
- nextSibling: element.nextSibling,
183
- });
184
- break;
185
209
  }
210
+ if (
211
+ !baseline.contentTouched &&
212
+ isContentMutation(mutation)
213
+ ) {
214
+ if (typeof element.innerHTML === "string") baseline.innerHTML = element.innerHTML;
215
+ baseline.textContent = element.textContent;
216
+ baseline.contentTouched = true;
217
+ }
218
+ return baseline;
186
219
  }
187
220
 
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;
221
+ type BaselineRestoreScope = {
222
+ attributes: Set<string>;
223
+ content: boolean;
224
+ position: boolean;
225
+ styles: Set<string>;
226
+ };
227
+
228
+ function restoreScopeForEntry(
229
+ scope: BaselineRestoreScope,
230
+ element: Element,
231
+ entry: MutationJournalEntry,
232
+ ): void {
233
+ if (entry.kind !== "mutation") return;
234
+ scope.position = true;
235
+ if (isContentMutation(entry.mutation)) scope.content = true;
236
+ mutationAttributeNames(entry.mutation)
237
+ .forEach((name) => scope.attributes.add(canonicalAttributeName(element, name)));
238
+ mutationStyleNames(entry.mutation).forEach((name) => scope.styles.add(name));
239
+ }
240
+
241
+ function restoreElementBaseline(
242
+ element: Element,
243
+ baseline: ElementBaseline,
244
+ scope: BaselineRestoreScope,
245
+ ): void {
246
+ if (scope.position && baseline.parent && !element.parentNode) {
247
+ try {
248
+ baseline.parent.insertBefore(element, baseline.nextSibling);
249
+ } catch {
250
+ baseline.parent.appendChild(element);
251
+ }
252
+ }
253
+ if (scope.content && baseline.contentTouched) {
254
+ if (baseline.innerHTML !== undefined) element.innerHTML = baseline.innerHTML;
255
+ else if (baseline.textContent !== undefined) element.textContent = baseline.textContent;
256
+ }
257
+ const style = (element as HTMLElement).style;
258
+ for (const name of scope.styles) {
259
+ const valueAndPriority = baseline.styleValues.get(name);
260
+ if (!valueAndPriority) continue;
261
+ const { value, priority } = valueAndPriority;
262
+ if (value) style.setProperty(name, value, priority);
263
+ else style.removeProperty(name);
264
+ }
265
+ // Restore attributes after CSSOM properties so a tracked `style` attribute
266
+ // preserves its pristine spelling as well as its pristine declarations.
267
+ for (const name of scope.attributes) {
268
+ if (!baseline.attributeValues.has(name)) continue;
269
+ const value = baseline.attributeValues.get(name);
270
+ if (value === null) element.removeAttribute(name);
271
+ else if (value !== undefined) element.setAttribute(name, value);
220
272
  }
221
273
  }
222
274
 
275
+ function artifactIdentity(
276
+ journal: MutationJournal,
277
+ ownerKeys: readonly string[],
278
+ artifactId?: string,
279
+ ): string {
280
+ return artifactId ?? ownerKeys[0] ?? journal.fallbackArtifactId;
281
+ }
282
+
283
+ function appendJournalEntry(
284
+ journal: MutationJournal,
285
+ mutation: Mutation,
286
+ element: Element,
287
+ targetIndex: number,
288
+ ownerKeys: readonly string[],
289
+ artifactId: string | undefined,
290
+ kind: MutationJournalEntry["kind"],
291
+ epoch: number,
292
+ insertKey?: string,
293
+ nodes?: Node[],
294
+ ): MutationJournalEntry {
295
+ const entry: MutationJournalEntry = {
296
+ seq: ++journalSequence,
297
+ artifactId: artifactIdentity(journal, ownerKeys, artifactId),
298
+ owners: new Set(ownerKeys),
299
+ target: {
300
+ selector: mutation.selector,
301
+ index: targetIndex,
302
+ el: elementReference(element),
303
+ },
304
+ mutation,
305
+ epoch,
306
+ kind,
307
+ insertKey,
308
+ nodes,
309
+ state: "applied",
310
+ };
311
+ journal.entries.push(entry);
312
+ return entry;
313
+ }
314
+
223
315
  export function applyToElement(mutation: Mutation, element: Element): void {
224
316
  switch (mutation.action) {
225
317
  case "html":
@@ -231,8 +323,7 @@ export function applyToElement(mutation: Mutation, element: Element): void {
231
323
  break;
232
324
  case "attribute":
233
325
  if (mutation.attribute) {
234
- if (!isStrictJsPolicy() ||
235
- isSafeMutationAttribute(mutation.attribute.name, mutation.attribute.value)) {
326
+ if (isSafeMutationAttribute(mutation.attribute.name, mutation.attribute.value)) {
236
327
  if (mutation.attribute.value === null) {
237
328
  element.removeAttribute(mutation.attribute.name);
238
329
  } else {
@@ -242,7 +333,7 @@ export function applyToElement(mutation: Mutation, element: Element): void {
242
333
  }
243
334
  if (mutation.attributes) {
244
335
  for (const [name, value] of Object.entries(mutation.attributes)) {
245
- if (isStrictJsPolicy() && !isSafeMutationAttribute(name, value)) continue;
336
+ if (!isSafeMutationAttribute(name, value)) continue;
246
337
  if (value === null) element.removeAttribute(name);
247
338
  else element.setAttribute(name, value);
248
339
  }
@@ -287,74 +378,226 @@ export function markInserted(el: Element, key: string): void {
287
378
  (el as HTMLElement).setAttribute(INSERT_ATTR, key);
288
379
  }
289
380
 
290
- function applyTrackedMutationWithSnapshots(
381
+ function collectInsertedNodes(mutation: Mutation, element: Element): Node[] {
382
+ const parent = element.parentNode;
383
+ if (!parent || typeof mutation.html !== "string") return [];
384
+ const previousSibling = element.previousSibling;
385
+ const nextSibling = element.nextSibling;
386
+ applyToElement(mutation, element);
387
+ const nodes: Node[] = [];
388
+ let current = mutation.action === "insertBefore"
389
+ ? (previousSibling ? previousSibling.nextSibling : parent.firstChild)
390
+ : element.nextSibling;
391
+ const boundary = mutation.action === "insertBefore" ? element : nextSibling;
392
+ while (current && current !== boundary) {
393
+ nodes.push(current);
394
+ current = current.nextSibling;
395
+ }
396
+ return nodes;
397
+ }
398
+
399
+ function insertedElements(nodes: readonly Node[]): Element[] {
400
+ return nodes.filter((node): node is Element => node instanceof Element);
401
+ }
402
+
403
+ function adoptedInsertNodes(
404
+ journal: MutationJournal,
405
+ element: Element,
406
+ key: string,
407
+ ): Node[] {
408
+ for (let index = journal.entries.length - 1; index >= 0; index -= 1) {
409
+ const entry = journal.entries[index];
410
+ if (
411
+ entry.insertKey === key &&
412
+ entry.target.el.deref() === element &&
413
+ entry.nodes?.some((node) => node.parentNode)
414
+ ) {
415
+ return entry.nodes;
416
+ }
417
+ }
418
+ const parent = element.parentElement;
419
+ if (!parent) return [];
420
+ return Array.from(parent.querySelectorAll("[" + INSERT_ATTR + "]")).filter(
421
+ (candidate) => candidate.getAttribute(INSERT_ATTR) === key,
422
+ );
423
+ }
424
+
425
+ function applyTrackedMutationWithJournal(
291
426
  mutation: Mutation,
292
427
  element: Element,
293
- snapshots: MutationSnapshot[],
428
+ journal: MutationJournal,
294
429
  key?: string,
295
- ): Element | null {
430
+ ownerKeys: readonly string[] = [],
431
+ artifactId?: string,
432
+ targetIndex = 0,
433
+ epoch = 0,
434
+ ): { inserted: Element | null; entry?: MutationJournalEntry } {
296
435
  return runMuted(() => {
436
+ captureElementBaseline(journal, element, mutation);
297
437
  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,
438
+ const nodes = collectInsertedNodes(mutation, element);
439
+ const elements = insertedElements(nodes);
440
+ if (key) elements.forEach((inserted) => markInserted(inserted, key));
441
+ if (!nodes.length) return { inserted: null };
442
+ const entry = appendJournalEntry(
443
+ journal,
444
+ mutation,
445
+ element,
446
+ targetIndex,
447
+ ownerKeys,
448
+ artifactId,
449
+ "insert",
450
+ epoch,
451
+ key,
452
+ nodes,
314
453
  );
315
- if (key) insertedElements.forEach((inserted) => markInserted(inserted, key));
316
- if (insertedNodes.length) snapshots.push({ kind: "insert", nodes: insertedNodes });
317
- return insertedElements[0] ?? null;
454
+ return { inserted: elements[0] ?? null, entry };
318
455
  }
319
- snapshotMutation(mutation, element, snapshots);
456
+ const entry = appendJournalEntry(
457
+ journal,
458
+ mutation,
459
+ element,
460
+ targetIndex,
461
+ ownerKeys,
462
+ artifactId,
463
+ "mutation",
464
+ epoch,
465
+ );
320
466
  applyToElement(mutation, element);
321
- return null;
467
+ return { inserted: null, entry };
322
468
  });
323
469
  }
324
470
 
325
- export function applyTrackedMutation(mutation: Mutation, element: Element, key?: string): Element | null {
326
- return applyTrackedMutationWithSnapshots(mutation, element, mutationSnapshots, key);
471
+ export function applyTrackedMutation(
472
+ mutation: Mutation,
473
+ element: Element,
474
+ key?: string,
475
+ ownerKeys: readonly string[] = [],
476
+ artifactId?: string,
477
+ onJournal?: (entry: MutationJournalEntry) => void,
478
+ epoch = 0,
479
+ ): Element | null {
480
+ let targetIndex = 0;
481
+ try {
482
+ targetIndex = queryAllDeep(mutation.selector).indexOf(element);
483
+ if (targetIndex < 0) targetIndex = 0;
484
+ } catch {}
485
+ if (key && isInsertAction(mutation.action) && isInsertDuplicate(element, key)) {
486
+ const nodes = adoptedInsertNodes(mutationJournal, element, key);
487
+ const entry = appendJournalEntry(
488
+ mutationJournal,
489
+ mutation,
490
+ element,
491
+ targetIndex,
492
+ ownerKeys,
493
+ artifactId,
494
+ "adopt-insert",
495
+ epoch,
496
+ key,
497
+ nodes,
498
+ );
499
+ onJournal?.(entry);
500
+ return insertedElements(nodes)[0] ?? null;
501
+ }
502
+ const result = applyTrackedMutationWithJournal(
503
+ mutation,
504
+ element,
505
+ mutationJournal,
506
+ key,
507
+ ownerKeys,
508
+ artifactId,
509
+ targetIndex,
510
+ epoch,
511
+ );
512
+ if (result.entry) onJournal?.(result.entry);
513
+ return result.inserted;
514
+ }
515
+
516
+ /** Reject a just-applied guarded mutation without disturbing earlier treatments. */
517
+ export function rejectTrackedMutation(
518
+ entry: MutationJournalEntry | undefined,
519
+ element: Element,
520
+ innerHTML: string,
521
+ ): void {
522
+ runMuted(() => { element.innerHTML = innerHTML; });
523
+ if (!entry) return;
524
+ priceRejectedMutations.add(entry.mutation);
525
+ entry.state = "invalidated";
526
+ mutationJournal.entries = mutationJournal.entries.filter((candidate) => candidate !== entry);
527
+ // Fail closed at mutation level: a price-violating mutation is suppressed on
528
+ // every element, so already-applied sibling targets roll back instead of
529
+ // drifting outside reassert coverage.
530
+ const doomed = new Set(
531
+ mutationJournal.entries.filter((candidate) => candidate.mutation === entry.mutation),
532
+ );
533
+ for (const candidate of doomed) candidate.state = "invalidated";
534
+ if (doomed.size > 0) rollbackJournalEntries(mutationJournal, doomed);
327
535
  }
328
536
 
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.
537
+ // The sentinel treats the baseline and earlier journal values as framework
538
+ // restore targets. Other content can be variant-owned widget state.
334
539
  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;
540
+ if (mutationJournal.baselines.get(element)?.innerHTML === current) return true;
541
+ for (const entry of mutationJournal.entries) {
542
+ if (entry.target.el.deref() !== element || entry.mutation.action !== "html") continue;
543
+ if (typeof entry.mutation.html === "string" && sanitizeMutationHtml(entry.mutation.html) === current) {
544
+ return true;
343
545
  }
344
546
  }
345
547
  return false;
346
548
  }
347
549
 
348
- function applyMutationsWithSnapshots(
550
+ function applyPriceGuardedMutation(
551
+ mutation: Mutation,
552
+ element: Element,
553
+ journal: MutationJournal,
554
+ key: string,
555
+ options: ApplyOptions,
556
+ index: number,
557
+ targetIndex: number,
558
+ ): void {
559
+ const guardedPrices = extractCurrencyTokens(element.textContent ?? "");
560
+ const guardedSnapshot = guardedPrices.length > 0 ? element.innerHTML : null;
561
+ const result = applyTrackedMutationWithJournal(
562
+ mutation,
563
+ element,
564
+ journal,
565
+ key,
566
+ options.ownerKeys,
567
+ options.artifactId,
568
+ targetIndex,
569
+ options.epoch ?? 0,
570
+ );
571
+ if (guardedSnapshot !== null) {
572
+ const remaining = extractCurrencyTokens(element.textContent ?? "");
573
+ const preserved = guardedPrices.every((token) =>
574
+ remaining.some((candidate) =>
575
+ candidate.symbol === token.symbol && candidate.cents === token.cents));
576
+ if (!preserved) {
577
+ rejectTrackedMutation(result.entry, element, guardedSnapshot);
578
+ options.onPricePreservationViolation?.({
579
+ mutation,
580
+ selector: mutation.selector,
581
+ originalPriceTokens: guardedPrices,
582
+ });
583
+ return;
584
+ }
585
+ }
586
+ options.onApplied?.(mutation, element, index, result.entry?.seq);
587
+ }
588
+
589
+ function applyMutationsWithJournal(
349
590
  mutations: Mutation[],
350
591
  options: ApplyOptions,
351
- snapshots: MutationSnapshot[],
592
+ journal: MutationJournal,
352
593
  ): void {
353
594
  const root = options.root || document;
354
595
  mutations.forEach((mutation, index) => {
355
- let elements: NodeListOf<Element>;
596
+ let elements: Element[];
356
597
  try {
357
- elements = root.querySelectorAll(mutation.selector);
598
+ elements = root === document
599
+ ? queryAllDeep(mutation.selector)
600
+ : Array.from(root.querySelectorAll(mutation.selector));
358
601
  } catch {
359
602
  options.onMiss?.(mutation, index);
360
603
  return;
@@ -363,29 +606,77 @@ function applyMutationsWithSnapshots(
363
606
  options.onMiss?.(mutation, index);
364
607
  return;
365
608
  }
366
- elements.forEach((element) => {
609
+ elements.forEach((element, targetIndex) => {
367
610
  const key = getMutationKey(mutation, index);
368
611
  if (isInsertAction(mutation.action)) {
369
- if (isInsertDuplicate(element, key)) return;
370
- applyTrackedMutationWithSnapshots(mutation, element, snapshots, key);
371
- options.onApplied?.(mutation, element, index);
612
+ if (isInsertDuplicate(element, key)) {
613
+ const entry = appendJournalEntry(
614
+ journal,
615
+ mutation,
616
+ element,
617
+ targetIndex,
618
+ options.ownerKeys ?? [],
619
+ options.artifactId,
620
+ "adopt-insert",
621
+ options.epoch ?? 0,
622
+ key,
623
+ adoptedInsertNodes(journal, element, key),
624
+ );
625
+ options.onApplied?.(mutation, element, index, entry.seq);
626
+ return;
627
+ }
628
+ const result = applyTrackedMutationWithJournal(
629
+ mutation,
630
+ element,
631
+ journal,
632
+ key,
633
+ options.ownerKeys,
634
+ options.artifactId,
635
+ targetIndex,
636
+ options.epoch ?? 0,
637
+ );
638
+ options.onApplied?.(mutation, element, index, result.entry?.seq);
372
639
  return;
373
640
  }
374
- applyTrackedMutationWithSnapshots(mutation, element, snapshots, key);
375
- options.onApplied?.(mutation, element, index);
641
+ // Content changes are guarded; hiding or styling a price element can be
642
+ // a legitimate compare-at-price treatment and is intentionally allowed.
643
+ const priceBearingContent = mutation.action === "html"
644
+ || mutation.action === "text"
645
+ || mutation.action === "setText";
646
+ if (options.preservePrices && !options.priceTestExempt && priceBearingContent) {
647
+ applyPriceGuardedMutation(mutation, element, journal, key, options, index, targetIndex);
648
+ return;
649
+ }
650
+ const result = applyTrackedMutationWithJournal(
651
+ mutation,
652
+ element,
653
+ journal,
654
+ key,
655
+ options.ownerKeys,
656
+ options.artifactId,
657
+ targetIndex,
658
+ options.epoch ?? 0,
659
+ );
660
+ options.onApplied?.(mutation, element, index, result.entry?.seq);
376
661
  });
377
662
  });
378
663
  }
379
664
 
380
665
  export function applyMutations(mutations: Mutation[], options: ApplyOptions = {}): void {
381
- applyMutationsWithSnapshots(mutations, options, mutationSnapshots);
666
+ applyMutationsWithJournal(mutations, options, mutationJournal);
382
667
  }
383
668
 
384
669
  export function reassertNonStructuralMutations(mutations: Mutation[], options: ApplyOptions = {}): void {
385
670
  const root = options.root || document;
386
671
  mutations.forEach((mutation, index) => {
387
- if (isInsertAction(mutation.action) || mutation.action === "remove") return;
388
- const elements = root.querySelectorAll(mutation.selector);
672
+ if (
673
+ isInsertAction(mutation.action)
674
+ || mutation.action === "remove"
675
+ || priceRejectedMutations.has(mutation)
676
+ ) return;
677
+ const elements = root === document
678
+ ? queryAllDeep(mutation.selector)
679
+ : Array.from(root.querySelectorAll(mutation.selector));
389
680
  elements.forEach((element) => {
390
681
  runMuted(() => applyToElement(mutation, element));
391
682
  options.onApplied?.(mutation, element, index);
@@ -393,14 +684,231 @@ export function reassertNonStructuralMutations(mutations: Mutation[], options: A
393
684
  });
394
685
  }
395
686
 
396
- export function resetAppliedMutations(): void {
397
- resetReassertSentinel();
687
+ function resolveJournalTarget(
688
+ journal: MutationJournal,
689
+ entry: MutationJournalEntry,
690
+ ): Element | undefined {
691
+ const current = entry.target.el.deref();
692
+ if (current && (entry.mutation.action === "remove" || current.isConnected !== false)) {
693
+ return current;
694
+ }
695
+ try {
696
+ const replacement = queryAllDeep(entry.target.selector)[entry.target.index];
697
+ if (replacement) {
698
+ captureElementBaseline(journal, replacement, entry.mutation);
699
+ entry.target.el = elementReference(replacement);
700
+ return replacement;
701
+ }
702
+ } catch {}
703
+ return current;
704
+ }
705
+
706
+ function replayInsertEntry(
707
+ journal: MutationJournal,
708
+ entry: MutationJournalEntry,
709
+ element: Element,
710
+ ): void {
711
+ const key = entry.insertKey;
712
+ if (key && isInsertDuplicate(element, key)) {
713
+ entry.nodes = adoptedInsertNodes(journal, element, key);
714
+ entry.kind = "adopt-insert";
715
+ entry.state = "applied";
716
+ return;
717
+ }
718
+ const nodes = collectInsertedNodes(entry.mutation, element);
719
+ if (key) insertedElements(nodes).forEach((inserted) => markInserted(inserted, key));
720
+ entry.nodes = nodes;
721
+ entry.kind = "insert";
722
+ entry.state = "applied";
723
+ }
724
+
725
+ function replayTouchedTargets(
726
+ journal: MutationJournal,
727
+ touched: Set<Element>,
728
+ restoreScopes: Map<Element, BaselineRestoreScope>,
729
+ ): void {
730
+ for (const [element, scope] of restoreScopes) {
731
+ const baseline = journal.baselines.get(element);
732
+ if (baseline) restoreElementBaseline(element, baseline, scope);
733
+ }
734
+ for (const entry of journal.entries) {
735
+ const element = resolveJournalTarget(journal, entry);
736
+ if (!element || !touched.has(element)) continue;
737
+ if (entry.kind === "insert" || entry.kind === "adopt-insert") {
738
+ replayInsertEntry(journal, entry, element);
739
+ } else {
740
+ applyToElement(entry.mutation, element);
741
+ entry.state = "applied";
742
+ replayReassertSentinelEntry(entry.seq, element);
743
+ }
744
+ }
745
+ }
746
+
747
+ function addRestoreScope(
748
+ scopes: Map<Element, BaselineRestoreScope>,
749
+ element: Element,
750
+ entry: MutationJournalEntry,
751
+ ): void {
752
+ let scope = scopes.get(element);
753
+ if (!scope) {
754
+ scope = { attributes: new Set(), content: false, position: false, styles: new Set() };
755
+ scopes.set(element, scope);
756
+ }
757
+ restoreScopeForEntry(scope, element, entry);
758
+ }
759
+
760
+ function pruneElementBaseline(journal: MutationJournal, element: Element): void {
761
+ const baseline = journal.baselines.get(element);
762
+ if (!baseline) return;
763
+ const remaining = journal.entries.filter((entry) => entry.target.el.deref() === element);
764
+ if (remaining.length === 0) {
765
+ journal.baselines.delete(element);
766
+ return;
767
+ }
768
+ const contentNeeded = remaining.some((entry) =>
769
+ entry.kind === "mutation" && isContentMutation(entry.mutation));
770
+ if (!contentNeeded) {
771
+ baseline.contentTouched = false;
772
+ delete baseline.innerHTML;
773
+ delete baseline.textContent;
774
+ }
775
+ const attributeNames = new Set(remaining.flatMap((entry) =>
776
+ entry.kind === "mutation"
777
+ ? mutationAttributeNames(entry.mutation).map((name) => canonicalAttributeName(element, name))
778
+ : []));
779
+ for (const name of baseline.attributeValues.keys()) {
780
+ if (!attributeNames.has(name)) baseline.attributeValues.delete(name);
781
+ }
782
+ const styleNames = new Set(remaining.flatMap((entry) =>
783
+ entry.kind === "mutation" ? mutationStyleNames(entry.mutation) : []));
784
+ for (const name of baseline.styleValues.keys()) {
785
+ if (!styleNames.has(name)) baseline.styleValues.delete(name);
786
+ }
787
+ if (!attributeNames.has("style") && styleNames.size === 0) {
788
+ baseline.styleAttributeTouched = false;
789
+ baseline.styleAttributeValue = null;
790
+ }
791
+ }
792
+
793
+ function rollbackJournalEntries(
794
+ journal: MutationJournal,
795
+ doomed: Set<MutationJournalEntry>,
796
+ options: { preserveReplacementTargets?: boolean } = {},
797
+ ): void {
798
+ if (doomed.size === 0) return;
799
+ const touched = new Set<Element>();
800
+ const restoreScopes = new Map<Element, BaselineRestoreScope>();
801
+ for (const entry of doomed) {
802
+ // A teardown owns the exact node Apex mutated, not a later framework node
803
+ // that happens to match the same selector/index. SPA frameworks commonly
804
+ // replace route subtrees before reInit() reconciles Page activation. Using
805
+ // resolveJournalTarget() here would adopt that replacement and restore the
806
+ // old route's baseline into it (innerHTML/textContent included), detaching
807
+ // the framework's listeners. When the selector now resolves to a different
808
+ // node, the disconnected target has been retired and needs no rollback.
809
+ const element = options.preserveReplacementTargets
810
+ ? entry.target.el.deref()
811
+ : resolveJournalTarget(journal, entry);
812
+ let supersededByFrameworkNode = false;
813
+ if (options.preserveReplacementTargets && element?.isConnected === false) {
814
+ try {
815
+ const current = document.querySelectorAll(entry.target.selector)[entry.target.index];
816
+ supersededByFrameworkNode = Boolean(current && current !== element);
817
+ } catch {}
818
+ }
819
+ if (element && !supersededByFrameworkNode) {
820
+ touched.add(element);
821
+ addRestoreScope(restoreScopes, element, entry);
822
+ }
823
+ }
824
+ for (let index = journal.entries.length - 1; index >= 0; index -= 1) {
825
+ if (doomed.has(journal.entries[index])) journal.entries.splice(index, 1);
826
+ }
398
827
  runMuted(() => {
399
- for (let i = mutationSnapshots.length - 1; i >= 0; i -= 1) {
400
- restoreSnapshot(mutationSnapshots[i]);
828
+ for (const entry of doomed) {
829
+ for (const node of entry.nodes ?? []) {
830
+ const survives = journal.entries.some((candidate) => candidate.nodes?.includes(node));
831
+ if (!survives) node.parentNode?.removeChild(node);
832
+ }
401
833
  }
834
+ replayTouchedTargets(journal, touched, restoreScopes);
402
835
  });
403
- mutationSnapshots.length = 0;
836
+ touched.forEach((element) => pruneElementBaseline(journal, element));
837
+ }
838
+
839
+ function resetJournal(journal: MutationJournal): void {
840
+ const doomed = new Set(journal.entries);
841
+ rollbackJournalEntries(journal, doomed);
842
+ journal.baselines = new WeakMap();
843
+ }
844
+
845
+ export function resetAppliedMutations(): void {
846
+ resetReassertSentinel();
847
+ resetJournal(mutationJournal);
848
+ }
849
+
850
+ export function resetAppliedMutationsForOwner(
851
+ ownerKey: string,
852
+ artifactId?: string,
853
+ options: { preserveReplacementTargets?: boolean } = {},
854
+ ): void {
855
+ const doomed = new Set<MutationJournalEntry>();
856
+ for (const entry of mutationJournal.entries) {
857
+ if (artifactId !== undefined && entry.artifactId !== artifactId) continue;
858
+ if (!entry.owners.delete(ownerKey)) continue;
859
+ if (entry.owners.size === 0) doomed.add(entry);
860
+ }
861
+ resetReassertSentinelForOwner(ownerKey, artifactId);
862
+ rollbackJournalEntries(mutationJournal, doomed, options);
863
+ }
864
+
865
+ export function addAppliedMutationOwners(
866
+ existingOwnerKeys: ReadonlySet<string>,
867
+ ownerKeys: readonly string[],
868
+ artifactId?: string,
869
+ ): void {
870
+ if (existingOwnerKeys.size === 0 || ownerKeys.length === 0) return;
871
+ for (const entry of mutationJournal.entries) {
872
+ if (artifactId !== undefined && entry.artifactId !== artifactId) continue;
873
+ if (
874
+ !Array.from(existingOwnerKeys).some((ownerKey) =>
875
+ entry.owners.has(ownerKey)
876
+ )
877
+ ) continue;
878
+ ownerKeys.forEach((ownerKey) => entry.owners.add(ownerKey));
879
+ }
880
+ addReassertSentinelOwners(existingOwnerKeys, ownerKeys, artifactId);
881
+ }
882
+
883
+ export function invalidateArtifact(artifactId: string): void {
884
+ mutationJournal.entries.forEach((entry) => {
885
+ if (entry.artifactId === artifactId) entry.state = "invalidated";
886
+ });
887
+ }
888
+
889
+ export function replayArtifact(artifactId: string): void {
890
+ const selected = mutationJournal.entries.filter((entry) => entry.artifactId === artifactId);
891
+ if (!selected.length) return;
892
+ const touched = new Set<Element>();
893
+ const restoreScopes = new Map<Element, BaselineRestoreScope>();
894
+ for (const entry of selected) {
895
+ entry.state = "invalidated";
896
+ const element = resolveJournalTarget(mutationJournal, entry);
897
+ if (element) {
898
+ touched.add(element);
899
+ addRestoreScope(restoreScopes, element, entry);
900
+ }
901
+ }
902
+ runMuted(() => replayTouchedTargets(mutationJournal, touched, restoreScopes));
903
+ }
904
+
905
+ export function replayForOwner(ownerKey: string): void {
906
+ const artifactIds = new Set(
907
+ mutationJournal.entries
908
+ .filter((entry) => entry.owners.has(ownerKey))
909
+ .map((entry) => entry.artifactId),
910
+ );
911
+ artifactIds.forEach(replayArtifact);
404
912
  }
405
913
 
406
914
  export type MutationApplicationContext = {
@@ -410,32 +918,33 @@ export type MutationApplicationContext = {
410
918
  };
411
919
 
412
920
  /**
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.
921
+ * Owns only the journal entries created through this context. Rollback
922
+ * restores its baseline without touching shopper assignment entries.
416
923
  */
417
924
  export function createMutationApplicationContext(): MutationApplicationContext {
418
- const snapshots: MutationSnapshot[] = [];
925
+ const journal: MutationJournal = {
926
+ entries: [],
927
+ baselines: new WeakMap(),
928
+ fallbackArtifactId: "context-" + ++applicationContextSequence,
929
+ };
419
930
  return {
420
931
  apply(mutations, options = {}) {
421
- applyMutationsWithSnapshots(mutations, options, snapshots);
932
+ applyMutationsWithJournal(mutations, options, journal);
422
933
  },
423
934
  rollback() {
424
- runMuted(() => {
425
- for (let i = snapshots.length - 1; i >= 0; i -= 1) {
426
- restoreSnapshot(snapshots[i]);
427
- }
428
- });
429
- snapshots.length = 0;
935
+ resetJournal(journal);
430
936
  },
431
937
  get snapshotCount() {
432
- return snapshots.length;
938
+ return journal.entries.length;
433
939
  },
434
940
  };
435
941
  }
436
942
 
437
943
  /** @internal — exposed for unit testing only. Tree-shaken from production build. */
438
944
  export const __test = {
945
+ baselineAttributeNames(element: Element): string[] {
946
+ return Array.from(mutationJournal.baselines.get(element)?.attributeValues.keys() ?? []);
947
+ },
439
948
  normalizeCssPropertyName,
440
949
  };
441
950