@drip-apex/sdk 0.1.0 → 0.3.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 (246) 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 +19283 -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 +19267 -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 +16636 -13533
  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 +28 -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 +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -53,6 +53,26 @@ describe("platform/router runtime", () => {
53
53
  expect(resolveRouterMode(config, "spa")).toBe("navigation");
54
54
  });
55
55
 
56
+ it("keeps auto-routed SPA page targeting observable without Navigation API", () => {
57
+ const g = globalThis as Record<string, unknown>;
58
+ g.navigation = undefined;
59
+ const config: InitConfig = {
60
+ platformHint: "spa",
61
+ experiments: [{
62
+ id: "exp_page_rule",
63
+ variations: [{ id: "var_1", weight: 1, mutations: [] }],
64
+ targeting: {
65
+ pageRules: [{
66
+ id: "products",
67
+ includeRules: [{ type: "simple", pattern: "/products/*" }],
68
+ }],
69
+ },
70
+ }],
71
+ };
72
+
73
+ expect(resolveRouterMode(config, resolvePlatform(config))).toBe("history");
74
+ });
75
+
56
76
  it("resolves activation mode to observer by default", () => {
57
77
  expect(resolveActivationMode({ experiments: [] })).toBe("observer");
58
78
  expect(resolveActivationMode({ experiments: [], activationMode: "manual" })).toBe("manual");
@@ -74,6 +94,112 @@ describe("platform/router runtime", () => {
74
94
  expect(deps.hash).toBe(true);
75
95
  });
76
96
 
97
+ it("builds path-only URL diff needs from published page targeting rules", () => {
98
+ const deps = computeUrlDiffNeeds([{
99
+ id: "exp_page_rule",
100
+ variations: [{ id: "var_1", weight: 1, mutations: [] }],
101
+ targeting: {
102
+ pageRules: [{
103
+ id: "products",
104
+ includeRules: [{ type: "simple", pattern: "/products/*" }],
105
+ }],
106
+ },
107
+ }]);
108
+
109
+ expect(deps).toEqual({ path: true, search: false, hash: false });
110
+ expect(shouldReevaluateForUrlChange(
111
+ "https://example.com/products/a?q=1",
112
+ "https://example.com/collections?q=1",
113
+ deps,
114
+ )).toBe(true);
115
+ expect(shouldReevaluateForUrlChange(
116
+ "https://example.com/products/a?q=1",
117
+ "https://example.com/products/a?q=2",
118
+ deps,
119
+ )).toBe(false);
120
+ });
121
+
122
+ it("builds URL diff needs from goal-only Page rules", () => {
123
+ const deps = computeUrlDiffNeeds([{
124
+ id: "exp_goal_page_rule",
125
+ variations: [{ id: "var_1", weight: 1, mutations: [] }],
126
+ goalPageRules: [{
127
+ id: "checkout",
128
+ includeRules: [{ type: "simple", pattern: "/checkout?step=*" }],
129
+ }],
130
+ }]);
131
+
132
+ expect(deps).toEqual({ path: true, search: true, hash: false });
133
+ });
134
+
135
+ it("reevaluates goal-only Page rules when a force-link signature changes", () => {
136
+ const deps = computeUrlDiffNeeds([{
137
+ id: "exp_goal_page_rule",
138
+ variations: [{ id: "var_1", weight: 1, mutations: [] }],
139
+ goalPageRules: [{
140
+ id: "products",
141
+ includeRules: [{ type: "simple", pattern: "/products/*" }],
142
+ }],
143
+ }]);
144
+
145
+ expect(deps).toEqual({ path: true, search: false, hash: false });
146
+ expect(shouldReevaluateForUrlChange(
147
+ "https://example.com/products/a",
148
+ "https://example.com/products/a?drip_force=exp_goal_page_rule:var_1",
149
+ deps,
150
+ )).toBe(true);
151
+ expect(shouldReevaluateForUrlChange(
152
+ "https://example.com/products/a?q=1",
153
+ "https://example.com/products/a?q=2",
154
+ deps,
155
+ )).toBe(false);
156
+ expect(shouldReevaluateForUrlChange(
157
+ "https://example.com/products/a?q=1",
158
+ "https://example.com/collections?q=1",
159
+ deps,
160
+ )).toBe(true);
161
+ });
162
+
163
+ it("builds URL diff needs from URL-backed audience rules and advanced JS", () => {
164
+ const audienceDeps = computeUrlDiffNeeds([{
165
+ id: "exp_audience",
166
+ variations: [{ id: "var_1", weight: 1, mutations: [] }],
167
+ targeting: {
168
+ attributes: [{ attribute: "url.path", operator: "contains", value: "/products" }],
169
+ segmentRules: [{
170
+ id: "query",
171
+ rules: {
172
+ version: 1,
173
+ operator: "and",
174
+ conditions: [{ signal: "url.query", operator: "is_set", value: "campaign" }],
175
+ },
176
+ }],
177
+ },
178
+ }]);
179
+ expect(audienceDeps).toEqual({ path: true, search: true, hash: false });
180
+
181
+ const jsDeps = computeUrlDiffNeeds([{
182
+ id: "exp_js",
183
+ variations: [{ id: "var_1", weight: 1, mutations: [] }],
184
+ targeting: {
185
+ pageRules: [{ id: "advanced", advancedCondition: { code: "return location.hash === '#sale';" } }],
186
+ },
187
+ }]);
188
+ expect(jsDeps).toEqual({ path: true, search: true, hash: true });
189
+
190
+ const documentJsDeps = computeUrlDiffNeeds([{
191
+ id: "exp_document_js",
192
+ variations: [{ id: "var_1", weight: 1, mutations: [] }],
193
+ targeting: {
194
+ pageRules: [{
195
+ id: "advanced-document",
196
+ advancedCondition: { code: "return document.documentURI.endsWith('#sale');" },
197
+ }],
198
+ },
199
+ }]);
200
+ expect(documentJsDeps).toEqual({ path: true, search: true, hash: true });
201
+ });
202
+
77
203
  it("builds URL diff needs from route-captured runtime signals", () => {
78
204
  const queryDeps = computeUrlDiffNeeds([], [
79
205
  {
@@ -122,6 +248,14 @@ describe("platform/router runtime", () => {
122
248
  ).toBe(true);
123
249
  });
124
250
 
251
+ it("treats the clear sentinel as an observable force signature", () => {
252
+ expect(shouldReevaluateForUrlChange(
253
+ "https://example.com/products/a?drip_force=exp_1:var_a",
254
+ "https://example.com/products/a?drip_force=clear",
255
+ { path: false, search: false, hash: false },
256
+ )).toBe(true);
257
+ });
258
+
125
259
  it("reevaluates when readable force params change without URL targeting deps", () => {
126
260
  const deps = { path: false, search: false, hash: false };
127
261
  expect(
@@ -157,13 +291,46 @@ describe("platform/router runtime", () => {
157
291
  expect(a).toBe(b);
158
292
  });
159
293
 
160
- it("allows perf flag overrides per init config", () => {
161
- const flags = readPerfFlags({
162
- experiments: [],
163
- perfFlags: { perf_spa_incremental_reapply_v1: false },
164
- });
165
- expect(flags.perf_platform_router_v1).toBe(true);
166
- expect(flags.perf_spa_incremental_reapply_v1).toBe(false);
294
+ it("changes the assignment signature when the active URL block changes", () => {
295
+ const first = assignmentSignature([
296
+ {
297
+ experimentId: "exp_a",
298
+ variationId: "var_1",
299
+ activeUrlBlockId: "product-a",
300
+ } as any,
301
+ ]);
302
+ const second = assignmentSignature([
303
+ {
304
+ experimentId: "exp_a",
305
+ variationId: "var_1",
306
+ activeUrlBlockId: "product-b",
307
+ } as any,
308
+ ]);
309
+ const flat = assignmentSignature([
310
+ {
311
+ experimentId: "exp_a",
312
+ variationId: "var_1",
313
+ } as any,
314
+ ]);
315
+
316
+ expect(first).not.toBe(second);
317
+ expect(first).not.toBe(flat);
318
+ expect(first).not.toContain("mutations");
319
+ });
320
+
321
+ it("changes the assignment signature when page activity changes", () => {
322
+ const active = assignmentSignature([
323
+ { experimentId: "exp_a", variationId: "var_1", pageActive: true } as any,
324
+ ]);
325
+ const inactive = assignmentSignature([
326
+ { experimentId: "exp_a", variationId: "var_1", pageActive: false } as any,
327
+ ]);
328
+
329
+ expect(active).not.toBe(inactive);
330
+ });
331
+
332
+ it("defaults the surviving empty-project perf flag off", () => {
333
+ const flags = readPerfFlags({ experiments: [] });
167
334
  expect(flags.perf_empty_project_v1).toBe(false);
168
335
  });
169
336
 
package/src/runtime.ts CHANGED
@@ -17,9 +17,6 @@ type UrlDiffNeeds = {
17
17
  export type PerfFlagState = Record<PerfFeatureFlag, boolean>;
18
18
 
19
19
  const PERF_FLAG_DEFAULTS: PerfFlagState = {
20
- perf_platform_router_v1: true,
21
- perf_shopify_pixel_buffer_v1: true,
22
- perf_spa_incremental_reapply_v1: true,
23
20
  perf_empty_project_v1: false,
24
21
  };
25
22
 
@@ -157,8 +154,10 @@ function parseMaybeUrl(url: string): URL | null {
157
154
  }
158
155
 
159
156
  function readForceSignature(params: URLSearchParams): string {
160
- const canonical = params.get("drip_force") ?? "";
161
- if (canonical) return canonical;
157
+ const canonical = params.get("drip_force");
158
+ if (canonical !== null) {
159
+ return canonical.trim().toLowerCase() === "clear" ? "__drip_force_clear__" : canonical;
160
+ }
162
161
  const experimentId =
163
162
  params.get("drip_experiment_id") ??
164
163
  params.get("drip_experiment") ??
@@ -179,7 +178,7 @@ function readForceSignature(params: URLSearchParams): string {
179
178
  function markUrlDepsForPattern(
180
179
  deps: UrlDiffNeeds,
181
180
  pattern: string,
182
- type: "regex" | "simple",
181
+ type: "regex" | "simple" | undefined,
183
182
  ): void {
184
183
  if (type === "regex") {
185
184
  deps.path = true;
@@ -192,18 +191,86 @@ function markUrlDepsForPattern(
192
191
  if (pattern.includes("#")) deps.hash = true;
193
192
  }
194
193
 
194
+ function markUrlDepsForSignal(deps: UrlDiffNeeds, signal: string): void {
195
+ if (signal === "url.path") deps.path = true;
196
+ if (signal === "url.query") deps.search = true;
197
+ if (signal === "url.full") {
198
+ deps.path = true;
199
+ deps.search = true;
200
+ deps.hash = true;
201
+ }
202
+ }
203
+
204
+ function jsMayReadUrl(code: string | undefined): boolean {
205
+ // `document` is included because document.documentURI/baseURI expose the
206
+ // full URL without mentioning `location` or `url`.
207
+ return Boolean(code?.trim() && /\b(?:location|url|document)\b/i.test(code));
208
+ }
209
+
195
210
  export function computeUrlDiffNeeds(
196
211
  experiments: Experiment[],
197
212
  signals: InitConfig["signals"] = [],
198
213
  ): UrlDiffNeeds {
199
214
  const deps: UrlDiffNeeds = { path: false, search: false, hash: false };
215
+ let hasUrlTargeting = false;
200
216
  for (const exp of experiments) {
201
217
  const rules = exp.targeting?.url;
202
- if (!rules || rules.length === 0) continue;
203
- for (const rule of rules) {
218
+ for (const rule of rules ?? []) {
219
+ hasUrlTargeting = true;
204
220
  markUrlDepsForPattern(deps, rule.pattern, rule.type);
205
221
  if (deps.path && deps.search && deps.hash) return deps;
206
222
  }
223
+ for (const rule of [
224
+ ...(exp.targeting?.pageRules ?? []),
225
+ ...(exp.goalPageRules ?? []),
226
+ ]) {
227
+ hasUrlTargeting = true;
228
+ // Published rules without a recognized matcher still depend on the
229
+ // current page. Never let an unknown shape suppress pathname rechecks.
230
+ deps.path = true;
231
+ for (const urlRule of [...(rule.includeRules ?? []), ...(rule.excludeRules ?? [])]) {
232
+ markUrlDepsForPattern(deps, urlRule.pattern, urlRule.type);
233
+ }
234
+ if (
235
+ jsMayReadUrl(rule.advancedCondition?.code)
236
+ || jsMayReadUrl(rule.trigger?.triggerJs)
237
+ ) {
238
+ deps.path = true;
239
+ deps.search = true;
240
+ deps.hash = true;
241
+ }
242
+ if (deps.path && deps.search && deps.hash) return deps;
243
+ }
244
+ for (const rule of exp.targeting?.attributes ?? []) {
245
+ markUrlDepsForSignal(deps, rule.attribute);
246
+ if (rule.attribute.startsWith("url.")) hasUrlTargeting = true;
247
+ }
248
+ for (const rule of exp.targeting?.segmentRules ?? []) {
249
+ const conditions = [
250
+ ...(rule.rules?.conditions ?? []),
251
+ ...(rule.rules?.groups ?? []).flat(),
252
+ ];
253
+ for (const condition of conditions) {
254
+ markUrlDepsForSignal(deps, condition.signal);
255
+ if (condition.signal.startsWith("url.")) hasUrlTargeting = true;
256
+ if (jsMayReadUrl(condition.js?.code) || jsMayReadUrl(condition.js?.resetCode)) {
257
+ hasUrlTargeting = true;
258
+ deps.path = true;
259
+ deps.search = true;
260
+ deps.hash = true;
261
+ }
262
+ }
263
+ if (deps.path && deps.search && deps.hash) return deps;
264
+ }
265
+ for (const variation of exp.variations) {
266
+ for (const block of variation.urlBlocks ?? []) {
267
+ for (const rule of block.urlRules) {
268
+ hasUrlTargeting = true;
269
+ markUrlDepsForPattern(deps, rule.pattern, rule.type);
270
+ if (deps.path && deps.search && deps.hash) return deps;
271
+ }
272
+ }
273
+ }
207
274
  }
208
275
 
209
276
  for (const definition of signals ?? []) {
@@ -219,6 +286,7 @@ export function computeUrlDiffNeeds(
219
286
  if (deps.path && deps.search && deps.hash) return deps;
220
287
  }
221
288
 
289
+ if (hasUrlTargeting && !deps.path && !deps.search && !deps.hash) deps.path = true;
222
290
  return deps;
223
291
  }
224
292
 
@@ -231,21 +299,27 @@ export function shouldReevaluateForUrlChange(
231
299
  const prev = parseMaybeUrl(previousUrl);
232
300
  const next = parseMaybeUrl(nextUrl);
233
301
  if (!prev || !next) return true;
302
+ const prevForce = readForceSignature(prev.searchParams);
303
+ const nextForce = readForceSignature(next.searchParams);
304
+ if (prevForce !== nextForce) return true;
234
305
  if (deps.path && prev.pathname !== next.pathname) return true;
235
306
  if (deps.search && prev.search !== next.search) return true;
236
307
  if (deps.hash && prev.hash !== next.hash) return true;
237
- if (!deps.path && !deps.search && !deps.hash) {
238
- const prevForce = readForceSignature(prev.searchParams);
239
- const nextForce = readForceSignature(next.searchParams);
240
- return prevForce !== nextForce;
241
- }
242
308
  return false;
243
309
  }
244
310
 
245
311
  export function assignmentSignature(assignments: Assignment[]): string {
246
312
  if (!assignments.length) return "none";
247
313
  return assignments
248
- .map((item) => item.experimentId + ":" + item.variationId)
314
+ .map((item) =>
315
+ item.experimentId +
316
+ ":" +
317
+ item.variationId +
318
+ ":" +
319
+ (item.activeUrlBlockId ? `block:${item.activeUrlBlockId}` : "flat") +
320
+ ":" +
321
+ (item.pageActive ? 1 : 0),
322
+ )
249
323
  .sort()
250
324
  .join("|");
251
325
  }
@@ -0,0 +1,34 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import { isSafeMutationAttribute, sanitizeHtml } from "./sanitize";
4
+
5
+ describe("isSafeMutationAttribute", () => {
6
+ it("allows removals without allowing dangerous attribute writes", () => {
7
+ expect(isSafeMutationAttribute("onclick", null)).toBe(true);
8
+ expect(isSafeMutationAttribute("onclick", "x")).toBe(false);
9
+ expect(isSafeMutationAttribute("srcdoc", null)).toBe(true);
10
+ expect(isSafeMutationAttribute("", null)).toBe(false);
11
+ expect(isSafeMutationAttribute("href", "https://example.com")).toBe(true);
12
+ expect(isSafeMutationAttribute("href", "javascript:alert(1)")).toBe(false);
13
+ });
14
+ });
15
+
16
+ describe("sanitizeHtml", () => {
17
+ it("unconditionally strips unsafe mutation attributes", () => {
18
+ const sanitized = sanitizeHtml([
19
+ '<iframe srcdoc="<script>alert(1)</script>"></iframe>',
20
+ '<a href="data:text/html,alert(1)" onclick="alert(1)">data</a>',
21
+ '<a href="java\nscript:alert(1)">javascript</a>',
22
+ '<img src="data:image/png;base64,iVBORw0KGgo=">',
23
+ ].join(""));
24
+ const template = document.createElement("template");
25
+ template.innerHTML = sanitized;
26
+ expect(template.content.querySelector("iframe")?.hasAttribute("srcdoc")).toBe(false);
27
+ const anchors = template.content.querySelectorAll("a");
28
+ expect(anchors[0]?.hasAttribute("href")).toBe(false);
29
+ expect(anchors[0]?.hasAttribute("onclick")).toBe(false);
30
+ expect(anchors[1]?.hasAttribute("href")).toBe(false);
31
+ expect(template.content.querySelector("img")?.getAttribute("src"))
32
+ .toBe("data:image/png;base64,iVBORw0KGgo=");
33
+ });
34
+ });
package/src/sanitize.ts CHANGED
@@ -1,3 +1,25 @@
1
+ const URL_ATTRIBUTES = new Set(["href", "src", "action", "formaction", "xlink:href"]);
2
+ const SAFE_DATA_URL_MEDIA_TYPES = new Set([
3
+ "image/png",
4
+ "image/jpeg",
5
+ "image/gif",
6
+ "image/webp",
7
+ "image/avif",
8
+ ]);
9
+
10
+ export function isSafeMutationAttribute(name: string, value: string | null): boolean {
11
+ const normalizedName = name.trim().toLowerCase();
12
+ if (!normalizedName) return false;
13
+ if (value === null) return true;
14
+ if (normalizedName.startsWith("on") || normalizedName === "srcdoc") return false;
15
+ if (!URL_ATTRIBUTES.has(normalizedName)) return true;
16
+ const normalizedValue = value.replace(/[\u0000-\u0020]+/g, "").toLowerCase();
17
+ if (normalizedValue.startsWith("javascript:")) return false;
18
+ if (!normalizedValue.startsWith("data:")) return true;
19
+ const mediaType = normalizedValue.slice(5).split(/[;,]/, 1)[0];
20
+ return SAFE_DATA_URL_MEDIA_TYPES.has(mediaType);
21
+ }
22
+
1
23
  /**
2
24
  * Lightweight HTML sanitizer using the browser's DOMParser.
3
25
  * Strips <script> tags, on* event handlers, and javascript: URLs.
@@ -13,19 +35,8 @@ export function sanitizeHtml(html: string): string {
13
35
  // Walk every element to strip dangerous attributes
14
36
  for (const el of Array.from(doc.body.querySelectorAll("*"))) {
15
37
  for (const attr of Array.from(el.attributes)) {
16
- // Remove on* event handler attributes (onclick, onerror, etc.)
17
- if (attr.name.toLowerCase().startsWith("on")) {
38
+ if (!isSafeMutationAttribute(attr.name, attr.value)) {
18
39
  el.removeAttribute(attr.name);
19
- continue;
20
- }
21
-
22
- // Remove javascript: URLs in href, src, action, formaction, xlink:href
23
- const urlAttrs = ["href", "src", "action", "formaction", "xlink:href"];
24
- if (urlAttrs.includes(attr.name.toLowerCase())) {
25
- const value = attr.value.replace(/\s+/g, "").toLowerCase();
26
- if (value.startsWith("javascript:")) {
27
- el.removeAttribute(attr.name);
28
- }
29
40
  }
30
41
  }
31
42
  }
@@ -0,0 +1,187 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { setupGoals } from "./goals";
4
+ import { applyMutations, applyToElement, resetAppliedMutations } from "./mutate";
5
+ import { trackAppliedMutation, resetReassertSentinel } from "./reassert-sentinel";
6
+ import { queryAllDeep } from "./shadow";
7
+
8
+ class BookingDrawer extends HTMLElement {
9
+ readonly root = this.attachShadow({ mode: "open" });
10
+
11
+ render(): void {
12
+ this.root.innerHTML = `
13
+ <section class="booking" data-step="control">
14
+ <strong class="title">Control</strong>
15
+ <button class="continue">Continue</button>
16
+ </section>`;
17
+ }
18
+
19
+ renderWithoutContinue(): void {
20
+ this.root.innerHTML = `
21
+ <section class="booking" data-step="control">
22
+ <strong class="title">Control</strong>
23
+ </section>`;
24
+ }
25
+ }
26
+
27
+ if (!customElements.get("booking-drawer")) customElements.define("booking-drawer", BookingDrawer);
28
+
29
+ function fixture(): BookingDrawer {
30
+ const drawer = document.createElement("booking-drawer") as BookingDrawer;
31
+ drawer.render();
32
+ document.body.appendChild(drawer);
33
+ queryAllDeep("[data-shadow-registry-probe]");
34
+ return drawer;
35
+ }
36
+
37
+ describe("open shadow DOM support", () => {
38
+ afterEach(() => {
39
+ setupGoals([], [], vi.fn());
40
+ resetReassertSentinel();
41
+ resetAppliedMutations();
42
+ document.body.innerHTML = "";
43
+ localStorage.clear();
44
+ });
45
+
46
+ it("applies text, html, and attribute mutations inside an open root", () => {
47
+ const drawer = fixture();
48
+ applyMutations([
49
+ { selector: ".title", action: "text", text: "Treatment" },
50
+ { selector: ".continue", action: "html", html: "<span>Book now</span>" },
51
+ { selector: ".booking", action: "attribute", attributes: { "data-step": "treatment" } },
52
+ ]);
53
+
54
+ expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
55
+ expect(drawer.root.querySelector(".continue")?.innerHTML).toBe("<span>Book now</span>");
56
+ expect(drawer.root.querySelector(".booking")?.getAttribute("data-step")).toBe("treatment");
57
+ });
58
+
59
+ it("reasserts text, html, and attribute mutations after the widget replaces its shadow children", async () => {
60
+ const drawer = fixture();
61
+ await Promise.resolve();
62
+ const mutations = [
63
+ { selector: ".title", action: "text" as const, text: "Treatment" },
64
+ { selector: ".continue", action: "html" as const, html: "<span>Book now</span>" },
65
+ { selector: ".booking", action: "attribute" as const, attributes: { "data-step": "treatment" } },
66
+ ];
67
+ for (const mutation of mutations) {
68
+ const original = queryAllDeep(mutation.selector)[0];
69
+ applyToElement(mutation, original);
70
+ trackAppliedMutation(original, mutation, {}, (element, treatment) => applyToElement(treatment, element));
71
+ }
72
+
73
+ drawer.render();
74
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
75
+
76
+ expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
77
+ expect(drawer.root.querySelector(".continue")?.innerHTML).toBe("<span>Book now</span>");
78
+ expect(drawer.root.querySelector(".booking")?.getAttribute("data-step")).toBe("treatment");
79
+
80
+ // A second full re-render must also reassert: the entry has to migrate to
81
+ // the replacement element's observer root, not stay keyed under the
82
+ // disconnected element (where the next observe() sweep would purge it).
83
+ drawer.render();
84
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
85
+
86
+ expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
87
+ expect(drawer.root.querySelector(".continue")?.innerHTML).toBe("<span>Book now</span>");
88
+ expect(drawer.root.querySelector(".booking")?.getAttribute("data-step")).toBe("treatment");
89
+ });
90
+
91
+ it("resurrects a sibling treatment whose replacement appears only in a later re-render", async () => {
92
+ const drawer = fixture();
93
+ await Promise.resolve();
94
+ const mutations = [
95
+ { selector: ".title", action: "text" as const, text: "Treatment" },
96
+ { selector: ".continue", action: "text" as const, text: "Book now" },
97
+ ];
98
+ for (const mutation of mutations) {
99
+ const original = queryAllDeep(mutation.selector)[0];
100
+ applyToElement(mutation, original);
101
+ trackAppliedMutation(original, mutation, {}, (element, treatment) => applyToElement(treatment, element));
102
+ }
103
+
104
+ // Partial re-render: .title migrates (running observe()'s GC sweep) while
105
+ // .continue has no replacement in this batch — its entry must survive.
106
+ drawer.renderWithoutContinue();
107
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
108
+ expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
109
+
110
+ drawer.render();
111
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
112
+ expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
113
+ expect(drawer.root.querySelector(".continue")?.textContent).toBe("Book now");
114
+ });
115
+
116
+ it("discovers hosts that attach a shadow root after insertion", () => {
117
+ const el = document.createElement("div");
118
+ document.body.appendChild(el);
119
+ const root = el.attachShadow({ mode: "open" });
120
+ root.innerHTML = '<button class="late-btn">Go</button>';
121
+
122
+ const found = queryAllDeep(".late-btn");
123
+ expect(found).toHaveLength(1);
124
+ expect(found[0]).toBe(root.querySelector(".late-btn"));
125
+ });
126
+
127
+ it("returns multi-host matches in document order, not registration order", () => {
128
+ const first = fixture();
129
+ const second = fixture();
130
+ // Re-insert the first host at the front: registration order is now
131
+ // (second, first) while document order is (first, second).
132
+ first.remove();
133
+ queryAllDeep("[data-shadow-registry-probe]");
134
+ document.body.insertBefore(first, second);
135
+
136
+ const titles = queryAllDeep(".title");
137
+ expect(titles).toHaveLength(2);
138
+ expect(titles[0]).toBe(first.root.querySelector(".title"));
139
+ expect(titles[1]).toBe(second.root.querySelector(".title"));
140
+ });
141
+
142
+ it("fires selector and text click goals from a shadow-internal target", () => {
143
+ const drawer = fixture();
144
+ const callback = vi.fn();
145
+ setupGoals(
146
+ [{ experimentId: "exp", variationId: "var" }],
147
+ [{ id: "exp", goals: [{ id: "click", type: "click", selector: ".booking .continue", textMatch: "Continue" }] }],
148
+ callback,
149
+ );
150
+
151
+ (drawer.root.querySelector(".continue") as HTMLButtonElement).click();
152
+ expect(callback).toHaveBeenCalledWith(
153
+ "click",
154
+ { experimentId: "exp", variationId: "var" },
155
+ {},
156
+ );
157
+ });
158
+
159
+ it("fires click goals when the composed path starts at a Text node", () => {
160
+ const drawer = fixture();
161
+ const callback = vi.fn();
162
+ setupGoals(
163
+ [{ experimentId: "exp", variationId: "var" }],
164
+ [{ id: "exp", goals: [{ id: "click", type: "click", selector: ".booking .continue" }] }],
165
+ callback,
166
+ );
167
+
168
+ const textNode = drawer.root.querySelector(".continue")!.firstChild!;
169
+ textNode.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true }));
170
+ expect(callback).toHaveBeenCalledWith(
171
+ "click",
172
+ { experimentId: "exp", variationId: "var" },
173
+ {},
174
+ );
175
+ });
176
+
177
+ it("issues no per-host queries on a shadow-free document", () => {
178
+ document.body.innerHTML = '<div class="missing"></div>';
179
+ const documentQuery = vi.spyOn(document, "querySelectorAll");
180
+
181
+ expect(queryAllDeep(".absent")).toEqual([]);
182
+ // One selector query plus the one miss-path host rescan — nothing else.
183
+ expect(documentQuery).toHaveBeenCalledTimes(2);
184
+ expect(documentQuery).toHaveBeenNthCalledWith(1, ".absent");
185
+ expect(documentQuery).toHaveBeenNthCalledWith(2, "*");
186
+ });
187
+ });