@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
@@ -0,0 +1,7 @@
1
+ import { Mutation } from "./types";
2
+ export declare function waitForElement(selector: string, callback: (el: Element) => void, options?: {
3
+ ttlMs?: number;
4
+ onMiss?: () => void;
5
+ }): void;
6
+ export declare function applyMutationWithWait(mutation: Mutation, index: number, onApplied?: ((mutation: Mutation, element: Element, index: number) => void) | null, onMiss?: ((mutation: Mutation, index: number) => void) | null): void;
7
+ export declare function resetWaitEntries(): void;
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@drip-apex/sdk",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "main": "dist/drip.js",
6
+ "types": "dist/types/index.d.ts",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/types/index.d.ts",
13
+ "default": "./dist/drip.js"
14
+ },
15
+ "./define": {
16
+ "types": "./dist/types/define.d.ts",
17
+ "default": "./src/define.ts"
18
+ },
19
+ "./flags": {
20
+ "types": "./dist/types/flags.d.ts",
21
+ "default": "./src/flags.ts"
22
+ },
23
+ "./react": {
24
+ "types": "./dist/types/react/index.d.ts",
25
+ "default": "./dist/react/index.js"
26
+ },
27
+ "./chooser": {
28
+ "types": "./dist/types/module.d.ts",
29
+ "import": "./dist/index.mjs",
30
+ "default": "./dist/index.mjs"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "src"
36
+ ],
37
+ "scripts": {
38
+ "build": "node build.mjs && npm run build:types",
39
+ "build:types": "tsc -p tsconfig.json --emitDeclarationOnly --outDir dist/types",
40
+ "typecheck": "tsc --noEmit",
41
+ "deploy:pages": "npm run build && ../worker/node_modules/.bin/wrangler pages deploy dist --project-name drip-sdk",
42
+ "deploy:pages:staging": "npm run build && ../worker/node_modules/.bin/wrangler pages deploy dist --project-name ${SDK_PAGES_PROJECT_STAGING:-drip-sdk-staging}",
43
+ "test": "vitest run && npm run build && node --test bundle-report.test.mjs package-entry.test.mjs",
44
+ "check:docs": "node check-docs-init-config.mjs",
45
+ "test:watch": "vitest"
46
+ },
47
+ "peerDependencies": {
48
+ "react": ">=18.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "react": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "devDependencies": {
56
+ "@testing-library/react": "^16.3.1",
57
+ "@drip/event-schema": "file:../event-schema",
58
+ "@types/react": "^19.2.14",
59
+ "esbuild": "0.28.1",
60
+ "jsdom": "^27.3.0",
61
+ "react": "^19.2.7",
62
+ "react-dom": "^19.2.7",
63
+ "terser": "5.48.0",
64
+ "typescript": "^5.6.3",
65
+ "vitest": "^3.2.6"
66
+ },
67
+ "overrides": {
68
+ "esbuild": "0.28.1",
69
+ "picomatch": "^4.0.4",
70
+ "postcss": "^8.5.15",
71
+ "rollup": "^4.60.4",
72
+ "vite": "7.3.5"
73
+ }
74
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * analytics.ts — Automatic pageview and engagement tracking.
3
+ *
4
+ * Fires two event types through the existing track() pipeline:
5
+ * - "pageview" on every page load / SPA navigation
6
+ * - "page_exit" on navigation away / tab close (with duration + scroll depth)
7
+ */
8
+
9
+ type TrackFn = (type: string, data?: Record<string, unknown>) => void;
10
+
11
+ let pageEnteredAt = 0;
12
+ let maxScrollDepth = 0;
13
+ let scrollCleanup: (() => void) | null = null;
14
+ let referrer = "";
15
+ let utmParams: Record<string, string> = {};
16
+ let initialized = false;
17
+
18
+ /** Parse UTM params from URL. Persist in sessionStorage so they stick across the session. */
19
+ function captureUtm(): Record<string, string> {
20
+ const KEY = "drip_utm";
21
+ try {
22
+ const stored = sessionStorage.getItem(KEY);
23
+ if (stored) return JSON.parse(stored);
24
+ } catch {}
25
+ const params = new URLSearchParams(location.search);
26
+ const utms: Record<string, string> = {};
27
+ for (const k of [
28
+ "utm_source",
29
+ "utm_medium",
30
+ "utm_campaign",
31
+ "utm_term",
32
+ "utm_content",
33
+ ]) {
34
+ const v = params.get(k);
35
+ if (v) utms[k] = v;
36
+ }
37
+ if (Object.keys(utms).length) {
38
+ try {
39
+ sessionStorage.setItem(KEY, JSON.stringify(utms));
40
+ } catch {}
41
+ }
42
+ return utms;
43
+ }
44
+
45
+ /** Capture and normalise the referring domain. */
46
+ function captureReferrer(): string {
47
+ const ref = document.referrer;
48
+ if (!ref) return "(direct)";
49
+ try {
50
+ const host = new URL(ref).hostname;
51
+ if (host === location.hostname) return "(internal)";
52
+ return host;
53
+ } catch {
54
+ return "(direct)";
55
+ }
56
+ }
57
+
58
+ /** Track max scroll depth as 0-100 percentage. Returns cleanup fn. */
59
+ function startScrollTracking(): () => void {
60
+ const handler = () => {
61
+ // The snippet can execute in <head> before <body> exists.
62
+ if (!document.body) return;
63
+ const docHeight = Math.max(
64
+ document.body.scrollHeight,
65
+ document.documentElement.scrollHeight,
66
+ );
67
+ const viewportHeight = window.innerHeight;
68
+ if (docHeight <= viewportHeight) {
69
+ maxScrollDepth = 100;
70
+ return;
71
+ }
72
+ const pct = Math.round(((window.scrollY + viewportHeight) / docHeight) * 100);
73
+ if (pct > maxScrollDepth) maxScrollDepth = pct;
74
+ };
75
+ window.addEventListener("scroll", handler, { passive: true });
76
+ let domReady: (() => void) | null = null;
77
+ if (document.body) {
78
+ handler();
79
+ } else {
80
+ domReady = () => handler();
81
+ document.addEventListener("DOMContentLoaded", domReady, { once: true });
82
+ }
83
+ return () => {
84
+ window.removeEventListener("scroll", handler);
85
+ // A page exit before DOM readiness must not leave the deferred
86
+ // measurement alive to contaminate the next page's scroll depth.
87
+ if (domReady) document.removeEventListener("DOMContentLoaded", domReady);
88
+ };
89
+ }
90
+
91
+ /** Clean URL: origin + pathname only (strips query params). */
92
+ export function cleanUrl(): string {
93
+ return location.origin + location.pathname;
94
+ }
95
+
96
+ /** Called on every page load / SPA navigation. */
97
+ export function onPageEnter(trackFn: TrackFn): void {
98
+ pageEnteredAt = Date.now();
99
+ maxScrollDepth = 0;
100
+
101
+ // Capture referrer + UTMs once per session
102
+ if (!initialized) {
103
+ referrer = captureReferrer();
104
+ utmParams = captureUtm();
105
+ initialized = true;
106
+ }
107
+
108
+ // Start scroll tracking
109
+ if (scrollCleanup) {
110
+ scrollCleanup();
111
+ scrollCleanup = null;
112
+ }
113
+ scrollCleanup = startScrollTracking();
114
+
115
+ // Fire pageview
116
+ const data: Record<string, unknown> = {
117
+ referrer,
118
+ path: location.pathname,
119
+ title: document.title,
120
+ };
121
+ // Merge UTMs into event data (only non-empty values)
122
+ for (const [k, v] of Object.entries(utmParams)) {
123
+ if (v) data[k] = v;
124
+ }
125
+ trackFn("pageview", data);
126
+ }
127
+
128
+ /** Called on SPA navigation away / page unload. */
129
+ export function onPageExit(trackFn: TrackFn): void {
130
+ if (!pageEnteredAt) return;
131
+ const duration = Math.round((Date.now() - pageEnteredAt) / 1000);
132
+
133
+ if (scrollCleanup) {
134
+ scrollCleanup();
135
+ scrollCleanup = null;
136
+ }
137
+
138
+ trackFn("page_exit", {
139
+ duration,
140
+ scroll_depth: maxScrollDepth,
141
+ path: location.pathname,
142
+ });
143
+
144
+ pageEnteredAt = 0;
145
+ maxScrollDepth = 0;
146
+ }
@@ -0,0 +1,127 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { applyAntiFlicker, removeAntiFlicker, resolveAntiFlickerCss } from "./antiflicker";
5
+
6
+ afterEach(() => {
7
+ vi.unstubAllGlobals();
8
+ if (vi.isFakeTimers()) {
9
+ removeAntiFlicker();
10
+ vi.runAllTimers();
11
+ }
12
+ vi.useRealTimers();
13
+ document.head.innerHTML = "";
14
+ document.documentElement.className = "";
15
+ });
16
+
17
+ describe("anti-flicker css resolution", () => {
18
+ it("does not fall back to full-page hiding for scoped mode without selectors", () => {
19
+ expect(resolveAntiFlickerCss("scoped")).toBeNull();
20
+ expect(resolveAntiFlickerCss("scoped", [])).toBeNull();
21
+ });
22
+
23
+ it("builds scoped css when selectors are available", () => {
24
+ const css = resolveAntiFlickerCss("scoped", [".hero h1", ".cta"]);
25
+ expect(css).toContain(".hero h1,.cta");
26
+ expect(css).toContain("visibility: hidden");
27
+ });
28
+
29
+ it("preserves explicit full-page hiding", () => {
30
+ const css = resolveAntiFlickerCss("full");
31
+ expect(css).toContain("html.drip-af");
32
+ expect(css).toContain("opacity: 0");
33
+ });
34
+
35
+ it("binds reveal timers to their owner document after the global DOM tears down", () => {
36
+ vi.useFakeTimers();
37
+ applyAntiFlicker("full", 100);
38
+
39
+ const ownerDocument = document;
40
+ expect(ownerDocument.documentElement.classList.contains("drip-af")).toBe(true);
41
+ expect(ownerDocument.getElementById("drip-antiflicker")).not.toBeNull();
42
+
43
+ vi.stubGlobal("document", undefined);
44
+ expect(() => vi.advanceTimersByTime(180)).not.toThrow();
45
+
46
+ expect(ownerDocument.documentElement.classList.contains("drip-af")).toBe(false);
47
+ expect(ownerDocument.documentElement.classList.contains("drip-af-reveal")).toBe(false);
48
+ expect(ownerDocument.getElementById("drip-antiflicker")).toBeNull();
49
+ });
50
+
51
+ it("clears old timers and supports a fresh anti-flicker cycle after cleanup", () => {
52
+ vi.useFakeTimers();
53
+ applyAntiFlicker("full", 1_000);
54
+
55
+ removeAntiFlicker();
56
+ vi.advanceTimersByTime(80);
57
+ expect(document.getElementById("drip-antiflicker")).toBeNull();
58
+ expect(document.documentElement.classList.contains("drip-af-reveal")).toBe(false);
59
+
60
+ applyAntiFlicker("full", 200);
61
+ vi.advanceTimersByTime(160);
62
+ expect(document.documentElement.classList.contains("drip-af-reveal")).toBe(true);
63
+ vi.advanceTimersByTime(120);
64
+
65
+ expect(document.getElementById("drip-antiflicker")).toBeNull();
66
+ expect(document.documentElement.classList.contains("drip-af")).toBe(false);
67
+ expect(document.documentElement.classList.contains("drip-af-reveal")).toBe(false);
68
+ });
69
+
70
+ it("re-arms immediately when re-init starts during reveal cleanup", () => {
71
+ vi.useFakeTimers();
72
+ applyAntiFlicker("full", 1_000);
73
+ const originalStyle = document.getElementById("drip-antiflicker");
74
+
75
+ removeAntiFlicker();
76
+ expect(document.documentElement.classList.contains("drip-af-reveal")).toBe(true);
77
+ applyAntiFlicker("full", 200);
78
+
79
+ expect(document.getElementById("drip-antiflicker")).toBe(originalStyle);
80
+ expect(document.documentElement.classList.contains("drip-af")).toBe(true);
81
+ expect(document.documentElement.classList.contains("drip-af-reveal")).toBe(false);
82
+
83
+ vi.advanceTimersByTime(160);
84
+ expect(document.documentElement.classList.contains("drip-af-reveal")).toBe(true);
85
+ vi.advanceTimersByTime(120);
86
+ expect(document.getElementById("drip-antiflicker")).toBeNull();
87
+ });
88
+
89
+ it("is safe when invoked without a DOM", () => {
90
+ vi.useFakeTimers();
91
+ vi.stubGlobal("document", undefined);
92
+
93
+ expect(() => applyAntiFlicker("full", 100)).not.toThrow();
94
+ expect(() => removeAntiFlicker()).not.toThrow();
95
+ expect(vi.getTimerCount()).toBe(0);
96
+ });
97
+
98
+ it("can explicitly clean its owner document after the global DOM disappears", () => {
99
+ vi.useFakeTimers();
100
+ applyAntiFlicker("full", 1_000);
101
+ const ownerDocument = document;
102
+ vi.stubGlobal("document", undefined);
103
+
104
+ expect(() => removeAntiFlicker()).not.toThrow();
105
+ vi.advanceTimersByTime(80);
106
+
107
+ expect(ownerDocument.getElementById("drip-antiflicker")).toBeNull();
108
+ expect(ownerDocument.documentElement.classList.contains("drip-af")).toBe(false);
109
+ expect(ownerDocument.documentElement.classList.contains("drip-af-reveal")).toBe(false);
110
+ });
111
+
112
+ it("cleans its owner when the ambient document changes", () => {
113
+ vi.useFakeTimers();
114
+ applyAntiFlicker("full", 1_000);
115
+ const ownerDocument = document;
116
+ const replacementDocument = document.implementation.createHTMLDocument("replacement");
117
+ vi.stubGlobal("document", replacementDocument);
118
+
119
+ removeAntiFlicker();
120
+ vi.advanceTimersByTime(80);
121
+
122
+ expect(ownerDocument.getElementById("drip-antiflicker")).toBeNull();
123
+ expect(ownerDocument.documentElement.classList.contains("drip-af")).toBe(false);
124
+ expect(ownerDocument.documentElement.classList.contains("drip-af-reveal")).toBe(false);
125
+ expect(replacementDocument.documentElement.className).toBe("");
126
+ });
127
+ });
@@ -0,0 +1,147 @@
1
+ import type { AntiFlickerMode } from "./types";
2
+
3
+ const STYLE_ID = "drip-antiflicker";
4
+ const DEFAULT_TIMEOUT_MS = 1200;
5
+ const PROGRESSIVE_REVEAL_RATIO = 0.8; // Start progressive reveal at 80% of timeout
6
+ const REVEAL_CLEANUP_MS = 80;
7
+
8
+ const FULL_PAGE_CSS =
9
+ "html.drip-af { opacity: 0 !important; }" +
10
+ " html.drip-af-reveal { transition: opacity 0.05s ease-in !important; }";
11
+
12
+ let progressiveRevealTimer: ReturnType<typeof setTimeout> | null = null;
13
+ let hardRevealTimer: ReturnType<typeof setTimeout> | null = null;
14
+ let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
15
+ let antiFlickerOwnerDocument: Document | null = null;
16
+
17
+ function activeDocument(): Document | null {
18
+ if (typeof document === "undefined" || !document.documentElement) return null;
19
+ return document;
20
+ }
21
+
22
+ function clearTimers(): void {
23
+ if (progressiveRevealTimer !== null) {
24
+ clearTimeout(progressiveRevealTimer);
25
+ progressiveRevealTimer = null;
26
+ }
27
+ if (hardRevealTimer !== null) {
28
+ clearTimeout(hardRevealTimer);
29
+ hardRevealTimer = null;
30
+ }
31
+ if (cleanupTimer !== null) {
32
+ clearTimeout(cleanupTimer);
33
+ cleanupTimer = null;
34
+ }
35
+ }
36
+
37
+ function cleanupDocumentImmediately(ownerDocument: Document): void {
38
+ ownerDocument.documentElement?.classList.remove("drip-af", "drip-af-reveal");
39
+ ownerDocument.getElementById(STYLE_ID)?.remove();
40
+ if (antiFlickerOwnerDocument === ownerDocument) antiFlickerOwnerDocument = null;
41
+ }
42
+
43
+ export function resolveAntiFlickerCss(
44
+ mode: AntiFlickerMode = "scoped",
45
+ scopedSelectors?: string[],
46
+ customCss?: string,
47
+ ): string | null {
48
+ if (mode === "off") return null;
49
+
50
+ if (mode === "custom") {
51
+ return customCss && customCss.trim() ? customCss : null;
52
+ }
53
+
54
+ if (mode === "scoped") {
55
+ if (!scopedSelectors || scopedSelectors.length === 0) {
56
+ return null;
57
+ }
58
+
59
+ const selectorList = scopedSelectors.join(",");
60
+ return (
61
+ selectorList + " { visibility: hidden !important; }" +
62
+ " .drip-af-reveal " + selectorList + " { visibility: visible !important; transition: visibility 0s, opacity 0.05s ease-in !important; }"
63
+ );
64
+ }
65
+
66
+ return FULL_PAGE_CSS;
67
+ }
68
+
69
+ /**
70
+ * Apply anti-flicker hiding based on mode.
71
+ * - "scoped": only hides elements matching provided selectors (preferred).
72
+ * - "full": hides entire page with opacity:0 (legacy).
73
+ * - "off": no-op.
74
+ * Scoped mode is a no-op when no selectors are available.
75
+ *
76
+ * Progressive reveal: at 80% of timeout, begins a CSS transition to fade
77
+ * content in gradually rather than a sudden flash at timeout expiry.
78
+ */
79
+ export function applyAntiFlicker(
80
+ mode: AntiFlickerMode = "scoped",
81
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
82
+ scopedSelectors?: string[],
83
+ customCss?: string,
84
+ ): void {
85
+ const ownerDocument = activeDocument();
86
+ if (!ownerDocument) return;
87
+ const existingStyle = ownerDocument.getElementById(STYLE_ID);
88
+ // A live barrier owns its current timers. During the short reveal cleanup
89
+ // window, however, network config or re-init may need to arm a fresh barrier
90
+ // immediately against the same document.
91
+ if (existingStyle && cleanupTimer === null) return;
92
+ const css = resolveAntiFlickerCss(mode, scopedSelectors, customCss);
93
+ if (!css) return;
94
+ clearTimers();
95
+ if (antiFlickerOwnerDocument && antiFlickerOwnerDocument !== ownerDocument) {
96
+ cleanupDocumentImmediately(antiFlickerOwnerDocument);
97
+ }
98
+ antiFlickerOwnerDocument = ownerDocument;
99
+
100
+ const root = ownerDocument.documentElement;
101
+ const style = existingStyle ?? ownerDocument.createElement("style");
102
+ style.id = STYLE_ID;
103
+
104
+ style.textContent = css;
105
+ root.classList.remove("drip-af-reveal");
106
+ if (mode === "full") {
107
+ root.classList.add("drip-af");
108
+ } else {
109
+ root.classList.remove("drip-af");
110
+ }
111
+
112
+ if (!style.isConnected) (ownerDocument.head || root).appendChild(style);
113
+
114
+ // Progressive reveal: at 80% of timeout, begin transition before hard timeout
115
+ const progressiveMs = Math.round(timeoutMs * PROGRESSIVE_REVEAL_RATIO);
116
+ progressiveRevealTimer = setTimeout(() => {
117
+ progressiveRevealTimer = null;
118
+ // Add reveal class to start transition (content fades in over 50ms)
119
+ root.classList.add("drip-af-reveal");
120
+ }, progressiveMs);
121
+
122
+ // Hard timeout: fully remove anti-flicker
123
+ hardRevealTimer = setTimeout(() => {
124
+ hardRevealTimer = null;
125
+ removeAntiFlickerFrom(ownerDocument);
126
+ }, timeoutMs);
127
+ }
128
+
129
+ function removeAntiFlickerFrom(ownerDocument: Document | null): void {
130
+ clearTimers();
131
+ if (!ownerDocument?.documentElement) return;
132
+ const root = ownerDocument.documentElement;
133
+ root.classList.remove("drip-af");
134
+ root.classList.add("drip-af-reveal");
135
+ const el = ownerDocument.getElementById(STYLE_ID);
136
+ // Remove style (if present) and class after transition completes
137
+ cleanupTimer = setTimeout(() => {
138
+ cleanupTimer = null;
139
+ el?.remove();
140
+ root.classList.remove("drip-af-reveal");
141
+ if (antiFlickerOwnerDocument === ownerDocument) antiFlickerOwnerDocument = null;
142
+ }, REVEAL_CLEANUP_MS);
143
+ }
144
+
145
+ export function removeAntiFlicker(): void {
146
+ removeAntiFlickerFrom(antiFlickerOwnerDocument ?? activeDocument());
147
+ }
@@ -0,0 +1,171 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { Experiment } from "./types";
3
+
4
+ function makeStorage() {
5
+ const map = new Map<string, string>();
6
+ return {
7
+ getItem: (key: string) => (map.has(key) ? map.get(key)! : null),
8
+ key: (index: number) => Array.from(map.keys())[index] ?? null,
9
+ removeItem: (key: string) => {
10
+ map.delete(key);
11
+ },
12
+ setItem: (key: string, value: string) => {
13
+ map.set(key, value);
14
+ },
15
+ get length() {
16
+ return map.size;
17
+ },
18
+ };
19
+ }
20
+
21
+ function experiment(
22
+ id: string,
23
+ weights: [number, number],
24
+ epoch = 1,
25
+ ): Experiment {
26
+ return {
27
+ id,
28
+ status: "running",
29
+ trafficAllocation: 1,
30
+ assignmentEpoch: epoch,
31
+ variations: [
32
+ { id: `${id}_control`, weight: weights[0], mutations: [], isControl: true },
33
+ { id: `${id}_variant`, weight: weights[1], mutations: [] },
34
+ ],
35
+ };
36
+ }
37
+
38
+ describe("persisted assignment store", () => {
39
+ beforeEach(() => {
40
+ vi.resetModules();
41
+ (globalThis as any).localStorage = makeStorage();
42
+ (globalThis as any).document = { cookie: "" };
43
+ (globalThis as any).location = {
44
+ href: "https://store.test/products/a",
45
+ origin: "https://store.test",
46
+ pathname: "/products/a",
47
+ search: "",
48
+ };
49
+ });
50
+
51
+ it("reuses a valid persisted assignment even when weights would now choose another arm", async () => {
52
+ const { evaluate } = await import("./index");
53
+ const first = evaluate([experiment("exp_1", [1, 0])], {
54
+ url: "https://store.test/products/a",
55
+ userId: "visitor-1",
56
+ });
57
+ expect(first[0]?.variationId).toBe("exp_1_control");
58
+
59
+ const second = evaluate([experiment("exp_1", [0, 1])], {
60
+ url: "https://store.test/products/a",
61
+ userId: "visitor-1",
62
+ });
63
+ expect(second[0]?.variationId).toBe("exp_1_control");
64
+ expect(second[0]?.assignmentEpoch).toBe(1);
65
+ });
66
+
67
+ it("computes and persists a fresh assignment after an epoch bump", async () => {
68
+ const { evaluate } = await import("./index");
69
+ evaluate([experiment("exp_1", [1, 0], 1)], {
70
+ url: "https://store.test/products/a",
71
+ userId: "visitor-1",
72
+ });
73
+
74
+ const bumped = evaluate([experiment("exp_1", [0, 1], 2)], {
75
+ url: "https://store.test/products/a",
76
+ userId: "visitor-1",
77
+ });
78
+
79
+ expect(bumped[0]?.variationId).toBe("exp_1_variant");
80
+ expect(bumped[0]?.assignmentEpoch).toBe(2);
81
+ expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}")).toEqual({
82
+ exp_1: ["exp_1_variant", 2, expect.any(Number)],
83
+ });
84
+ });
85
+
86
+ it("persists parallel experiment assignments independently", async () => {
87
+ const { evaluate } = await import("./index");
88
+ const assignments = evaluate(
89
+ [experiment("exp_1", [1, 0]), experiment("exp_2", [0, 1])],
90
+ {
91
+ url: "https://store.test/products/a",
92
+ userId: "visitor-1",
93
+ },
94
+ );
95
+
96
+ expect(assignments.map((assignment) => assignment.variationId)).toEqual([
97
+ "exp_1_control",
98
+ "exp_2_variant",
99
+ ]);
100
+ expect(JSON.parse(localStorage.getItem("drip_assignments_v1") ?? "{}")).toMatchObject({
101
+ exp_1: ["exp_1_control", 1, expect.any(Number)],
102
+ exp_2: ["exp_2_variant", 1, expect.any(Number)],
103
+ });
104
+ });
105
+
106
+ it("does not create a new persistent assignment while off target", async () => {
107
+ const { evaluate } = await import("./index");
108
+ const offTarget = experiment("exp_1", [1, 0]);
109
+ offTarget.targeting = {
110
+ lifecycle: { mode: "persistent" },
111
+ url: [{ type: "simple", pattern: "/collections/*" }],
112
+ };
113
+
114
+ expect(evaluate([offTarget], {
115
+ url: "https://store.test/products/a",
116
+ userId: "visitor-1",
117
+ })).toEqual([]);
118
+ expect(localStorage.getItem("drip_assignments_v1")).toBeNull();
119
+ });
120
+
121
+ it("does not restore when non-page audience targeting is no longer eligible", async () => {
122
+ localStorage.setItem(
123
+ "drip_assignments_v1",
124
+ JSON.stringify({ exp_1: ["exp_1_control", 1, 123] }),
125
+ );
126
+ const { evaluate } = await import("./index");
127
+ const offTarget = experiment("exp_1", [1, 0]);
128
+ offTarget.targeting = {
129
+ attributes: [{ attribute: "customer_tier", operator: "equals", value: "vip" }],
130
+ lifecycle: { mode: "persistent" },
131
+ url: [{ type: "simple", pattern: "/collections/*" }],
132
+ };
133
+
134
+ expect(evaluate([offTarget], {
135
+ attributes: { customer_tier: "standard" },
136
+ url: "https://store.test/products/a",
137
+ userId: "visitor-1",
138
+ })).toEqual([]);
139
+ });
140
+
141
+ it("does not resurrect a persisted assignment that loses its exclusion group", async () => {
142
+ localStorage.setItem(
143
+ "drip_assignments_v1",
144
+ JSON.stringify({ exp_1: ["exp_1_control", 1, 123] }),
145
+ );
146
+ const { evaluate } = await import("./index");
147
+ const restored = experiment("exp_1", [1, 0]);
148
+ restored.trafficAllocation = 0;
149
+ restored.targeting = {
150
+ lifecycle: { mode: "persistent" },
151
+ url: [{ type: "simple", pattern: "/collections/*" }],
152
+ };
153
+ const winner = experiment("exp_2", [1, 0]);
154
+
155
+ const assignments = evaluate([restored, winner], {
156
+ exclusionGroups: [
157
+ {
158
+ id: "group_1",
159
+ members: [
160
+ { experimentId: "exp_1", share: 0 },
161
+ { experimentId: "exp_2", share: 1 },
162
+ ],
163
+ },
164
+ ],
165
+ url: "https://store.test/products/a",
166
+ userId: "visitor-1",
167
+ });
168
+
169
+ expect(assignments.map((assignment) => assignment.experimentId)).toEqual(["exp_2"]);
170
+ });
171
+ });