@drip-apex/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React bindings for Drip SDK.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { DripProvider, useExperiment, useVariation, useFeatureFlag, useFeatureFlagValue, useTrackGoal } from "@drip-apex/sdk/react";
|
|
6
|
+
*
|
|
7
|
+
* function App() {
|
|
8
|
+
* return (
|
|
9
|
+
* <DripProvider shopId="shop_xxx" endpoint="https://events.drip-apex.com">
|
|
10
|
+
* <HeroSection />
|
|
11
|
+
* </DripProvider>
|
|
12
|
+
* );
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* function HeroSection() {
|
|
16
|
+
* const assignment = useExperiment("hero-test");
|
|
17
|
+
* const variation = useVariation("hero-test");
|
|
18
|
+
* const isDarkMode = useFeatureFlag("dark-mode-rollout");
|
|
19
|
+
* const offerCopy = useFeatureFlagValue("offer-copy", "Default offer");
|
|
20
|
+
* const trackGoal = useTrackGoal();
|
|
21
|
+
*
|
|
22
|
+
* return (
|
|
23
|
+
* <div>
|
|
24
|
+
* <h1>{variation?.id === "variant-a" ? "New headline" : "Default headline"}</h1>
|
|
25
|
+
* <button onClick={() => trackGoal("cta-click")}>CTA</button>
|
|
26
|
+
* </div>
|
|
27
|
+
* );
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
createContext,
|
|
33
|
+
useContext,
|
|
34
|
+
useEffect,
|
|
35
|
+
useMemo,
|
|
36
|
+
useState,
|
|
37
|
+
useCallback,
|
|
38
|
+
useRef,
|
|
39
|
+
type ReactNode,
|
|
40
|
+
} from "react";
|
|
41
|
+
import type { Assignment, Experiment, InitConfig, Variation } from "../types";
|
|
42
|
+
import type { RevenueEventData } from "../index";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Context
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
interface DripContextValue {
|
|
49
|
+
/** Current experiment assignments. */
|
|
50
|
+
assignments: Assignment[];
|
|
51
|
+
/** Loaded experiment configs. */
|
|
52
|
+
experiments: Experiment[];
|
|
53
|
+
/** Whether the SDK is still loading config. */
|
|
54
|
+
loading: boolean;
|
|
55
|
+
/** Track a goal by goalId with optional data. */
|
|
56
|
+
trackGoal: (goalId: string, data?: Record<string, unknown>) => void;
|
|
57
|
+
/** Track a custom event. */
|
|
58
|
+
track: (type: string, data?: Record<string, unknown>) => void;
|
|
59
|
+
/** Track revenue. */
|
|
60
|
+
trackRevenue: (revenue: number, data?: RevenueEventData) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DripContext = createContext<DripContextValue | null>(null);
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Provider
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export interface DripProviderProps extends Omit<InitConfig, "onExperimentViewed"> {
|
|
70
|
+
children: ReactNode;
|
|
71
|
+
/** Callback when an experiment is viewed (assignment applied). */
|
|
72
|
+
onExperimentViewed?: (assignment: Assignment) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Drip SDK provider for React applications.
|
|
77
|
+
*
|
|
78
|
+
* Initializes the SDK, fetches experiment config (if shopId provided),
|
|
79
|
+
* and provides assignment context to child components.
|
|
80
|
+
*/
|
|
81
|
+
export function DripProvider({
|
|
82
|
+
children,
|
|
83
|
+
onExperimentViewed,
|
|
84
|
+
...config
|
|
85
|
+
}: DripProviderProps) {
|
|
86
|
+
const [assignments, setAssignments] = useState<Assignment[]>([]);
|
|
87
|
+
const [experiments, setExperiments] = useState<Experiment[]>(config.experiments ?? []);
|
|
88
|
+
const [loading, setLoading] = useState(!config.experiments?.length);
|
|
89
|
+
const initRef = useRef(false);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (initRef.current) return;
|
|
93
|
+
initRef.current = true;
|
|
94
|
+
let mounted = true;
|
|
95
|
+
|
|
96
|
+
const syncFromSdk = () => {
|
|
97
|
+
// Dynamic import to avoid SSR issues (SDK accesses DOM/window)
|
|
98
|
+
import("../index").then((sdk) => {
|
|
99
|
+
if (!mounted) return;
|
|
100
|
+
setAssignments(sdk.getAssignments());
|
|
101
|
+
setExperiments(sdk.getExperiments());
|
|
102
|
+
setLoading(false);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const onInitialized = () => {
|
|
107
|
+
syncFromSdk();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
document.addEventListener("drip_initialized", onInitialized as EventListener);
|
|
111
|
+
|
|
112
|
+
import("../index").then((sdk) => {
|
|
113
|
+
if (!mounted) return;
|
|
114
|
+
const result = sdk.init({
|
|
115
|
+
...config,
|
|
116
|
+
onExperimentViewed: (a) => {
|
|
117
|
+
onExperimentViewed?.(a);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
setAssignments(result);
|
|
121
|
+
setExperiments(config.experiments ?? sdk.getExperiments());
|
|
122
|
+
setLoading(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return () => {
|
|
126
|
+
mounted = false;
|
|
127
|
+
document.removeEventListener("drip_initialized", onInitialized as EventListener);
|
|
128
|
+
};
|
|
129
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
130
|
+
|
|
131
|
+
const trackGoal = useCallback((goalId: string, data?: Record<string, unknown>) => {
|
|
132
|
+
import("../index").then((sdk) => sdk.trackGoal(goalId, data));
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
const track = useCallback((type: string, data?: Record<string, unknown>) => {
|
|
136
|
+
import("../index").then((sdk) => sdk.track(type, data));
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
const trackRevenue = useCallback((revenue: number, data?: RevenueEventData) => {
|
|
140
|
+
import("../index").then((sdk) => sdk.trackRevenue(revenue, data));
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
const value = useMemo<DripContextValue>(
|
|
144
|
+
() => ({ assignments, experiments, loading, trackGoal, track, trackRevenue }),
|
|
145
|
+
[assignments, experiments, loading, trackGoal, track, trackRevenue],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return <DripContext.Provider value={value}>{children}</DripContext.Provider>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Hooks
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function useDrip(): DripContextValue {
|
|
156
|
+
const ctx = useContext(DripContext);
|
|
157
|
+
if (!ctx) {
|
|
158
|
+
throw new Error("useDrip must be used within a <DripProvider>");
|
|
159
|
+
}
|
|
160
|
+
return ctx;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get the assignment for a specific experiment by ID or slug.
|
|
165
|
+
* Returns `null` if the user is not assigned to this experiment.
|
|
166
|
+
*/
|
|
167
|
+
export function useExperiment(experimentId: string): Assignment | null {
|
|
168
|
+
const { assignments } = useDrip();
|
|
169
|
+
return useMemo(
|
|
170
|
+
() => assignments.find((a) => a.experimentId === experimentId) ?? null,
|
|
171
|
+
[assignments, experimentId],
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get the assigned variation for a specific experiment.
|
|
177
|
+
* Returns `null` if the user is not assigned.
|
|
178
|
+
*/
|
|
179
|
+
export function useVariation(experimentId: string): Pick<Variation, "id" | "isControl"> | null {
|
|
180
|
+
const assignment = useExperiment(experimentId);
|
|
181
|
+
return useMemo(() => {
|
|
182
|
+
if (!assignment) return null;
|
|
183
|
+
return { id: assignment.variationId, isControl: assignment.isControl };
|
|
184
|
+
}, [assignment]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Evaluate a feature flag (experiment-backed).
|
|
189
|
+
* Returns `true` if the user is bucketed into a non-control variation.
|
|
190
|
+
*/
|
|
191
|
+
export function useFeatureFlag(flagId: string): boolean {
|
|
192
|
+
const { assignments } = useDrip();
|
|
193
|
+
return useMemo(() => {
|
|
194
|
+
const a = assignments.find((a) => a.experimentId === flagId);
|
|
195
|
+
return a ? !a.isControl : false;
|
|
196
|
+
}, [assignments, flagId]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read the typed value assigned by a feature flag.
|
|
201
|
+
* Returns the fallback when the visitor is not assigned or the flag is missing.
|
|
202
|
+
*/
|
|
203
|
+
export function useFeatureFlagValue<T = unknown>(flagId: string, fallback: T): T | unknown {
|
|
204
|
+
const { assignments } = useDrip();
|
|
205
|
+
return useMemo(() => {
|
|
206
|
+
const assignment = assignments.find((assignment) => assignment.experimentId === flagId);
|
|
207
|
+
return assignment?.value === undefined ? fallback : assignment.value;
|
|
208
|
+
}, [assignments, fallback, flagId]);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Returns a function to track goals.
|
|
213
|
+
*/
|
|
214
|
+
export function useTrackGoal(): (goalId: string, data?: Record<string, unknown>) => void {
|
|
215
|
+
const { trackGoal } = useDrip();
|
|
216
|
+
return trackGoal;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Returns a function to track custom events.
|
|
221
|
+
*/
|
|
222
|
+
export function useTrack(): (type: string, data?: Record<string, unknown>) => void {
|
|
223
|
+
const { track } = useDrip();
|
|
224
|
+
return track;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Returns a function to track revenue.
|
|
229
|
+
*/
|
|
230
|
+
export function useTrackRevenue(): (revenue: number, data?: Record<string, unknown>) => void {
|
|
231
|
+
const { trackRevenue } = useDrip();
|
|
232
|
+
return trackRevenue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get all current assignments.
|
|
237
|
+
*/
|
|
238
|
+
export function useAssignments(): Assignment[] {
|
|
239
|
+
const { assignments } = useDrip();
|
|
240
|
+
return assignments;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Whether the SDK is still loading experiment config.
|
|
245
|
+
*/
|
|
246
|
+
export function useLoading(): boolean {
|
|
247
|
+
const { loading } = useDrip();
|
|
248
|
+
return loading;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Re-export types for convenience
|
|
252
|
+
export type { Assignment, Experiment, Variation, InitConfig } from "../types";
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { applyToElement, applyTrackedMutation, resetAppliedMutations } from "./mutate";
|
|
4
|
+
import { runMuted } from "./observer-mute";
|
|
5
|
+
import { resetReassertSentinel, trackAppliedMutation } from "./reassert-sentinel";
|
|
6
|
+
import type { Mutation } from "./types";
|
|
7
|
+
|
|
8
|
+
let frames: FrameRequestCallback[] = [];
|
|
9
|
+
|
|
10
|
+
async function deliverMutations(): Promise<void> {
|
|
11
|
+
await Promise.resolve();
|
|
12
|
+
await Promise.resolve();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function runNextFrame(): Promise<void> {
|
|
16
|
+
await deliverMutations();
|
|
17
|
+
const callback = frames.shift();
|
|
18
|
+
callback?.(performance.now());
|
|
19
|
+
await deliverMutations();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function applyAndTrack(element: Element, mutation: Mutation, context = {}) {
|
|
23
|
+
// The tracked apply captures the pre-treatment snapshot the sentinel uses
|
|
24
|
+
// to attribute in-place framework reverts (matching the production path).
|
|
25
|
+
runMuted(() => applyTrackedMutation(mutation, element));
|
|
26
|
+
trackAppliedMutation(element, mutation, context, (target, applied) => applyToElement(applied, target));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("reassert sentinel", () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
frames = [];
|
|
32
|
+
vi.stubGlobal("requestAnimationFrame", vi.fn((callback: FrameRequestCallback) => {
|
|
33
|
+
frames.push(callback);
|
|
34
|
+
return frames.length;
|
|
35
|
+
}));
|
|
36
|
+
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
37
|
+
resetAppliedMutations();
|
|
38
|
+
document.body.innerHTML = "";
|
|
39
|
+
delete (window as Window & { __dripPerf?: unknown }).__dripPerf;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
resetReassertSentinel();
|
|
44
|
+
vi.unstubAllGlobals();
|
|
45
|
+
vi.restoreAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it.each([
|
|
49
|
+
["text", { selector: "#target", action: "text", text: "Variant" } as Mutation, (element: Element) => { element.textContent = "Control"; }, (element: Element) => element.textContent],
|
|
50
|
+
["attribute", { selector: "#target", action: "attribute", attributes: { "aria-label": "Variant" } } as Mutation, (element: Element) => { element.setAttribute("aria-label", "Control"); }, (element: Element) => element.getAttribute("aria-label")],
|
|
51
|
+
["html", { selector: "#target", action: "html", html: "<strong>Variant</strong>" } as Mutation, (element: Element) => { element.innerHTML = "<em>Control</em>"; }, (element: Element) => element.textContent],
|
|
52
|
+
])("re-applies an external %s overwrite on the next animation frame", async (_label, mutation, overwrite, read) => {
|
|
53
|
+
const element = document.createElement("div");
|
|
54
|
+
element.id = "target";
|
|
55
|
+
document.body.appendChild(element);
|
|
56
|
+
applyAndTrack(element, mutation);
|
|
57
|
+
|
|
58
|
+
overwrite(element);
|
|
59
|
+
await runNextFrame();
|
|
60
|
+
|
|
61
|
+
expect(read(element)).toBe("Variant");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("re-applies an in-place hydration revert to the original content", async () => {
|
|
65
|
+
const element = document.createElement("div");
|
|
66
|
+
element.id = "target";
|
|
67
|
+
element.innerHTML = "<strong>Control</strong>";
|
|
68
|
+
document.body.appendChild(element);
|
|
69
|
+
const mutation = { selector: "#target", action: "html", html: "<strong>Variant</strong>" } as Mutation;
|
|
70
|
+
applyAndTrack(element, mutation);
|
|
71
|
+
|
|
72
|
+
// React/Vue reconciliation patches the same node back to the original
|
|
73
|
+
// server-rendered content instead of replacing the subtree.
|
|
74
|
+
element.querySelector("strong")!.textContent = "Control";
|
|
75
|
+
await runNextFrame();
|
|
76
|
+
|
|
77
|
+
expect(element.textContent).toBe("Variant");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("re-applies a descendant attribute-only hydration revert in an html treatment", async () => {
|
|
81
|
+
const element = document.createElement("div");
|
|
82
|
+
element.id = "target";
|
|
83
|
+
element.innerHTML = '<img class="control" aria-label="Product">';
|
|
84
|
+
document.body.appendChild(element);
|
|
85
|
+
const mutation = {
|
|
86
|
+
selector: "#target",
|
|
87
|
+
action: "html",
|
|
88
|
+
html: '<img class="variant" aria-label="Product">',
|
|
89
|
+
} as Mutation;
|
|
90
|
+
applyAndTrack(element, mutation);
|
|
91
|
+
|
|
92
|
+
// Hydration keeps the descendant node but restores only its attribute.
|
|
93
|
+
element.querySelector("img")!.className = "control";
|
|
94
|
+
await runNextFrame();
|
|
95
|
+
|
|
96
|
+
expect(element.querySelector("img")!.className).toBe("variant");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("re-applies an added-only restore of removed original html", async () => {
|
|
100
|
+
const element = document.createElement("div");
|
|
101
|
+
element.id = "target";
|
|
102
|
+
element.innerHTML = "<span>Shared</span><i>Control only</i>";
|
|
103
|
+
document.body.appendChild(element);
|
|
104
|
+
const mutation = { selector: "#target", action: "html", html: "<span>Shared</span>" } as Mutation;
|
|
105
|
+
applyAndTrack(element, mutation);
|
|
106
|
+
|
|
107
|
+
const restored = document.createElement("i");
|
|
108
|
+
restored.textContent = "Control only";
|
|
109
|
+
element.appendChild(restored);
|
|
110
|
+
await runNextFrame();
|
|
111
|
+
|
|
112
|
+
expect(element.innerHTML).toBe("<span>Shared</span>");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("leaves variant-owned html subtree changes alone within the revert window", async () => {
|
|
116
|
+
const element = document.createElement("div");
|
|
117
|
+
element.id = "target";
|
|
118
|
+
document.body.appendChild(element);
|
|
119
|
+
const mutation = { selector: "#target", action: "html", html: '<div class="widget"><span>slide-1</span></div>' } as Mutation;
|
|
120
|
+
applyAndTrack(element, mutation);
|
|
121
|
+
|
|
122
|
+
// Variation JS initializes a widget right after apply: the evolved
|
|
123
|
+
// content is nothing the original ever had, so it must not be erased.
|
|
124
|
+
element.querySelector(".widget")!.appendChild(document.createElement("span"));
|
|
125
|
+
await runNextFrame();
|
|
126
|
+
|
|
127
|
+
expect(element.querySelectorAll(".widget span")).toHaveLength(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("leaves surviving subtree changes alone after the revert window", async () => {
|
|
131
|
+
const element = document.createElement("div");
|
|
132
|
+
element.id = "target";
|
|
133
|
+
document.body.appendChild(element);
|
|
134
|
+
const mutation = { selector: "#target", action: "html", html: '<div class="widget"><span>slide-1</span></div>' } as Mutation;
|
|
135
|
+
applyAndTrack(element, mutation);
|
|
136
|
+
vi.spyOn(Date, "now").mockReturnValue(Date.now() + 11_000);
|
|
137
|
+
|
|
138
|
+
element.querySelector(".widget")!.appendChild(document.createElement("span"));
|
|
139
|
+
await runNextFrame();
|
|
140
|
+
|
|
141
|
+
expect(element.querySelectorAll(".widget span")).toHaveLength(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("re-applies a wholesale framework replace even after widget activity", async () => {
|
|
145
|
+
const element = document.createElement("div");
|
|
146
|
+
element.id = "target";
|
|
147
|
+
document.body.appendChild(element);
|
|
148
|
+
const mutation = { selector: "#target", action: "html", html: '<div class="widget">v</div>' } as Mutation;
|
|
149
|
+
applyAndTrack(element, mutation);
|
|
150
|
+
|
|
151
|
+
element.querySelector(".widget")!.appendChild(document.createElement("span"));
|
|
152
|
+
await runNextFrame();
|
|
153
|
+
// Framework hydration restores the original content wholesale.
|
|
154
|
+
element.innerHTML = "<em>Control</em>";
|
|
155
|
+
await runNextFrame();
|
|
156
|
+
|
|
157
|
+
expect(element.querySelector(".widget")).not.toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("does not schedule a re-apply for SDK writes", async () => {
|
|
161
|
+
const element = document.createElement("div");
|
|
162
|
+
document.body.appendChild(element);
|
|
163
|
+
const mutation = { selector: "div", action: "text", text: "Variant" } as Mutation;
|
|
164
|
+
applyAndTrack(element, mutation);
|
|
165
|
+
|
|
166
|
+
runMuted(() => applyToElement(mutation, element));
|
|
167
|
+
await deliverMutations();
|
|
168
|
+
|
|
169
|
+
expect(frames).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("does no sentinel work for mutations outside treated subtrees", async () => {
|
|
173
|
+
const treated = document.createElement("div");
|
|
174
|
+
const outside = document.createElement("div");
|
|
175
|
+
document.body.append(treated, outside);
|
|
176
|
+
const mutation = { selector: "#treated", action: "text", text: "Variant" } as Mutation;
|
|
177
|
+
runMuted(() => applyTrackedMutation(mutation, treated));
|
|
178
|
+
const apply = vi.fn((target: Element, applied: Mutation) => applyToElement(applied, target));
|
|
179
|
+
trackAppliedMutation(treated, mutation, {}, apply);
|
|
180
|
+
|
|
181
|
+
outside.textContent = "Unrelated storefront update";
|
|
182
|
+
await deliverMutations();
|
|
183
|
+
|
|
184
|
+
expect(frames).toHaveLength(0);
|
|
185
|
+
expect(apply).not.toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("accepts browser-canonical style values after unrelated style mutations", async () => {
|
|
189
|
+
const element = document.createElement("div");
|
|
190
|
+
document.body.appendChild(element);
|
|
191
|
+
const mutation = { selector: "div", action: "style", styles: { backgroundColor: "#ffa04c" } } as Mutation;
|
|
192
|
+
applyAndTrack(element, mutation);
|
|
193
|
+
|
|
194
|
+
expect(element.style.getPropertyValue("background-color")).not.toBe("#ffa04c");
|
|
195
|
+
element.style.setProperty("padding", "1px");
|
|
196
|
+
await deliverMutations();
|
|
197
|
+
|
|
198
|
+
expect(frames).toHaveLength(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("re-attaches observation when a treated root is replaced and registered again", async () => {
|
|
202
|
+
const original = document.createElement("div");
|
|
203
|
+
document.body.appendChild(original);
|
|
204
|
+
const mutation = { selector: "#target", action: "text", text: "Variant" } as Mutation;
|
|
205
|
+
applyAndTrack(original, mutation);
|
|
206
|
+
|
|
207
|
+
const replacement = document.createElement("div");
|
|
208
|
+
original.replaceWith(replacement);
|
|
209
|
+
applyAndTrack(replacement, mutation);
|
|
210
|
+
replacement.textContent = "Control";
|
|
211
|
+
await runNextFrame();
|
|
212
|
+
|
|
213
|
+
expect(replacement.textContent).toBe("Variant");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("disconnects and unregisters a replaced treated root", () => {
|
|
217
|
+
const instances: Array<{
|
|
218
|
+
disconnected: boolean;
|
|
219
|
+
takeRecordCalls: number;
|
|
220
|
+
}> = [];
|
|
221
|
+
class TestMutationObserver {
|
|
222
|
+
disconnected = false;
|
|
223
|
+
takeRecordCalls = 0;
|
|
224
|
+
|
|
225
|
+
constructor(_callback: MutationCallback) {
|
|
226
|
+
instances.push(this);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
observe(): void {}
|
|
230
|
+
|
|
231
|
+
disconnect(): void {
|
|
232
|
+
this.disconnected = true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
takeRecords(): MutationRecord[] {
|
|
236
|
+
this.takeRecordCalls += 1;
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
vi.stubGlobal("MutationObserver", TestMutationObserver);
|
|
241
|
+
|
|
242
|
+
const original = document.createElement("div");
|
|
243
|
+
document.body.appendChild(original);
|
|
244
|
+
const mutation = { selector: "#target", action: "text", text: "Variant" } as Mutation;
|
|
245
|
+
applyAndTrack(original, mutation);
|
|
246
|
+
|
|
247
|
+
const replacement = document.createElement("div");
|
|
248
|
+
original.replaceWith(replacement);
|
|
249
|
+
applyAndTrack(replacement, mutation);
|
|
250
|
+
|
|
251
|
+
expect(instances).toHaveLength(2);
|
|
252
|
+
expect(instances[0]?.disconnected).toBe(true);
|
|
253
|
+
const oldTakeRecordCalls = instances[0]?.takeRecordCalls;
|
|
254
|
+
runMuted(() => {});
|
|
255
|
+
expect(instances[0]?.takeRecordCalls).toBe(oldTakeRecordCalls);
|
|
256
|
+
expect(instances[1]?.takeRecordCalls).toBeGreaterThan(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("stops after twelve re-applies in ten seconds and marks the loop", async () => {
|
|
260
|
+
const element = document.createElement("div");
|
|
261
|
+
document.body.appendChild(element);
|
|
262
|
+
const mutation = { selector: "div", action: "text", text: "Variant" } as Mutation;
|
|
263
|
+
applyAndTrack(element, mutation);
|
|
264
|
+
|
|
265
|
+
for (let index = 0; index < 12; index += 1) {
|
|
266
|
+
element.textContent = `Control ${index}`;
|
|
267
|
+
await runNextFrame();
|
|
268
|
+
expect(element.textContent).toBe("Variant");
|
|
269
|
+
}
|
|
270
|
+
element.textContent = "Framework still owns this";
|
|
271
|
+
await runNextFrame();
|
|
272
|
+
|
|
273
|
+
expect(element.textContent).toBe("Framework still owns this");
|
|
274
|
+
expect((window as Window & { __dripPerf?: Record<string, unknown> }).__dripPerf).toMatchObject({
|
|
275
|
+
reapply_count: 12,
|
|
276
|
+
reassert_loop_detected: true,
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("fires the HTML re-init event with assignment detail", async () => {
|
|
281
|
+
const listener = vi.fn();
|
|
282
|
+
document.addEventListener("drip:mutation-reapplied", listener);
|
|
283
|
+
const element = document.createElement("div");
|
|
284
|
+
document.body.appendChild(element);
|
|
285
|
+
const mutation = { selector: ".carousel", action: "html", html: "<div>Slides</div>" } as Mutation;
|
|
286
|
+
applyAndTrack(element, mutation, { experimentId: "exp_1", variationId: "var_1" });
|
|
287
|
+
|
|
288
|
+
element.innerHTML = "<div>Server slides</div>";
|
|
289
|
+
await runNextFrame();
|
|
290
|
+
|
|
291
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({
|
|
292
|
+
detail: {
|
|
293
|
+
experimentId: "exp_1",
|
|
294
|
+
variationId: "var_1",
|
|
295
|
+
selector: ".carousel",
|
|
296
|
+
action: "html",
|
|
297
|
+
},
|
|
298
|
+
}));
|
|
299
|
+
document.removeEventListener("drip:mutation-reapplied", listener);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("disconnects and forgets tracked entries when applied mutations reset", async () => {
|
|
303
|
+
const element = document.createElement("div");
|
|
304
|
+
document.body.appendChild(element);
|
|
305
|
+
const mutation = { selector: "div", action: "text", text: "Variant" } as Mutation;
|
|
306
|
+
applyAndTrack(element, mutation);
|
|
307
|
+
|
|
308
|
+
resetAppliedMutations();
|
|
309
|
+
element.textContent = "Control";
|
|
310
|
+
await deliverMutations();
|
|
311
|
+
|
|
312
|
+
expect(frames).toHaveLength(0);
|
|
313
|
+
});
|
|
314
|
+
});
|