@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
@@ -0,0 +1,3204 @@
1
+ declare const __DRIP_FEATURE_QA__: boolean;
2
+ declare const __DRIP_FEATURE_DEVTOOLS__: boolean;
3
+ declare const __DRIP_FEATURE_AUDIT__: boolean;
4
+ declare const __DRIP_FEATURE_HEATMAP__: boolean;
5
+ declare const __DRIP_FEATURE_REPLAY__: boolean;
6
+ declare const __DRIP_FEATURE_HARNESS__: boolean;
7
+ const FEATURE_QA = __DRIP_FEATURE_QA__;
8
+ const FEATURE_DEVTOOLS = __DRIP_FEATURE_DEVTOOLS__;
9
+ const FEATURE_AUDIT = __DRIP_FEATURE_AUDIT__;
10
+ const FEATURE_HEATMAP = __DRIP_FEATURE_HEATMAP__;
11
+ const FEATURE_REPLAY = __DRIP_FEATURE_REPLAY__;
12
+ const FEATURE_HARNESS = __DRIP_FEATURE_HARNESS__;
13
+ import { cleanUrl, onPageEnter, onPageExit } from "./analytics";
14
+ import { normalizeEndpoint } from "./endpoint";
15
+ import { EVENT_TYPE_PATTERN, normalizeEventName } from "@drip/event-schema/event-name";
16
+ import { isExperimentServable } from "@drip/event-schema/experiment-servability";
17
+ import {
18
+ applyAntiFlicker,
19
+ bootedTooLateForAntiFlicker,
20
+ removeAntiFlicker,
21
+ } from "./antiflicker";
22
+ import {
23
+ clearPersistedAssignments,
24
+ findPersistedVariation,
25
+ persistAssignmentForExperiment,
26
+ prunePersistedAssignments,
27
+ removePersistedAssignmentsForExperiments,
28
+ __assignmentStoreInternals,
29
+ } from "./assignment-store";
30
+ import { cleanupBucketingStorage } from "./bucketing-cleanup";
31
+ import { chooseVariation, inTrafficAllocation } from "./bucket";
32
+ import { buildAssignment, decideAssignments } from "./decide-assignments";
33
+ import { weekdayScheduleBlocksDelivery } from "./weekday-schedule";
34
+ import { CONSENT_REQUIRED, DNT_BLOCKED, evaluateSynchronousConsent, readConfiguredConsent, readPath, resolveSynchronousConsentGranted } from "./consent-evaluation";
35
+ import { clearEarlyInteractions, drainBootstrapEarlyInteractions, drainEarlyInteractions } from "./early-interaction-capture";
36
+ import { getCwvMetrics, markCwvReveal, startCwvMeasurement, waitForCwvMetrics } from "./cwv";
37
+ import { collectDeliveryTiming } from "./delivery-timing";
38
+ import {
39
+ startStorefrontVitals,
40
+ stopStorefrontVitals,
41
+ type StorefrontVitalsContext,
42
+ } from "./storefront-vitals";
43
+ import {
44
+ browserGroupStickyStore,
45
+ clearBrowserGroupStickyStore,
46
+ resolveExclusionGroups,
47
+ type ExclusionGroupDecision,
48
+ type PersistedGroupSelection,
49
+ } from "./exclusion-groups";
50
+ import type { CompactGroupSelections } from "./persistence-decoders";
51
+ import {
52
+ applyCommercePrice,
53
+ isCommercePriceWorkPending,
54
+ cleanupCommercePrice,
55
+ hasCommercePriceState,
56
+ COMMERCE_PRICE_SELECTORS,
57
+ isCommercePriceActive,
58
+ isCommercePriceExposureEligible,
59
+ notifyCommerceCartStampConfirmed,
60
+ setCommercePriceExposureListener,
61
+ } from "./commerce-price";
62
+ import {
63
+ applyCommerceShipping,
64
+ classifyShippingExposure,
65
+ cleanupCommerceShipping,
66
+ isShippingCartEligible,
67
+ isShippingCartEligibilityConfirmed,
68
+ } from "./commerce-shipping";
69
+ import {
70
+ addCssOwners,
71
+ injectCss,
72
+ injectExperimentCss,
73
+ injectExternalCss,
74
+ removeCssForOwner,
75
+ } from "./css";
76
+ import { configureDebug, debugLog, debugWarn, debugGroup, debugGroupEnd, isDebugEnabled } from "./debug";
77
+ import { loadDebugBuild } from "./debug-loader";
78
+ import { observeDomChanges } from "./dom-trigger";
79
+ import { edgeRuntimeActive, readEdgeRuntimeAssignments } from "./edge-runtime";
80
+ import { normalizeDataLayerEntry } from "./data-layer-normalize";
81
+ import { interceptEditorLaunch, isEditorDevicePreview } from "./editor-bootstrap";
82
+ import { cleanupGoals, goalWiringKeys, setupGoals, triggerGoal, triggerGoalsForEvent } from "./goals";
83
+ import { clearQaForceFromUrl, isBot, isDuplicate, isIframe, isOptedOut, isQaMode, isScreenshotMode, parseForceParam, persistQaForce, persistUrlQaForce, exitQaMode, getQaSessionId } from "./guards";
84
+ import { adoptUserId, applyHoldoutGate, configureIdentityStorage, getEphemeralUserId, getUserId, hashToFloat, isHoldoutVisitor, variationHashToFloat } from "./hash";
85
+ import { createBucketingHandoverPayload, handoverMintTimeoutMs, hasBucketingHandover, installBucketingHandover, readBucketingHandover } from "./handover";
86
+ import { revealAll } from "./hideflicker";
87
+ import {
88
+ addExternalJsOwners,
89
+ execJs,
90
+ loadExternalJs,
91
+ removeExternalJsForOwner,
92
+ setJsPolicy,
93
+ } from "./js";
94
+ import {
95
+ addAppliedMutationOwners,
96
+ applyMutations,
97
+ applyToElement,
98
+ invalidateArtifact,
99
+ reassertNonStructuralMutations,
100
+ replayArtifact,
101
+ resetAppliedMutationsForOwner,
102
+ } from "./mutate";
103
+ import { registerMutedObserver, runMuted, unregisterMutedObserver } from "./observer-mute";
104
+ import { assignmentsHaveVisualWork, awaitHydration, hasMutationHold, holdMutations, releaseMutations, signalHydrated } from "./hydration";
105
+ import {
106
+ assignmentsHaveCustomJs,
107
+ assignmentsHaveStructuralMutations,
108
+ installEarlyReassert,
109
+ shouldUseEarlyReassert,
110
+ } from "./early-reassert";
111
+ import { trackAppliedMutation } from "./reassert-sentinel";
112
+ import { observeRouteChanges, PAGE_URL_POLL_INTERVAL_MS } from "./router";
113
+ import { buildExposureSignalContext } from "./exposure-signals";
114
+ import {
115
+ buildRedirectDestination,
116
+ clearPendingRedirectAssignmentCarry,
117
+ finalizeRedirectAssignmentCarryHandover,
118
+ hasGeneratedRedirectAssignmentCarry,
119
+ filterUnsafeRedirectAssignments,
120
+ importRedirectAssignmentCarry,
121
+ isRedirectLoop,
122
+ markRedirectAssignmentCarryExposed,
123
+ parseRedirectAssignmentCarry,
124
+ persistPendingRedirectAssignmentCarry,
125
+ readPendingRedirectAssignmentCarry,
126
+ REDIRECT_ASSIGNMENT_PARAM,
127
+ redirectPlanForAssignment,
128
+ stripRedirectAssignmentCarry,
129
+ validateRedirectAssignmentCarry,
130
+ verifyRedirectAssignmentCarry,
131
+ } from "./redirect";
132
+ import type { VerifiedRedirectCarry } from "./redirect";
133
+ import { buildPrerequisiteContext, evaluateProjectPrerequisite } from "./prerequisites";
134
+ import {
135
+ REPLAY_PROGRESS_KEY_PREFIX,
136
+ REPLAY_SAMPLE_KEY_PREFIX,
137
+ resolveReplayRecorderUrl,
138
+ startReplayCapture,
139
+ type ReplayController,
140
+ } from "./replay";
141
+ import {
142
+ buildCartAttributeEntries,
143
+ buildCartMetafieldInputs,
144
+ buildSignedCartAttributeEntries,
145
+ APEX_CART_ATTRIBUTE_KEYS,
146
+ cannotRequestRequiredCommerceAuthorization,
147
+ getCartMetafieldStaleKeys as collectCartMetafieldStaleKeys,
148
+ invalidateShopifyCartAttributeSync,
149
+ normalizeCartMetafieldNamespace,
150
+ syncShopifyCartAttributes,
151
+ type CartAssignment,
152
+ type CartAttributeEntry,
153
+ type CartMetafieldInput,
154
+ type CartAttributeSyncInput,
155
+ } from "./shopify-cart";
156
+ import { onShopifyCartMutation } from "./shopify-cart-events";
157
+ import {
158
+ getPendingGiftChoices,
159
+ noteCartSnapshotForGifts,
160
+ selectGiftOption,
161
+ syncGiftWithPurchase,
162
+ type GiftSyncOptions,
163
+ } from "./commerce-gift";
164
+ import { CART_DRAWER_OPEN_SIGNAL, installSurfaceSignals } from "./surface-signals";
165
+ import {
166
+ assignmentSignature,
167
+ canUseEmptyProjectProfile,
168
+ computeUrlDiffNeeds,
169
+ getBlankScreenMs,
170
+ readPerfFlags,
171
+ resolveActivationMode,
172
+ resolvePlatform,
173
+ resolveRouterMode,
174
+ shouldReevaluateForUrlChange,
175
+ } from "./runtime";
176
+ import type { PerfFlagState } from "./runtime";
177
+ import {
178
+ buildTargetingAttributes,
179
+ evaluateTargetingGatesDetailed,
180
+ matchPageTargetingRule,
181
+ readShopifySignal,
182
+ targetingHasJsTriggers,
183
+ } from "./targeting";
184
+ import { createApexRuntimeTools, type ApexRuntimeTools } from "./runtime-tools";
185
+ import { createEventId, Tracker } from "./track";
186
+ import {
187
+ clearStoredSignals,
188
+ configureSignalPersistenceAllowed,
189
+ clearRuntimeSignal,
190
+ getSignals as getStoredSignals,
191
+ getSignalDiagnostics,
192
+ rememberProduct,
193
+ setCartSignals,
194
+ setRuntimeSignal,
195
+ setSearchSignal,
196
+ setSignal as setStoredSignal,
197
+ } from "./signals";
198
+ import {
199
+ evaluateFlag as evaluateFlagFromExperiments,
200
+ evaluateFlagAssignment as evaluateFlagAssignmentFromExperiments,
201
+ evaluateFlagValue as evaluateFlagValueFromExperiments,
202
+ } from "./flags";
203
+ import {
204
+ ActivationMode,
205
+ AntiFlickerMode,
206
+ Assignment,
207
+ ActivationDiagnostics,
208
+ ActivationGateDiagnostic,
209
+ ConditionDiagnostic,
210
+ ConfigProfile,
211
+ ConsentModeSettings,
212
+ CustomJsPolicy,
213
+ DebugSettings,
214
+ ExecutionMode,
215
+ Experiment,
216
+ ExperimentActivationDiagnostic,
217
+ ExclusionGroupConfig,
218
+ ExclusionGroupMemberConfig,
219
+ Goal,
220
+ InitConfig,
221
+ PageTargetingRule,
222
+ PageActivationDiagnostic,
223
+ PageDeactivationMode,
224
+ PageTriggerMode,
225
+ PageTriggerSettings,
226
+ PerfFeatureFlag,
227
+ PerfFeatureFlags,
228
+ PlatformHint,
229
+ ProjectPrerequisites,
230
+ RouterMode,
231
+ SegmentCondition,
232
+ SpaSettings,
233
+ Targeting,
234
+ ExternalTrackingSettings,
235
+ ProjectCodeSettings,
236
+ RuntimeEnvironmentSettings,
237
+ RuntimeSignalDefinition,
238
+ SdkDiagnostics,
239
+ SnippetCriticalMutation,
240
+ SnippetCriticalMutationPlanItem,
241
+ SnippetDeliveryUrlRule,
242
+ BucketingCleanupSettings,
243
+ DataProtectionSettings,
244
+ DataLayerSignalMapping,
245
+ DataLayerTriggerSettings,
246
+ ExposureDestinationsSettings,
247
+ Mutation,
248
+ TrackEvent,
249
+ TrackingStart,
250
+ Variation,
251
+ } from "./types";
252
+ import {
253
+ addWaitEntryOwners,
254
+ applyMutationWithWait,
255
+ resetWaitEntriesForOwner,
256
+ } from "./wait";import { OrderIdentityPlatform, SDKState, QaAssistantRuntimeSnapshot, PageActivationRuntimeState, PageRulePollController, state, runtimeSharedState } from "./runtime-state";
257
+ import { applyRuntimeAntiFlicker, SDK_SCRIPT_SRC, HeatmapController, SemanticController, FetchAttemptInfo, FetchRetryOptions, DEFAULT_CONFIG_FETCH_TIMEOUT_MS, DEFAULT_CONFIG_FETCH_SLOW_MS, CONFIG_FETCH_RETRY_COUNT, CONFIG_FETCH_RETRY_DELAY_MS, CONFIG_FETCH_MAX_WAIT_MS, CACHED_REDIRECT_HIDE_TIMEOUT_MS, createFetchTimeoutError, fetchWithTimeout, fetchWithRetry, CONFIG_CACHE_KEY_PREFIX, DEFAULT_CONFIG_CACHE_TTL_MS, DEFAULT_ANTI_FLICKER_TIMEOUT_MS, DEFAULT_TRACKING_IDLE_TIMEOUT_MS, DEFAULT_EXECUTION_MODE, DEFAULT_TRACKING_START, DEFAULT_JS_POLICY, PAGE_RULE_POLL_WINDOW_MS, PAGE_RULE_POLL_INTERVAL_MS, CONSENT_COOKIE_NAME, IDENTITY_CONTEXT_COOKIE_NAME, IDENTITY_CONTEXT_COOKIE_MAX_AGE_SECONDS, IDENTITY_CONTEXT_COOKIE_MAX_ENCODED_BYTES, SESSION_STORAGE_KEY, collectScopedSelectors, ResolvedRuntimeConfig, UrlDiffNeeds, ConfigSource, PendingPerfEvent, InitPhaseKey, InitPerfContext, MAX_PERF_DURATION_MS, monotonicNow, boundedPerfDuration, createInitPerfContext, BootstrapPredecision, bootstrapPredecision, addInitPhase, DeliveryHealthStatus, DeliveryHealthTracker, RouteDiagnostic, DataLayerDiagnostic, ShopifySyncDiagnostic, ExposureDestinationDiagnostic, RuntimeLoadDiagnostic, DripPerfWindow, normalizeNumber, resolveRuntimeConfig, resolvePageTriggerMode, resolveDomPageTrigger, dtLog, traceName, traceStep, traceGate, browserDoNotTrackValue, trackingBlocked, storefrontVitalsGateOpen, syncStorefrontVitals, doNotTrackEnabled, storageAllowed, readWindowPath, configuredConsentGranted, syncConfiguredConsent, CUSTOM_CONSENT_POLL_INTERVAL_MS, CUSTOM_CONSENT_POLL_MAX_MS, MANUAL_CALLBACK_TRIGGER_RERUN_INTERVAL_MS, MANUAL_CALLBACK_TRIGGER_RERUN_MAX_MS, MANUAL_CALLBACK_TRIGGER_MAX_RERUNS, EVENT_DRIVEN_CONSENT_PROVIDERS, shouldPollConfiguredConsent, installConsentChangeListeners, resolveConsentRequired, resolveConsentGranted, persistentStorageAllowed, coerceSignalValue, captureUrlSignals, installClickSignalCaptures, captureEventPropertySignals, setCapturedSignal, setPublicSignal, getPublicSignals, normalizePageApiName, pageTriggerSignal, triggerPage, readElementCaptureValue, captureDomAndGlobalSignals, consentAwareDataProtection, writeConsentCookie, readConsentCookie, clearConsentStorage, applyConsentStorageMode, runWhenDomReady, applyProjectCode, resetProjectCode, runProjectStartupCode, collectJsTriggerConditions, collectPageTriggerRules, PageDetectorMode, PageRuleReconcileScope, collectPageDetectorRules, experimentsForGlobalRouteReevaluation, conditionUsesActivateTrigger, buildJsTriggerContext, runJsTriggerReset, normalizeActivationValue, installActivateJsTrigger, installPageTriggerRule, installJsTriggerObserver, notifyQaAssistantRuntimeListeners, HarnessManifest, harnessPollTimer, harnessPollKey, harnessVisibilityInstalled, activeHarnessAssignment, stopHarnessPolling, applyHarnessManifest, pollHarnessManifest, startHarnessPolling, getEventsEndpoint, mintBucketingHandoverPayload, verifyBucketingHandoverToken, clearScheduledAnalyticsStart, normalizeDataLayerTriggers, applyDataLayerMappings, readNumericPath, readStringPath, RESERVED_DATA_LAYER_REVENUE_KEYS, PendingDataLayerEvent, MAX_PENDING_DATA_LAYER_EVENTS, pendingDataLayerEvents, processedDataLayerEntries, isDataLayerEntryObject, markDataLayerEntryProcessed, trackDataLayerRevenue, trackDataLayerEvent, enqueuePendingDataLayerEvent, drainPendingDataLayerEvents, finishInitializingAssignments, handleDataLayerEvent, recordObserverError } from "./config-runtime";
258
+ import { scheduleDeferredRouteReevaluation, installDataLayerTriggers, experimentsUseSurfaceSignals, rememberExposureDestination, clarityUpgradeSent, clarityTagKey, pushExposureToQueue, emitExposureDestinations, ensureTrackerStarted, exposureAssignmentKey, priceAssignmentAuthorizationKey, isPriceDisplayAssignmentAuthorized, isShippingDisplayAssignmentAuthorized, isOffersDisplayAssignmentAuthorized, giftSyncOptions, assignmentHasRenderableArtifacts, authorizeOffersDisplayAndRender, revokeOffersDisplayAuthorization, exposureEligibleAssignments, isShippingExposureCartEligible, trackViewedAssignmentsAfterShippingEligibility, EMPTY_TARGETING_ATTRIBUTES, TargetingEvaluationPass, activeTargetingEvaluationPass, createTargetingEvaluationPass, targetingAttributesForPass, trackViewedAssignments, sameStringSet, setupGoalTracking, setupHeatmapTracking, cleanupHeatmapTracking, setupReplayTracking, cleanupReplayTracking, setupSemanticTracking, cleanupSemanticTracking, buildExperimentBehaviorEvents, buildPageExitEvents, analyticsTrack, syncLoadDiagnosticsToPerf, updateLoadDiagnostics, resetLoadDiagnostics, queuePerfEvent, RuntimeInstallClaim, RUNTIME_INSTALL_CLAIM_KEY, RUNTIME_INSTALL_CONFLICT_KEY, telemetryLabel, readRuntimeInstallClaim, rememberRuntimeInstallClaim, claimInstallConflictEmission, sdkAttributionData, sendRuntimeEventNow, sendRuntimePerfNow, readDripPerf, markDripPerf, numberPerf, coexistencePerfFields, buildPerfEvent, flushPendingPerf, orderIdentityEndpoint, ORDER_EXPOSURE_STORAGE_KEY, ORDER_EXPOSURE_MAX_ENTRIES, ORDER_EXPOSURE_TTL_MS, StoredOrderExposure, pruneStoredOrderExposures, readStoredOrderExposures, writeStoredOrderExposures, clearIdentityContextCookie, recentIdentityContextExposures, buildIdentityContextCookieValue, generateSessionId, getIdentityContextSessionId, writeIdentityContextCookie, isOffTargetPersistedAssignment, restoreRecordedExposureDedup, rememberOrderExposure, mergedOrderIdentityAssignments, cleanOrderIdentityText, resolveOrderIdentityPlatform, detectOrderIdentity, orderIdentityDedupeKey, scheduleOrderIdentityBeacon, deliveryStatus, queueDeliveryHealthEvent, flushPendingDeliveryHealth, startAnalyticsStage, scheduleAnalyticsStart, startAnalyticsIfReady, getConfigCacheKey, storageAvailable, getConfigCacheTtlMs, configuredStoreOrigins, readCachedExperiments, hashConfigPayload, writeCachedExperiments, withRuntimeSettings, serializeForceMap, getHashId } from "./analytics-runtime";
259
+ import { generateQaSessionId, SHOPIFY_ASSIGNMENT_SYNC_DELAYS_MS, shopifyAssignmentSyncTimers, CommerceOnlyAdoptionState, commercePriceAdoptionState, ensureCommercePriceAdoptionState, currentCommercePriceContext, withCommercePriceAssignments, clearShopifyAssignmentSyncTimers, adoptAuthoritativePriceAssignments, OffersAdoptionPlan, planAuthoritativeOffersAdoption, canAdoptAuthoritativeOffersAssignments, adoptAuthoritativeOffersAssignments, sdkCartMintAllowed, buildSdkCartMintInput, buildScopedCartAttributeSyncInput, buildActiveCartAttributeSyncInput, sdkCartMintSync, syncSdkCartMint, removeSdkCartMintTrigger, onSdkCartMintClick, installSdkCartMintTrigger, HeadlessCartAuthorizationState, headlessCartAuthorizationState, ensureHeadlessCartAuthorizationState, hasChargeAffectingCommerceAssignment, hasActiveAuthorizationPlane, hasActivePriceAuthorizationPlane, hasActiveShippingAuthorizationPlane, hasActiveOffersAuthorizationPlane, hasActiveCommerceAuthorizationPlane, requiresCommerceCohortPin, getChargeAffectingCommerceExperimentIds, isCohortStampedOnCart, syncActiveAssignmentToShopifyCart, configureChargeCorrectnessCartSync, holdoutConfigEpoch, publishAssignmentsToShopify, scheduleShopifyAssignmentSync, variationsAllowed, updateProjectActivationDiagnostics, trackingAllowed, experimentById, targetingLifecycleMode, preservePersistentAssignments, stopAnalyticsStage, canMintVerifiedRedirectCarry, planRedirectAssignment, navigateForRedirectAssignment, RECONCILE_MAX_DRAINS, reevaluateAssignments, reevaluateAssignmentsPass, reapplyQaAssistantForceMap, reconcileForcedUnavailable, __getQaAssistantRuntimeSnapshot, __subscribeQaAssistantRuntime, __forceQaAssistantVariation, __resetQaAssistantForce, clearQaForceFromCurrentUrl, initWithExperiments } from "./activation-runtime";
260
+ import { loadQaToolbar, loadQaAssistant, loadDevtoolsIfEnabled, loadDevtools, init, trackEvent, track, funnelStepStorageKey, shouldTrackFunnelStep, trackStep, trackGoal, setConsent, hold, release, hydrated, recheckPrerequisites, reInit, RevenueEventData, normalizeRevenueCurrency, trackRevenue, flush, getAssignments, buildCurrentCartAttributes, getCartAttributes, getCartMetafields, getCartMetafieldStaleKeys, mergeCartAttributes, normalizedHeadlessCartToken, getSignedCartAttributes, confirmCartAttributesWritten, getExperiments, getGoals, getActiveExperimentIds, getRuntimeValue, getRuntimeTools, buildFlagContext, evaluateFlag, evaluateFlagAssignment, evaluateFlagValue, getDiagnostics, QueueCommand, processCommand, processQueue, installedGlobalApi, createGlobalApi, installGlobalApi } from "./public-api";
261
+
262
+
263
+ export type PersistedAssignmentMap = ReturnType<typeof prunePersistedAssignments>;
264
+ export const GLOBAL_PAGE_PREFIX = "\u0001experiment:";
265
+ export const appliedAssignmentsByKey = new Map<string, Assignment>();
266
+ export type ArtifactId = string & { readonly __artifactId: unique symbol };
267
+ export const invalidatedArtifactIds = new Set<ArtifactId>();
268
+ export /**
269
+ * Per-artifact epochs, keyed exactly like `appliedAssignmentsByKey`: the apply
270
+ * pass that last wrote each artifact. A deferred continuation in
271
+ * `applyAssignmentCode` revalidates its own artifact keys against its pass
272
+ * epoch at resume, so a page-scoped reapply invalidates only the artifacts it
273
+ * rewrote — an unrelated page's pending external CSS/JS continuation survives.
274
+ * Teardown deletes the entry, and pass epochs only ever increase, so a
275
+ * torn-down artifact that is applied again never revives a stale continuation.
276
+ */
277
+ const artifactEpochs = new Map<string, number>();
278
+ export let applyPassEpoch = 0;
279
+
280
+ export /** A continuation is current while any artifact it applied still holds its pass epoch. */
281
+ function artifactEpochCurrent(
282
+ ownerKeys: readonly string[],
283
+ epoch: number,
284
+ ): boolean {
285
+ return ownerKeys.some((ownerKey) => artifactEpochs.get(ownerKey) === epoch);
286
+ }
287
+
288
+ export type PageActivationTransition = {
289
+ pageId: string;
290
+ mode: PageDeactivationMode;
291
+ previousActive: boolean;
292
+ active: boolean;
293
+ teardown: boolean;
294
+ reapply: boolean;
295
+ };
296
+
297
+ export function normalizePageDeactivationMode(
298
+ rule: PageTargetingRule,
299
+ ): PageDeactivationMode {
300
+ const mode = rule.trigger?.deactivationMode;
301
+ if (mode === "reset" || mode === "deactivate" || mode === "persistent") return mode;
302
+ if (mode === "force-reset") return "force_reset";
303
+ return rule.disabled || rule.trigger?.disabled ? "deactivate" : "reset";
304
+ }
305
+
306
+ export function effectiveDeactivationMode(
307
+ experiment: Experiment,
308
+ rule: PageTargetingRule,
309
+ ): PageDeactivationMode {
310
+ if (
311
+ rule.trigger &&
312
+ Object.prototype.hasOwnProperty.call(rule.trigger, "deactivationMode")
313
+ ) {
314
+ return normalizePageDeactivationMode(rule);
315
+ }
316
+ // Legacy experiments can still store an explicit experiment mode. Keep it
317
+ // only until every affected Page has a mode and cached configs have expired.
318
+ return targetingLifecycleMode(experiment);
319
+ }
320
+
321
+ export function includedPageRules(experiment: Experiment | undefined): PageTargetingRule[] {
322
+ return (experiment?.targeting?.pageRules ?? []).filter(
323
+ (rule) => rule.include !== false && Boolean(rule.id),
324
+ );
325
+ }
326
+
327
+ export // Page activation must evaluate against the same URL the assignment pass uses:
328
+ // the InitConfig override at init time, the live URL on re-evaluation.
329
+ function currentPageMatchContext(
330
+ url: string,
331
+ targetingPass?: TargetingEvaluationPass,
332
+ ): {
333
+ url: string;
334
+ attributes: Record<string, unknown>;
335
+ } {
336
+ const sourceAttributes = state.config?.attributes ?? EMPTY_TARGETING_ATTRIBUTES;
337
+ return {
338
+ url,
339
+ attributes: targetingAttributesForPass(
340
+ targetingPass,
341
+ sourceAttributes,
342
+ url,
343
+ persistentStorageAllowed(),
344
+ ),
345
+ };
346
+ }
347
+
348
+ export function pageActivationDiagnosticRows(
349
+ options: { includePageNames?: boolean } = {},
350
+ ): PageActivationDiagnostic[] {
351
+ const names = new Map<string, string>();
352
+ const alwaysEmbeddedPageIds = new Set<string>();
353
+ for (const experiment of state.config?.experiments ?? []) {
354
+ for (const rule of experiment.targeting?.pageRules ?? []) {
355
+ if (rule.id && rule.name) names.set(rule.id, rule.name);
356
+ if (rule.id && rule.trigger?.alwaysEmbedInSnippet === true) {
357
+ alwaysEmbeddedPageIds.add(rule.id);
358
+ }
359
+ }
360
+ }
361
+ return Array.from(state.pageActivation.entries())
362
+ .filter(([pageId]) => !pageId.startsWith(GLOBAL_PAGE_PREFIX))
363
+ .map(([pageId, pageState]) => ({
364
+ pageId,
365
+ ...(options.includePageNames && names.get(pageId)
366
+ ? { pageName: names.get(pageId) }
367
+ : {}),
368
+ active: pageState.active,
369
+ appliedArtifactCount: pageState.appliedKeys.size,
370
+ ...(alwaysEmbeddedPageIds.has(pageId)
371
+ ? { alwaysEmbedInSnippet: true }
372
+ : {}),
373
+ ...(pageState.lastTransitionMode
374
+ ? { lastTransitionMode: pageState.lastTransitionMode }
375
+ : {}),
376
+ ...(pageState.isolationFallback
377
+ ? { isolationFallback: pageState.isolationFallback }
378
+ : {}),
379
+ ...(pageState.isolationFallbackExperimentId
380
+ ? {
381
+ isolationFallbackExperimentId:
382
+ pageState.isolationFallbackExperimentId,
383
+ }
384
+ : {}),
385
+ ...(pageState.triggerDetector
386
+ ? { triggerDetector: pageState.triggerDetector }
387
+ : {}),
388
+ ...(pageState.triggerPollAttempts !== undefined
389
+ ? { triggerPollAttempts: pageState.triggerPollAttempts }
390
+ : {}),
391
+ ...(pageState.triggerOutcome
392
+ ? { triggerOutcome: pageState.triggerOutcome }
393
+ : {}),
394
+ ...(pageState.triggerElapsedMs !== undefined
395
+ ? { triggerElapsedMs: pageState.triggerElapsedMs }
396
+ : {}),
397
+ }))
398
+ .sort((left, right) => left.pageId.localeCompare(right.pageId));
399
+ }
400
+
401
+ export function reconcilePageActivation(
402
+ experiments: Experiment[],
403
+ url: string,
404
+ options: {
405
+ previous?: ReadonlyMap<string, PageActivationRuntimeState>;
406
+ persistedAssignments?: PersistedAssignmentMap;
407
+ targetingPass?: TargetingEvaluationPass;
408
+ pageRuleScope?: PageRuleReconcileScope;
409
+ } = {},
410
+ ): {
411
+ transitions: PageActivationTransition[];
412
+ next: Map<string, PageActivationRuntimeState>;
413
+ pollRules: PageTargetingRule[];
414
+ } {
415
+ const context = currentPageMatchContext(url, options.targetingPass);
416
+ const previous = options.previous ?? state.pageActivation;
417
+ const definitions = new Map<
418
+ string,
419
+ { mode: PageDeactivationMode; matched: boolean; evaluate: boolean; scopeOnly?: boolean }
420
+ >();
421
+ const pollRules = new Map<string, PageTargetingRule>();
422
+ for (const experiment of experiments) {
423
+ const rules = includedPageRules(experiment);
424
+ const activatableRules = rules.filter(
425
+ (rule) => !rule.disabled && !rule.trigger?.disabled,
426
+ );
427
+ if (rules.length === 0) {
428
+ const pageId = GLOBAL_PAGE_PREFIX + experiment.id;
429
+ const shouldEvaluate = !options.pageRuleScope ||
430
+ options.pageRuleScope.includeImmediate;
431
+ const hasPersistentAssignment =
432
+ targetingLifecycleMode(experiment) === "persistent" &&
433
+ Boolean(
434
+ options.persistedAssignments &&
435
+ findPersistedVariation(experiment, options.persistedAssignments),
436
+ );
437
+ const hasRedirectCarry = Array.from(
438
+ state.carryImportedAssignmentKeys,
439
+ ).some((key) => key.startsWith(experiment.id + ":"));
440
+ definitions.set(pageId, {
441
+ // A global entry has no Page rule that can own deactivation behavior.
442
+ mode: targetingLifecycleMode(experiment),
443
+ matched: shouldEvaluate
444
+ ? hasPersistentAssignment ||
445
+ hasRedirectCarry ||
446
+ evaluateTargetingGatesDetailed(
447
+ experiment.targeting,
448
+ context,
449
+ ).matched
450
+ : previous.get(pageId)?.active === true,
451
+ evaluate: shouldEvaluate,
452
+ });
453
+ }
454
+ for (const rule of activatableRules) {
455
+ const triggerMode = rule.trigger?.mode ?? "immediate";
456
+ const shouldEvaluate = !options.pageRuleScope ||
457
+ (triggerMode === "immediate"
458
+ ? options.pageRuleScope.includeImmediate
459
+ : options.pageRuleScope.triggeredPageIds.has(rule.id));
460
+ const matched = shouldEvaluate
461
+ ? matchPageTargetingRule(rule, context)
462
+ : previous.get(rule.id)?.active === true;
463
+ if (shouldEvaluate) {
464
+ if (matched) {
465
+ cancelPageRulePoll(rule.id);
466
+ } else if (
467
+ rule.trigger?.pollOnRules === true &&
468
+ triggerMode === "immediate"
469
+ ) {
470
+ // Start after the caller completes teardown. Teardown cancels the
471
+ // previous retry window and must not cancel this new retry window.
472
+ pollRules.set(rule.id, rule);
473
+ }
474
+ }
475
+ const existing = definitions.get(rule.id);
476
+ if (existing) {
477
+ existing.matched = existing.matched || matched;
478
+ existing.evaluate = existing.evaluate || shouldEvaluate;
479
+ if (existing.scopeOnly) {
480
+ existing.mode = effectiveDeactivationMode(experiment, rule);
481
+ existing.scopeOnly = false;
482
+ }
483
+ } else {
484
+ definitions.set(rule.id, {
485
+ mode: effectiveDeactivationMode(experiment, rule),
486
+ matched,
487
+ evaluate: shouldEvaluate,
488
+ });
489
+ }
490
+ }
491
+ const targetingIds = new Set(activatableRules.map((rule) => rule.id));
492
+ for (const rule of experiment.goalPageRules ?? []) {
493
+ if (!rule.id || targetingIds.has(rule.id) || definitions.has(rule.id)) continue;
494
+ const triggerMode = rule.trigger?.mode ?? "immediate";
495
+ const shouldEvaluate = !options.pageRuleScope ||
496
+ (triggerMode === "immediate"
497
+ ? options.pageRuleScope.includeImmediate
498
+ : options.pageRuleScope.triggeredPageIds.has(rule.id));
499
+ definitions.set(rule.id, {
500
+ mode: "reset",
501
+ matched: shouldEvaluate
502
+ ? matchPageTargetingRule(rule, context)
503
+ : previous.get(rule.id)?.active === true,
504
+ evaluate: shouldEvaluate,
505
+ scopeOnly: true,
506
+ });
507
+ }
508
+ }
509
+
510
+ const transitions: PageActivationTransition[] = [];
511
+ const next = new Map<string, PageActivationRuntimeState>();
512
+ for (const [pageId, pageState] of previous) {
513
+ if (definitions.has(pageId)) continue;
514
+ if (pageState.scopeOnly) continue;
515
+ transitions.push({
516
+ pageId,
517
+ mode:
518
+ pageState.configuredMode ??
519
+ pageState.lastTransitionMode ??
520
+ "reset",
521
+ previousActive: pageState.active,
522
+ active: false,
523
+ teardown: pageState.active || pageState.appliedKeys.size > 0,
524
+ reapply: false,
525
+ });
526
+ }
527
+ for (const [pageId, definition] of definitions) {
528
+ const pageState = previous.get(pageId) ?? {
529
+ active: false,
530
+ appliedKeys: new Set<string>(),
531
+ };
532
+ const previousActive = pageState.active;
533
+ if (definition.scopeOnly) {
534
+ if (
535
+ !pageState.scopeOnly &&
536
+ (pageState.active || pageState.appliedKeys.size > 0)
537
+ ) {
538
+ transitions.push({
539
+ pageId,
540
+ mode:
541
+ pageState.configuredMode ??
542
+ pageState.lastTransitionMode ??
543
+ "reset",
544
+ previousActive,
545
+ active: false,
546
+ teardown: true,
547
+ reapply: false,
548
+ });
549
+ }
550
+ pageState.active = definition.matched;
551
+ pageState.scopeOnly = true;
552
+ pageState.appliedKeys.clear();
553
+ pageState.configuredMode = undefined;
554
+ pageState.lastTransitionMode = undefined;
555
+ pageState.isolationFallback = undefined;
556
+ pageState.isolationFallbackExperimentId = undefined;
557
+ next.set(pageId, pageState);
558
+ continue;
559
+ }
560
+ pageState.scopeOnly = false;
561
+ let active = definition.matched;
562
+ let teardown = false;
563
+ let reapply = !previousActive && active;
564
+
565
+ if (definition.mode === "persistent") {
566
+ active = previousActive || definition.matched;
567
+ reapply = !previousActive && active;
568
+ } else if (definition.mode === "force_reset" && definition.evaluate) {
569
+ teardown = previousActive || pageState.appliedKeys.size > 0;
570
+ active = definition.matched;
571
+ reapply = active;
572
+ } else if (previousActive && !definition.matched) {
573
+ teardown = true;
574
+ }
575
+
576
+ pageState.active = active;
577
+ pageState.configuredMode = definition.mode;
578
+ if (
579
+ active !== previousActive ||
580
+ (definition.mode === "force_reset" && definition.evaluate)
581
+ ) {
582
+ pageState.lastTransitionMode = definition.mode;
583
+ pageState.isolationFallback = undefined;
584
+ pageState.isolationFallbackExperimentId = undefined;
585
+ }
586
+ next.set(pageId, pageState);
587
+ transitions.push({
588
+ pageId,
589
+ mode: definition.mode,
590
+ previousActive,
591
+ active,
592
+ teardown,
593
+ reapply,
594
+ });
595
+ }
596
+ return { transitions, next, pollRules: Array.from(pollRules.values()) };
597
+ }
598
+
599
+ export function cancelPageRulePoll(pageId: string): void {
600
+ const controller = runtimeSharedState.pageRulePollControllers.get(pageId);
601
+ if (!controller) return;
602
+ window.clearInterval(controller.intervalId);
603
+ runtimeSharedState.pageRulePollControllers.delete(pageId);
604
+ }
605
+
606
+ export function cancelAllPageRulePolls(): void {
607
+ for (const pageId of Array.from(runtimeSharedState.pageRulePollControllers.keys())) {
608
+ cancelPageRulePoll(pageId);
609
+ }
610
+ }
611
+
612
+ export function pageRulePollSettings(): {
613
+ intervalMs: number;
614
+ windowMs: number;
615
+ } {
616
+ return {
617
+ intervalMs: normalizeNumber(
618
+ state.config?.pageTrigger?.rulePollIntervalMs,
619
+ PAGE_RULE_POLL_INTERVAL_MS,
620
+ ),
621
+ windowMs: normalizeNumber(
622
+ state.config?.pageTrigger?.rulePollWindowMs,
623
+ PAGE_RULE_POLL_WINDOW_MS,
624
+ ),
625
+ };
626
+ }
627
+
628
+ export function deactivationModeForTriggeredRule(
629
+ rule: PageTargetingRule,
630
+ ): PageDeactivationMode {
631
+ for (const experiment of state.config?.experiments ?? []) {
632
+ const configured = includedPageRules(experiment).find(
633
+ (candidate) => candidate.id === rule.id,
634
+ );
635
+ if (configured) return effectiveDeactivationMode(experiment, configured);
636
+ }
637
+ return normalizePageDeactivationMode(rule);
638
+ }
639
+
640
+ export function reconcileTriggeredPageMatch(
641
+ rule: PageTargetingRule,
642
+ matched: boolean,
643
+ detector?: PageDetectorMode,
644
+ options: {
645
+ elapsedMs?: number;
646
+ fromPoll?: boolean;
647
+ pollAttempts?: number;
648
+ } = {},
649
+ ): boolean {
650
+ const pageState = state.pageActivation.get(rule.id) ?? {
651
+ active: false,
652
+ appliedKeys: new Set<string>(),
653
+ };
654
+ const previousActive = pageState.active;
655
+ const mode = deactivationModeForTriggeredRule(rule);
656
+ let active = matched;
657
+ let teardown = false;
658
+
659
+ if (mode === "persistent") {
660
+ active = previousActive || matched;
661
+ } else if (mode === "force_reset") {
662
+ teardown = previousActive || pageState.appliedKeys.size > 0;
663
+ } else if (previousActive && !matched) {
664
+ teardown = true;
665
+ }
666
+
667
+ if (teardown) {
668
+ cancelPageRulePoll(rule.id);
669
+ teardownTransaction([{ pageId: rule.id, mode }], {
670
+ goalUrl: typeof location !== "undefined" ? location.href : undefined,
671
+ });
672
+ }
673
+
674
+ pageState.active = active;
675
+ pageState.configuredMode = mode;
676
+ if (detector) pageState.triggerDetector = detector;
677
+ pageState.triggerPollAttempts = options.pollAttempts ?? 0;
678
+ pageState.triggerElapsedMs = Math.max(0, Math.round(options.elapsedMs ?? 0));
679
+ pageState.triggerOutcome = matched ? "activated" : "condition_failed";
680
+ if (active !== previousActive || mode === "force_reset") {
681
+ pageState.lastTransitionMode = mode;
682
+ pageState.isolationFallback = undefined;
683
+ pageState.isolationFallbackExperimentId = undefined;
684
+ }
685
+ state.pageActivation.set(rule.id, pageState);
686
+
687
+ const experiments = state.config?.experiments ?? [];
688
+ syncAssignmentPageActive(state.assignments, experiments);
689
+ state.lastAssignmentSignature = assignmentSignature(state.assignments);
690
+ reconcileArtifacts(
691
+ desiredArtifactSet(state.assignments),
692
+ typeof location !== "undefined" ? location.href : undefined,
693
+ );
694
+ if (matched && state.analyticsStarted && state.config) {
695
+ trackViewedAssignments(state.config);
696
+ }
697
+ notifyQaAssistantRuntimeListeners();
698
+
699
+ if (!matched && !options.fromPoll && rule.trigger?.pollOnRules === true) {
700
+ startPageRulePoll(rule, detector);
701
+ }
702
+
703
+ return active !== previousActive;
704
+ }
705
+
706
+ export function startPageRulePoll(
707
+ rule: PageTargetingRule,
708
+ detector?: PageDetectorMode,
709
+ ): void {
710
+ if (
711
+ typeof window === "undefined" ||
712
+ runtimeSharedState.pageRulePollControllers.has(rule.id)
713
+ ) return;
714
+ const { intervalMs, windowMs } = pageRulePollSettings();
715
+ const maxAttempts = Math.max(1, Math.ceil(windowMs / intervalMs));
716
+ const startedAt = Date.now();
717
+ let attempts = 0;
718
+ const intervalId = window.setInterval(() => {
719
+ attempts += 1;
720
+ const elapsedMs = Date.now() - startedAt;
721
+ const pageState = state.pageActivation.get(rule.id);
722
+ if (elapsedMs > windowMs) {
723
+ cancelPageRulePoll(rule.id);
724
+ if (pageState) {
725
+ pageState.triggerPollAttempts = attempts;
726
+ pageState.triggerElapsedMs = elapsedMs;
727
+ pageState.triggerOutcome = "window_expired";
728
+ }
729
+ notifyQaAssistantRuntimeListeners();
730
+ return;
731
+ }
732
+ const context = currentPageMatchContext(location.href);
733
+ const matched = matchPageTargetingRule(rule, context);
734
+ if (matched) {
735
+ cancelPageRulePoll(rule.id);
736
+ reconcileTriggeredPageMatch(rule, true, detector, {
737
+ elapsedMs,
738
+ fromPoll: true,
739
+ pollAttempts: attempts,
740
+ });
741
+ reevaluateAssignments("dom", false, false, {
742
+ triggeredPageIds: new Set([rule.id]),
743
+ includeImmediate: false,
744
+ });
745
+ return;
746
+ }
747
+ if (pageState) {
748
+ pageState.triggerPollAttempts = attempts;
749
+ pageState.triggerElapsedMs = elapsedMs;
750
+ }
751
+ if (attempts >= maxAttempts) {
752
+ cancelPageRulePoll(rule.id);
753
+ if (pageState) pageState.triggerOutcome = "window_expired";
754
+ notifyQaAssistantRuntimeListeners();
755
+ }
756
+ }, intervalMs);
757
+ runtimeSharedState.pageRulePollControllers.set(rule.id, { intervalId, startedAt });
758
+ }
759
+
760
+ export function firePageDetector(
761
+ detector: PageDetectorMode,
762
+ resolvedUrl: string,
763
+ ): Set<string> {
764
+ const rules = runtimeSharedState.pageDetectorRules.get(detector) ?? [];
765
+ const triggeredPageIds = new Set<string>();
766
+ for (const rule of rules) {
767
+ if (detector !== "dom" && runtimeSharedState.pageDetectorLastUrls.get(rule.id) === resolvedUrl) {
768
+ continue;
769
+ }
770
+ if (detector !== "dom") runtimeSharedState.pageDetectorLastUrls.set(rule.id, resolvedUrl);
771
+ triggeredPageIds.add(rule.id);
772
+ cancelPageRulePoll(rule.id);
773
+ const matched = matchPageTargetingRule(
774
+ rule,
775
+ currentPageMatchContext(resolvedUrl),
776
+ );
777
+ reconcileTriggeredPageMatch(rule, matched, detector);
778
+ }
779
+ return triggeredPageIds;
780
+ }
781
+
782
+ export function activePageIds(): Set<string> {
783
+ return new Set(
784
+ Array.from(state.pageActivation.entries())
785
+ .filter(
786
+ ([pageId, pageState]) =>
787
+ !pageId.startsWith(GLOBAL_PAGE_PREFIX) && pageState.active,
788
+ )
789
+ .map(([pageId]) => pageId),
790
+ );
791
+ }
792
+
793
+ export function experimentHasActivePage(experiment: Experiment | undefined): boolean {
794
+ if (!experiment) return false;
795
+ const rules = includedPageRules(experiment);
796
+ if (!rules.length) {
797
+ return state.pageActivation.get(GLOBAL_PAGE_PREFIX + experiment.id)?.active === true;
798
+ }
799
+ return rules.some(
800
+ (rule) =>
801
+ !rule.disabled &&
802
+ !rule.trigger?.disabled &&
803
+ state.pageActivation.get(rule.id)?.active === true,
804
+ );
805
+ }
806
+
807
+ export function syncAssignmentPageActive(
808
+ assignments: Assignment[],
809
+ experiments: Experiment[],
810
+ ): Assignment[] {
811
+ const byId = experimentById(experiments);
812
+ for (const assignment of assignments) {
813
+ assignment.pageActive = experimentHasActivePage(
814
+ byId.get(assignment.experimentId),
815
+ );
816
+ }
817
+ return assignments;
818
+ }
819
+
820
+ export function assignmentPageIds(assignment: Assignment): string[] {
821
+ const experiment = state.config?.experiments?.find(
822
+ (candidate) => candidate.id === assignment.experimentId,
823
+ );
824
+ const rules = includedPageRules(experiment);
825
+ if (!rules.length) return [GLOBAL_PAGE_PREFIX + assignment.experimentId];
826
+ return Array.from(
827
+ new Set(
828
+ rules
829
+ .filter(
830
+ (rule) =>
831
+ !rule.disabled &&
832
+ !rule.trigger?.disabled &&
833
+ state.pageActivation.get(rule.id)?.active === true,
834
+ )
835
+ .map((rule) => rule.id),
836
+ ),
837
+ );
838
+ }
839
+
840
+ export function stableArtifactJson(value: unknown): string {
841
+ if (value === null || typeof value !== "object") {
842
+ return JSON.stringify(value) ?? "null";
843
+ }
844
+ if (Array.isArray(value)) {
845
+ return `[${value.map(stableArtifactJson).join(",")}]`;
846
+ }
847
+ const record = value as Record<string, unknown>;
848
+ return `{${Object.keys(record)
849
+ .filter((key) => record[key] !== undefined)
850
+ .sort()
851
+ .map((key) => `${JSON.stringify(key)}:${stableArtifactJson(record[key])}`)
852
+ .join(",")}}`;
853
+ }
854
+
855
+ export function artifactContentHash(assignment: Assignment): string {
856
+ const content = stableArtifactJson({
857
+ mutations: assignment.mutations,
858
+ css: assignment.css ?? null,
859
+ experimentCss: assignment.experimentCss ?? null,
860
+ js: assignment.js ?? null,
861
+ experimentJs: assignment.experimentJs ?? null,
862
+ externalCssUrl: assignment.externalCssUrl ?? null,
863
+ externalJsUrl: assignment.externalJsUrl ?? null,
864
+ });
865
+ let first = 0x811c9dc5;
866
+ let second = 0x9e3779b9;
867
+ for (let index = 0; index < content.length; index += 1) {
868
+ const code = content.charCodeAt(index);
869
+ first = Math.imul(first ^ code, 0x01000193);
870
+ second = Math.imul(second ^ code, 0x85ebca6b);
871
+ }
872
+ return (first >>> 0).toString(16).padStart(8, "0") +
873
+ (second >>> 0).toString(16).padStart(8, "0");
874
+ }
875
+
876
+ export function baseArtifactApplicationKey(assignment: Assignment): ArtifactId {
877
+ return `${assignment.experimentId}\u0000${assignment.variationId}\u0000${assignment.activeUrlBlockId ?? "flat"}\u0000${artifactContentHash(assignment)}` as ArtifactId;
878
+ }
879
+
880
+ export function artifactApplicationKey(
881
+ assignment: Assignment,
882
+ pageId: string,
883
+ ): string {
884
+ return `${pageId}\u0000${baseArtifactApplicationKey(assignment)}`;
885
+ }
886
+
887
+ export function artifactIdFromApplicationKey(applicationKey: string): ArtifactId {
888
+ const separator = applicationKey.indexOf("\u0000");
889
+ return (separator < 0 ? applicationKey : applicationKey.slice(separator + 1)) as ArtifactId;
890
+ }
891
+
892
+ export function assignmentRuntimeKeys(assignments: Assignment[]): Set<string> {
893
+ return new Set(assignments.map((assignment) => [
894
+ assignment.experimentId,
895
+ assignment.variationId,
896
+ assignment.activeUrlBlockId ?? "flat",
897
+ assignment.pageActive ? "active" : "inactive",
898
+ ].join("\u0000")));
899
+ }
900
+
901
+ export function artifactOwnerId(applicationKey: string): string {
902
+ const separator = applicationKey.indexOf("\u0000");
903
+ return separator < 0 ? applicationKey : applicationKey.slice(0, separator);
904
+ }
905
+
906
+ export function redirectCarryAssignmentKey(
907
+ experimentId: string,
908
+ variationId: string,
909
+ epoch: number,
910
+ ): string {
911
+ return `${experimentId}:${variationId}:${epoch}`;
912
+ }
913
+
914
+ export function shouldPreserveOffTargetRedirectAssignment(
915
+ assignment: Assignment,
916
+ ): boolean {
917
+ return isAssignmentArtifactInactive(assignment) ||
918
+ state.carryImportedAssignmentKeys.has(
919
+ redirectCarryAssignmentKey(
920
+ assignment.experimentId,
921
+ assignment.variationId,
922
+ Math.max(
923
+ 1,
924
+ Number.isSafeInteger(assignment.assignmentEpoch)
925
+ ? assignment.assignmentEpoch!
926
+ : 1,
927
+ ),
928
+ ),
929
+ );
930
+ }
931
+
932
+ export function isAssignmentArtifactInactive(
933
+ assignment: Assignment,
934
+ ): boolean {
935
+ if (!renderable(assignment)) return true;
936
+ if (!assignment.redirectUrl) return false;
937
+ if (typeof location === "undefined") return false;
938
+ const destination = buildRedirectDestination(
939
+ location.href,
940
+ assignment.redirectUrl,
941
+ assignment,
942
+ "",
943
+ undefined,
944
+ false,
945
+ );
946
+ if (destination && isRedirectLoop(location.href, destination)) return true;
947
+ return false;
948
+ }
949
+
950
+ export /** Page plane: this assignment may drive DOM, CSS, JS, and commerce display. */
951
+ function renderable(assignment: Assignment): boolean {
952
+ return assignment.pageActive &&
953
+ !state.contaminatedAssignmentKeys.has(exposureAssignmentKey(assignment));
954
+ }
955
+
956
+ export /**
957
+ * Exposure plane: rendered assignments become analytics-visible only after
958
+ * mutation delivery resolves. This predicate is also the shared identity
959
+ * gate for pageviews, page exits, and generic analytics events.
960
+ */
961
+ function exposable(assignment: Assignment): boolean {
962
+ return renderable(assignment) &&
963
+ !state.mutationPendingAssignmentKeys.has(exposureAssignmentKey(assignment));
964
+ }
965
+
966
+ export /**
967
+ * Goal/charge plane: identity survives page deactivation, but never precedes
968
+ * mutation delivery. A mutation-bearing treatment becomes attributable only
969
+ * after its delivery tracker resolves cleanly.
970
+ */
971
+ function attributable(assignment: Assignment): boolean {
972
+ const key = exposureAssignmentKey(assignment);
973
+ return assignment.experimentId.length > 0 && assignment.variationId.length > 0 &&
974
+ !state.mutationPendingAssignmentKeys.has(key) &&
975
+ !state.contaminatedAssignmentKeys.has(key);
976
+ }
977
+
978
+ export function recordAttributableAssignments(assignments: Assignment[]): void {
979
+ for (const assignment of assignments) {
980
+ if (attributable(assignment)) {
981
+ // Copy: page recompute mutates live Assignment objects (pageActive) and
982
+ // the ledger must keep the snapshot that earned attribution.
983
+ runtimeSharedState.attributionLedger.set(assignment.experimentId, { ...assignment });
984
+ }
985
+ }
986
+ }
987
+
988
+ export function attributionLedgerAssignments(): Assignment[] {
989
+ const currentExperimentIds = new Set(
990
+ state.assignments.map((assignment) => assignment.experimentId),
991
+ );
992
+ const assignments: Assignment[] = [];
993
+ for (const assignment of runtimeSharedState.attributionLedger.values()) {
994
+ const key = exposureAssignmentKey(assignment);
995
+ if (state.contaminatedAssignmentKeys.has(key)) {
996
+ runtimeSharedState.attributionLedger.delete(assignment.experimentId);
997
+ continue;
998
+ }
999
+ if (
1000
+ state.mutationPendingAssignmentKeys.has(key) ||
1001
+ currentExperimentIds.has(assignment.experimentId)
1002
+ ) continue;
1003
+ assignments.push(assignment);
1004
+ }
1005
+ return assignments;
1006
+ }
1007
+
1008
+ export function artifactRenderableAssignments(
1009
+ assignments: Assignment[],
1010
+ ): Assignment[] {
1011
+ return assignments
1012
+ .filter(renderable)
1013
+ .filter((assignment) => !isAssignmentArtifactInactive(assignment));
1014
+ }
1015
+
1016
+ export /**
1017
+ * Render plane: `state.assignments` intersected with `state.pageActivation`.
1018
+ * Single source for every global runtime that renders, observes, or wires a
1019
+ * treatment, so no caller can hand a subsystem a hand-picked subset.
1020
+ */
1021
+ function activeRenderableAssignments(): Assignment[] {
1022
+ return artifactRenderableAssignments(state.assignments);
1023
+ }
1024
+
1025
+ export /**
1026
+ * Charge plane: deliberately page-independent. Deactivating a page stops
1027
+ * rendering, but Shopify still charges this cart's pinned commerce arms, so
1028
+ * the cohort stamp and its cart listener must outlive a display teardown.
1029
+ */
1030
+ function chargeAffectingAssignments(): Assignment[] {
1031
+ return withCommercePriceAssignments(state.assignments).filter(attributable);
1032
+ }
1033
+
1034
+ export function loadPersistedAssignments(
1035
+ experiments: Experiment[],
1036
+ ): PersistedAssignmentMap | undefined {
1037
+ if (!persistentStorageAllowed() || state.qaMode || state.screenshotMode) {
1038
+ return undefined;
1039
+ }
1040
+ return prunePersistedAssignments(experiments);
1041
+ }
1042
+
1043
+ export function buildPersistedAssignmentResult(
1044
+ experiment: Experiment,
1045
+ persisted: NonNullable<ReturnType<typeof findPersistedVariation>>,
1046
+ hashId: string,
1047
+ gates: ActivationGateDiagnostic[],
1048
+ targetingConditions: ConditionDiagnostic[],
1049
+ url: string,
1050
+ reason: "persisted_assignment" | "persisted_assignment_off_target",
1051
+ ): { assignment: Assignment; diagnostic: ExperimentActivationDiagnostic } {
1052
+ const assignment = buildAssignment(
1053
+ experiment,
1054
+ persisted.variation,
1055
+ persisted.index,
1056
+ hashId,
1057
+ url,
1058
+ );
1059
+ if (reason === "persisted_assignment_off_target") {
1060
+ assignment.pageActive = experimentHasActivePage(experiment);
1061
+ }
1062
+ gates.push(
1063
+ {
1064
+ id: "traffic",
1065
+ label: "Traffic allocation",
1066
+ status: "skipped",
1067
+ reason: "persisted_assignment",
1068
+ },
1069
+ {
1070
+ id: "assignment",
1071
+ label: "Variation assignment",
1072
+ status: "passed",
1073
+ reason,
1074
+ detail: {
1075
+ variationId: persisted.variation.id,
1076
+ index: persisted.index,
1077
+ epoch: persisted.record.epoch,
1078
+ },
1079
+ },
1080
+ );
1081
+ for (const gate of gates.slice(-2)) __DRIP_FEATURE_DEVTOOLS__ && traceGate(experiment, gate);
1082
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`Reused persisted variation ${persisted.variation.id}`, {
1083
+ experimentId: experiment.id,
1084
+ index: persisted.index,
1085
+ epoch: persisted.record.epoch,
1086
+ offTarget: reason === "persisted_assignment_off_target",
1087
+ });
1088
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("eval", `${experiment.id}: persisted → variation ${persisted.index}`, {
1089
+ variationId: persisted.variation.id,
1090
+ epoch: persisted.record.epoch,
1091
+ offTarget: reason === "persisted_assignment_off_target",
1092
+ });
1093
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("eval", `${experiment.id}: persisted → var ${persisted.index}`, {
1094
+ variationId: persisted.variation.id,
1095
+ epoch: persisted.record.epoch,
1096
+ offTarget: reason === "persisted_assignment_off_target",
1097
+ });
1098
+ return {
1099
+ assignment,
1100
+ diagnostic: experimentDiagnostic(
1101
+ experiment,
1102
+ gates,
1103
+ assignment,
1104
+ reason,
1105
+ false,
1106
+ targetingConditions,
1107
+ ),
1108
+ };
1109
+ }
1110
+
1111
+ export function withAssignmentEpochData(
1112
+ data: Record<string, unknown> | undefined,
1113
+ experimentId?: string,
1114
+ variationId?: string,
1115
+ ): Record<string, unknown> | undefined {
1116
+ if (!experimentId || !variationId) return data;
1117
+ const assignment = state.assignments.find(
1118
+ (item) =>
1119
+ item.experimentId === experimentId && item.variationId === variationId,
1120
+ );
1121
+ if (!assignment?.assignmentEpoch) return data;
1122
+ if (assignment.kind && assignment.kind !== "experiment") return data;
1123
+ return { ...(data ?? {}), assignment_epoch: assignment.assignmentEpoch };
1124
+ }
1125
+
1126
+ export function usesStickyAssignmentPersistence(experiment: Experiment): boolean {
1127
+ return !experiment.kind || experiment.kind === "experiment";
1128
+ }
1129
+
1130
+ export function persistActiveExperimentAssignments(experiments: Experiment[]): void {
1131
+ if (!persistentStorageAllowed()) return;
1132
+ // QA traffic must never write to or read from a real visitor's sticky
1133
+ // bucket; screenshot mode is short-circuited earlier.
1134
+ if (state.qaMode) return;
1135
+ const byId = experimentById(experiments);
1136
+ for (const assignment of state.assignments) {
1137
+ // Off-target restores already came from this exact validated store record.
1138
+ // Keep their original assignedAt while on-target revisits refresh it.
1139
+ if (isOffTargetPersistedAssignment(assignment)) continue;
1140
+ if (assignment.kind && assignment.kind !== "experiment") continue;
1141
+ if (state.forceMap?.[assignment.experimentId]) continue;
1142
+ const experiment = byId.get(assignment.experimentId);
1143
+ if (!experiment || !usesStickyAssignmentPersistence(experiment)) continue;
1144
+ const variationExists = experiment.variations.some(
1145
+ (variation) => variation.id === assignment.variationId,
1146
+ );
1147
+ if (!variationExists) continue;
1148
+ persistAssignmentForExperiment(experiment, assignment.variationId);
1149
+ }
1150
+ }
1151
+
1152
+ export function experimentDiagnostic(
1153
+ experiment: Experiment,
1154
+ gates: ActivationGateDiagnostic[],
1155
+ assignment: Assignment | null,
1156
+ reason?: string,
1157
+ forced = false,
1158
+ targetingConditions?: ConditionDiagnostic[],
1159
+ ): ExperimentActivationDiagnostic {
1160
+ const failed = gates.find((gate) => gate.status === "failed");
1161
+ return {
1162
+ experimentId: experiment.id,
1163
+ experimentName: experiment.name,
1164
+ status: assignment ? "passed" : "failed",
1165
+ reason: reason ?? failed?.reason,
1166
+ assignedVariationId: assignment?.variationId,
1167
+ assignedVariationIndex: assignment?.index,
1168
+ forced,
1169
+ gates,
1170
+ ...(targetingConditions ? { targetingConditions } : {}),
1171
+ };
1172
+ }
1173
+
1174
+
1175
+ export function evaluateExperimentDetailed(
1176
+ experiment: Experiment,
1177
+ ctx: {
1178
+ url: string;
1179
+ attributes: Record<string, unknown>;
1180
+ userId: string;
1181
+ storeOrigin?: string | string[];
1182
+ persistedAssignments?: PersistedAssignmentMap;
1183
+ holdout?: { enabled?: boolean; percentage?: number };
1184
+ },
1185
+ ): { assignment: Assignment | null; diagnostic: ExperimentActivationDiagnostic } {
1186
+ const gates: ActivationGateDiagnostic[] = [];
1187
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`Initiated experiment ${traceName(experiment.id, experiment.name)}`, {
1188
+ kind: experiment.kind ?? "experiment",
1189
+ status: experiment.status ?? "running",
1190
+ variations: experiment.variations.length,
1191
+ });
1192
+
1193
+ if (!experiment.variations.length) {
1194
+ gates.push({ id: "assignment", label: "Variation assignment", status: "failed", reason: "no_variations" });
1195
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("eval", `${experiment.id}: skip (no variations)`);
1196
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("eval", `${experiment.id}: skip (no variations)`);
1197
+ return { assignment: null, diagnostic: experimentDiagnostic(experiment, gates, null, "no_variations") };
1198
+ }
1199
+
1200
+ const forcedVarId = state.forceMap?.[experiment.id];
1201
+ const forcedIdx = forcedVarId
1202
+ ? experiment.variations.findIndex((v) => v.id === forcedVarId)
1203
+ : -1;
1204
+ const forcedVariation = forcedIdx >= 0 ? experiment.variations[forcedIdx] : null;
1205
+
1206
+ const statusOk = isExperimentServable(experiment, { qaMode: state.qaMode || state.screenshotMode, environment: state.config?.environments?.activeKey || "production" });
1207
+ gates.push({
1208
+ id: "environment",
1209
+ label: "Experiment status",
1210
+ status: forcedVariation ? "skipped" : statusOk ? "passed" : "failed",
1211
+ reason: forcedVariation ? "forced_variation" : statusOk ? "running" : `status_${experiment.status}`,
1212
+ });
1213
+ __DRIP_FEATURE_DEVTOOLS__ && traceGate(experiment, gates[gates.length - 1]);
1214
+ if ((!forcedVariation || experiment.runtime_disabled === true) && !statusOk) {
1215
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("eval", `${experiment.id}: skip (status=${experiment.status})`);
1216
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("eval", `${experiment.id}: skip (status=${experiment.status})`);
1217
+ return { assignment: null, diagnostic: experimentDiagnostic(experiment, gates, null, `status_${experiment.status}`) };
1218
+ }
1219
+
1220
+ const weekdayAllowed = !weekdayScheduleBlocksDelivery(
1221
+ experiment.weekdaySchedule,
1222
+ new Date(),
1223
+ );
1224
+ gates.push({
1225
+ id: "weekday",
1226
+ label: "Weekday schedule",
1227
+ status: forcedVariation ? "skipped" : weekdayAllowed ? "passed" : "failed",
1228
+ reason: forcedVariation
1229
+ ? "forced_variation"
1230
+ : weekdayAllowed
1231
+ ? "active_weekday"
1232
+ : "excluded_weekday",
1233
+ });
1234
+ __DRIP_FEATURE_DEVTOOLS__ && traceGate(experiment, gates[gates.length - 1]);
1235
+ if (!forcedVariation && !weekdayAllowed) {
1236
+ return {
1237
+ assignment: null,
1238
+ diagnostic: experimentDiagnostic(
1239
+ experiment,
1240
+ gates,
1241
+ null,
1242
+ "excluded_weekday",
1243
+ ),
1244
+ };
1245
+ }
1246
+
1247
+ const hashId = getHashId(experiment, ctx.userId, ctx.attributes);
1248
+ const persisted =
1249
+ ctx.persistedAssignments && usesStickyAssignmentPersistence(experiment)
1250
+ ? findPersistedVariation(experiment, ctx.persistedAssignments)
1251
+ : null;
1252
+ const carryImportedDestination =
1253
+ persisted &&
1254
+ experiment.experimentType === "redirect" &&
1255
+ state.carryImportedAssignmentKeys.has(
1256
+ redirectCarryAssignmentKey(
1257
+ experiment.id,
1258
+ persisted.variation.id,
1259
+ persisted.record.epoch,
1260
+ ),
1261
+ ) &&
1262
+ (() => {
1263
+ if (!persisted.variation.redirectUrl) return false;
1264
+ const destination = buildRedirectDestination(
1265
+ ctx.url,
1266
+ persisted.variation.redirectUrl,
1267
+ {
1268
+ experimentId: experiment.id,
1269
+ variationId: persisted.variation.id,
1270
+ assignmentEpoch: persisted.record.epoch,
1271
+ },
1272
+ "",
1273
+ undefined,
1274
+ false,
1275
+ );
1276
+ return Boolean(destination && isRedirectLoop(ctx.url, destination));
1277
+ })();
1278
+ if (carryImportedDestination) {
1279
+ // The signed carry proves that the visitor qualified on the source page.
1280
+ // Destination hosts can lack the source's segment, attribute, or DOM
1281
+ // signals, so rechecking any targeting gate here would discard a verified
1282
+ // cross-origin assignment. Ordinary same-origin sticky assignments do not
1283
+ // receive this bypass and remain subject to the audience-only recheck.
1284
+ return buildPersistedAssignmentResult(
1285
+ experiment,
1286
+ persisted,
1287
+ hashId,
1288
+ gates,
1289
+ [],
1290
+ ctx.url,
1291
+ "persisted_assignment_off_target",
1292
+ );
1293
+ }
1294
+
1295
+ const targeting = evaluateTargetingGatesDetailed(experiment.targeting, {
1296
+ url: ctx.url,
1297
+ attributes: ctx.attributes,
1298
+ hostAgnosticIncludeOrigin:
1299
+ experiment.experimentType === "redirect"
1300
+ ? ctx.storeOrigin
1301
+ : undefined,
1302
+ });
1303
+ gates.push(...targeting.gates);
1304
+ for (const gate of targeting.gates) __DRIP_FEATURE_DEVTOOLS__ && traceGate(experiment, gate);
1305
+ let forcedOffTarget = false;
1306
+ if (!targeting.matched) {
1307
+ const pageMismatch = targeting.gates.some(
1308
+ (gate) => gate.id === "page" && gate.status === "failed",
1309
+ );
1310
+ const previewForceOffTarget = Boolean(
1311
+ forcedVariation && (state.qaMode || state.screenshotMode) && pageMismatch,
1312
+ );
1313
+ const otherwiseEligible = (persisted || previewForceOffTarget) && pageMismatch
1314
+ ? evaluateTargetingGatesDetailed(
1315
+ {
1316
+ ...experiment.targeting,
1317
+ url: [],
1318
+ pageRules: [],
1319
+ // Legacy experiments carry URL rules in `rules`; blank them too so
1320
+ // this recheck is audience-only (see normalizeRuntimeUrlRules).
1321
+ rules: [],
1322
+ } as Targeting,
1323
+ {
1324
+ url: ctx.url,
1325
+ attributes: ctx.attributes,
1326
+ hostAgnosticIncludeOrigin:
1327
+ experiment.experimentType === "redirect"
1328
+ ? ctx.storeOrigin
1329
+ : undefined,
1330
+ },
1331
+ ).matched
1332
+ : false;
1333
+ if (previewForceOffTarget && otherwiseEligible) {
1334
+ forcedOffTarget = true;
1335
+ } else if (persisted && otherwiseEligible) {
1336
+ return buildPersistedAssignmentResult(
1337
+ experiment,
1338
+ persisted,
1339
+ hashId,
1340
+ gates,
1341
+ targeting.conditions,
1342
+ ctx.url,
1343
+ "persisted_assignment_off_target",
1344
+ );
1345
+ }
1346
+ if (!forcedOffTarget) {
1347
+ const reason = targeting.gates.find((gate) => gate.status === "failed")?.reason ?? "targeting_mismatch";
1348
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("eval", `${experiment.id}: skip (targeting mismatch)`);
1349
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("eval", `${experiment.id}: skip (targeting mismatch)`);
1350
+ return { assignment: null, diagnostic: experimentDiagnostic(experiment, gates, null, reason, false, targeting.conditions) };
1351
+ }
1352
+ }
1353
+
1354
+ if (forcedVariation) {
1355
+ const assignment = buildAssignment(experiment, forcedVariation, forcedIdx, ctx.userId, ctx.url);
1356
+ if (forcedOffTarget) assignment.pageActive = false;
1357
+ gates.push(
1358
+ { id: "traffic", label: "Traffic allocation", status: "skipped", reason: "forced_variation" },
1359
+ { id: "assignment", label: "Variation assignment", status: "passed", reason: "forced_variation" },
1360
+ );
1361
+ for (const gate of gates.slice(-2)) __DRIP_FEATURE_DEVTOOLS__ && traceGate(experiment, gate);
1362
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`Initiated variation ${forcedVarId}`, {
1363
+ experimentId: experiment.id,
1364
+ index: forcedIdx,
1365
+ forced: true,
1366
+ });
1367
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("eval", `${experiment.id}: FORCED → variation ${forcedIdx}`, { variationId: forcedVarId });
1368
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("eval", `${experiment.id}: FORCED → var ${forcedIdx}`, { variationId: forcedVarId });
1369
+ return { assignment, diagnostic: experimentDiagnostic(experiment, gates, assignment, "forced_variation", true, targeting.conditions) };
1370
+ }
1371
+ if (persisted) {
1372
+ return buildPersistedAssignmentResult(
1373
+ experiment,
1374
+ persisted,
1375
+ hashId,
1376
+ gates,
1377
+ targeting.conditions,
1378
+ ctx.url,
1379
+ "persisted_assignment",
1380
+ );
1381
+ }
1382
+
1383
+ const predecidedVariationId = runtimeSharedState.initialPredecidedByExperiment?.get(experiment.id);
1384
+ const predecidedIndex = predecidedVariationId
1385
+ ? experiment.variations.findIndex((variation) => variation.id === predecidedVariationId)
1386
+ : -1;
1387
+ if (predecidedIndex >= 0) {
1388
+ const assignment = buildAssignment(
1389
+ experiment,
1390
+ experiment.variations[predecidedIndex],
1391
+ predecidedIndex,
1392
+ hashId,
1393
+ ctx.url,
1394
+ );
1395
+ gates.push(
1396
+ { id: "traffic", label: "Traffic allocation", status: "skipped", reason: "persisted_assignment" },
1397
+ { id: "assignment", label: "Variation assignment", status: "passed", reason: "persisted_assignment", detail: { variationId: predecidedVariationId, index: predecidedIndex } },
1398
+ );
1399
+ return { assignment, diagnostic: experimentDiagnostic(experiment, gates, assignment, "persisted_assignment", false, targeting.conditions) };
1400
+ }
1401
+ const hashValue = hashToFloat(experiment.id, hashId);
1402
+ const inAllocation = inTrafficAllocation(hashValue, experiment.trafficAllocation ?? 1);
1403
+ gates.push({
1404
+ id: "traffic",
1405
+ label: "Traffic allocation",
1406
+ status: inAllocation ? "passed" : "failed",
1407
+ reason: inAllocation ? "included" : "outside_allocation",
1408
+ detail: { hashValue, allocation: experiment.trafficAllocation ?? 1 },
1409
+ });
1410
+ __DRIP_FEATURE_DEVTOOLS__ && traceGate(experiment, gates[gates.length - 1]);
1411
+ if (!inAllocation) {
1412
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("eval", `${experiment.id}: skip (outside traffic allocation)`, { hashValue, allocation: experiment.trafficAllocation });
1413
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("eval", `${experiment.id}: skip (traffic allocation)`, { hashValue, allocation: experiment.trafficAllocation });
1414
+ return { assignment: null, diagnostic: experimentDiagnostic(experiment, gates, null, "outside_allocation", false, targeting.conditions) };
1415
+ }
1416
+
1417
+ const assignment = decideAssignments({
1418
+ experiments: [experiment],
1419
+ holdout: ctx.holdout,
1420
+ visitorId: hashId,
1421
+ storedAssignments: {},
1422
+ now: Date.now(),
1423
+ url: ctx.url,
1424
+ })[0];
1425
+ if (!assignment) {
1426
+ gates.push({ id: "assignment", label: "Variation assignment", status: "failed", reason: "invalid_weighting" });
1427
+ return { assignment: null, diagnostic: experimentDiagnostic(experiment, gates, null, "invalid_weighting", false, targeting.conditions) };
1428
+ }
1429
+
1430
+ const variation = experiment.variations[assignment.index];
1431
+ if (persistentStorageAllowed() && !state.qaMode && usesStickyAssignmentPersistence(experiment)) {
1432
+ persistAssignmentForExperiment(experiment, variation.id);
1433
+ }
1434
+ gates.push({
1435
+ id: "assignment",
1436
+ label: "Variation assignment",
1437
+ status: "passed",
1438
+ reason: "assigned",
1439
+ detail: { variationId: variation.id, index: assignment.index },
1440
+ });
1441
+ __DRIP_FEATURE_DEVTOOLS__ && traceGate(experiment, gates[gates.length - 1]);
1442
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`Initiated variation ${variation.id}`, {
1443
+ experimentId: experiment.id,
1444
+ index: assignment.index,
1445
+ hashValue,
1446
+ });
1447
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("eval", `${experiment.id}: assigned → variation ${assignment.index}`, { variationId: variation.id, hashValue });
1448
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("eval", `${experiment.id}: → var ${assignment.index} (${variation.id})`, { variationId: variation.id, hashValue });
1449
+ return { assignment, diagnostic: experimentDiagnostic(experiment, gates, assignment, "assigned", false, targeting.conditions) };
1450
+ }
1451
+
1452
+ export function evaluateExperiment(
1453
+ experiment: Experiment,
1454
+ ctx: {
1455
+ url: string;
1456
+ attributes: Record<string, unknown>;
1457
+ userId: string;
1458
+ storeOrigin?: string | string[];
1459
+ persistedAssignments?: PersistedAssignmentMap;
1460
+ },
1461
+ ): Assignment | null {
1462
+ return evaluateExperimentDetailed(experiment, ctx).assignment;
1463
+ }
1464
+
1465
+ export function stoppedPersonalizationDiagnostic(
1466
+ diagnostic: ExperimentActivationDiagnostic,
1467
+ ): ExperimentActivationDiagnostic {
1468
+ const { assignedVariationId: _assignedVariationId, assignedVariationIndex: _assignedVariationIndex, ...rest } = diagnostic;
1469
+ return {
1470
+ ...rest,
1471
+ status: "failed",
1472
+ reason: "personalization_stopped_after_prior_match",
1473
+ gates: [
1474
+ ...diagnostic.gates,
1475
+ {
1476
+ id: "personalization_priority",
1477
+ label: "Personalization priority",
1478
+ status: "failed",
1479
+ reason: "stopped_after_prior_match",
1480
+ },
1481
+ ],
1482
+ };
1483
+ }
1484
+
1485
+ export function enforcePersonalizationStopAfterMatch(
1486
+ assignments: Assignment[],
1487
+ diagnostics?: ExperimentActivationDiagnostic[],
1488
+ ): { assignments: Assignment[]; diagnostics?: ExperimentActivationDiagnostic[] } {
1489
+ const stoppedKeys = new Set<string>();
1490
+ const suppressedExperimentIds = new Set<string>();
1491
+ const filtered: Assignment[] = [];
1492
+
1493
+ for (const assignment of assignments) {
1494
+ const key = assignment.personalization?.key;
1495
+ if (key && stoppedKeys.has(key)) {
1496
+ suppressedExperimentIds.add(assignment.experimentId);
1497
+ continue;
1498
+ }
1499
+ filtered.push(assignment);
1500
+ if (key && assignment.personalization?.stopAfterMatch !== false) {
1501
+ stoppedKeys.add(key);
1502
+ }
1503
+ }
1504
+
1505
+ if (!diagnostics || suppressedExperimentIds.size === 0) {
1506
+ return { assignments: filtered, diagnostics };
1507
+ }
1508
+
1509
+ return {
1510
+ assignments: filtered,
1511
+ diagnostics: diagnostics.map((diagnostic) =>
1512
+ suppressedExperimentIds.has(diagnostic.experimentId)
1513
+ ? stoppedPersonalizationDiagnostic(diagnostic)
1514
+ : diagnostic
1515
+ ),
1516
+ };
1517
+ }
1518
+
1519
+ export function excludedByGroupDiagnostic(
1520
+ diagnostic: ExperimentActivationDiagnostic,
1521
+ decision: ExclusionGroupDecision,
1522
+ ): ExperimentActivationDiagnostic {
1523
+ const { assignedVariationId: _assignedVariationId, assignedVariationIndex: _assignedVariationIndex, ...rest } = diagnostic;
1524
+ const reason = decision.sticky
1525
+ ? "exclusion_group_sticky_other"
1526
+ : "exclusion_group_overlap_lost";
1527
+ return {
1528
+ ...rest,
1529
+ status: "failed",
1530
+ reason,
1531
+ gates: [
1532
+ ...diagnostic.gates,
1533
+ {
1534
+ id: "exclusion_group",
1535
+ label: "Exclusion group",
1536
+ status: "failed",
1537
+ reason,
1538
+ detail: {
1539
+ groupId: decision.groupId,
1540
+ winnerExperimentId: decision.winnerExperimentId,
1541
+ },
1542
+ },
1543
+ ],
1544
+ };
1545
+ }
1546
+
1547
+ export function keptByGroupDiagnostic(
1548
+ diagnostic: ExperimentActivationDiagnostic,
1549
+ reason: "exclusion_group_no_overlap" | "exclusion_group_overlap_winner" | "exclusion_group_sticky_winner",
1550
+ detail: { groupId: string; conflictingExperimentIds: string[] },
1551
+ ): ExperimentActivationDiagnostic {
1552
+ return {
1553
+ ...diagnostic,
1554
+ gates: [
1555
+ ...diagnostic.gates,
1556
+ {
1557
+ id: "exclusion_group",
1558
+ label: "Exclusion group",
1559
+ status: "passed",
1560
+ reason,
1561
+ detail,
1562
+ },
1563
+ ],
1564
+ };
1565
+ }
1566
+
1567
+ export function applyExclusionAndPersonalization(
1568
+ assignments: Assignment[],
1569
+ context: {
1570
+ userId: string;
1571
+ exclusionGroups?: ExclusionGroupConfig[];
1572
+ storedGroupSelections?: CompactGroupSelections;
1573
+ },
1574
+ diagnostics?: ExperimentActivationDiagnostic[],
1575
+ ): {
1576
+ assignments: Assignment[];
1577
+ diagnostics?: ExperimentActivationDiagnostic[];
1578
+ decisions: ExclusionGroupDecision[];
1579
+ } {
1580
+ const bootstrapStore = context.storedGroupSelections
1581
+ ? {
1582
+ read: (groupId: string) => {
1583
+ const value = context.storedGroupSelections?.[groupId];
1584
+ return value ? { experimentId: value[0], epoch: value[1] } : null;
1585
+ },
1586
+ write: (groupId: string, selection: PersistedGroupSelection) =>
1587
+ browserGroupStickyStore.write(groupId, selection),
1588
+ }
1589
+ : undefined;
1590
+ const { assignments: afterGroups, decisions } = resolveExclusionGroups(
1591
+ assignments,
1592
+ context.exclusionGroups,
1593
+ {
1594
+ userId: context.userId,
1595
+ forcedExperimentIds: state.forceMap ? Object.keys(state.forceMap) : [],
1596
+ persist: persistentStorageAllowed() && !state.qaMode,
1597
+ store: bootstrapStore,
1598
+ },
1599
+ );
1600
+
1601
+ let nextDiagnostics = diagnostics;
1602
+ if (diagnostics && context.exclusionGroups?.length) {
1603
+ const suppressed = new Map<string, ExclusionGroupDecision>();
1604
+ const decisionsByWinner = new Map<string, ExclusionGroupDecision>();
1605
+ for (const decision of decisions) {
1606
+ if (decision.winnerExperimentId) {
1607
+ decisionsByWinner.set(decision.winnerExperimentId, decision);
1608
+ }
1609
+ for (const experimentId of decision.suppressedExperimentIds) {
1610
+ suppressed.set(experimentId, decision);
1611
+ }
1612
+ }
1613
+ const provisionalIds = new Set(assignments.map((assignment) => assignment.experimentId));
1614
+ const forcedIds = new Set(state.forceMap ? Object.keys(state.forceMap) : []);
1615
+ nextDiagnostics = diagnostics.map((diagnostic) => {
1616
+ const losingDecision = suppressed.get(diagnostic.experimentId);
1617
+ if (losingDecision) return excludedByGroupDiagnostic(diagnostic, losingDecision);
1618
+
1619
+ const winningDecision = decisionsByWinner.get(diagnostic.experimentId);
1620
+ if (winningDecision) {
1621
+ return keptByGroupDiagnostic(
1622
+ diagnostic,
1623
+ winningDecision.sticky ? "exclusion_group_sticky_winner" : "exclusion_group_overlap_winner",
1624
+ {
1625
+ groupId: winningDecision.groupId,
1626
+ conflictingExperimentIds: winningDecision.conflictingExperimentIds,
1627
+ },
1628
+ );
1629
+ }
1630
+
1631
+ const group = context.exclusionGroups?.find((candidate) =>
1632
+ candidate.members.some((member) => member.experimentId === diagnostic.experimentId)
1633
+ );
1634
+ if (!group || !provisionalIds.has(diagnostic.experimentId) || forcedIds.has(diagnostic.experimentId)) {
1635
+ return diagnostic;
1636
+ }
1637
+ const eligibleIds = group.members
1638
+ .map((member) => member.experimentId)
1639
+ .filter((experimentId) => provisionalIds.has(experimentId) && !forcedIds.has(experimentId));
1640
+ return eligibleIds.length === 1
1641
+ ? keptByGroupDiagnostic(diagnostic, "exclusion_group_no_overlap", {
1642
+ groupId: group.id,
1643
+ conflictingExperimentIds: eligibleIds,
1644
+ })
1645
+ : diagnostic;
1646
+ });
1647
+ }
1648
+
1649
+ const personalization = enforcePersonalizationStopAfterMatch(afterGroups, nextDiagnostics);
1650
+ return {
1651
+ assignments: personalization.assignments,
1652
+ diagnostics: personalization.diagnostics ?? nextDiagnostics,
1653
+ decisions,
1654
+ };
1655
+ }
1656
+
1657
+ export function evaluate(
1658
+ experiments: Experiment[],
1659
+ context?: {
1660
+ url?: string;
1661
+ attributes?: Record<string, unknown>;
1662
+ userId?: string;
1663
+ persistSignals?: boolean;
1664
+ storeOrigin?: string | string[];
1665
+ exclusionGroups?: ExclusionGroupConfig[];
1666
+ },
1667
+ ): Assignment[] {
1668
+ const url = context?.url ?? location.href;
1669
+ const attributes = buildTargetingAttributes(context?.attributes ?? {}, url, {
1670
+ persistSignals: context?.persistSignals,
1671
+ });
1672
+ const userId = getUserId(context?.userId);
1673
+ const assignments: Assignment[] = [];
1674
+ const persistedAssignments = loadPersistedAssignments(experiments);
1675
+ for (const experiment of experiments) {
1676
+ const assignment = evaluateExperiment(experiment, {
1677
+ url,
1678
+ attributes,
1679
+ userId,
1680
+ storeOrigin: context?.storeOrigin,
1681
+ persistedAssignments,
1682
+ });
1683
+ if (assignment) assignments.push(assignment);
1684
+ }
1685
+ return applyExclusionAndPersonalization(assignments, {
1686
+ userId,
1687
+ exclusionGroups: context?.exclusionGroups,
1688
+ }).assignments;
1689
+ }
1690
+
1691
+ export function evaluateWithDiagnostics(
1692
+ experiments: Experiment[],
1693
+ context?: {
1694
+ url?: string;
1695
+ attributes?: Record<string, unknown>;
1696
+ userId?: string;
1697
+ persistSignals?: boolean;
1698
+ storeOrigin?: string | string[];
1699
+ exclusionGroups?: ExclusionGroupConfig[];
1700
+ storedGroupSelections?: CompactGroupSelections;
1701
+ persistedAssignments?: PersistedAssignmentMap | null;
1702
+ holdout?: { enabled?: boolean; percentage?: number };
1703
+ targetingPass?: TargetingEvaluationPass;
1704
+ },
1705
+ ): { assignments: Assignment[]; diagnostics: ExperimentActivationDiagnostic[] } {
1706
+ const url = context?.url ?? location.href;
1707
+ const sourceAttributes = context?.attributes ?? EMPTY_TARGETING_ATTRIBUTES;
1708
+ const persistSignals = context?.persistSignals !== false;
1709
+ const attributes = targetingAttributesForPass(
1710
+ context?.targetingPass,
1711
+ sourceAttributes,
1712
+ url,
1713
+ persistSignals,
1714
+ );
1715
+ const userId = getUserId(context?.userId);
1716
+ const assignments: Assignment[] = [];
1717
+ const diagnostics: ExperimentActivationDiagnostic[] = [];
1718
+ const persistedAssignments = context && "persistedAssignments" in context
1719
+ ? context.persistedAssignments ?? undefined
1720
+ : loadPersistedAssignments(experiments);
1721
+ for (const experiment of experiments) {
1722
+ const result = evaluateExperimentDetailed(experiment, {
1723
+ url,
1724
+ attributes,
1725
+ userId,
1726
+ storeOrigin: context?.storeOrigin,
1727
+ persistedAssignments,
1728
+ holdout: context?.holdout,
1729
+ });
1730
+ diagnostics.push(result.diagnostic);
1731
+ if (result.assignment) assignments.push(result.assignment);
1732
+ }
1733
+ const resolved = applyExclusionAndPersonalization(
1734
+ assignments,
1735
+ { userId, exclusionGroups: context?.exclusionGroups, storedGroupSelections: context?.storedGroupSelections },
1736
+ diagnostics,
1737
+ );
1738
+ return {
1739
+ assignments: syncAssignmentPageActive(resolved.assignments, experiments),
1740
+ diagnostics: resolved.diagnostics ?? diagnostics,
1741
+ };
1742
+ }
1743
+
1744
+ export function evaluateEdgeRuntimeAssignments(
1745
+ experiments: Experiment[],
1746
+ context: {
1747
+ url: string;
1748
+ attributes: Record<string, unknown>;
1749
+ userId: string;
1750
+ },
1751
+ eligibleAssignments?: Assignment[],
1752
+ eligibleDiagnostics?: ExperimentActivationDiagnostic[],
1753
+ ) {
1754
+ const authoritative = readEdgeRuntimeAssignments();
1755
+ if (!authoritative) return null;
1756
+ const assignments: Assignment[] = [];
1757
+ const diagnostics: ExperimentActivationDiagnostic[] = [];
1758
+ const eligibleByExperiment = eligibleAssignments
1759
+ ? new Map(eligibleAssignments.map((assignment) => [assignment.experimentId, assignment]))
1760
+ : null;
1761
+ const eligibleDiagnosticsByExperiment = eligibleDiagnostics
1762
+ ? new Map(eligibleDiagnostics.map((diagnostic) => [diagnostic.experimentId, diagnostic]))
1763
+ : null;
1764
+ for (const experiment of experiments) {
1765
+ const edgeAssignment = authoritative.get(experiment.id);
1766
+ if (!edgeAssignment) {
1767
+ if (authoritative.size === 0) {
1768
+ diagnostics.push(experimentDiagnostic(
1769
+ experiment,
1770
+ [{ id: "assignment", label: "Variation assignment", status: "failed", reason: "edge_runtime_assignment_missing" }],
1771
+ null,
1772
+ "edge_runtime_assignment_missing",
1773
+ ));
1774
+ }
1775
+ continue;
1776
+ }
1777
+ const variationIndex = experiment.variations.findIndex(
1778
+ (variation) => variation.id === edgeAssignment.variationId,
1779
+ );
1780
+ if (variationIndex < 0 || variationIndex !== edgeAssignment.index) {
1781
+ diagnostics.push(experimentDiagnostic(
1782
+ experiment,
1783
+ [{ id: "assignment", label: "Variation assignment", status: "failed", reason: "edge_runtime_assignment_invalid" }],
1784
+ null,
1785
+ "edge_runtime_assignment_invalid",
1786
+ ));
1787
+ continue;
1788
+ }
1789
+ const eligibleAssignment = eligibleByExperiment?.get(experiment.id);
1790
+ if (eligibleByExperiment && !eligibleAssignment) {
1791
+ const diagnostic = eligibleDiagnosticsByExperiment?.get(experiment.id);
1792
+ if (diagnostic) diagnostics.push(diagnostic);
1793
+ continue;
1794
+ }
1795
+ const assignment = buildAssignment(
1796
+ experiment,
1797
+ experiment.variations[variationIndex],
1798
+ variationIndex,
1799
+ getHashId(experiment, context.userId, context.attributes),
1800
+ context.url,
1801
+ );
1802
+ if (eligibleAssignment) assignment.pageActive = eligibleAssignment.pageActive;
1803
+ assignments.push(assignment);
1804
+ diagnostics.push(experimentDiagnostic(
1805
+ experiment,
1806
+ [{
1807
+ id: "assignment",
1808
+ label: "Variation assignment",
1809
+ status: "passed",
1810
+ reason: "edge_runtime_assignment",
1811
+ detail: { variationId: assignment.variationId, index: assignment.index },
1812
+ }],
1813
+ assignment,
1814
+ "edge_runtime_assignment",
1815
+ ));
1816
+ }
1817
+ return { assignments, diagnostics, experimentIds: authoritative };
1818
+ }
1819
+
1820
+ export function resolveCombinedEdgeEvaluation(
1821
+ edgeAssignments: Assignment[],
1822
+ fallbackAssignments: Assignment[],
1823
+ diagnostics: ExperimentActivationDiagnostic[],
1824
+ context: {
1825
+ userId: string;
1826
+ exclusionGroups?: ExclusionGroupConfig[];
1827
+ },
1828
+ ): { assignments: Assignment[]; diagnostics: ExperimentActivationDiagnostic[] } {
1829
+ const preferredIds = new Set(edgeAssignments.map((assignment) => assignment.experimentId));
1830
+ const combined = [...edgeAssignments, ...fallbackAssignments];
1831
+ const assignedIds = new Set(combined.map((assignment) => assignment.experimentId));
1832
+ const suppressed = new Map<string, ExclusionGroupDecision>();
1833
+
1834
+ for (const group of context.exclusionGroups ?? []) {
1835
+ const winner = group.members.find(
1836
+ (member) => preferredIds.has(member.experimentId) && assignedIds.has(member.experimentId),
1837
+ );
1838
+ if (!winner) continue;
1839
+ const conflictingExperimentIds = group.members
1840
+ .map((member) => member.experimentId)
1841
+ .filter((experimentId) => assignedIds.has(experimentId));
1842
+ const suppressedExperimentIds = conflictingExperimentIds.filter(
1843
+ (experimentId) => experimentId !== winner.experimentId && !preferredIds.has(experimentId),
1844
+ );
1845
+ if (!suppressedExperimentIds.length) continue;
1846
+ const decision: ExclusionGroupDecision = {
1847
+ groupId: group.id,
1848
+ conflictingExperimentIds,
1849
+ winnerExperimentId: winner.experimentId,
1850
+ suppressedExperimentIds,
1851
+ sticky: false,
1852
+ };
1853
+ for (const experimentId of suppressedExperimentIds) {
1854
+ suppressed.set(experimentId, decision);
1855
+ }
1856
+ }
1857
+
1858
+ const preferred = suppressed.size
1859
+ ? combined.filter((assignment) => !suppressed.has(assignment.experimentId))
1860
+ : combined;
1861
+ const preferredDiagnostics = suppressed.size
1862
+ ? diagnostics.map((diagnostic) => {
1863
+ const decision = suppressed.get(diagnostic.experimentId);
1864
+ return decision ? excludedByGroupDiagnostic(diagnostic, decision) : diagnostic;
1865
+ })
1866
+ : diagnostics;
1867
+ const resolved = applyExclusionAndPersonalization(
1868
+ preferred,
1869
+ context,
1870
+ preferredDiagnostics,
1871
+ );
1872
+ return {
1873
+ assignments: resolved.assignments,
1874
+ diagnostics: resolved.diagnostics ?? preferredDiagnostics,
1875
+ };
1876
+ }
1877
+
1878
+ export function escapeDeliveryRegex(value: string): string {
1879
+ return value.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1880
+ }
1881
+
1882
+ export function matchSimpleDeliveryPattern(pattern: string, url: string): boolean {
1883
+ if (!pattern) return false;
1884
+ try {
1885
+ const current = new URL(url, location.href);
1886
+ const cleanPattern = pattern.includes("://")
1887
+ ? pattern
1888
+ : (pattern.startsWith("/") ? pattern : "/" + pattern);
1889
+ const re = new RegExp("^" + escapeDeliveryRegex(cleanPattern) + "$");
1890
+ return pattern.includes("://")
1891
+ ? re.test(current.href)
1892
+ : re.test(current.pathname + current.search) || re.test(current.pathname);
1893
+ } catch {
1894
+ return false;
1895
+ }
1896
+ }
1897
+
1898
+ export function matchRegexDeliveryPattern(pattern: string, url: string): boolean {
1899
+ try {
1900
+ const current = new URL(url, location.href);
1901
+ const re = new RegExp(pattern);
1902
+ return re.test(current.href) || re.test(current.pathname + current.search) || re.test(current.pathname);
1903
+ } catch {
1904
+ return false;
1905
+ }
1906
+ }
1907
+
1908
+ export function matchesDeliveryUrlRules(rules: SnippetDeliveryUrlRule[] | undefined, url: string): boolean {
1909
+ if (!rules?.length) return true;
1910
+ let hasInclude = false;
1911
+ let includeHit = false;
1912
+ for (const rule of rules) {
1913
+ if (!rule || typeof rule.pattern !== "string") continue;
1914
+ const matched = rule.type === "regex"
1915
+ ? matchRegexDeliveryPattern(rule.pattern, url)
1916
+ : matchSimpleDeliveryPattern(rule.pattern, url);
1917
+ if (rule.include === false && matched) return false;
1918
+ if (rule.include !== false) {
1919
+ hasInclude = true;
1920
+ if (matched) includeHit = true;
1921
+ }
1922
+ }
1923
+ return !hasInclude || includeHit;
1924
+ }
1925
+
1926
+ export function expandCriticalMutation(raw: SnippetCriticalMutation): Mutation | null {
1927
+ const [selector, action, value] = raw;
1928
+ if (typeof selector !== "string" || !selector || typeof action !== "string") return null;
1929
+ const base = { selector, action } as Mutation;
1930
+ switch (action) {
1931
+ case "text":
1932
+ case "setText":
1933
+ return typeof value === "string" ? { ...base, text: value } : null;
1934
+ case "html":
1935
+ case "insertBefore":
1936
+ case "insertAfter":
1937
+ return typeof value === "string" ? { ...base, html: value } : null;
1938
+ case "attribute":
1939
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1940
+ if ("name" in value) return { ...base, attribute: value as Mutation["attribute"] };
1941
+ return { ...base, attributes: value as Record<string, string | null> };
1942
+ case "style":
1943
+ case "setStyle":
1944
+ return value && typeof value === "object" && !Array.isArray(value)
1945
+ ? { ...base, styles: value as Record<string, string> }
1946
+ : null;
1947
+ case "remove":
1948
+ return base;
1949
+ default:
1950
+ return null;
1951
+ }
1952
+ }
1953
+
1954
+ export function buildCriticalPathAssignments(
1955
+ experiments: Experiment[],
1956
+ plan: SnippetCriticalMutationPlanItem[] | undefined,
1957
+ ctx: { url: string; attributes: Record<string, unknown>; userId: string },
1958
+ ): {
1959
+ assignments: Assignment[];
1960
+ plannedExperimentIds: Set<string>;
1961
+ skippedDiagnostics: ExperimentActivationDiagnostic[];
1962
+ } {
1963
+ const plannedExperimentIds = new Set<string>();
1964
+ const assignments: Assignment[] = [];
1965
+ const skippedDiagnostics: ExperimentActivationDiagnostic[] = [];
1966
+ if (!plan?.length || state.forceMap) {
1967
+ return { assignments, plannedExperimentIds, skippedDiagnostics };
1968
+ }
1969
+
1970
+ const experimentsById = new Map(experiments.map((experiment) => [experiment.id, experiment]));
1971
+ for (const item of plan) {
1972
+ if (!item?.e || !Array.isArray(item.v)) continue;
1973
+ const experiment = experimentsById.get(item.e);
1974
+ if (!experiment || !isExperimentServable(experiment, { qaMode: false, environment: state.config?.environments?.activeKey || "production" })) continue;
1975
+ plannedExperimentIds.add(experiment.id);
1976
+ if (
1977
+ !state.forceMap?.[experiment.id] &&
1978
+ weekdayScheduleBlocksDelivery(experiment.weekdaySchedule, new Date())
1979
+ ) {
1980
+ skippedDiagnostics.push(experimentDiagnostic(
1981
+ experiment,
1982
+ [{ id: "weekday", label: "Weekday schedule", status: "failed", reason: "excluded_weekday" }],
1983
+ null,
1984
+ "excluded_weekday",
1985
+ ));
1986
+ continue;
1987
+ }
1988
+ if (!matchesDeliveryUrlRules(item.r, ctx.url)) {
1989
+ skippedDiagnostics.push(experimentDiagnostic(
1990
+ experiment,
1991
+ [
1992
+ { id: "page", label: "Page check", status: "failed", reason: "url_mismatch" },
1993
+ // Critical-path plans only contain campaigns without non-URL
1994
+ // targeting, so the truthful audience result is "all visitors".
1995
+ { id: "audience", label: "Audience check", status: "skipped", reason: "no_audience_rules" },
1996
+ ],
1997
+ null,
1998
+ "url_mismatch",
1999
+ ));
2000
+ continue;
2001
+ }
2002
+
2003
+ const hashId = getHashId(experiment, ctx.userId, ctx.attributes);
2004
+ const hashValue = hashToFloat(experiment.id, hashId);
2005
+ if (!inTrafficAllocation(hashValue, experiment.trafficAllocation ?? 1)) {
2006
+ skippedDiagnostics.push(experimentDiagnostic(
2007
+ experiment,
2008
+ [
2009
+ { id: "page", label: "Page check", status: "passed", reason: "precompiled_delivery_match" },
2010
+ { id: "audience", label: "Audience check", status: "skipped", reason: "no_audience_rules" },
2011
+ {
2012
+ id: "traffic",
2013
+ label: "Traffic allocation",
2014
+ status: "failed",
2015
+ reason: "outside_allocation",
2016
+ detail: { hashValue, allocation: experiment.trafficAllocation ?? 1 },
2017
+ },
2018
+ ],
2019
+ null,
2020
+ "outside_allocation",
2021
+ ));
2022
+ continue;
2023
+ }
2024
+
2025
+ const allocation = experiment.trafficAllocation ?? 1;
2026
+ const variationHashValue =
2027
+ allocation >= 1 ? hashValue : variationHashToFloat(experiment.id, hashId);
2028
+ const planIndex = chooseVariation(variationHashValue, item.v.map((variation) => variation.w));
2029
+ const plannedVariation = planIndex >= 0 ? item.v[planIndex] : null;
2030
+ if (!plannedVariation) continue;
2031
+ const fullIndex = experiment.variations.findIndex((variation) => variation.id === plannedVariation.id);
2032
+ if (fullIndex < 0) continue;
2033
+ const fullVariation = experiment.variations[fullIndex];
2034
+ const mutations = plannedVariation.m
2035
+ .map(expandCriticalMutation)
2036
+ .filter((mutation): mutation is Mutation => Boolean(mutation));
2037
+
2038
+ assignments.push({
2039
+ experimentId: experiment.id,
2040
+ experimentName: experiment.name,
2041
+ variationId: fullVariation.id,
2042
+ pageActive: true,
2043
+ index: fullIndex,
2044
+ mutations,
2045
+ value: fullVariation.value,
2046
+ featureFlag: experiment.featureFlag,
2047
+ personalization: experiment.personalization,
2048
+ kind: experiment.kind ?? (experiment.personalization ? "personalization" : experiment.featureFlag ? "feature_flag" : "experiment"),
2049
+ experimentCss: experiment.css,
2050
+ experimentJs: experiment.js,
2051
+ experimentResetJs: experiment.resetJs,
2052
+ css: fullVariation.css,
2053
+ js: fullVariation.js,
2054
+ resetJs: fullVariation.resetJs,
2055
+ externalCssUrl: fullVariation.externalCssUrl,
2056
+ externalJsUrl: fullVariation.externalJsUrl,
2057
+ harnessSession: fullVariation.harnessSession,
2058
+ artifactSource: fullVariation.artifactSource,
2059
+ isControl: Boolean(fullVariation.isControl || plannedVariation.c),
2060
+ hashValue: hashId,
2061
+ });
2062
+ }
2063
+ return { assignments, plannedExperimentIds, skippedDiagnostics };
2064
+ }
2065
+
2066
+ export function runPostRevealReassert(): void {
2067
+ try {
2068
+ if (!state.config || !resolveDomPageTrigger(state.config, state.pageTriggerMode)) return;
2069
+ if (!state.assignments.length) return;
2070
+ reassertAssignmentMutations(state.assignments);
2071
+ } catch {}
2072
+ }
2073
+
2074
+ export function schedulePostRevealReassert(): void {
2075
+ try {
2076
+ const first = () => runPostRevealReassert();
2077
+ if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
2078
+ window.requestAnimationFrame(first);
2079
+ } else if (typeof window !== "undefined") {
2080
+ window.setTimeout(first, 0);
2081
+ } else {
2082
+ setTimeout(first, 0);
2083
+ }
2084
+
2085
+ const debounceMs = state.config?.spa?.debounceMs ?? 250;
2086
+ if (typeof window !== "undefined") {
2087
+ window.setTimeout(runPostRevealReassert, debounceMs);
2088
+ } else {
2089
+ setTimeout(runPostRevealReassert, debounceMs);
2090
+ }
2091
+ } catch {}
2092
+ }
2093
+
2094
+ export function revealExperimentContent(options: { postRevealReassert?: boolean } = {}): void {
2095
+ const revealAt = markDripPerf("reveal_at");
2096
+ markCwvReveal();
2097
+ updateLoadDiagnostics({ stage: "revealed", revealAt });
2098
+ revealAll();
2099
+ removeAntiFlicker();
2100
+ if (options.postRevealReassert !== false) {
2101
+ schedulePostRevealReassert();
2102
+ }
2103
+ }
2104
+
2105
+ export function applyAssignmentCode(
2106
+ assignment: Assignment,
2107
+ applyEpoch: number,
2108
+ appliedExperimentCode: Set<string>,
2109
+ ownerKeys: readonly string[],
2110
+ ownerIds: readonly string[],
2111
+ runCustomJs = true,
2112
+ onCustomJsRun?: () => void,
2113
+ ): void {
2114
+ const artifactId = baseArtifactApplicationKey(assignment);
2115
+ if (assignment.experimentCss && !appliedExperimentCode.has(assignment.experimentId)) {
2116
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`${assignment.experimentId}: Applying experiment CSS`, {
2117
+ bytes: assignment.experimentCss.length,
2118
+ });
2119
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `${assignment.experimentId}: injecting experiment CSS`);
2120
+ injectExperimentCss(
2121
+ assignment.experimentCss,
2122
+ assignment.experimentId,
2123
+ ownerIds,
2124
+ artifactId,
2125
+ applyEpoch,
2126
+ );
2127
+ }
2128
+ if (assignment.css) {
2129
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`${assignment.experimentId}: Applying CSS`, {
2130
+ variationId: assignment.variationId,
2131
+ bytes: assignment.css.length,
2132
+ });
2133
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `${assignment.experimentId}: injecting CSS`);
2134
+ injectCss(
2135
+ assignment.css,
2136
+ assignment.experimentId,
2137
+ assignment.variationId,
2138
+ ownerIds,
2139
+ artifactId,
2140
+ applyEpoch,
2141
+ );
2142
+ }
2143
+
2144
+ if (
2145
+ (!runCustomJs || !assignment.experimentJs) &&
2146
+ !assignment.externalCssUrl &&
2147
+ !assignment.externalJsUrl &&
2148
+ (!runCustomJs || !assignment.js)
2149
+ ) {
2150
+ appliedExperimentCode.add(assignment.experimentId);
2151
+ return;
2152
+ }
2153
+
2154
+ runWhenDomReady(() => {
2155
+ if (!artifactEpochCurrent(ownerKeys, applyEpoch)) return;
2156
+ const label = assignment.experimentId + "-" + assignment.variationId;
2157
+ if (
2158
+ runCustomJs &&
2159
+ assignment.experimentJs &&
2160
+ !appliedExperimentCode.has(assignment.experimentId)
2161
+ ) {
2162
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`${assignment.experimentId}: Applying experiment JS`, {
2163
+ bytes: assignment.experimentJs.length,
2164
+ });
2165
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `${assignment.experimentId}: executing experiment JS`);
2166
+ runMuted(() => execJs(assignment.experimentJs!, assignment.experimentId + "-experiment-js"));
2167
+ onCustomJsRun?.();
2168
+ }
2169
+ appliedExperimentCode.add(assignment.experimentId);
2170
+ const cssReady = assignment.externalCssUrl
2171
+ ? injectExternalCss(
2172
+ assignment.externalCssUrl,
2173
+ assignment.experimentId,
2174
+ assignment.variationId,
2175
+ ownerIds,
2176
+ artifactId,
2177
+ applyEpoch,
2178
+ )
2179
+ .then(() => {
2180
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`${assignment.experimentId}: External CSS loaded`, {
2181
+ variationId: assignment.variationId,
2182
+ url: assignment.externalCssUrl,
2183
+ });
2184
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `${assignment.experimentId}: external CSS loaded`);
2185
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("harness", "external CSS loaded", { url: assignment.externalCssUrl });
2186
+ })
2187
+ .catch((error) => {
2188
+ __DRIP_FEATURE_DEVTOOLS__ && debugWarn("apply", error instanceof Error ? error.message : String(error));
2189
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("harness", "external CSS failed", { url: assignment.externalCssUrl });
2190
+ })
2191
+ : Promise.resolve();
2192
+
2193
+ cssReady.then(() => {
2194
+ if (!artifactEpochCurrent(ownerKeys, applyEpoch)) return;
2195
+ const jsReady = assignment.externalJsUrl
2196
+ ? loadExternalJs(assignment.externalJsUrl, label, ownerIds, artifactId, applyEpoch)
2197
+ .then(() => {
2198
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`${assignment.experimentId}: External JS loaded`, {
2199
+ variationId: assignment.variationId,
2200
+ url: assignment.externalJsUrl,
2201
+ });
2202
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `${assignment.experimentId}: external JS loaded`);
2203
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("harness", "external JS loaded", { url: assignment.externalJsUrl });
2204
+ })
2205
+ .catch((error) => {
2206
+ __DRIP_FEATURE_DEVTOOLS__ && debugWarn("apply", error instanceof Error ? error.message : String(error));
2207
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("harness", "external JS failed", { url: assignment.externalJsUrl });
2208
+ })
2209
+ : Promise.resolve();
2210
+
2211
+ jsReady.then(() => {
2212
+ if (!artifactEpochCurrent(ownerKeys, applyEpoch)) return;
2213
+ if (runCustomJs && assignment.js) {
2214
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`${assignment.experimentId}: Applying JS`, {
2215
+ variationId: assignment.variationId,
2216
+ bytes: assignment.js.length,
2217
+ });
2218
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `${assignment.experimentId}: executing JS`);
2219
+ runMuted(() => execJs(assignment.js!, label));
2220
+ onCustomJsRun?.();
2221
+ }
2222
+ });
2223
+ });
2224
+ });
2225
+ }
2226
+
2227
+ export function recordAppliedAssignment(
2228
+ assignment: Assignment,
2229
+ pageIds: readonly string[],
2230
+ ): string[] {
2231
+ return pageIds.map((pageId) => {
2232
+ const key = artifactApplicationKey(assignment, pageId);
2233
+ const pageState = state.pageActivation.get(pageId) ?? {
2234
+ active: true,
2235
+ appliedKeys: new Set<string>(),
2236
+ };
2237
+ pageState.appliedKeys.add(key);
2238
+ state.pageActivation.set(pageId, pageState);
2239
+ appliedAssignmentsByKey.set(key, assignment);
2240
+ artifactEpochs.set(key, applyPassEpoch);
2241
+ return key;
2242
+ });
2243
+ }
2244
+
2245
+ export function appliedOwnerKeysForAssignment(assignment: Assignment): string[] {
2246
+ const baseKey = baseArtifactApplicationKey(assignment);
2247
+ return Array.from(appliedAssignmentsByKey.entries())
2248
+ .filter(([, applied]) => baseArtifactApplicationKey(applied) === baseKey)
2249
+ .map(([ownerKey]) => ownerKey);
2250
+ }
2251
+
2252
+ export function applyAssignments(
2253
+ assignments: Assignment[],
2254
+ activationMode: ActivationMode,
2255
+ options: { pageIds?: ReadonlySet<string>; goalUrl?: string } = {},
2256
+ ): void {
2257
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`Applying assignment set`, {
2258
+ assignments: assignments.length,
2259
+ activationMode,
2260
+ });
2261
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `Applying ${assignments.length} assignment(s)`);
2262
+ __DRIP_FEATURE_DEVTOOLS__ && dtLog("apply", `Applying ${assignments.length} assignment(s)`);
2263
+ const applyGeneration = ++applyPassEpoch;
2264
+ const appliedExperimentCode = new Set<string>();
2265
+ const customJsRan = new Set<string>();
2266
+ // Keep destination redirect assignments active for attribution, goals, and
2267
+ // exposure dedupe, but render only assignments whose artifacts belong to
2268
+ // the current (source) page.
2269
+ const ownerPageIdsByAssignment = new Map<Assignment, string[]>();
2270
+ const existingOwnerKeysByAssignment = new Map<Assignment, string[]>();
2271
+ const renderableAssignments = artifactRenderableAssignments(assignments).filter(
2272
+ (assignment) => {
2273
+ const pageIds = assignmentPageIds(assignment).filter(
2274
+ (pageId) => !options.pageIds || options.pageIds.has(pageId),
2275
+ );
2276
+ ownerPageIdsByAssignment.set(assignment, pageIds);
2277
+ existingOwnerKeysByAssignment.set(
2278
+ assignment,
2279
+ appliedOwnerKeysForAssignment(assignment),
2280
+ );
2281
+ return pageIds.length > 0;
2282
+ },
2283
+ );
2284
+ const offersDisplayWithheld = (assignment: Assignment): boolean =>
2285
+ !isOffersDisplayAssignmentAuthorized(assignment);
2286
+
2287
+ // Delivery state is assignment-scoped. A page-limited SPA pass may replace
2288
+ // the tracker for assignments it is about to deliver, but must not promote
2289
+ // an unrelated page's still-pending assignment into the attribution plane.
2290
+ for (const assignment of renderableAssignments) {
2291
+ const exposureKey = exposureAssignmentKey(assignment);
2292
+ state.mutationPendingAssignmentKeys.delete(exposureKey);
2293
+ if (
2294
+ assignment.mutations.length > 0 &&
2295
+ !offersDisplayWithheld(assignment) &&
2296
+ (existingOwnerKeysByAssignment.get(assignment)?.length ?? 0) === 0
2297
+ ) {
2298
+ state.mutationPendingAssignmentKeys.add(exposureKey);
2299
+ // Same prune-hole as contamination: pending keys for removed assignments
2300
+ // are dropped on re-init, so an undelivered treatment must leave the
2301
+ // ledger now and re-enter only through clean delivery.
2302
+ runtimeSharedState.attributionLedger.delete(assignment.experimentId);
2303
+ }
2304
+ }
2305
+
2306
+ const commerce = state.config?.commerce;
2307
+ const commerceActive = Boolean(commerce?.price || commerce?.shipping || commerce?.offers);
2308
+ const renderableCommerceAssignments = commerceActive
2309
+ ? artifactRenderableAssignments(
2310
+ withCommercePriceAssignments(renderableAssignments).filter(renderable),
2311
+ )
2312
+ : renderableAssignments;
2313
+ // The charge plane is reconciled before the price gate below reads the cart
2314
+ // stamp; the pass therefore skips the cart step of syncGlobalRuntimes().
2315
+ reconcileCartSync();
2316
+ if (state.config?.holdout_v1_enabled === true && assignments.length === 0) {
2317
+ // Cohort identity must cross the storefront -> checkout boundary even
2318
+ // when this page has no assignments. Held-out visitors additionally need
2319
+ // a privilege-reducing clear that cannot be gated on analytics consent.
2320
+ syncActiveAssignmentToShopifyCart({ holdoutCartClear: state.holdout });
2321
+ }
2322
+
2323
+ // Offers-enforced assignments render nothing until their cart stamp is
2324
+ // authorized: a companion treatment shown before the signed envelope lands
2325
+ // could advertise an offer Shopify never charges. Identity, goal wiring,
2326
+ // and cart-sync behavior are unchanged — only DOM application (mutations,
2327
+ // CSS/JS) is withheld; the display-authorization callback re-applies.
2328
+ // Count total mutations to know when all have been applied
2329
+ let pendingMutations = 0;
2330
+ for (const a of renderableAssignments) {
2331
+ if (offersDisplayWithheld(a)) continue;
2332
+ if ((existingOwnerKeysByAssignment.get(a)?.length ?? 0) > 0) continue;
2333
+ pendingMutations += a.mutations.length;
2334
+ }
2335
+ // Counts a price treatment whose activation is still PENDING (Shopify has not yet
2336
+ // published product metadata), so the reveal cannot flash the original price. The
2337
+ // anti-flicker timeout still bounds how long content may stay hidden.
2338
+ const pricePending = commerce?.price && isCommercePriceWorkPending(
2339
+ commerce.price,
2340
+ renderableCommerceAssignments,
2341
+ isPriceDisplayAssignmentAuthorized,
2342
+ ) ? 1 : 0;
2343
+ pendingMutations += pricePending;
2344
+ let priceResolved = 0;
2345
+ const mutationKey = (assignment: Assignment, index: number) => (
2346
+ `${assignment.experimentId}\u0000${assignment.variationId}\u0000${index}`
2347
+ );
2348
+ const deliveryTrackers = new Map<string, DeliveryHealthTracker>();
2349
+ const deliveryKey = (assignment: Assignment) => `${assignment.experimentId}\u0000${assignment.variationId}`;
2350
+ for (const assignment of renderableAssignments) {
2351
+ if (offersDisplayWithheld(assignment)) continue;
2352
+ if ((existingOwnerKeysByAssignment.get(assignment)?.length ?? 0) > 0) {
2353
+ continue;
2354
+ }
2355
+ deliveryTrackers.set(deliveryKey(assignment), {
2356
+ assignment,
2357
+ expected: assignment.mutations.length,
2358
+ applied: new Set<number>(),
2359
+ missed: new Set<number>(),
2360
+ emitted: false,
2361
+ });
2362
+ }
2363
+ const contaminateAssignment = (tracker: DeliveryHealthTracker): void => {
2364
+ const assignment = tracker.assignment;
2365
+ const exposureKey = exposureAssignmentKey(assignment);
2366
+ if (state.contaminatedAssignmentKeys.has(exposureKey)) return;
2367
+ state.mutationPendingAssignmentKeys.delete(exposureKey);
2368
+ state.contaminatedAssignmentKeys.add(exposureKey);
2369
+ // Evict rather than rely on the read-time contamination check: a later
2370
+ // re-init prunes orphaned contamination keys for removed assignments, and
2371
+ // a surviving ledger entry would re-enter cart labels fail-open.
2372
+ runtimeSharedState.attributionLedger.delete(assignment.experimentId);
2373
+ const artifactId = baseArtifactApplicationKey(assignment);
2374
+ invalidatedArtifactIds.add(artifactId);
2375
+ invalidateArtifact(artifactId);
2376
+ const ownerKeys = appliedOwnerKeysForAssignment(assignment);
2377
+ for (const ownerKey of ownerKeys) unregisterArtifactOwner(ownerKey);
2378
+ for (const pageId of new Set([
2379
+ ...(ownerPageIdsByAssignment.get(assignment) ?? []),
2380
+ ...ownerKeys.map(artifactOwnerId),
2381
+ ])) {
2382
+ rollbackArtifactOwner(pageId, artifactId);
2383
+ }
2384
+ const authorizationKey = priceAssignmentAuthorizationKey(assignment);
2385
+ state.authorizedPriceAssignmentKeys?.delete(authorizationKey);
2386
+ state.authorizedShippingAssignmentKeys?.delete(authorizationKey);
2387
+ state.authorizedOffersAssignmentKeys?.delete(authorizationKey);
2388
+ // Observer delivery can run variation JS before its selector tracker
2389
+ // expires. Reset unconditionally: reset scripts are idempotent cleanup and
2390
+ // execJs contains failures so a broken customer reset cannot abort rollback.
2391
+ runAssignmentResetJs([assignment]);
2392
+ reconcileGoals();
2393
+ // Authorization revocation and visible commerce rollback are one
2394
+ // transaction: never leave a contaminated price/shipping/offer rendered
2395
+ // until the next global pass.
2396
+ reconcileCommercePrice();
2397
+ reconcileCommerceShipping();
2398
+ reconcileCartSync();
2399
+ scheduleShopifyAssignmentSync();
2400
+ const event: TrackEvent = {
2401
+ type: "variant_contamination_rollback",
2402
+ data: {
2403
+ missed_selector_count: tracker.missed.size,
2404
+ expected_mutations: tracker.expected,
2405
+ applied_mutations: tracker.applied.size,
2406
+ apply_generation: applyGeneration,
2407
+ activation_mode: activationMode,
2408
+ },
2409
+ url: cleanUrl(),
2410
+ experimentId: assignment.experimentId,
2411
+ variationId: assignment.variationId,
2412
+ };
2413
+ if (state.tracker && !trackingBlocked()) state.tracker.track(event);
2414
+ else state.pendingDeliveryHealth.push(event);
2415
+ };
2416
+ const maybeEmitDeliveryHealth = (tracker: DeliveryHealthTracker): void => {
2417
+ if (tracker.emitted) return;
2418
+ if (tracker.expected > 0 && tracker.applied.size + tracker.missed.size < tracker.expected) return;
2419
+ tracker.emitted = true;
2420
+ if (tracker.missed.size > 0) {
2421
+ contaminateAssignment(tracker);
2422
+ } else {
2423
+ const exposureKey = exposureAssignmentKey(tracker.assignment);
2424
+ const wasPending = state.mutationPendingAssignmentKeys.delete(exposureKey);
2425
+ recordAttributableAssignments([tracker.assignment]);
2426
+ // The assignment was deliberately excluded from every runtime while its
2427
+ // selectors were unresolved. Promote every goal/identity/charge plane in
2428
+ // this same turn rather than waiting for a route change or re-init.
2429
+ if (wasPending) {
2430
+ reconcileGoals();
2431
+ reconcileCommercePrice();
2432
+ reconcileCommerceShipping();
2433
+ reconcileCartSync();
2434
+ scheduleShopifyAssignmentSync();
2435
+ }
2436
+ }
2437
+ queueDeliveryHealthEvent(tracker.assignment, {
2438
+ expectedMutations: tracker.expected,
2439
+ appliedMutations: tracker.applied.size,
2440
+ selectorMisses: tracker.missed.size,
2441
+ applyGeneration,
2442
+ activationMode,
2443
+ });
2444
+ if (tracker.missed.size === 0 && state.analyticsStarted && state.config && !trackingBlocked()) {
2445
+ trackViewedAssignments(state.config, [tracker.assignment]);
2446
+ }
2447
+ };
2448
+ updateLoadDiagnostics({
2449
+ stage: "mutations_scheduled",
2450
+ pendingMutations,
2451
+ appliedMutations: 0,
2452
+ missedMutations: 0,
2453
+ });
2454
+
2455
+ const appliedMutationKeys = new Set<string>();
2456
+ const missedMutationKeys = new Set<string>();
2457
+ let revealed = false;
2458
+ const maybeRevealResolvedMutations = (): void => {
2459
+ if (!revealed && appliedMutationKeys.size + missedMutationKeys.size + priceResolved >= pendingMutations) {
2460
+ revealed = true;
2461
+ revealExperimentContent();
2462
+ }
2463
+ };
2464
+ if (appliedMutationKeys.size > 0) maybeRevealResolvedMutations();
2465
+ const onMutationApplied = pendingMutations > 0
2466
+ ? (
2467
+ assignment: Assignment,
2468
+ ownerKeys: readonly string[],
2469
+ mutation: Mutation,
2470
+ element: Element,
2471
+ index: number,
2472
+ journalSeq?: number,
2473
+ ) => {
2474
+ if (activationMode === "observer") {
2475
+ trackAppliedMutation(
2476
+ element,
2477
+ mutation,
2478
+ {
2479
+ experimentId: assignment.experimentId,
2480
+ variationId: assignment.variationId,
2481
+ journalSeq,
2482
+ artifactId: baseArtifactApplicationKey(assignment),
2483
+ ownerKeys,
2484
+ },
2485
+ (target, appliedMutation) => applyToElement(appliedMutation, target),
2486
+ );
2487
+ }
2488
+ const key = mutationKey(assignment, index);
2489
+ if (appliedMutationKeys.has(key)) return;
2490
+ appliedMutationKeys.add(key);
2491
+ missedMutationKeys.delete(key);
2492
+ const tracker = deliveryTrackers.get(deliveryKey(assignment));
2493
+ if (tracker) {
2494
+ tracker.applied.add(index);
2495
+ tracker.missed.delete(index);
2496
+ maybeEmitDeliveryHealth(tracker);
2497
+ }
2498
+ const firstMutationAt = markDripPerf("first_mutation_at");
2499
+ updateLoadDiagnostics({
2500
+ stage: "mutation_applied",
2501
+ firstMutationAt,
2502
+ pendingMutations,
2503
+ appliedMutations: appliedMutationKeys.size,
2504
+ missedMutations: missedMutationKeys.size,
2505
+ });
2506
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `Mutation applied (${appliedMutationKeys.size}/${pendingMutations})`, {
2507
+ selector: mutation.selector,
2508
+ });
2509
+ maybeRevealResolvedMutations();
2510
+ }
2511
+ : null;
2512
+ const onMutationMissed = pendingMutations > 0
2513
+ ? (assignment: Assignment, mutation: Mutation, index: number) => {
2514
+ const key = mutationKey(assignment, index);
2515
+ if (appliedMutationKeys.has(key) || missedMutationKeys.has(key)) return;
2516
+ missedMutationKeys.add(key);
2517
+ const tracker = deliveryTrackers.get(deliveryKey(assignment));
2518
+ if (tracker) {
2519
+ tracker.missed.add(index);
2520
+ maybeEmitDeliveryHealth(tracker);
2521
+ }
2522
+ updateLoadDiagnostics({
2523
+ stage: "mutation_missed",
2524
+ pendingMutations,
2525
+ appliedMutations: appliedMutationKeys.size,
2526
+ missedMutations: missedMutationKeys.size,
2527
+ });
2528
+ __DRIP_FEATURE_DEVTOOLS__ && debugWarn("apply", `${assignment.experimentId}: selector did not match before expiry`, {
2529
+ variationId: assignment.variationId,
2530
+ selector: mutation.selector,
2531
+ });
2532
+ maybeRevealResolvedMutations();
2533
+ }
2534
+ : null;
2535
+
2536
+ for (const assignment of renderableAssignments) {
2537
+ if (offersDisplayWithheld(assignment)) continue;
2538
+ // A manual mutation earlier in this pass may have contaminated the
2539
+ // assignment after the renderable set was built.
2540
+ if (!renderable(assignment)) continue;
2541
+ const ownerPageIds = ownerPageIdsByAssignment.get(assignment) ?? [];
2542
+ const artifactId = baseArtifactApplicationKey(assignment);
2543
+ const ownerKeys = assignmentHasRenderableArtifacts(assignment)
2544
+ ? recordAppliedAssignment(assignment, ownerPageIds)
2545
+ : [];
2546
+ const existingOwnerKeys = new Set(
2547
+ existingOwnerKeysByAssignment.get(assignment) ?? [],
2548
+ );
2549
+ const existingOwnerIds = new Set(
2550
+ Array.from(existingOwnerKeys, artifactOwnerId),
2551
+ );
2552
+ const ownershipOnly = existingOwnerKeys.size > 0;
2553
+ if (ownershipOnly) {
2554
+ addAppliedMutationOwners(existingOwnerIds, ownerPageIds, artifactId);
2555
+ addWaitEntryOwners(existingOwnerIds, ownerPageIds, artifactId);
2556
+ } else if (assignment.mutations.length) {
2557
+ __DRIP_FEATURE_DEVTOOLS__ && traceStep(`${assignment.experimentId}: Applying mutations`, {
2558
+ variationId: assignment.variationId,
2559
+ count: assignment.mutations.length,
2560
+ activationMode,
2561
+ });
2562
+ __DRIP_FEATURE_DEVTOOLS__ && debugLog("apply", `${assignment.experimentId}: ${assignment.mutations.length} mutation(s)`, { activationMode });
2563
+ if (activationMode === "manual") {
2564
+ applyMutations(assignment.mutations, {
2565
+ artifactId: baseArtifactApplicationKey(assignment),
2566
+ epoch: applyGeneration,
2567
+ ownerKeys: ownerPageIds,
2568
+ preservePrices: assignment.artifactSource === "operator_chat",
2569
+ onPricePreservationViolation: ({ selector, originalPriceTokens }) => {
2570
+ if (!state.config) return;
2571
+ sendRuntimeEventNow(state.config, "sdk_price_preservation_blocked", {
2572
+ experiment_id: assignment.experimentId,
2573
+ variation_id: assignment.variationId,
2574
+ selector,
2575
+ original_price_tokens: originalPriceTokens.map((token) => token.raw),
2576
+ });
2577
+ },
2578
+ onApplied: onMutationApplied
2579
+ ? (mutation, element, index, journalSeq) => onMutationApplied(
2580
+ assignment,
2581
+ ownerPageIds,
2582
+ mutation,
2583
+ element,
2584
+ index,
2585
+ journalSeq,
2586
+ )
2587
+ : undefined,
2588
+ onMiss: onMutationMissed
2589
+ ? (mutation, index) => onMutationMissed(assignment, mutation, index)
2590
+ : undefined,
2591
+ });
2592
+ } else {
2593
+ // DOM mutations via MutationObserver-backed waitForElement
2594
+ assignment.mutations.forEach((m, i) => applyMutationWithWait(
2595
+ m,
2596
+ i,
2597
+ onMutationApplied
2598
+ ? (mutation, element, index, journalSeq) => onMutationApplied(
2599
+ assignment,
2600
+ ownerPageIds,
2601
+ mutation,
2602
+ element,
2603
+ index,
2604
+ journalSeq,
2605
+ )
2606
+ : undefined,
2607
+ onMutationMissed
2608
+ ? (mutation, index) => onMutationMissed(assignment, mutation, index)
2609
+ : undefined,
2610
+ ownerPageIds,
2611
+ baseArtifactApplicationKey(assignment),
2612
+ applyGeneration,
2613
+ assignment.artifactSource === "operator_chat" ? {
2614
+ enabled: true,
2615
+ onViolation: ({ selector, originalPriceTokens }) => {
2616
+ if (!state.config) return;
2617
+ sendRuntimeEventNow(state.config, "sdk_price_preservation_blocked", {
2618
+ experiment_id: assignment.experimentId,
2619
+ variation_id: assignment.variationId,
2620
+ selector,
2621
+ original_price_tokens: originalPriceTokens.map((token) => token.raw),
2622
+ });
2623
+ },
2624
+ } : undefined,
2625
+ ));
2626
+ }
2627
+ } else {
2628
+ const tracker = deliveryTrackers.get(deliveryKey(assignment));
2629
+ if (tracker) maybeEmitDeliveryHealth(tracker);
2630
+ }
2631
+ // Manual mutation delivery can contaminate and roll back this assignment
2632
+ // synchronously. Re-check immediately before every remaining artifact
2633
+ // surface so CSS and custom JS cannot be re-injected in the same pass.
2634
+ if (!renderable(assignment)) continue;
2635
+ applyAssignmentCode(
2636
+ assignment,
2637
+ applyGeneration,
2638
+ appliedExperimentCode,
2639
+ ownerKeys,
2640
+ ownerPageIds,
2641
+ !ownershipOnly,
2642
+ () => customJsRan.add(exposureAssignmentKey(assignment)),
2643
+ );
2644
+ if (!ownershipOnly) invalidatedArtifactIds.delete(artifactId);
2645
+ }
2646
+
2647
+ pendingCommercePriceResolve = () => {
2648
+ priceResolved = pricePending;
2649
+ if (pricePending) maybeRevealResolvedMutations();
2650
+ };
2651
+ // Goals, harness polling and commerce price/shipping clean up before they
2652
+ // render, so they reconcile from the full active set — never this pass's
2653
+ // page subset.
2654
+ syncGlobalRuntimes({ goalUrl: options.goalUrl, skipCartSync: true });
2655
+
2656
+ // If there were no mutations (or manual mode), reveal immediately.
2657
+ if (pendingMutations === 0 || activationMode === "manual") {
2658
+ revealExperimentContent();
2659
+ }
2660
+ }
2661
+
2662
+ export function reassertAssignmentMutations(assignments: Assignment[]): void {
2663
+ const mutations = artifactRenderableAssignments(assignments).reduce<Assignment["mutations"]>((all, assignment) => {
2664
+ all.push(...assignment.mutations);
2665
+ return all;
2666
+ }, []);
2667
+ if (!mutations.length) return;
2668
+ reassertNonStructuralMutations(mutations);
2669
+ }
2670
+
2671
+ export type TeardownPage = {
2672
+ pageId: string;
2673
+ mode: PageDeactivationMode;
2674
+ };
2675
+
2676
+ export type TeardownRuntimeOptions = {
2677
+ goalUrl?: string;
2678
+ skipCartSync?: boolean;
2679
+ };
2680
+
2681
+ export type TeardownSelection = Map<string, {
2682
+ artifactIds: Set<ArtifactId>;
2683
+ mode: PageDeactivationMode;
2684
+ }>;
2685
+
2686
+ export function runAssignmentResetJs(assignments: Iterable<Assignment>): void {
2687
+ const resetExperimentIds = new Set<string>();
2688
+ const resetAssignmentKeys = new Set<string>();
2689
+ for (const assignment of assignments) {
2690
+ const assignmentKey = assignment.experimentId + "\u0000" + assignment.variationId;
2691
+ if (assignment.resetJs && !resetAssignmentKeys.has(assignmentKey)) {
2692
+ resetAssignmentKeys.add(assignmentKey);
2693
+ execJs(assignment.resetJs, assignment.experimentId + "-reset");
2694
+ }
2695
+ if (
2696
+ assignment.experimentResetJs &&
2697
+ !resetExperimentIds.has(assignment.experimentId)
2698
+ ) {
2699
+ resetExperimentIds.add(assignment.experimentId);
2700
+ execJs(
2701
+ assignment.experimentResetJs,
2702
+ assignment.experimentId + "-experiment-reset",
2703
+ );
2704
+ }
2705
+ }
2706
+ }
2707
+
2708
+ export /**
2709
+ * Reveal hook for a price activation that is still resolving. The latest
2710
+ * `applyAssignments` pass owns it; `applyCommercePrice` keeps calling the
2711
+ * stable wrapper, so a late resolution still reveals the current pass.
2712
+ */
2713
+ let pendingCommercePriceResolve: (() => void) | null = null;
2714
+
2715
+ export function resolveCommercePricePending(): void {
2716
+ pendingCommercePriceResolve?.();
2717
+ }
2718
+
2719
+ export function unwireGoals(): void {
2720
+ state.wiredGoalKeys = new Set();
2721
+ cleanupGoals();
2722
+ }
2723
+
2724
+ export function goalWiringEligible(): boolean {
2725
+ return state.analyticsStarted &&
2726
+ !trackingBlocked() &&
2727
+ state.trackingPrerequisiteMet;
2728
+ }
2729
+
2730
+ export /**
2731
+ * Diff the wired goal set against the assigned plane. Removing one page's goals
2732
+ * therefore never removes the document listener while other goals live, and a
2733
+ * teardown that changes nothing still leaves the surviving goals wired.
2734
+ */
2735
+ function reconcileGoals(url?: string): void {
2736
+ if (!goalWiringEligible()) return;
2737
+ const experiments = state.config?.experiments ?? [];
2738
+ const activePageIdsForGoals = activePageIds();
2739
+ const desired = goalWiringKeys(
2740
+ state.assignments.filter(attributable),
2741
+ experiments,
2742
+ activePageIdsForGoals,
2743
+ );
2744
+ if (sameStringSet(desired, state.wiredGoalKeys)) {
2745
+ if (url) state.wiredGoalUrl = url;
2746
+ return;
2747
+ }
2748
+ setupGoalTracking(experiments, url, activePageIdsForGoals);
2749
+ }
2750
+
2751
+ export function reconcileHarnessPolling(): void {
2752
+ startHarnessPolling(activeRenderableAssignments());
2753
+ }
2754
+
2755
+ export function reconcileCommercePrice(): void {
2756
+ try {
2757
+ applyCommercePrice(
2758
+ state.config?.commerce?.price,
2759
+ artifactRenderableAssignments(
2760
+ withCommercePriceAssignments(activeRenderableAssignments()).filter(renderable),
2761
+ ),
2762
+ isCohortStampedOnCart,
2763
+ resolveCommercePricePending,
2764
+ isPriceDisplayAssignmentAuthorized,
2765
+ );
2766
+ } catch {
2767
+ resolveCommercePricePending();
2768
+ }
2769
+ }
2770
+
2771
+ export function reconcileCommerceShipping(): void {
2772
+ applyCommerceShipping(
2773
+ state.config?.commerce?.shipping,
2774
+ activeRenderableAssignments().filter(isShippingDisplayAssignmentAuthorized),
2775
+ trackViewedAssignmentsAfterShippingEligibility,
2776
+ );
2777
+ }
2778
+
2779
+ export function reconcileCartSync(): void {
2780
+ const commerce = state.config?.commerce;
2781
+ if (commerce?.price || commerce?.shipping || commerce?.offers) {
2782
+ configureChargeCorrectnessCartSync(chargeAffectingAssignments());
2783
+ return;
2784
+ }
2785
+ // No commerce planes remain, so no cart sync will run — but an earlier
2786
+ // GWP insertion may still sit on the persistent cart with its Function
2787
+ // authorization gone. The manager exits instantly without insertion
2788
+ // records, so this is free for every non-commerce shop.
2789
+ void syncGiftWithPurchase(commerce?.offers, withCommercePriceAssignments(state.assignments).filter(attributable));
2790
+ }
2791
+
2792
+ export /**
2793
+ * Reconcile every global runtime singleton from runtime state. No page-scoped
2794
+ * path may call a global cleanup: teardown and apply change state, this
2795
+ * function decides what the document should hold afterwards.
2796
+ *
2797
+ * `skipCartSync` is for callers that already reconciled the charge plane in
2798
+ * the same pass (`applyAssignments`, which must stamp the cart before the
2799
+ * price gate is computed) and for the commerce callbacks that run *inside*
2800
+ * the cart-sync completion path, where re-entering it would recurse.
2801
+ */
2802
+ function syncGlobalRuntimes(
2803
+ options: { goalUrl?: string; skipCartSync?: boolean } = {},
2804
+ ): void {
2805
+ if (!options.skipCartSync) reconcileCartSync();
2806
+ reconcileGoals(options.goalUrl);
2807
+ reconcileHarnessPolling();
2808
+ reconcileCommercePrice();
2809
+ reconcileCommerceShipping();
2810
+ }
2811
+
2812
+ export function configuredPageDeactivationModes(): Map<string, PageDeactivationMode> {
2813
+ const modes = new Map<string, PageDeactivationMode>();
2814
+ for (const experiment of state.config?.experiments ?? []) {
2815
+ const rules = includedPageRules(experiment);
2816
+ if (rules.length === 0) {
2817
+ modes.set(GLOBAL_PAGE_PREFIX + experiment.id, targetingLifecycleMode(experiment));
2818
+ continue;
2819
+ }
2820
+ for (const rule of rules) {
2821
+ if (!modes.has(rule.id)) {
2822
+ modes.set(rule.id, effectiveDeactivationMode(experiment, rule));
2823
+ }
2824
+ }
2825
+ }
2826
+ return modes;
2827
+ }
2828
+
2829
+ export function allAppliedPagesWithModes(): TeardownPage[] {
2830
+ const configuredModes = configuredPageDeactivationModes();
2831
+ return Array.from(state.pageActivation.entries())
2832
+ .filter(([, pageState]) => pageState.appliedKeys.size > 0)
2833
+ .map(([pageId, pageState]) => ({
2834
+ pageId,
2835
+ mode:
2836
+ configuredModes.get(pageId) ??
2837
+ pageState.configuredMode ??
2838
+ pageState.lastTransitionMode ??
2839
+ "reset",
2840
+ }));
2841
+ }
2842
+
2843
+ export function rollbackArtifactOwner(pageId: string, artifactId: ArtifactId): void {
2844
+ resetWaitEntriesForOwner(pageId, artifactId);
2845
+ resetAppliedMutationsForOwner(pageId, artifactId, {
2846
+ preserveReplacementTargets: true,
2847
+ });
2848
+ removeCssForOwner(pageId, artifactId);
2849
+ removeExternalJsForOwner(pageId, artifactId);
2850
+ }
2851
+
2852
+ export function unregisterArtifactOwner(ownerKey: string): void {
2853
+ artifactEpochs.delete(ownerKey);
2854
+ appliedAssignmentsByKey.delete(ownerKey);
2855
+ state.pageActivation.get(artifactOwnerId(ownerKey))?.appliedKeys.delete(ownerKey);
2856
+ }
2857
+
2858
+ export /**
2859
+ * Run one teardown as an ordered document transaction. The reset closure is
2860
+ * complete before merchant code runs: all artifacts of every resetting
2861
+ * experiment are invalidated, selected owners are unregistered, reset JS runs
2862
+ * once, selected owners roll back, and surviving artifacts replay.
2863
+ */
2864
+ function executeTeardownTransaction(
2865
+ selection: TeardownSelection,
2866
+ runtimeOptions: TeardownRuntimeOptions = {},
2867
+ ): void {
2868
+ const liveBefore = liveArtifactInventory();
2869
+ const selectedOwnerKeys = new Set<string>();
2870
+ for (const [pageId, selected] of selection) {
2871
+ for (const artifactId of selected.artifactIds) {
2872
+ const ownerKey = `${pageId}\u0000${artifactId}`;
2873
+ if (appliedAssignmentsByKey.has(ownerKey)) selectedOwnerKeys.add(ownerKey);
2874
+ }
2875
+ }
2876
+
2877
+ const resettingExperimentIds = new Set<string>();
2878
+ for (const ownerKey of selectedOwnerKeys) {
2879
+ const selected = selection.get(artifactOwnerId(ownerKey));
2880
+ const assignment = appliedAssignmentsByKey.get(ownerKey);
2881
+ if (
2882
+ selected &&
2883
+ (selected.mode === "reset" || selected.mode === "force_reset") &&
2884
+ assignment &&
2885
+ (assignment.resetJs || assignment.experimentResetJs)
2886
+ ) {
2887
+ resettingExperimentIds.add(assignment.experimentId);
2888
+ }
2889
+ }
2890
+
2891
+ const closureArtifactIds = new Set<ArtifactId>();
2892
+ const closureAssignments = new Map<ArtifactId, Assignment>();
2893
+ const closurePageIds = new Set<string>();
2894
+ const closureOwnerKeysByExperiment = new Map<string, Set<string>>();
2895
+ for (const [artifactId, live] of liveBefore) {
2896
+ if (!resettingExperimentIds.has(live.assignment.experimentId)) continue;
2897
+ closureArtifactIds.add(artifactId);
2898
+ closureAssignments.set(artifactId, live.assignment);
2899
+ const ownerKeys = closureOwnerKeysByExperiment.get(live.assignment.experimentId)
2900
+ ?? new Set<string>();
2901
+ live.ownerKeys.forEach((ownerKey) => {
2902
+ ownerKeys.add(ownerKey);
2903
+ closurePageIds.add(artifactOwnerId(ownerKey));
2904
+ });
2905
+ closureOwnerKeysByExperiment.set(live.assignment.experimentId, ownerKeys);
2906
+ }
2907
+
2908
+ const configuredModes = configuredPageDeactivationModes();
2909
+ const modeForPage = (pageId: string): PageDeactivationMode =>
2910
+ selection.get(pageId)?.mode ??
2911
+ configuredModes.get(pageId) ??
2912
+ state.pageActivation.get(pageId)?.configuredMode ??
2913
+ state.pageActivation.get(pageId)?.lastTransitionMode ??
2914
+ "reset";
2915
+ const resetSuppressedExperimentIds = new Set<string>();
2916
+ const isolationFallbackExperimentIds = new Set<string>();
2917
+ for (const [experimentId, ownerKeys] of closureOwnerKeysByExperiment) {
2918
+ if (Array.from(ownerKeys).some((ownerKey) => modeForPage(artifactOwnerId(ownerKey)) === "deactivate")) {
2919
+ resetSuppressedExperimentIds.add(experimentId);
2920
+ }
2921
+ if (Array.from(ownerKeys).some((ownerKey) => !selectedOwnerKeys.has(ownerKey))) {
2922
+ isolationFallbackExperimentIds.add(experimentId);
2923
+ }
2924
+ }
2925
+
2926
+ const fallbackExperimentId = isolationFallbackExperimentIds.values().next().value as string | undefined;
2927
+ if (fallbackExperimentId) {
2928
+ for (const pageId of closurePageIds) {
2929
+ const pageState = state.pageActivation.get(pageId);
2930
+ if (!pageState) continue;
2931
+ pageState.isolationFallback = "whole_experiment";
2932
+ pageState.isolationFallbackExperimentId = fallbackExperimentId;
2933
+ }
2934
+ }
2935
+
2936
+ // D6 serializes nested reconciles, but the registry and every journal must
2937
+ // already describe invalid state before merchant reset JS can trigger one.
2938
+ for (const artifactId of closureArtifactIds) {
2939
+ invalidatedArtifactIds.add(artifactId);
2940
+ invalidateArtifact(artifactId);
2941
+ }
2942
+ for (const ownerKeys of closureOwnerKeysByExperiment.values()) {
2943
+ ownerKeys.forEach((ownerKey) => artifactEpochs.delete(ownerKey));
2944
+ }
2945
+ for (const ownerKey of selectedOwnerKeys) unregisterArtifactOwner(ownerKey);
2946
+
2947
+ runAssignmentResetJs(
2948
+ Array.from(closureAssignments.values()).filter(
2949
+ (assignment) => !resetSuppressedExperimentIds.has(assignment.experimentId),
2950
+ ),
2951
+ );
2952
+
2953
+ for (const ownerKey of selectedOwnerKeys) {
2954
+ rollbackArtifactOwner(
2955
+ artifactOwnerId(ownerKey),
2956
+ artifactIdFromApplicationKey(ownerKey),
2957
+ );
2958
+ }
2959
+
2960
+ const liveAfterRollback = liveArtifactInventory();
2961
+ const desired = desiredArtifactSet(state.assignments);
2962
+ const replay = Array.from(closureArtifactIds)
2963
+ .filter((artifactId) => liveAfterRollback.has(artifactId) && desired.has(artifactId))
2964
+ .map((artifactId) => desired.get(artifactId)!);
2965
+ replayDesiredArtifacts(replay);
2966
+ for (const artifactId of closureArtifactIds) {
2967
+ if (!liveArtifactInventory().has(artifactId)) invalidatedArtifactIds.delete(artifactId);
2968
+ }
2969
+
2970
+ revealExperimentContent({ postRevealReassert: false });
2971
+ syncGlobalRuntimes(runtimeOptions);
2972
+ }
2973
+
2974
+ export function teardownTransaction(
2975
+ pages: TeardownPage[],
2976
+ runtimeOptions: TeardownRuntimeOptions = {},
2977
+ ): void {
2978
+ pages.forEach((page) => cancelPageRulePoll(page.pageId));
2979
+ const selection: TeardownSelection = new Map();
2980
+ for (const page of pages) {
2981
+ const pageState = state.pageActivation.get(page.pageId);
2982
+ if (!pageState || pageState.appliedKeys.size === 0) continue;
2983
+ const existing = selection.get(page.pageId);
2984
+ const artifactIds = existing?.artifactIds ?? new Set<ArtifactId>();
2985
+ pageState.appliedKeys.forEach((ownerKey) =>
2986
+ artifactIds.add(artifactIdFromApplicationKey(ownerKey)));
2987
+ selection.set(page.pageId, { artifactIds, mode: page.mode });
2988
+ }
2989
+ executeTeardownTransaction(selection, runtimeOptions);
2990
+ }
2991
+
2992
+ export function teardownArtifactsTransaction(
2993
+ artifactIds: ReadonlySet<ArtifactId>,
2994
+ runtimeOptions: TeardownRuntimeOptions = {},
2995
+ ): void {
2996
+ const configuredModes = configuredPageDeactivationModes();
2997
+ const selection: TeardownSelection = new Map();
2998
+ const live = liveArtifactInventory();
2999
+ for (const artifactId of artifactIds) {
3000
+ const artifact = live.get(artifactId);
3001
+ if (!artifact) continue;
3002
+ for (const pageId of artifact.ownerPageIds) {
3003
+ const pageState = state.pageActivation.get(pageId);
3004
+ const existing = selection.get(pageId);
3005
+ const selectedArtifactIds = existing?.artifactIds ?? new Set<ArtifactId>();
3006
+ selectedArtifactIds.add(artifactId);
3007
+ selection.set(pageId, {
3008
+ artifactIds: selectedArtifactIds,
3009
+ mode:
3010
+ configuredModes.get(pageId) ??
3011
+ pageState?.configuredMode ??
3012
+ pageState?.lastTransitionMode ??
3013
+ "reset",
3014
+ });
3015
+ }
3016
+ }
3017
+ executeTeardownTransaction(selection, runtimeOptions);
3018
+ }
3019
+
3020
+ export type DesiredArtifact = {
3021
+ id: ArtifactId;
3022
+ assignment: Assignment;
3023
+ ownerPageIds: Set<string>;
3024
+ };
3025
+
3026
+ export type LiveArtifact = DesiredArtifact & {
3027
+ ownerKeys: Set<string>;
3028
+ };
3029
+
3030
+ export type ArtifactReconcileResult = {
3031
+ applied: number;
3032
+ ownerUpdates: number;
3033
+ removed: number;
3034
+ replayed: number;
3035
+ };
3036
+
3037
+ export function desiredArtifactSet(assignments: Assignment[]): Map<ArtifactId, DesiredArtifact> {
3038
+ const desired = new Map<ArtifactId, DesiredArtifact>();
3039
+ for (const assignment of artifactRenderableAssignments(assignments)) {
3040
+ if (!isOffersDisplayAssignmentAuthorized(assignment)) continue;
3041
+ if (!assignmentHasRenderableArtifacts(assignment)) continue;
3042
+ const ownerPageIds = assignmentPageIds(assignment);
3043
+ if (ownerPageIds.length === 0) continue;
3044
+ const id = baseArtifactApplicationKey(assignment);
3045
+ const existing = desired.get(id);
3046
+ if (existing) {
3047
+ ownerPageIds.forEach((pageId) => existing.ownerPageIds.add(pageId));
3048
+ continue;
3049
+ }
3050
+ desired.set(id, { id, assignment, ownerPageIds: new Set(ownerPageIds) });
3051
+ }
3052
+ return desired;
3053
+ }
3054
+
3055
+ export function liveArtifactInventory(): Map<ArtifactId, LiveArtifact> {
3056
+ const live = new Map<ArtifactId, LiveArtifact>();
3057
+ for (const [ownerKey, assignment] of appliedAssignmentsByKey) {
3058
+ const id = artifactIdFromApplicationKey(ownerKey);
3059
+ const pageId = artifactOwnerId(ownerKey);
3060
+ const existing = live.get(id);
3061
+ if (existing) {
3062
+ existing.ownerKeys.add(ownerKey);
3063
+ existing.ownerPageIds.add(pageId);
3064
+ continue;
3065
+ }
3066
+ live.set(id, {
3067
+ id,
3068
+ assignment,
3069
+ ownerKeys: new Set([ownerKey]),
3070
+ ownerPageIds: new Set([pageId]),
3071
+ });
3072
+ }
3073
+ return live;
3074
+ }
3075
+
3076
+ export function removeArtifactOwner(artifactId: ArtifactId, pageId: string): void {
3077
+ const ownerKey = `${pageId}\u0000${artifactId}`;
3078
+ unregisterArtifactOwner(ownerKey);
3079
+ rollbackArtifactOwner(pageId, artifactId);
3080
+ }
3081
+
3082
+ export function registerDesiredOwners(
3083
+ desired: DesiredArtifact,
3084
+ live: LiveArtifact,
3085
+ ): { ownerKeys: string[]; added: boolean; epoch: number } {
3086
+ const liveEpoch = Array.from(live.ownerKeys)
3087
+ .map((ownerKey) => artifactEpochs.get(ownerKey) ?? 0)
3088
+ .reduce((highest, epoch) => Math.max(highest, epoch), 0);
3089
+ let added = false;
3090
+ const ownerKeys: string[] = [];
3091
+ for (const pageId of desired.ownerPageIds) {
3092
+ const ownerKey = `${pageId}\u0000${desired.id}`;
3093
+ ownerKeys.push(ownerKey);
3094
+ const pageState = state.pageActivation.get(pageId) ?? {
3095
+ active: true,
3096
+ appliedKeys: new Set<string>(),
3097
+ };
3098
+ if (!appliedAssignmentsByKey.has(ownerKey)) added = true;
3099
+ pageState.appliedKeys.add(ownerKey);
3100
+ state.pageActivation.set(pageId, pageState);
3101
+ appliedAssignmentsByKey.set(ownerKey, desired.assignment);
3102
+ if (!artifactEpochs.has(ownerKey)) artifactEpochs.set(ownerKey, liveEpoch);
3103
+ }
3104
+ addAppliedMutationOwners(live.ownerPageIds, Array.from(desired.ownerPageIds), desired.id);
3105
+ addWaitEntryOwners(live.ownerPageIds, Array.from(desired.ownerPageIds), desired.id);
3106
+ addCssOwners(live.ownerPageIds, Array.from(desired.ownerPageIds), desired.id);
3107
+ addExternalJsOwners(live.ownerPageIds, Array.from(desired.ownerPageIds), desired.id);
3108
+ return { ownerKeys, added, epoch: liveEpoch };
3109
+ }
3110
+
3111
+ export function replayDesiredArtifacts(artifacts: DesiredArtifact[]): void {
3112
+ if (artifacts.length === 0) return;
3113
+ const replayEpoch = ++applyPassEpoch;
3114
+ const appliedExperimentCode = new Set<string>();
3115
+ for (const desired of artifacts) {
3116
+ const live = liveArtifactInventory().get(desired.id);
3117
+ if (!live) continue;
3118
+ const registered = registerDesiredOwners(desired, live);
3119
+ for (const ownerKey of registered.ownerKeys) artifactEpochs.set(ownerKey, replayEpoch);
3120
+ replayArtifact(desired.id);
3121
+ for (const pageId of desired.ownerPageIds) {
3122
+ removeExternalJsForOwner(pageId, desired.id);
3123
+ }
3124
+ applyAssignmentCode(
3125
+ desired.assignment,
3126
+ replayEpoch,
3127
+ appliedExperimentCode,
3128
+ registered.ownerKeys,
3129
+ Array.from(desired.ownerPageIds),
3130
+ true,
3131
+ );
3132
+ invalidatedArtifactIds.delete(desired.id);
3133
+ }
3134
+ }
3135
+
3136
+ export function invalidateLiveArtifactsForReplay(): void {
3137
+ teardownTransaction(allAppliedPagesWithModes());
3138
+ }
3139
+
3140
+ export function reconcileArtifacts(
3141
+ desired: Map<ArtifactId, DesiredArtifact>,
3142
+ goalUrl?: string,
3143
+ ): ArtifactReconcileResult {
3144
+ const result: ArtifactReconcileResult = {
3145
+ applied: 0,
3146
+ ownerUpdates: 0,
3147
+ removed: 0,
3148
+ replayed: 0,
3149
+ };
3150
+
3151
+ const removedArtifactIds = new Set(
3152
+ Array.from(liveArtifactInventory().keys()).filter(
3153
+ (artifactId) => !desired.has(artifactId),
3154
+ ),
3155
+ );
3156
+ if (removedArtifactIds.size > 0) {
3157
+ teardownArtifactsTransaction(removedArtifactIds, { goalUrl });
3158
+ result.removed = removedArtifactIds.size;
3159
+ }
3160
+
3161
+ let live = liveArtifactInventory();
3162
+ for (const [artifactId, liveArtifact] of live) {
3163
+ const wanted = desired.get(artifactId);
3164
+ if (!wanted) continue;
3165
+ for (const pageId of liveArtifact.ownerPageIds) {
3166
+ if (wanted.ownerPageIds.has(pageId)) continue;
3167
+ removeArtifactOwner(artifactId, pageId);
3168
+ result.ownerUpdates += 1;
3169
+ }
3170
+ }
3171
+
3172
+ live = liveArtifactInventory();
3173
+ const missing: DesiredArtifact[] = [];
3174
+ const replay: DesiredArtifact[] = [];
3175
+ for (const wanted of desired.values()) {
3176
+ const existing = live.get(wanted.id);
3177
+ if (!existing) {
3178
+ missing.push(wanted);
3179
+ continue;
3180
+ }
3181
+ if (invalidatedArtifactIds.has(wanted.id)) {
3182
+ replay.push(wanted);
3183
+ continue;
3184
+ }
3185
+ const registered = registerDesiredOwners(wanted, existing);
3186
+ if (registered.added) result.ownerUpdates += 1;
3187
+ }
3188
+
3189
+ replayDesiredArtifacts(replay);
3190
+ result.replayed = replay.length;
3191
+ if (missing.length > 0) {
3192
+ const pageIds = new Set(missing.flatMap((artifact) => Array.from(artifact.ownerPageIds)));
3193
+ applyAssignments(
3194
+ missing.map((artifact) => artifact.assignment),
3195
+ state.activationMode,
3196
+ { pageIds, goalUrl },
3197
+ );
3198
+ result.applied = missing.length;
3199
+ }
3200
+ if (missing.length === 0) syncGlobalRuntimes({ goalUrl });
3201
+ if (replay.length > 0 && missing.length === 0) revealExperimentContent();
3202
+ return result;
3203
+ }
3204
+