@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.
- package/README.md +5 -0
- package/dist/bundle-report.json +584 -226
- package/dist/define.cjs +123 -0
- package/dist/define.cjs.map +7 -0
- package/dist/define.mjs +102 -0
- package/dist/define.mjs.map +7 -0
- package/dist/drip.cjs +19283 -0
- package/dist/drip.cjs.map +7 -0
- package/dist/drip.debug.js +1 -1
- package/dist/drip.debug.js.map +1 -1
- package/dist/drip.heatmap.js +1 -1
- package/dist/drip.heatmap.js.map +1 -1
- package/dist/drip.js +1 -1
- package/dist/drip.js.map +1 -1
- package/dist/drip.live.js +1 -1
- package/dist/drip.live.js.map +1 -1
- package/dist/drip.mjs +19267 -0
- package/dist/drip.mjs.map +7 -0
- package/dist/drip.replay-recorder.js +2 -0
- package/dist/drip.replay-recorder.js.map +1 -0
- package/dist/editor.js +147 -142
- package/dist/editor.js.map +4 -4
- package/dist/flags.cjs +1788 -0
- package/dist/flags.cjs.map +7 -0
- package/dist/flags.mjs +1765 -0
- package/dist/flags.mjs.map +7 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +2 -2
- package/dist/metafiles/define.cjs.meta.json +28 -0
- package/dist/metafiles/define.mjs.meta.json +32 -0
- package/dist/metafiles/drip.cjs.meta.json +3193 -0
- package/dist/metafiles/drip.debug.meta.json +2859 -574
- package/dist/metafiles/drip.heatmap.meta.json +2732 -460
- package/dist/metafiles/drip.live.meta.json +2732 -460
- package/dist/metafiles/drip.meta.json +2859 -574
- package/dist/metafiles/drip.mjs.meta.json +3235 -0
- package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
- package/dist/metafiles/editor.meta.json +67 -27
- package/dist/metafiles/flags.cjs.meta.json +220 -0
- package/dist/metafiles/flags.mjs.meta.json +225 -0
- package/dist/metafiles/index.meta.json +2717 -491
- package/dist/metafiles/index.mjs.meta.json +14 -4
- package/dist/react/index.js +16636 -13533
- package/dist/react/index.js.map +4 -4
- package/dist/sri.json +20 -15
- package/dist/types/activation-runtime.d.ts +91 -0
- package/dist/types/analytics-runtime.d.ts +166 -0
- package/dist/types/antiflicker.d.ts +9 -3
- package/dist/types/assignment-cookie-sync.d.ts +31 -0
- package/dist/types/assignment-runtime.d.ts +257 -0
- package/dist/types/assignment-store.d.ts +4 -8
- package/dist/types/bucket.d.ts +1 -2
- package/dist/types/compact-store.d.ts +1 -0
- package/dist/types/config-runtime.d.ts +313 -0
- package/dist/types/consent-evaluation.d.ts +28 -0
- package/dist/types/css.d.ts +5 -3
- package/dist/types/debug-loader.d.ts +2 -0
- package/dist/types/decide-assignments.d.ts +31 -0
- package/dist/types/delivery-timing.d.ts +8 -0
- package/dist/types/early-interaction-capture.d.ts +79 -0
- package/dist/types/early-reassert.d.ts +32 -0
- package/dist/types/edge-runtime.d.ts +11 -0
- package/dist/types/editor/api.d.ts +3 -0
- package/dist/types/editor/controller.d.ts +3 -1
- package/dist/types/editor/publication-result.d.ts +11 -0
- package/dist/types/editor/types.d.ts +2 -1
- package/dist/types/editor/view-kit.d.ts +1 -1
- package/dist/types/endpoint.d.ts +8 -0
- package/dist/types/exclusion-groups.d.ts +4 -7
- package/dist/types/flags.d.ts +2 -0
- package/dist/types/goal-eligibility.d.ts +4 -0
- package/dist/types/goals.d.ts +10 -1
- package/dist/types/guards.d.ts +7 -7
- package/dist/types/handover.d.ts +12 -1
- package/dist/types/hash.d.ts +8 -4
- package/dist/types/hydration.d.ts +2 -1
- package/dist/types/index.d.ts +24 -128
- package/dist/types/init-test-helpers.d.ts +48 -0
- package/dist/types/inline-bootstrap.d.ts +26 -0
- package/dist/types/js.d.ts +3 -1
- package/dist/types/money.d.ts +12 -0
- package/dist/types/mutate.d.ts +45 -6
- package/dist/types/performance.d.ts +2 -0
- package/dist/types/persistence-decoders.d.ts +12 -0
- package/dist/types/public-api.d.ts +209 -0
- package/dist/types/qa-assistant-state.d.ts +34 -0
- package/dist/types/react/index.d.ts +28 -3
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/redirect.d.ts +48 -0
- package/dist/types/replay-recorder-entry.d.ts +1 -0
- package/dist/types/replay-signal-state.d.ts +6 -0
- package/dist/types/replay-signals.d.ts +13 -0
- package/dist/types/replay.d.ts +110 -0
- package/dist/types/router.d.ts +12 -2
- package/dist/types/runtime-state.d.ts +166 -0
- package/dist/types/sanitize.d.ts +1 -0
- package/dist/types/shadow.d.ts +3 -0
- package/dist/types/shopify-cart.d.ts +25 -1
- package/dist/types/storefront-vitals.d.ts +24 -0
- package/dist/types/targeting-diagnostics.d.ts +1 -0
- package/dist/types/targeting.d.ts +16 -2
- package/dist/types/track.d.ts +15 -1
- package/dist/types/types.d.ts +115 -9
- package/dist/types/wait.d.ts +14 -1
- package/dist/types/weekday-schedule.d.ts +3 -0
- package/package.json +17 -8
- package/src/activation-runtime.ts +2737 -0
- package/src/analytics-runtime.ts +2175 -0
- package/src/antiflicker.test.ts +216 -44
- package/src/antiflicker.ts +103 -28
- package/src/artifact-concurrency.test.ts +1091 -0
- package/src/assignment-cookie-sync.test.ts +202 -0
- package/src/assignment-cookie-sync.ts +189 -0
- package/src/assignment-runtime.ts +3204 -0
- package/src/assignment-store.test.ts +56 -0
- package/src/assignment-store.ts +7 -38
- package/src/bucket.ts +3 -18
- package/src/commerce-analytics-lifecycle.test.ts +75 -15
- package/src/commerce-config-lifecycle.test.ts +81 -1
- package/src/commerce-price-preserve.test.ts +133 -0
- package/src/commerce-price.test.ts +97 -0
- package/src/commerce-price.ts +74 -20
- package/src/commerce-shipping.test.ts +53 -0
- package/src/commerce-shipping.ts +5 -11
- package/src/compact-store.ts +15 -0
- package/src/config-fetch.test.ts +480 -9
- package/src/config-runtime.ts +2071 -0
- package/src/consent-evaluation-borlabs.test.ts +41 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +151 -0
- package/src/css.test.ts +30 -0
- package/src/css.ts +120 -6
- package/src/cwv.test.ts +60 -77
- package/src/debug-loader.test.ts +214 -0
- package/src/debug-loader.ts +41 -0
- package/src/decide-assignments.test.ts +196 -0
- package/src/decide-assignments.ts +204 -0
- package/src/delivery-timing.test.ts +104 -0
- package/src/delivery-timing.ts +89 -0
- package/src/early-interaction-capture.test.ts +451 -0
- package/src/early-interaction-capture.ts +230 -0
- package/src/early-reassert.test.ts +207 -0
- package/src/early-reassert.ts +152 -0
- package/src/edge-adoption.test.ts +435 -0
- package/src/edge-runtime.ts +30 -0
- package/src/editor/api.test.ts +109 -0
- package/src/editor/api.ts +34 -3
- package/src/editor/controller.test.ts +297 -14
- package/src/editor/controller.ts +69 -5
- package/src/editor/index.test.ts +2 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/publication-result.ts +58 -0
- package/src/editor/types.ts +10 -1
- package/src/editor/view-kit.ts +2 -1
- package/src/editor/view.test.ts +108 -0
- package/src/editor/view.ts +49 -7
- package/src/endpoint.test.ts +15 -0
- package/src/endpoint.ts +10 -0
- package/src/exclusion-groups.ts +8 -18
- package/src/flags.test.ts +88 -1
- package/src/flags.ts +29 -29
- package/src/global-runtime-reconciler.test.ts +271 -0
- package/src/goal-eligibility.test.ts +29 -0
- package/src/goal-eligibility.ts +17 -0
- package/src/goals.test.ts +144 -1
- package/src/goals.ts +97 -18
- package/src/guards.test.ts +99 -1
- package/src/guards.ts +34 -15
- package/src/handover-cache-order.test.ts +6 -3
- package/src/handover.test.ts +154 -8
- package/src/handover.ts +122 -12
- package/src/hash.ts +29 -54
- package/src/holdout.test.ts +26 -0
- package/src/hydration.test.ts +73 -2
- package/src/hydration.ts +18 -9
- package/src/index.ts +130 -7287
- package/src/init-analytics.test.ts +1694 -0
- package/src/init-anti-flicker.test.ts +1211 -0
- package/src/init-cart-attributes.test.ts +2003 -0
- package/src/init-config-resolution.test.ts +999 -0
- package/src/init-consent.test.ts +971 -0
- package/src/init-empty.test.ts +11 -5406
- package/src/init-integrations.test.ts +846 -0
- package/src/init-page-activation.test.ts +5064 -0
- package/src/init-qa-loading.test.ts +740 -0
- package/src/init-redirects.test.ts +2156 -0
- package/src/init-test-helpers.ts +287 -0
- package/src/inline-bootstrap.test.ts +184 -0
- package/src/inline-bootstrap.ts +73 -0
- package/src/js.test.ts +51 -1
- package/src/js.ts +86 -2
- package/src/money.test.ts +55 -1
- package/src/money.ts +104 -2
- package/src/money.web-parity.contract.test.ts +85 -0
- package/src/mutate.test.ts +474 -10
- package/src/mutate.ts +739 -230
- package/src/page-trigger.test.ts +218 -8
- package/src/performance.ts +10 -0
- package/src/persistence-decoders.ts +18 -0
- package/src/public-api.ts +1828 -0
- package/src/qa-assistant-state.test.ts +249 -3
- package/src/qa-assistant-state.ts +146 -6
- package/src/qa-assistant-view.test.ts +220 -2
- package/src/qa-assistant-view.ts +102 -5
- package/src/react/index.test.tsx +37 -0
- package/src/react/index.tsx +19 -3
- package/src/reassert-sentinel.test.ts +155 -1
- package/src/reassert-sentinel.ts +168 -8
- package/src/redirect.test.ts +541 -0
- package/src/redirect.ts +572 -0
- package/src/replay-recorder-entry.ts +115 -0
- package/src/replay-signal-state.ts +100 -0
- package/src/replay-signals.test.ts +249 -0
- package/src/replay-signals.ts +151 -0
- package/src/replay.test.ts +764 -0
- package/src/replay.ts +514 -0
- package/src/router.test.ts +61 -2
- package/src/router.ts +50 -12
- package/src/runtime-state.ts +507 -0
- package/src/runtime.test.ts +174 -7
- package/src/runtime.ts +88 -14
- package/src/sanitize.test.ts +34 -0
- package/src/sanitize.ts +23 -12
- package/src/shadow.test.ts +187 -0
- package/src/shadow.ts +99 -0
- package/src/shopify-cart.test.ts +733 -23
- package/src/shopify-cart.ts +402 -49
- package/src/storefront-vitals.test.ts +217 -0
- package/src/storefront-vitals.ts +206 -0
- package/src/surface-signals.ts +6 -0
- package/src/targeting-conformance.test.ts +9 -0
- package/src/targeting-diagnostics.ts +38 -8
- package/src/targeting-parity-corpus.json +559 -0
- package/src/targeting-parity-corpus.test.ts +18 -0
- package/src/targeting.detailed.test.ts +105 -0
- package/src/targeting.test.ts +211 -0
- package/src/targeting.ts +88 -207
- package/src/track.test.ts +147 -0
- package/src/track.ts +125 -26
- package/src/types.ts +120 -13
- package/src/wait.test.ts +23 -0
- package/src/wait.ts +129 -13
- package/src/weekday-delivery.test.ts +56 -0
- package/src/weekday-schedule-parity-corpus.json +92 -0
- package/src/weekday-schedule.test.ts +69 -0
- 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
|
+
|