@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,230 @@
|
|
|
1
|
+
import { isExperimentServable } from "@drip/event-schema/experiment-servability";
|
|
2
|
+
import { CONSENT_GRANTED, CONSENT_REQUIRED, DNT_BLOCKED, evaluateSynchronousConsentForProvider, readConfiguredConsent } from "./consent-evaluation";
|
|
3
|
+
import { isOptedOut } from "./guards";
|
|
4
|
+
import { getOrCreateSessionId } from "./track";
|
|
5
|
+
import type { Assignment, DataProtectionSettings, Experiment, Goal } from "./types";
|
|
6
|
+
import { safeRegexTest } from "./safe-regex";
|
|
7
|
+
import { buildTargetingAttributes, matchedPageRuleIds } from "./targeting";
|
|
8
|
+
import { weekdayScheduleBlocksDelivery } from "./weekday-schedule";
|
|
9
|
+
|
|
10
|
+
export const EARLY_INTERACTION_STORAGE_KEY = "drip_early_interactions_v1";
|
|
11
|
+
export const EARLY_INTERACTION_TTL_MS = 15 * 60 * 1000;
|
|
12
|
+
const EARLY_INTERACTION_MAX_ENTRIES = 50;
|
|
13
|
+
const PAIRED_ACTION_WINDOW_MS = 1000;
|
|
14
|
+
type StorageMode = NonNullable<DataProtectionSettings["storageMode"]>;
|
|
15
|
+
type ConsentState = "allowed" | "denied" | "unknown";
|
|
16
|
+
|
|
17
|
+
export type EarlyInteractionEntry = {
|
|
18
|
+
goalId: string; experimentId: string; variationId: string; assignmentEpoch: number;
|
|
19
|
+
kind: "click" | "submit"; data: Record<string, unknown>; timestamp: number;
|
|
20
|
+
sessionId: string; pageUrl: string; configRevision: number;
|
|
21
|
+
};
|
|
22
|
+
type EarlyCaptureState = { entries: EarlyInteractionEntry[]; stop: () => void; storageMode: StorageMode };
|
|
23
|
+
declare global {
|
|
24
|
+
var __dripEarlyInteractions: EarlyCaptureState | undefined;
|
|
25
|
+
var __dripHasConsent: boolean | undefined;
|
|
26
|
+
var __dripInstallEarlyInteractionCapture: typeof installEarlyInteractionCapture | undefined;
|
|
27
|
+
var __dripPredecided: { decision?: Array<[string, string]> } | undefined;
|
|
28
|
+
var __dripEarlyQ: Array<{ kind: "click" | "submit"; target: Element; timestamp: number; id: number; pageUrl: string; consentAllowed: boolean }> | undefined;
|
|
29
|
+
var __dripEarlyStop: (() => void) | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function drainBootstrapEarlyInteractions(input: ConsentConfig & {
|
|
33
|
+
assignments: Assignment[];
|
|
34
|
+
experiments: Experiment[];
|
|
35
|
+
activePageIds?: ReadonlySet<string>;
|
|
36
|
+
qaMode?: boolean;
|
|
37
|
+
environment?: string;
|
|
38
|
+
now?: number;
|
|
39
|
+
consume: (goalId: string, assignment: Assignment, timestamp: number, kind: "click" | "submit", pageUrl: string) => void;
|
|
40
|
+
}): void {
|
|
41
|
+
const queued = globalThis.__dripEarlyQ;
|
|
42
|
+
globalThis.__dripEarlyStop?.();
|
|
43
|
+
globalThis.__dripEarlyStop = undefined;
|
|
44
|
+
globalThis.__dripEarlyQ = [];
|
|
45
|
+
if (!Array.isArray(queued) || queued.length === 0) return;
|
|
46
|
+
const liveConsentAllowed = !isOptedOut() && consentState(input) === "allowed";
|
|
47
|
+
const experiments = new Map(input.experiments.map((experiment) => [experiment.id, experiment]));
|
|
48
|
+
const now = input.now ?? Date.now();
|
|
49
|
+
const seen = new Set<number>();
|
|
50
|
+
const lastActions = new Map<string, { kind: "click" | "submit"; timestamp: number; form: Element | null }>();
|
|
51
|
+
for (const entry of queued) {
|
|
52
|
+
if (!entry || entry.consentAllowed !== true || !liveConsentAllowed || seen.has(entry.id) || !Number.isFinite(entry.timestamp) || now - entry.timestamp > EARLY_INTERACTION_TTL_MS || entry.timestamp > now || !(entry.target instanceof Element)) continue;
|
|
53
|
+
seen.add(entry.id);
|
|
54
|
+
for (const assignment of input.assignments) {
|
|
55
|
+
const experiment = experiments.get(assignment.experimentId);
|
|
56
|
+
if (!experiment || !isExperimentServable(experiment, { qaMode: input.qaMode === true, environment: input.environment || "production" })) continue;
|
|
57
|
+
const clickTimeAttributes = buildTargetingAttributes({}, entry.pageUrl, { persistSignals: false });
|
|
58
|
+
const clickTimePageIds = matchedPageRuleIds(
|
|
59
|
+
[...(experiment.targeting?.pageRules ?? []), ...(experiment.goalPageRules ?? [])],
|
|
60
|
+
{ url: entry.pageUrl, attributes: clickTimeAttributes },
|
|
61
|
+
);
|
|
62
|
+
for (const goal of experiment.goals ?? []) {
|
|
63
|
+
if ((goal.type !== "click" && String(goal.type) !== "submit") || !goal.selector) continue;
|
|
64
|
+
if (!goalIsActiveForBootstrap(goal, clickTimePageIds, entry.pageUrl)) continue;
|
|
65
|
+
const matched = matchingElement(entry.target, goal, entry.kind);
|
|
66
|
+
if (matched) {
|
|
67
|
+
const actionKey = `${assignment.experimentId}:${goal.id}`;
|
|
68
|
+
const prior = lastActions.get(actionKey);
|
|
69
|
+
const form = logicalForm(matched);
|
|
70
|
+
if (prior && prior.kind !== entry.kind && entry.timestamp - prior.timestamp <= PAIRED_ACTION_WINDOW_MS && form && prior.form === form) continue;
|
|
71
|
+
lastActions.set(actionKey, { kind: entry.kind, timestamp: entry.timestamp, form });
|
|
72
|
+
input.consume(goal.id, assignment, entry.timestamp, entry.kind, entry.pageUrl);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function logicalForm(matched: Element): HTMLFormElement | null {
|
|
80
|
+
return matched.closest("form") ??
|
|
81
|
+
(matched as HTMLButtonElement | HTMLInputElement).form ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function goalIsActiveForBootstrap(goal: Goal, activePageIds: ReadonlySet<string>, pageUrl: string): boolean {
|
|
85
|
+
if (goal.pageId) return activePageIds.has(goal.pageId);
|
|
86
|
+
return !goal.urlPattern || safeRegexTest(goal.urlPattern, pageUrl);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readCookie(name: string): string {
|
|
90
|
+
try { const match = (document.cookie || "").match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); return match ? decodeURIComponent(match[1]) : ""; }
|
|
91
|
+
catch { return ""; }
|
|
92
|
+
}
|
|
93
|
+
function consentState(config: ConsentConfig): ConsentState {
|
|
94
|
+
const configured = readConfiguredConsent(config.consentProvider, config.consentRequiredGroup, config.consentGetter);
|
|
95
|
+
const explicit = typeof globalThis.__dripHasConsent === "boolean" ? globalThis.__dripHasConsent : undefined;
|
|
96
|
+
const cookie = readCookie("drip_consent");
|
|
97
|
+
const nav = globalThis.navigator as Navigator & { msDoNotTrack?: string } | undefined;
|
|
98
|
+
const bits = evaluateSynchronousConsentForProvider({
|
|
99
|
+
requireConsent: config.requireConsent, hasConsent: explicit, consentModeEnabled: config.consentModeEnabled,
|
|
100
|
+
provider: config.consentProvider, configuredConsentGranted: configured, consentCookieGranted: cookie === "1",
|
|
101
|
+
respectDoNotTrack: config.respectDoNotTrack,
|
|
102
|
+
doNotTrackValue: nav?.doNotTrack ?? nav?.msDoNotTrack ?? (globalThis as any).doNotTrack ?? "",
|
|
103
|
+
});
|
|
104
|
+
if (bits & DNT_BLOCKED) return "denied";
|
|
105
|
+
if (!(bits & CONSENT_REQUIRED)) return "allowed";
|
|
106
|
+
if (bits & CONSENT_GRANTED) return "allowed";
|
|
107
|
+
if (explicit === false || configured === false || cookie === "0") return "denied";
|
|
108
|
+
return "unknown";
|
|
109
|
+
}
|
|
110
|
+
function getStorage(mode: StorageMode): Storage | null {
|
|
111
|
+
try { return mode === "sessionStorage" ? globalThis.sessionStorage ?? null : mode === "localStorage" ? globalThis.localStorage ?? null : null; }
|
|
112
|
+
catch { return null; }
|
|
113
|
+
}
|
|
114
|
+
function readPersisted(mode: StorageMode): EarlyInteractionEntry[] {
|
|
115
|
+
try { const raw = mode === "cookie" ? readCookie(EARLY_INTERACTION_STORAGE_KEY) : getStorage(mode)?.getItem(EARLY_INTERACTION_STORAGE_KEY) ?? ""; const parsed = JSON.parse(raw || "[]"); return Array.isArray(parsed) ? parsed : []; }
|
|
116
|
+
catch { return []; }
|
|
117
|
+
}
|
|
118
|
+
function persist(mode: StorageMode, entries: EarlyInteractionEntry[]): void {
|
|
119
|
+
const bounded = entries.slice(-EARLY_INTERACTION_MAX_ENTRIES); let value = JSON.stringify(bounded);
|
|
120
|
+
if (mode === "cookie") while (encodeURIComponent(value).length > 3500 && bounded.length) { bounded.shift(); value = JSON.stringify(bounded); }
|
|
121
|
+
try { if (mode === "cookie") document.cookie = `${EARLY_INTERACTION_STORAGE_KEY}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=900`; else if (mode !== "memory") getStorage(mode)?.setItem(EARLY_INTERACTION_STORAGE_KEY, value); } catch {}
|
|
122
|
+
}
|
|
123
|
+
export function clearEarlyInteractions(mode?: StorageMode): void {
|
|
124
|
+
for (const candidate of mode ? [mode] : (["localStorage", "sessionStorage", "cookie", "memory"] as StorageMode[])) {
|
|
125
|
+
try { if (candidate === "cookie") document.cookie = `${EARLY_INTERACTION_STORAGE_KEY}=; Path=/; SameSite=Lax; Max-Age=0`; else if (candidate !== "memory") getStorage(candidate)?.removeItem(EARLY_INTERACTION_STORAGE_KEY); } catch {}
|
|
126
|
+
}
|
|
127
|
+
if (globalThis.__dripEarlyInteractions) globalThis.__dripEarlyInteractions.entries.length = 0;
|
|
128
|
+
}
|
|
129
|
+
function matchingElement(target: Element, goal: Goal, kind: "click" | "submit"): Element | null {
|
|
130
|
+
if (!goal.selector) return null;
|
|
131
|
+
try {
|
|
132
|
+
if (kind === "submit") {
|
|
133
|
+
const form = target instanceof HTMLFormElement ? target : target.closest("form");
|
|
134
|
+
if (!form) return null;
|
|
135
|
+
if (form.matches(goal.selector)) return form;
|
|
136
|
+
const descendant = form.querySelector(goal.selector);
|
|
137
|
+
if (descendant) return descendant;
|
|
138
|
+
// Form-associated controls may live outside the form (`form="id"`).
|
|
139
|
+
return Array.from(document.querySelectorAll(goal.selector)).find((element) =>
|
|
140
|
+
(element instanceof HTMLButtonElement || element instanceof HTMLInputElement) && element.form === form
|
|
141
|
+
) ?? null;
|
|
142
|
+
}
|
|
143
|
+
const match = target.closest(goal.selector); return match && (!goal.textMatch || (match.textContent || "").trim().toLowerCase().includes(goal.textMatch.trim().toLowerCase())) ? match : null;
|
|
144
|
+
} catch { return null; }
|
|
145
|
+
}
|
|
146
|
+
type ConsentConfig = { requireConsent?: boolean; consentModeEnabled?: boolean; consentProvider?: "custom" | "onetrust" | "usercentrics" | "cookiebot" | "ccm19" | "pandectes" | "cookiescript" | "borlabs"; consentRequiredGroup?: string; consentGetter?: string; respectDoNotTrack?: boolean };
|
|
147
|
+
export type EarlyCaptureConfig = ConsentConfig & { experiments: Experiment[]; assignments: Pick<Assignment, "experimentId" | "variationId" | "assignmentEpoch">[]; attributes?: Record<string, unknown>; configRevision: number; pageUrl: string; storageMode?: StorageMode; forcedExperimentIds?: string[]; now?: number };
|
|
148
|
+
const CONSENT_EVENTS = ["OneTrustGroupsUpdated", "OptanonConsentChanged", "CookiebotOnConsentReady", "CookiebotOnAccept", "CookiebotOnDecline", "CookieConsentDeclaration", "UC_UI_CMP_EVENT", "ccm19EmbeddingAccepted", "ccm19WidgetClosed", "ccm19WidgetLoaded", "PandectesInitialized", "visitorConsentCollected", "CookieScriptLoaded", "CookieScriptAccept", "CookieScriptAcceptAll", "CookieScriptReject", "CookieScriptClose"];
|
|
149
|
+
|
|
150
|
+
export function installEarlyInteractionCapture(config: EarlyCaptureConfig): void {
|
|
151
|
+
globalThis.__dripEarlyInteractions?.stop();
|
|
152
|
+
const mode = config.storageMode ?? "localStorage";
|
|
153
|
+
if (!Number.isSafeInteger(config.configRevision) || isOptedOut()) { clearEarlyInteractions(); return; }
|
|
154
|
+
const entries = readPersisted(mode);
|
|
155
|
+
let armed = false;
|
|
156
|
+
const sessionId = getOrCreateSessionId();
|
|
157
|
+
const assignmentMap = new Map(config.assignments.map((a) => [a.experimentId, a]));
|
|
158
|
+
const forcedExperimentIds = new Set(config.forcedExperimentIds ?? []);
|
|
159
|
+
const targetingAttributes = buildTargetingAttributes(
|
|
160
|
+
config.attributes ?? {},
|
|
161
|
+
config.pageUrl,
|
|
162
|
+
{ persistSignals: false },
|
|
163
|
+
);
|
|
164
|
+
const activePageIds = matchedPageRuleIds(
|
|
165
|
+
config.experiments.flatMap((experiment) => [...(experiment.targeting?.pageRules ?? []), ...(experiment.goalPageRules ?? [])]),
|
|
166
|
+
{ url: config.pageUrl, attributes: targetingAttributes },
|
|
167
|
+
);
|
|
168
|
+
const goals = config.experiments.flatMap((experiment) => {
|
|
169
|
+
if (
|
|
170
|
+
!forcedExperimentIds.has(experiment.id) &&
|
|
171
|
+
weekdayScheduleBlocksDelivery(
|
|
172
|
+
experiment.weekdaySchedule,
|
|
173
|
+
new Date(config.now ?? Date.now()),
|
|
174
|
+
)
|
|
175
|
+
) return [];
|
|
176
|
+
const assignment = assignmentMap.get(experiment.id);
|
|
177
|
+
if (!assignment || !Number.isSafeInteger(assignment.assignmentEpoch) || (assignment.assignmentEpoch ?? 0) < 1) return [];
|
|
178
|
+
return (experiment.goals ?? []).filter((goal) => {
|
|
179
|
+
if ((goal.type !== "click" && String(goal.type) !== "submit") || !goal.selector) return false;
|
|
180
|
+
if (goal.pageId) return activePageIds.has(goal.pageId);
|
|
181
|
+
return !goal.urlPattern || safeRegexTest(goal.urlPattern, config.pageUrl);
|
|
182
|
+
}).map((goal) => ({ goal, assignment }));
|
|
183
|
+
});
|
|
184
|
+
const lastActions = new Map<string, { at: number; kind: "click" | "submit"; form: Element | null }>();
|
|
185
|
+
const capture = (event: Event, kind: "click" | "submit") => {
|
|
186
|
+
if (isOptedOut()) { stop(); clearEarlyInteractions(mode); return; }
|
|
187
|
+
const consent = consentState(config); if (consent !== "allowed") { if (consent === "denied") clearEarlyInteractions(mode); return; }
|
|
188
|
+
const target = event.target; if (!(target instanceof Element)) return;
|
|
189
|
+
for (const { goal, assignment } of goals) {
|
|
190
|
+
const matched = matchingElement(target, goal, kind); if (!matched) continue;
|
|
191
|
+
const form = logicalForm(matched); const key = `${goal.id}:${assignment.experimentId}`;
|
|
192
|
+
const prior = lastActions.get(key);
|
|
193
|
+
if (prior && prior.kind !== kind && Date.now() - prior.at <= PAIRED_ACTION_WINDOW_MS && form && prior.form === form) continue;
|
|
194
|
+
lastActions.set(key, { at: Date.now(), kind, form });
|
|
195
|
+
entries.push({ goalId: goal.id, experimentId: assignment.experimentId, variationId: assignment.variationId, assignmentEpoch: assignment.assignmentEpoch!, kind, data: { early_interaction: true }, timestamp: Date.now(), sessionId, pageUrl: config.pageUrl.slice(0, 2048), configRevision: config.configRevision });
|
|
196
|
+
}
|
|
197
|
+
if (entries.length > EARLY_INTERACTION_MAX_ENTRIES) entries.splice(0, entries.length - EARLY_INTERACTION_MAX_ENTRIES); persist(mode, entries);
|
|
198
|
+
};
|
|
199
|
+
const click = (event: Event) => capture(event, "click"); const submit = (event: Event) => capture(event, "submit");
|
|
200
|
+
const arm = () => { if (armed) return; armed = true; document.addEventListener("click", click, true); document.addEventListener("submit", submit, true); };
|
|
201
|
+
const disarm = () => { if (!armed) return; armed = false; document.removeEventListener("click", click, true); document.removeEventListener("submit", submit, true); };
|
|
202
|
+
const consentChange = () => { const consent = consentState(config); if (consent === "allowed") arm(); else { disarm(); if (consent === "denied") clearEarlyInteractions(mode); } };
|
|
203
|
+
const stop = () => { disarm(); CONSENT_EVENTS.forEach((event) => window.removeEventListener(event, consentChange)); };
|
|
204
|
+
CONSENT_EVENTS.forEach((event) => window.addEventListener(event, consentChange)); consentChange();
|
|
205
|
+
globalThis.__dripEarlyInteractions = { entries, storageMode: mode, stop };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function drainEarlyInteractions(input: ConsentConfig & { qaMode?: boolean; environment?: string; configRevision: number | undefined; sessionId: string; storageMode?: StorageMode; experiments: Experiment[]; now?: number; forcedExperimentIds?: string[]; consume: (entry: EarlyInteractionEntry) => void }): void {
|
|
209
|
+
const mode = input.storageMode ?? globalThis.__dripEarlyInteractions?.storageMode ?? "localStorage";
|
|
210
|
+
globalThis.__dripEarlyInteractions?.stop();
|
|
211
|
+
if (isOptedOut() || consentState(input) !== "allowed") { clearEarlyInteractions(mode); return; }
|
|
212
|
+
const entries = [...readPersisted(mode), ...(globalThis.__dripEarlyInteractions?.entries ?? [])]; clearEarlyInteractions(mode);
|
|
213
|
+
const now = input.now ?? Date.now(); const seen = new Set<string>();
|
|
214
|
+
const forcedExperimentIds = new Set(input.forcedExperimentIds ?? []);
|
|
215
|
+
const experiments = new Map(input.experiments.filter((experiment) =>
|
|
216
|
+
isExperimentServable(experiment, { qaMode: input.qaMode === true, environment: input.environment || "production" }) &&
|
|
217
|
+
(
|
|
218
|
+
forcedExperimentIds.has(experiment.id) ||
|
|
219
|
+
!weekdayScheduleBlocksDelivery(experiment.weekdaySchedule, new Date(now))
|
|
220
|
+
)
|
|
221
|
+
).map((experiment) => [experiment.id, experiment]));
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const identity = JSON.stringify(entry); if (seen.has(identity)) continue; seen.add(identity);
|
|
224
|
+
if (!entry || typeof entry !== "object" || !Number.isFinite(entry.timestamp) || now - entry.timestamp > EARLY_INTERACTION_TTL_MS || entry.timestamp > now) continue;
|
|
225
|
+
if (entry.sessionId !== input.sessionId || entry.configRevision !== input.configRevision) continue;
|
|
226
|
+
const experiment = experiments.get(entry.experimentId);
|
|
227
|
+
if (!experiment || experiment.assignmentEpoch !== entry.assignmentEpoch || !experiment.variations.some((variation) => variation.id === entry.variationId) || !(experiment.goals ?? []).some((goal) => goal.id === entry.goalId)) continue;
|
|
228
|
+
try { input.consume(entry); } catch {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
assignmentsHaveCustomJs,
|
|
6
|
+
assignmentsHaveStructuralMutations,
|
|
7
|
+
installEarlyReassert,
|
|
8
|
+
shouldUseEarlyReassert,
|
|
9
|
+
} from "./early-reassert";
|
|
10
|
+
import type { Assignment } from "./types";
|
|
11
|
+
|
|
12
|
+
const assignment = {
|
|
13
|
+
experimentId: "exp", variationId: "treatment", mutations: [{ selector: "#hero", type: "text", value: "Treatment" }],
|
|
14
|
+
} as Assignment;
|
|
15
|
+
const runtimeSource = () => readFileSync(
|
|
16
|
+
process.cwd().endsWith("packages/sdk") ? "src/activation-runtime.ts" : "packages/sdk/src/activation-runtime.ts",
|
|
17
|
+
"utf8",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe("early hydration re-assert", () => {
|
|
21
|
+
const stops: Array<() => void> = [];
|
|
22
|
+
const install = (input: Parameters<typeof installEarlyReassert>[0]) => {
|
|
23
|
+
const stop = installEarlyReassert(input);
|
|
24
|
+
stops.push(stop);
|
|
25
|
+
return stop;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
stops.splice(0).forEach((stop) => stop());
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
vi.useRealTimers();
|
|
32
|
+
document.body.innerHTML = "";
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("requires the opt-in, trusted inline predecision, and visual consent", () => {
|
|
36
|
+
const eligible = { applyTiming: "early_reassert", hasTrustedPredecision: true, consentGranted: true, dntBlocked: false, configSource: "inline" };
|
|
37
|
+
expect(shouldUseEarlyReassert(eligible)).toBe(true);
|
|
38
|
+
expect(shouldUseEarlyReassert({ ...eligible, applyTiming: "after_hydration" })).toBe(false);
|
|
39
|
+
expect(shouldUseEarlyReassert({ ...eligible, hasTrustedPredecision: false })).toBe(false);
|
|
40
|
+
expect(shouldUseEarlyReassert({ ...eligible, consentGranted: false })).toBe(false);
|
|
41
|
+
expect(shouldUseEarlyReassert({ ...eligible, configSource: "network" })).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("repairs a hydration revert within one animation frame", async () => {
|
|
45
|
+
vi.useFakeTimers();
|
|
46
|
+
document.body.innerHTML = '<h1 id="hero">Treatment</h1>';
|
|
47
|
+
const diagnostics = {};
|
|
48
|
+
const reapply = vi.fn(() => { document.querySelector("#hero")!.textContent = "Treatment"; });
|
|
49
|
+
const stop = install({ resolveAssignments: () => [assignment], diagnostics, reapply });
|
|
50
|
+
await Promise.resolve();
|
|
51
|
+
document.querySelector("#hero")!.textContent = "Server";
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
expect(reapply).not.toHaveBeenCalled();
|
|
54
|
+
vi.advanceTimersByTime(16);
|
|
55
|
+
expect(reapply).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(document.querySelector("#hero")!.textContent).toBe("Treatment");
|
|
57
|
+
expect(diagnostics).toMatchObject({ reasserts_count: 1 });
|
|
58
|
+
stop();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("arms from an empty render plane when assignments arrive during hydration", async () => {
|
|
62
|
+
vi.useFakeTimers();
|
|
63
|
+
document.body.innerHTML = '<h1 id="hero">Treatment</h1>';
|
|
64
|
+
let live: Assignment[] = [];
|
|
65
|
+
const reapply = vi.fn(() => {
|
|
66
|
+
document.querySelector("#hero")!.textContent = "Treatment";
|
|
67
|
+
});
|
|
68
|
+
install({ resolveAssignments: () => live, diagnostics: {}, reapply });
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
|
|
71
|
+
live = [assignment];
|
|
72
|
+
document.querySelector("#hero")!.textContent = "Framework";
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
vi.advanceTimersByTime(16);
|
|
75
|
+
|
|
76
|
+
expect(reapply).toHaveBeenCalledWith([assignment]);
|
|
77
|
+
expect(document.querySelector("#hero")!.textContent).toBe("Treatment");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("trips the cap instead of re-applying forever", async () => {
|
|
81
|
+
vi.useFakeTimers();
|
|
82
|
+
document.body.innerHTML = '<h1 id="hero">Treatment</h1>';
|
|
83
|
+
const diagnostics: { reasserts_count?: number; revert_loops_detected?: number } = {};
|
|
84
|
+
const reapply = vi.fn();
|
|
85
|
+
install({ resolveAssignments: () => [assignment], diagnostics, reapply, cap: 2 });
|
|
86
|
+
await Promise.resolve();
|
|
87
|
+
for (let index = 0; index < 3; index += 1) {
|
|
88
|
+
document.querySelector("#hero")!.setAttribute("data-render", String(index));
|
|
89
|
+
await Promise.resolve();
|
|
90
|
+
vi.advanceTimersByTime(40);
|
|
91
|
+
}
|
|
92
|
+
expect(reapply).toHaveBeenCalledTimes(2);
|
|
93
|
+
expect(diagnostics.revert_loops_detected).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ignores unrelated DOM churn", async () => {
|
|
97
|
+
vi.useFakeTimers();
|
|
98
|
+
document.body.innerHTML = '<h1 id="hero">Treatment</h1><p id="other">x</p>';
|
|
99
|
+
const reapply = vi.fn();
|
|
100
|
+
install({ resolveAssignments: () => [assignment], diagnostics: {}, reapply });
|
|
101
|
+
await Promise.resolve();
|
|
102
|
+
document.querySelector("#other")!.textContent = "y";
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
vi.advanceTimersByTime(20);
|
|
105
|
+
expect(reapply).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not spend the cap on ancestor churn before repairing a real revert", async () => {
|
|
109
|
+
vi.useFakeTimers();
|
|
110
|
+
document.body.innerHTML = '<main id="wrapper"><h1 id="hero">Treatment</h1></main>';
|
|
111
|
+
const reapply = vi.fn(() => { document.querySelector("#hero")!.textContent = "Treatment"; });
|
|
112
|
+
install({ resolveAssignments: () => [assignment], diagnostics: {}, reapply, cap: 1 });
|
|
113
|
+
await Promise.resolve();
|
|
114
|
+
document.querySelector("#wrapper")!.className = "hydrated";
|
|
115
|
+
document.querySelector("#wrapper")!.append(document.createElement("span"));
|
|
116
|
+
await Promise.resolve();
|
|
117
|
+
vi.advanceTimersByTime(40);
|
|
118
|
+
expect(reapply).not.toHaveBeenCalled();
|
|
119
|
+
document.querySelector("#hero")!.textContent = "Server";
|
|
120
|
+
await Promise.resolve();
|
|
121
|
+
vi.advanceTimersByTime(40);
|
|
122
|
+
expect(reapply).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(document.querySelector("#hero")!.textContent).toBe("Treatment");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("fails closed for withheld offers while reasserting authorized assignments", async () => {
|
|
127
|
+
vi.useFakeTimers();
|
|
128
|
+
document.body.innerHTML = '<h1 id="hero">Treatment</h1><p id="offer">Offer</p>';
|
|
129
|
+
const reapply = vi.fn();
|
|
130
|
+
install({
|
|
131
|
+
resolveAssignments: () => [assignment], diagnostics: {}, reapply,
|
|
132
|
+
});
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
document.querySelector("#offer")!.textContent = "Control";
|
|
135
|
+
await Promise.resolve();
|
|
136
|
+
vi.advanceTimersByTime(20);
|
|
137
|
+
expect(reapply).not.toHaveBeenCalled();
|
|
138
|
+
document.querySelector("#hero")!.textContent = "Server";
|
|
139
|
+
await Promise.resolve();
|
|
140
|
+
vi.advanceTimersByTime(20);
|
|
141
|
+
expect(reapply).toHaveBeenCalledOnce();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// F3
|
|
145
|
+
it("stops instead of repairing once the live artifact set is empty", async () => {
|
|
146
|
+
vi.useFakeTimers();
|
|
147
|
+
document.body.innerHTML = '<h1 id="hero">Treatment</h1>';
|
|
148
|
+
const reapply = vi.fn();
|
|
149
|
+
let live: Assignment[] = [assignment];
|
|
150
|
+
install({ resolveAssignments: () => live, diagnostics: {}, reapply });
|
|
151
|
+
await Promise.resolve();
|
|
152
|
+
live = [];
|
|
153
|
+
document.querySelector("#hero")!.textContent = "Server";
|
|
154
|
+
await Promise.resolve();
|
|
155
|
+
vi.advanceTimersByTime(40);
|
|
156
|
+
expect(reapply).not.toHaveBeenCalled();
|
|
157
|
+
|
|
158
|
+
// The watcher is dead by construction: a later live entry does not revive it.
|
|
159
|
+
live = [assignment];
|
|
160
|
+
document.querySelector("#hero")!.textContent = "Server again";
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
vi.advanceTimersByTime(40);
|
|
163
|
+
expect(reapply).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("repairs with the live assignment set, not the install-time one", async () => {
|
|
167
|
+
vi.useFakeTimers();
|
|
168
|
+
document.body.innerHTML = '<h1 id="hero">Treatment</h1>';
|
|
169
|
+
const reapply = vi.fn();
|
|
170
|
+
const current = { ...assignment } as Assignment;
|
|
171
|
+
install({ resolveAssignments: () => [current], diagnostics: {}, reapply });
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
document.querySelector("#hero")!.textContent = "Server";
|
|
174
|
+
await Promise.resolve();
|
|
175
|
+
vi.advanceTimersByTime(20);
|
|
176
|
+
expect(reapply).toHaveBeenCalledWith([current]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("defers structural assignments instead of counting unrepaired reverts", () => {
|
|
180
|
+
const structural = { ...assignment, mutations: [{ selector: "#hero", action: "insertAfter", html: "<p>Treatment</p>" }] } as Assignment;
|
|
181
|
+
expect(assignmentsHaveStructuralMutations([structural])).toBe(true);
|
|
182
|
+
expect(assignmentsHaveStructuralMutations([assignment])).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.each([
|
|
186
|
+
["variation JS", { js: "document.title='variation'" }, true],
|
|
187
|
+
["experiment JS", { experimentJs: "document.title='experiment'" }, true],
|
|
188
|
+
["external JS URL", { externalJsUrl: "https://cdn.example.test/treatment.js" }, true],
|
|
189
|
+
["reset JS", { resetJs: "document.title='reset'", experimentResetJs: "document.title='experiment reset'" }, false],
|
|
190
|
+
["CSS", { css: "#hero { color: red; }", experimentCss: "#hero { display: block; }" }, false],
|
|
191
|
+
["empty variation JS", { js: " " }, false],
|
|
192
|
+
])("detects %s when deciding whether early reassert is safe", (_label, fields, expected) => {
|
|
193
|
+
expect(assignmentsHaveCustomJs([{ ...assignment, ...fields } as Assignment])).toBe(expected);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("generation-scopes teardown so a late first init cannot stop the second observer", () => {
|
|
197
|
+
const runtime = runtimeSource();
|
|
198
|
+
expect(runtime).toMatch(/clearTimeout\(runtimeSharedState\.earlyReassertTeardownTimer\)/);
|
|
199
|
+
expect(runtime).toMatch(/if \(runtimeSharedState\.stopEarlyReassert !== stop\) return;/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("diagnoses rejected hydration waits and still enters teardown scheduling", () => {
|
|
203
|
+
const runtime = runtimeSource();
|
|
204
|
+
expect(runtime).toMatch(/\.catch\(\(\) => debugWarn\("hydration", "wait failed"\)\)\s*\.finally/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Assignment } from "./types";
|
|
2
|
+
|
|
3
|
+
export type EarlyReassertDiagnostics = {
|
|
4
|
+
early_apply_at?: number;
|
|
5
|
+
reasserts_count?: number;
|
|
6
|
+
revert_loops_detected?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function assignmentsHaveStructuralMutations(assignments: Assignment[]): boolean {
|
|
10
|
+
return assignments.some((assignment) => assignment.mutations.some((mutation) =>
|
|
11
|
+
mutation.action === "remove" || mutation.action?.startsWith("insert")));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function assignmentsHaveCustomJs(assignments: Assignment[]): boolean {
|
|
15
|
+
return assignments.some((assignment) =>
|
|
16
|
+
Boolean(
|
|
17
|
+
assignment.js?.trim()
|
|
18
|
+
|| assignment.experimentJs?.trim()
|
|
19
|
+
|| assignment.externalJsUrl,
|
|
20
|
+
));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_CAP = 24;
|
|
24
|
+
const LOOP_FRAME_LIMIT = 6;
|
|
25
|
+
|
|
26
|
+
export function shouldUseEarlyReassert(input: {
|
|
27
|
+
applyTiming?: string;
|
|
28
|
+
hasTrustedPredecision: boolean;
|
|
29
|
+
consentGranted: boolean;
|
|
30
|
+
dntBlocked: boolean;
|
|
31
|
+
configSource: string;
|
|
32
|
+
}): boolean {
|
|
33
|
+
return input.applyTiming === "early_reassert"
|
|
34
|
+
&& input.configSource === "inline"
|
|
35
|
+
&& input.hasTrustedPredecision
|
|
36
|
+
&& input.consentGranted
|
|
37
|
+
&& !input.dntBlocked;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Watches only treatment selectors and batches hydration repairs into the next
|
|
42
|
+
* animation frame. The observer is deliberately SDK-side: the inline decision
|
|
43
|
+
* bootstrap remains a small, synchronous decision-only artifact.
|
|
44
|
+
*
|
|
45
|
+
* The watcher never captures an assignment array. It resolves the live applied
|
|
46
|
+
* set through `resolveAssignments` at fire time, so a page teardown that drops
|
|
47
|
+
* the artifacts stops it by construction rather than through a
|
|
48
|
+
* `stopEarlyReassert()` call nobody makes.
|
|
49
|
+
*/
|
|
50
|
+
export function installEarlyReassert(input: {
|
|
51
|
+
resolveAssignments: () => Assignment[];
|
|
52
|
+
reapply: (assignments: Assignment[]) => void;
|
|
53
|
+
diagnostics: EarlyReassertDiagnostics;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
cap?: number;
|
|
56
|
+
}): () => void {
|
|
57
|
+
if (typeof document === "undefined" || typeof MutationObserver === "undefined") return () => {};
|
|
58
|
+
const resolveWatchSet = (): { assignments: Assignment[]; selectors: string[] } => {
|
|
59
|
+
const assignments = input.resolveAssignments();
|
|
60
|
+
return {
|
|
61
|
+
assignments,
|
|
62
|
+
selectors: assignments.flatMap((assignment) =>
|
|
63
|
+
assignment.mutations.map((mutation) => mutation.selector)),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
let hasObservedWatchSet = resolveWatchSet().selectors.length > 0;
|
|
67
|
+
|
|
68
|
+
const cap = Math.max(1, input.cap ?? DEFAULT_CAP);
|
|
69
|
+
let stopped = false;
|
|
70
|
+
let armed = false;
|
|
71
|
+
let frame: number | ReturnType<typeof setTimeout> | null = null;
|
|
72
|
+
let consecutiveFrames = 0;
|
|
73
|
+
let lastFrameAt = 0;
|
|
74
|
+
const matchesTreatment = (node: Node, selectors: string[]): boolean => {
|
|
75
|
+
const element = node instanceof Element ? node : node.parentElement;
|
|
76
|
+
if (!element) return false;
|
|
77
|
+
return selectors.some((selector) => {
|
|
78
|
+
try { return element.matches(selector) || Boolean(element.closest(selector)); }
|
|
79
|
+
catch { return false; }
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const cancelFrame = () => {
|
|
83
|
+
if (frame === null) return;
|
|
84
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame as number);
|
|
85
|
+
else clearTimeout(frame as ReturnType<typeof setTimeout>);
|
|
86
|
+
frame = null;
|
|
87
|
+
};
|
|
88
|
+
const stop = () => {
|
|
89
|
+
if (stopped) return;
|
|
90
|
+
stopped = true;
|
|
91
|
+
observer.disconnect();
|
|
92
|
+
cancelFrame();
|
|
93
|
+
clearTimeout(timeout);
|
|
94
|
+
};
|
|
95
|
+
const run = (timestamp: number) => {
|
|
96
|
+
frame = null;
|
|
97
|
+
if (stopped) return;
|
|
98
|
+
// A selector this watcher armed for can still churn after its page was torn
|
|
99
|
+
// down. Resolve first: with no live artifact left there is nothing to
|
|
100
|
+
// repair, and re-applying would leave an unrevertable treatment behind.
|
|
101
|
+
const live = resolveWatchSet();
|
|
102
|
+
if (!live.selectors.length) {
|
|
103
|
+
if (hasObservedWatchSet) stop();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
hasObservedWatchSet = true;
|
|
107
|
+
const count = input.diagnostics.reasserts_count ?? 0;
|
|
108
|
+
if (count >= cap) {
|
|
109
|
+
input.diagnostics.revert_loops_detected = (input.diagnostics.revert_loops_detected ?? 0) + 1;
|
|
110
|
+
stop();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
consecutiveFrames = timestamp - lastFrameAt < 34 ? consecutiveFrames + 1 : 1;
|
|
114
|
+
lastFrameAt = timestamp;
|
|
115
|
+
if (consecutiveFrames > LOOP_FRAME_LIMIT) {
|
|
116
|
+
input.diagnostics.revert_loops_detected = (input.diagnostics.revert_loops_detected ?? 0) + 1;
|
|
117
|
+
stop();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
input.diagnostics.reasserts_count = count + 1;
|
|
121
|
+
input.reapply(live.assignments);
|
|
122
|
+
// The repair itself mutates the same nodes. Drop those synchronous records
|
|
123
|
+
// so only a later framework revert schedules another frame.
|
|
124
|
+
observer.takeRecords();
|
|
125
|
+
};
|
|
126
|
+
const schedule = () => {
|
|
127
|
+
if (stopped || frame !== null) return;
|
|
128
|
+
if (typeof requestAnimationFrame === "function") frame = requestAnimationFrame(run);
|
|
129
|
+
else frame = setTimeout(() => run(Date.now()), 16);
|
|
130
|
+
};
|
|
131
|
+
const observer = new MutationObserver((records) => {
|
|
132
|
+
if (!armed) return;
|
|
133
|
+
const live = resolveWatchSet();
|
|
134
|
+
if (!live.selectors.length) {
|
|
135
|
+
if (hasObservedWatchSet) stop();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
hasObservedWatchSet = true;
|
|
139
|
+
if (records.some((record) => matchesTreatment(record.target, live.selectors) || Array.from(record.addedNodes).some((node) => {
|
|
140
|
+
if (matchesTreatment(node, live.selectors)) return true;
|
|
141
|
+
if (!(node instanceof Element)) return false;
|
|
142
|
+
return live.selectors.some((selector) => {
|
|
143
|
+
try { return Boolean(node.querySelector(selector)); }
|
|
144
|
+
catch { return false; }
|
|
145
|
+
});
|
|
146
|
+
}))) schedule();
|
|
147
|
+
});
|
|
148
|
+
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
149
|
+
queueMicrotask(() => { observer.takeRecords(); armed = true; });
|
|
150
|
+
const timeout = setTimeout(stop, Math.max(250, input.timeoutMs ?? 8_000));
|
|
151
|
+
return stop;
|
|
152
|
+
}
|