@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
package/src/hash.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import { hashToFloat } from "@drip/shared-runtime/bucketing";
2
+ import { edgeRuntimeActive } from "./edge-runtime";
3
+
4
+ // The bucketing kernel lives in @drip/shared-runtime/bucketing (#2476). This
5
+ // module keeps re-exporting it so SDK call sites and the holdout gate below
6
+ // stay unchanged.
7
+ export { hashToFloat, murmurhash3_32_gc, variationHashToFloat } from "@drip/shared-runtime/bucketing";
8
+
1
9
  const UID_KEY = "drip_uid";
2
10
 
3
11
  type StorageMode = "localStorage" | "sessionStorage" | "cookie" | "memory";
@@ -7,62 +15,17 @@ let encodeStorageValues = false;
7
15
  let cookieDomain = "";
8
16
  let memoryUserId: string | null = null;
9
17
 
10
- export function murmurhash3_32_gc(key: string, seed = 0): number {
11
- let remainder = key.length & 3;
12
- let bytes = key.length - remainder;
13
- let h1 = seed;
14
- let c1 = 0xcc9e2d51;
15
- let c2 = 0x1b873593;
16
- let i = 0;
17
-
18
- while (i < bytes) {
19
- let k1 =
20
- (key.charCodeAt(i) & 0xff) |
21
- ((key.charCodeAt(++i) & 0xff) << 8) |
22
- ((key.charCodeAt(++i) & 0xff) << 16) |
23
- ((key.charCodeAt(++i) & 0xff) << 24);
24
- ++i;
25
-
26
- k1 = Math.imul(k1, c1);
27
- k1 = (k1 << 15) | (k1 >>> 17);
28
- k1 = Math.imul(k1, c2);
29
-
30
- h1 ^= k1;
31
- h1 = (h1 << 13) | (h1 >>> 19);
32
- h1 = Math.imul(h1, 5) + 0xe6546b64;
33
- }
34
-
35
- let k1 = 0;
36
- switch (remainder) {
37
- case 3:
38
- k1 ^= (key.charCodeAt(i + 2) & 0xff) << 16;
39
- case 2:
40
- k1 ^= (key.charCodeAt(i + 1) & 0xff) << 8;
41
- case 1:
42
- k1 ^= key.charCodeAt(i) & 0xff;
43
- k1 = Math.imul(k1, c1);
44
- k1 = (k1 << 15) | (k1 >>> 17);
45
- k1 = Math.imul(k1, c2);
46
- h1 ^= k1;
47
- }
48
-
49
- h1 ^= key.length;
50
- h1 ^= h1 >>> 16;
51
- h1 = Math.imul(h1, 0x85ebca6b);
52
- h1 ^= h1 >>> 13;
53
- h1 = Math.imul(h1, 0xc2b2ae35);
54
- h1 ^= h1 >>> 16;
55
-
56
- return h1 >>> 0;
57
- }
58
-
59
- export function hashToFloat(seed: string, value: string): number {
60
- const hash = murmurhash3_32_gc(seed + value, 1);
61
- return (hash % 10000) / 10000;
18
+ export function isHoldoutVisitor(
19
+ config: { holdout_v1_enabled?: unknown; holdout_pct?: unknown },
20
+ visitorId: string,
21
+ ): boolean {
22
+ const pct = config.holdout_pct;
23
+ return config.holdout_v1_enabled === true && typeof pct === "number" &&
24
+ pct > 0 && pct <= 10 && hashToFloat("apex:global-holdout:v1", visitorId) < pct / 100;
62
25
  }
63
26
 
64
- export function variationHashToFloat(experimentId: string, value: string): number {
65
- return hashToFloat(`${experimentId}:variation`, value);
27
+ export function applyHoldoutGate<T>(config: Parameters<typeof isHoldoutVisitor>[0], visitorId: string, values: T[]): T[] {
28
+ return isHoldoutVisitor(config, visitorId) ? [] : values;
66
29
  }
67
30
 
68
31
  function bytesToHex(bytes: Uint8Array): string {
@@ -195,6 +158,13 @@ function writeCookie(value: string): void {
195
158
 
196
159
  export function getUserId(provided?: string): string {
197
160
  if (provided) return provided;
161
+ if (edgeRuntimeActive()) {
162
+ const edgeUserId = readCookie();
163
+ if (edgeUserId) {
164
+ if (readStorage() !== edgeUserId) writeStorage(edgeUserId);
165
+ return edgeUserId;
166
+ }
167
+ }
198
168
  const existing = readStorage() || readCookie();
199
169
  if (existing) return existing;
200
170
  const created = generateId();
@@ -203,6 +173,11 @@ export function getUserId(provided?: string): string {
203
173
  return created;
204
174
  }
205
175
 
176
+ /** Reads an existing durable identity without minting or persisting a new one. */
177
+ export function getPersistedUserId(): string | null {
178
+ return readStorage() || readCookie();
179
+ }
180
+
206
181
  export function getEphemeralUserId(provided?: string): string {
207
182
  return provided || generateId();
208
183
  }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyHoldoutGate, isHoldoutVisitor } from "./hash";
3
+
4
+ describe("global holdout v1", () => {
5
+ it("is deterministic and sticky for a visitor", () => {
6
+ const config = { holdout_v1_enabled: true, holdout_pct: 5 };
7
+ const first = isHoldoutVisitor(config, "visitor-123");
8
+ expect(isHoldoutVisitor(config, "visitor-123")).toBe(first);
9
+ expect(Array.from({ length: 1000 }, (_, i) => isHoldoutVisitor(config, `v-${i}`)).filter(Boolean).length)
10
+ .toBeGreaterThan(20);
11
+ });
12
+
13
+ it("fails open for disabled, missing, or malformed config", () => {
14
+ expect(isHoldoutVisitor({}, "visitor")).toBe(false);
15
+ expect(isHoldoutVisitor({ holdout_v1_enabled: true }, "visitor")).toBe(false);
16
+ expect(isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 11 }, "visitor")).toBe(false);
17
+ });
18
+
19
+ it("short-circuits every engine input only for holdout visitors", () => {
20
+ const visitor = Array.from({ length: 1000 }, (_, i) => `v-${i}`).find((id) =>
21
+ isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, id));
22
+ expect(visitor).toBeTruthy();
23
+ expect(applyHoldoutGate({ holdout_v1_enabled: true, holdout_pct: 10 }, visitor!, ["experiment"])).toEqual([]);
24
+ expect(applyHoldoutGate({}, visitor!, ["experiment"])).toEqual(["experiment"]);
25
+ });
26
+ });
@@ -136,6 +136,50 @@ describe("hydration coexistence gate", () => {
136
136
  }
137
137
  });
138
138
 
139
+ it("reports head-install delay from the original outer clock", async () => {
140
+ Object.defineProperty(document, "readyState", { value: "loading", configurable: true });
141
+ try {
142
+ const gate = awaitHydration(1_000, { waitForHydration: true });
143
+ await vi.advanceTimersByTimeAsync(400);
144
+ document.body.innerHTML = '<div id="__next"></div>';
145
+ Object.defineProperty(document, "readyState", { value: "interactive", configurable: true });
146
+ document.dispatchEvent(new Event("DOMContentLoaded"));
147
+ await vi.advanceTimersByTimeAsync(200);
148
+ signalHydrated();
149
+ await expect(gate).resolves.toBeUndefined();
150
+
151
+ // The quiet detector may legitimately resolve before the explicit
152
+ // signal; the outer-clock guarantee is that the delay counts from the
153
+ // ORIGINAL start (>= 400ms pre-DCL wait). The restarted-clock bug
154
+ // reported only the post-recursion remainder (~80ms).
155
+ const applyDelayedMs = (window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf?.apply_delayed_ms;
156
+ expect(applyDelayedMs).toBeGreaterThanOrEqual(400);
157
+ expect(applyDelayedMs).toBeLessThanOrEqual(1_000);
158
+ } finally {
159
+ Object.defineProperty(document, "readyState", { value: "complete", configurable: true });
160
+ }
161
+ });
162
+
163
+ it("does not stamp delay metrics for a synchronous-apply teardown gate", async () => {
164
+ const gate = awaitHydration(500, { waitForHydration: true, recordMetrics: false });
165
+ signalHydrated();
166
+ await expect(gate).resolves.toBeUndefined();
167
+ expect((window as Window & { __dripPerf?: unknown }).__dripPerf).toBeUndefined();
168
+ });
169
+
170
+ it("keeps the first page-load hydration stamps across later gates", async () => {
171
+ document.body.innerHTML = '<div id="__next"></div>';
172
+ const first = awaitHydration(1_000);
173
+ await vi.advanceTimersByTimeAsync(200);
174
+ signalHydrated();
175
+ await expect(first).resolves.toBeUndefined();
176
+ const initial = { ...(window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf };
177
+
178
+ await vi.advanceTimersByTimeAsync(300);
179
+ await expect(awaitHydration(1_000)).resolves.toBeUndefined();
180
+ expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf).toMatchObject(initial);
181
+ });
182
+
139
183
  it("fails open at the head-install deadline without a framework marker or DOMContentLoaded", async () => {
140
184
  Object.defineProperty(document, "readyState", { value: "loading", configurable: true });
141
185
  try {
@@ -162,16 +206,43 @@ describe("hydration coexistence gate", () => {
162
206
  await expect(gate).resolves.toBeUndefined();
163
207
  });
164
208
 
165
- it("honors an explicit hydration wait without known framework markers", async () => {
209
+ it("resolves an explicit markerless hydration wait once the DOM is quiet", async () => {
166
210
  const gate = awaitHydration(1_000, { waitForHydration: true });
167
211
  let resolved = false;
168
212
  void gate.then(() => { resolved = true; });
169
213
 
170
- await vi.advanceTimersByTimeAsync(200);
214
+ await vi.advanceTimersByTimeAsync(48);
171
215
  expect(resolved).toBe(false);
172
216
 
217
+ await vi.advanceTimersByTimeAsync(32);
218
+ await expect(gate).resolves.toBeUndefined();
219
+ expect(resolved).toBe(true);
220
+ expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf?.hydration_timeout).toBeUndefined();
221
+ });
222
+
223
+ it("bounds a continuously mutating markerless page at the hydration timeout", async () => {
224
+ const gate = awaitHydration(200, { waitForHydration: true });
225
+
226
+ for (let elapsed = 0; elapsed < 200; elapsed += 16) {
227
+ document.body.toggleAttribute("data-mutating");
228
+ await Promise.resolve();
229
+ await vi.advanceTimersByTimeAsync(16);
230
+ }
231
+
232
+ await expect(gate).resolves.toBeUndefined();
233
+ expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf).toMatchObject({
234
+ apply_delayed_ms: 200,
235
+ hydration_timeout: true,
236
+ });
237
+ });
238
+
239
+ it("resolves a markerless hydration wait immediately on an explicit signal", async () => {
240
+ const gate = awaitHydration(1_000, { waitForHydration: true });
241
+
173
242
  signalHydrated();
243
+
174
244
  await expect(gate).resolves.toBeUndefined();
245
+ expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf?.hydration_timeout).toBeUndefined();
175
246
  });
176
247
 
177
248
  it("waits for two quiet frames and at least 50ms after framework mutations", async () => {
package/src/hydration.ts CHANGED
@@ -12,6 +12,10 @@ function perf(): Record<string, unknown> | null {
12
12
  return target.__dripPerf = target.__dripPerf ?? {};
13
13
  }
14
14
 
15
+ function stampOnce(metrics: Record<string, unknown>, name: string, value: unknown): void {
16
+ if (!(name in metrics)) metrics[name] = value;
17
+ }
18
+
15
19
  export function detectSsrFramework(): "nuxt" | "next" | "server-rendered" | null {
16
20
  if (typeof window === "undefined" || typeof document === "undefined") return null;
17
21
  const runtime = window as Window & { __NUXT__?: unknown; __NEXT_DATA__?: unknown; __next_f?: unknown };
@@ -46,9 +50,10 @@ export function signalHydrated(): void {
46
50
 
47
51
  export function awaitHydration(
48
52
  timeoutMs: number,
49
- options: { waitForHydration?: boolean } = {},
53
+ options: { waitForHydration?: boolean; recordMetrics?: boolean } = {},
54
+ outerStarted = Date.now(),
50
55
  ): Promise<void> {
51
- const started = Date.now();
56
+ const started = outerStarted;
52
57
  const boundedTimeoutMs = Number.isFinite(timeoutMs)
53
58
  ? Math.max(0, Math.min(10_000, timeoutMs))
54
59
  : 3_000;
@@ -74,7 +79,7 @@ export function awaitHydration(
74
79
  document.removeEventListener("DOMContentLoaded", proceed);
75
80
  signals.delete(proceed);
76
81
  const remaining = Math.max(0, boundedTimeoutMs - (Date.now() - started));
77
- void awaitHydration(remaining, options).then(resolve);
82
+ void awaitHydration(remaining, options, started).then(resolve);
78
83
  };
79
84
  document.addEventListener("DOMContentLoaded", proceed, { once: true });
80
85
  // A framework can call drip.hydrated() before the parser reaches the
@@ -110,12 +115,12 @@ export function awaitHydration(
110
115
  holds = 0;
111
116
  console.warn("[drip] Mutation hold timed out; applying fail-open");
112
117
  }
113
- const metrics = perf();
118
+ const metrics = options.recordMetrics === false ? undefined : perf();
114
119
  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;
120
+ if (needsHydration || hydratedAt) stampOnce(metrics, "hydration_detected_at", hydratedAt || Date.now());
121
+ stampOnce(metrics, "apply_delayed_ms", Math.max(0, Date.now() - started));
122
+ if (hydrationTimeout) stampOnce(metrics, "hydration_timeout", true);
123
+ if (holdTimeout) stampOnce(metrics, "hold_timeout", true);
119
124
  }
120
125
  signals.delete(check);
121
126
  clearTimeout(timer);
@@ -148,6 +153,10 @@ export function awaitHydration(
148
153
  check();
149
154
  return;
150
155
  }
156
+ // requestAnimationFrame is available in supported browsers, but not in
157
+ // every DOM-like host. Keep the explicit-signal/hard-timeout exits active
158
+ // rather than throwing when the quiet detector cannot be scheduled.
159
+ if (typeof requestAnimationFrame === "undefined") return;
151
160
  const onRecords: MutationCallback = () => {
152
161
  lastMutation = Date.now();
153
162
  quietFrames = 0;
@@ -160,7 +169,7 @@ export function awaitHydration(
160
169
 
161
170
  signals.add(check);
162
171
  const timer = setTimeout(() => finish(true), boundedTimeoutMs);
163
- if (needsHydration && frameworkDetected) {
172
+ if (needsHydration) {
164
173
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", beginQuiet, { once: true });
165
174
  else beginQuiet();
166
175
  }