@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,252 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ assignmentsHaveVisualWork,
5
+ awaitHydration,
6
+ detectSsrFramework,
7
+ holdMutations,
8
+ releaseMutations,
9
+ resetHydrationState,
10
+ signalHydrated,
11
+ } from "./hydration";
12
+ import type { Assignment } from "./types";
13
+
14
+ function clearFrameworkMarkers(): void {
15
+ document.documentElement.innerHTML = "<head></head><body></body>";
16
+ delete (window as Window & { __NUXT__?: unknown }).__NUXT__;
17
+ delete (window as Window & { __NEXT_DATA__?: unknown }).__NEXT_DATA__;
18
+ }
19
+
20
+ describe("hydration coexistence gate", () => {
21
+ beforeEach(() => {
22
+ vi.useFakeTimers();
23
+ resetHydrationState();
24
+ clearFrameworkMarkers();
25
+ delete (window as Window & { __dripPerf?: unknown }).__dripPerf;
26
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => (
27
+ setTimeout(() => callback(performance.now()), 16) as unknown as number
28
+ ));
29
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id));
30
+ });
31
+
32
+ afterEach(() => {
33
+ resetHydrationState();
34
+ vi.useRealTimers();
35
+ vi.unstubAllGlobals();
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ it("detects Nuxt, Next, and generic server-rendered markers", () => {
40
+ expect(detectSsrFramework()).toBeNull();
41
+ (window as Window & { __NUXT__?: unknown }).__NUXT__ = {};
42
+ expect(detectSsrFramework()).toBe("nuxt");
43
+ clearFrameworkMarkers();
44
+ document.body.innerHTML = '<div id="__nuxt"></div>';
45
+ expect(detectSsrFramework()).toBe("nuxt");
46
+ clearFrameworkMarkers();
47
+ (window as Window & { __NEXT_DATA__?: unknown }).__NEXT_DATA__ = {};
48
+ expect(detectSsrFramework()).toBe("next");
49
+ clearFrameworkMarkers();
50
+ document.body.innerHTML = '<div id="__next"></div>';
51
+ expect(detectSsrFramework()).toBe("next");
52
+ clearFrameworkMarkers();
53
+ (window as Window & { __next_f?: unknown }).__next_f = [];
54
+ expect(detectSsrFramework()).toBe("next");
55
+ delete (window as Window & { __next_f?: unknown }).__next_f;
56
+ clearFrameworkMarkers();
57
+ document.head.innerHTML = '<script src="/_next/static/chunks/app/layout.js"></script>';
58
+ expect(detectSsrFramework()).toBe("next");
59
+ clearFrameworkMarkers();
60
+ document.body.innerHTML = '<main data-server-rendered="true"></main>';
61
+ expect(detectSsrFramework()).toBe("server-rendered");
62
+ });
63
+
64
+ it("resolves on the drip:hydrated document event", async () => {
65
+ document.body.innerHTML = '<div id="__next"></div>';
66
+ const gate = awaitHydration(1_000);
67
+ document.dispatchEvent(new CustomEvent("drip:hydrated"));
68
+
69
+ await expect(gate).resolves.toBeUndefined();
70
+ expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf).toMatchObject({
71
+ hydration_detected_at: expect.any(Number),
72
+ apply_delayed_ms: expect.any(Number),
73
+ });
74
+ });
75
+
76
+ it("re-detects SSR markers after DOMContentLoaded for head installs", async () => {
77
+ // A head install runs before the body markers are parsed.
78
+ Object.defineProperty(document, "readyState", { value: "loading", configurable: true });
79
+ try {
80
+ const gate = awaitHydration(3_000, { waitForHydration: true });
81
+ let resolved = false;
82
+ void gate.then(() => { resolved = true; });
83
+
84
+ // The parser reaches the SSR root, then DOMContentLoaded fires.
85
+ document.body.innerHTML = '<div id="__next"></div>';
86
+ Object.defineProperty(document, "readyState", { value: "interactive", configurable: true });
87
+ document.dispatchEvent(new Event("DOMContentLoaded"));
88
+ await Promise.resolve();
89
+ await Promise.resolve();
90
+ expect(resolved).toBe(false);
91
+
92
+ document.dispatchEvent(new CustomEvent("drip:hydrated"));
93
+ await expect(gate).resolves.toBeUndefined();
94
+ } finally {
95
+ Object.defineProperty(document, "readyState", { value: "complete", configurable: true });
96
+ }
97
+ });
98
+
99
+ it("honors an explicit hydration signal before DOMContentLoaded", async () => {
100
+ Object.defineProperty(document, "readyState", { value: "loading", configurable: true });
101
+ try {
102
+ const gate = awaitHydration(3_000, { waitForHydration: true });
103
+ let resolved = false;
104
+ void gate.then(() => { resolved = true; });
105
+
106
+ signalHydrated();
107
+ await Promise.resolve();
108
+ await Promise.resolve();
109
+
110
+ await expect(gate).resolves.toBeUndefined();
111
+ expect(resolved).toBe(true);
112
+ } finally {
113
+ Object.defineProperty(document, "readyState", { value: "complete", configurable: true });
114
+ }
115
+ });
116
+
117
+ it("does not restart the timeout after the head-install budget is exhausted", async () => {
118
+ Object.defineProperty(document, "readyState", { value: "loading", configurable: true });
119
+ try {
120
+ const gate = awaitHydration(500, { waitForHydration: true });
121
+ let resolved = false;
122
+ void gate.then(() => { resolved = true; });
123
+
124
+ // The SSR marker appears, but DOMContentLoaded never arrives before the
125
+ // configured deadline. Re-detection must fail open at that deadline.
126
+ document.body.innerHTML = '<div id="__next"></div>';
127
+ await vi.advanceTimersByTimeAsync(500);
128
+ // Vitest schedules the recursive zero-budget fail-open timer on the next
129
+ // fake-clock tick after the original deadline callback.
130
+ await vi.advanceTimersByTimeAsync(1);
131
+
132
+ await expect(gate).resolves.toBeUndefined();
133
+ expect(resolved).toBe(true);
134
+ } finally {
135
+ Object.defineProperty(document, "readyState", { value: "complete", configurable: true });
136
+ }
137
+ });
138
+
139
+ it("fails open at the head-install deadline without a framework marker or DOMContentLoaded", async () => {
140
+ Object.defineProperty(document, "readyState", { value: "loading", configurable: true });
141
+ try {
142
+ const gate = awaitHydration(500, { waitForHydration: true });
143
+ let resolved = false;
144
+ void gate.then(() => { resolved = true; });
145
+
146
+ await vi.advanceTimersByTimeAsync(500);
147
+ await vi.advanceTimersByTimeAsync(1);
148
+
149
+ await expect(gate).resolves.toBeUndefined();
150
+ expect(resolved).toBe(true);
151
+ expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf?.hydration_timeout).toBe(true);
152
+ } finally {
153
+ Object.defineProperty(document, "readyState", { value: "complete", configurable: true });
154
+ }
155
+ });
156
+
157
+ it("resolves when the hydrated command signal is received", async () => {
158
+ document.body.innerHTML = '<div id="__nuxt"></div>';
159
+ const gate = awaitHydration(1_000);
160
+ signalHydrated();
161
+
162
+ await expect(gate).resolves.toBeUndefined();
163
+ });
164
+
165
+ it("honors an explicit hydration wait without known framework markers", async () => {
166
+ const gate = awaitHydration(1_000, { waitForHydration: true });
167
+ let resolved = false;
168
+ void gate.then(() => { resolved = true; });
169
+
170
+ await vi.advanceTimersByTimeAsync(200);
171
+ expect(resolved).toBe(false);
172
+
173
+ signalHydrated();
174
+ await expect(gate).resolves.toBeUndefined();
175
+ });
176
+
177
+ it("waits for two quiet frames and at least 50ms after framework mutations", async () => {
178
+ document.body.innerHTML = '<div id="__next"></div>';
179
+ const gate = awaitHydration(1_000);
180
+ document.getElementById("__next")?.appendChild(document.createElement("section"));
181
+ await Promise.resolve();
182
+
183
+ await vi.advanceTimersByTimeAsync(96);
184
+ await expect(gate).resolves.toBeUndefined();
185
+ });
186
+
187
+ it("fails open on the hard hydration timeout", async () => {
188
+ document.body.innerHTML = '<div id="__next"></div>';
189
+ vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1));
190
+ const gate = awaitHydration(500);
191
+ await vi.advanceTimersByTimeAsync(500);
192
+
193
+ await expect(gate).resolves.toBeUndefined();
194
+ expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf?.hydration_timeout).toBe(true);
195
+ });
196
+
197
+ it("composes nested holds and releases only when the counter reaches zero", async () => {
198
+ expect(holdMutations()).toBe(1);
199
+ expect(holdMutations()).toBe(2);
200
+ const gate = awaitHydration(1_000, { waitForHydration: false });
201
+ expect(releaseMutations()).toBe(1);
202
+ let resolved = false;
203
+ void gate.then(() => { resolved = true; });
204
+ await Promise.resolve();
205
+ expect(resolved).toBe(false);
206
+ expect(releaseMutations()).toBe(0);
207
+
208
+ await expect(gate).resolves.toBeUndefined();
209
+ });
210
+
211
+ it("fails open a held mutation gate at the configured timeout", async () => {
212
+ vi.spyOn(console, "warn").mockImplementation(() => {});
213
+ holdMutations();
214
+ const gate = awaitHydration(500, { waitForHydration: false });
215
+ await vi.advanceTimersByTimeAsync(500);
216
+
217
+ await expect(gate).resolves.toBeUndefined();
218
+ expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf?.hold_timeout).toBe(true);
219
+ });
220
+ });
221
+
222
+ describe("assignmentsHaveVisualWork", () => {
223
+ const assignment = (overrides: Partial<Assignment> = {}): Assignment => ({
224
+ experimentId: "exp-1",
225
+ variationId: "var-1",
226
+ index: 1,
227
+ isControl: false,
228
+ hashValue: "hash-1",
229
+ mutations: [],
230
+ ...overrides,
231
+ });
232
+
233
+ it("reports no visual work for empty or control/no-mutation sets", () => {
234
+ expect(assignmentsHaveVisualWork([])).toBe(false);
235
+ expect(assignmentsHaveVisualWork([assignment()])).toBe(false);
236
+ });
237
+
238
+ it("detects mutations, variation code, and experiment code", () => {
239
+ expect(
240
+ assignmentsHaveVisualWork([
241
+ assignment({ mutations: [{ selector: "h1", action: "text", text: "x" }] }),
242
+ ]),
243
+ ).toBe(true);
244
+ expect(assignmentsHaveVisualWork([assignment({ css: ".a{}" })])).toBe(true);
245
+ expect(assignmentsHaveVisualWork([assignment({ js: "1" })])).toBe(true);
246
+ expect(assignmentsHaveVisualWork([assignment({ experimentCss: ".b{}" })])).toBe(true);
247
+ expect(assignmentsHaveVisualWork([assignment({ experimentJs: "1" })])).toBe(true);
248
+ expect(
249
+ assignmentsHaveVisualWork([assignment({ externalJsUrl: "https://cdn.example/x.js" })]),
250
+ ).toBe(true);
251
+ });
252
+ });
@@ -0,0 +1,203 @@
1
+ import { registerMutedObserver, unregisterMutedObserver } from "./observer-mute";
2
+ import { isCommercePriceWorkPending } from "./commerce-price";
3
+ import type { Assignment, CommercePriceSettings } from "./types";
4
+
5
+ let holds = 0;
6
+ let hydratedAt = 0;
7
+ const signals = new Set<() => void>();
8
+
9
+ function perf(): Record<string, unknown> | null {
10
+ if (typeof window === "undefined") return null;
11
+ const target = window as Window & { __dripPerf?: Record<string, unknown> };
12
+ return target.__dripPerf = target.__dripPerf ?? {};
13
+ }
14
+
15
+ export function detectSsrFramework(): "nuxt" | "next" | "server-rendered" | null {
16
+ if (typeof window === "undefined" || typeof document === "undefined") return null;
17
+ const runtime = window as Window & { __NUXT__?: unknown; __NEXT_DATA__?: unknown; __next_f?: unknown };
18
+ if (runtime.__NUXT__ !== undefined || document.querySelector("#__nuxt")) return "nuxt";
19
+ if (
20
+ runtime.__NEXT_DATA__ !== undefined ||
21
+ runtime.__next_f !== undefined ||
22
+ document.querySelector("#__next") ||
23
+ document.querySelector('script[src*="/_next/static/"]')
24
+ ) return "next";
25
+ return document.querySelector("[data-server-rendered]") ? "server-rendered" : null;
26
+ }
27
+
28
+ export function holdMutations(): number {
29
+ return ++holds;
30
+ }
31
+
32
+ export function releaseMutations(): number {
33
+ holds = Math.max(0, holds - 1);
34
+ if (!holds) signals.forEach((signal) => signal());
35
+ return holds;
36
+ }
37
+
38
+ export function hasMutationHold(): boolean {
39
+ return holds > 0;
40
+ }
41
+
42
+ export function signalHydrated(): void {
43
+ hydratedAt ||= Date.now();
44
+ signals.forEach((signal) => signal());
45
+ }
46
+
47
+ export function awaitHydration(
48
+ timeoutMs: number,
49
+ options: { waitForHydration?: boolean } = {},
50
+ ): Promise<void> {
51
+ const started = Date.now();
52
+ const boundedTimeoutMs = Number.isFinite(timeoutMs)
53
+ ? Math.max(0, Math.min(10_000, timeoutMs))
54
+ : 3_000;
55
+ if (
56
+ boundedTimeoutMs > 0 &&
57
+ options.waitForHydration !== false &&
58
+ !hydratedAt &&
59
+ typeof document !== "undefined" &&
60
+ document.readyState === "loading" &&
61
+ detectSsrFramework() === null
62
+ ) {
63
+ // Head installs run before the SSR root markers (#__next, #__nuxt,
64
+ // data-server-rendered) are parsed; deciding "no hydration" now would
65
+ // silently skip after_hydration exactly where it matters. Re-detect
66
+ // once the DOM is available — racing the same hard timeout so a
67
+ // delayed DOMContentLoaded can never hold the gate past the budget.
68
+ return new Promise((resolve) => {
69
+ let settled = false;
70
+ const proceed = () => {
71
+ if (settled) return;
72
+ settled = true;
73
+ clearTimeout(fallback);
74
+ document.removeEventListener("DOMContentLoaded", proceed);
75
+ signals.delete(proceed);
76
+ const remaining = Math.max(0, boundedTimeoutMs - (Date.now() - started));
77
+ void awaitHydration(remaining, options).then(resolve);
78
+ };
79
+ document.addEventListener("DOMContentLoaded", proceed, { once: true });
80
+ // A framework can call drip.hydrated() before the parser reaches the
81
+ // root marker/DOMContentLoaded. Let that explicit signal re-enter the
82
+ // normal gate immediately instead of waiting for the hard deadline.
83
+ signals.add(proceed);
84
+ const fallback = setTimeout(proceed, boundedTimeoutMs);
85
+ });
86
+ }
87
+ const frameworkDetected = detectSsrFramework() !== null;
88
+ const explicitHydrationWait = options.waitForHydration === true;
89
+ const needsHydration =
90
+ options.waitForHydration !== false &&
91
+ !hydratedAt &&
92
+ (explicitHydrationWait || frameworkDetected);
93
+ if (!needsHydration && !holds) return Promise.resolve();
94
+
95
+ return new Promise((resolve) => {
96
+ let hydrationDone = !needsHydration;
97
+ let settled = false;
98
+ let observer: MutationObserver | null = null;
99
+ let frame = 0;
100
+ let quietFrames = 0;
101
+ let lastMutation = Date.now();
102
+
103
+ const finish = (timedOut = false) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ const hydrationTimeout = timedOut && !hydrationDone;
107
+ const holdTimeout = timedOut && holds > 0;
108
+ if (hydrationTimeout) hydratedAt = Date.now();
109
+ if (holdTimeout) {
110
+ holds = 0;
111
+ console.warn("[drip] Mutation hold timed out; applying fail-open");
112
+ }
113
+ const metrics = perf();
114
+ if (metrics) {
115
+ if (needsHydration || hydratedAt) metrics.hydration_detected_at = hydratedAt || Date.now();
116
+ metrics.apply_delayed_ms = Math.max(0, Date.now() - started);
117
+ if (hydrationTimeout) metrics.hydration_timeout = true;
118
+ if (holdTimeout) metrics.hold_timeout = true;
119
+ }
120
+ signals.delete(check);
121
+ clearTimeout(timer);
122
+ if (frame) cancelAnimationFrame(frame);
123
+ if (observer) {
124
+ observer.disconnect();
125
+ unregisterMutedObserver(observer);
126
+ }
127
+ resolve();
128
+ };
129
+
130
+ function check() {
131
+ if (hydratedAt) hydrationDone = true;
132
+ if (hydrationDone && !holds) finish();
133
+ }
134
+
135
+ const onFrame = () => {
136
+ quietFrames = Date.now() - lastMutation >= 50 ? quietFrames + 1 : 0;
137
+ if (quietFrames === 2) {
138
+ hydratedAt = Date.now();
139
+ hydrationDone = true;
140
+ check();
141
+ } else if (!settled) frame = requestAnimationFrame(onFrame);
142
+ };
143
+
144
+ const beginQuiet = () => {
145
+ if (!needsHydration || hydrationDone || settled) return;
146
+ if (typeof MutationObserver === "undefined" || !document.documentElement) {
147
+ hydrationDone = true;
148
+ check();
149
+ return;
150
+ }
151
+ const onRecords: MutationCallback = () => {
152
+ lastMutation = Date.now();
153
+ quietFrames = 0;
154
+ };
155
+ observer = new MutationObserver(onRecords);
156
+ registerMutedObserver(observer, onRecords);
157
+ observer.observe(document.documentElement, { attributes: true, characterData: true, childList: true, subtree: true });
158
+ frame = requestAnimationFrame(onFrame);
159
+ };
160
+
161
+ signals.add(check);
162
+ const timer = setTimeout(() => finish(true), boundedTimeoutMs);
163
+ if (needsHydration && frameworkDetected) {
164
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", beginQuiet, { once: true });
165
+ else beginQuiet();
166
+ }
167
+ check();
168
+ });
169
+ }
170
+
171
+ export function resetHydrationState(): void {
172
+ holds = 0;
173
+ hydratedAt = 0;
174
+ signals.forEach((signal) => signal());
175
+ }
176
+
177
+ // The anti-flicker reveal happens inside applyAssignments(), so deferring the
178
+ // apply (hydration wait / pre-init hold) is only justified when the assignment
179
+ // set carries visual work to coordinate. Untreated visitors — traffic-excluded
180
+ // or control/no-mutation assignments — must apply and reveal immediately.
181
+ export function assignmentsHaveVisualWork(
182
+ assignments: Assignment[],
183
+ priceSettings?: CommercePriceSettings,
184
+ ): boolean {
185
+ return (
186
+ assignments.some(
187
+ (assignment) =>
188
+ assignment.mutations.length > 0 ||
189
+ Boolean(
190
+ assignment.css ||
191
+ assignment.js ||
192
+ assignment.externalCssUrl ||
193
+ assignment.externalJsUrl ||
194
+ assignment.experimentCss ||
195
+ assignment.experimentJs,
196
+ ),
197
+ ) || isCommercePriceWorkPending(priceSettings, assignments)
198
+ );
199
+ }
200
+
201
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
202
+ document.addEventListener("drip:hydrated", signalHydrated);
203
+ }