@qoretechnologies/reqraft 0.10.56 → 0.10.57-pr.127.g0edd18d
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/.claude/CLAUDE.md +1 -0
- package/README.md +20 -0
- package/design/TRACKING.md +278 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/tracking/browser/attach.d.ts +8 -0
- package/dist/tracking/browser/attach.d.ts.map +1 -0
- package/dist/tracking/browser/attach.js +182 -0
- package/dist/tracking/browser/attach.js.map +1 -0
- package/dist/tracking/browser/env.d.ts +14 -0
- package/dist/tracking/browser/env.d.ts.map +1 -0
- package/dist/tracking/browser/env.js +150 -0
- package/dist/tracking/browser/env.js.map +1 -0
- package/dist/tracking/core/consent.d.ts +21 -0
- package/dist/tracking/core/consent.d.ts.map +1 -0
- package/dist/tracking/core/consent.js +39 -0
- package/dist/tracking/core/consent.js.map +1 -0
- package/dist/tracking/core/engagement.d.ts +20 -0
- package/dist/tracking/core/engagement.d.ts.map +1 -0
- package/dist/tracking/core/engagement.js +52 -0
- package/dist/tracking/core/engagement.js.map +1 -0
- package/dist/tracking/core/experiments.d.ts +109 -0
- package/dist/tracking/core/experiments.d.ts.map +1 -0
- package/dist/tracking/core/experiments.js +294 -0
- package/dist/tracking/core/experiments.js.map +1 -0
- package/dist/tracking/core/hash.d.ts +16 -0
- package/dist/tracking/core/hash.d.ts.map +1 -0
- package/dist/tracking/core/hash.js +31 -0
- package/dist/tracking/core/hash.js.map +1 -0
- package/dist/tracking/core/ids.d.ts +6 -0
- package/dist/tracking/core/ids.d.ts.map +1 -0
- package/dist/tracking/core/ids.js +31 -0
- package/dist/tracking/core/ids.js.map +1 -0
- package/dist/tracking/core/observable.d.ts +12 -0
- package/dist/tracking/core/observable.d.ts.map +1 -0
- package/dist/tracking/core/observable.js +32 -0
- package/dist/tracking/core/observable.js.map +1 -0
- package/dist/tracking/core/queue.d.ts +47 -0
- package/dist/tracking/core/queue.d.ts.map +1 -0
- package/dist/tracking/core/queue.js +205 -0
- package/dist/tracking/core/queue.js.map +1 -0
- package/dist/tracking/core/sections.d.ts +54 -0
- package/dist/tracking/core/sections.d.ts.map +1 -0
- package/dist/tracking/core/sections.js +156 -0
- package/dist/tracking/core/sections.js.map +1 -0
- package/dist/tracking/core/storage.d.ts +15 -0
- package/dist/tracking/core/storage.d.ts.map +1 -0
- package/dist/tracking/core/storage.js +72 -0
- package/dist/tracking/core/storage.js.map +1 -0
- package/dist/tracking/core/tracker.d.ts +122 -0
- package/dist/tracking/core/tracker.d.ts.map +1 -0
- package/dist/tracking/core/tracker.js +390 -0
- package/dist/tracking/core/tracker.js.map +1 -0
- package/dist/tracking/core/types.d.ts +130 -0
- package/dist/tracking/core/types.d.ts.map +1 -0
- package/dist/tracking/core/types.js +69 -0
- package/dist/tracking/core/types.js.map +1 -0
- package/dist/tracking/core/utm.d.ts +6 -0
- package/dist/tracking/core/utm.d.ts.map +1 -0
- package/dist/tracking/core/utm.js +30 -0
- package/dist/tracking/core/utm.js.map +1 -0
- package/dist/tracking/index.d.ts +46 -0
- package/dist/tracking/index.d.ts.map +1 -0
- package/dist/tracking/index.js +91 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/tracking/markup.d.ts +12 -0
- package/dist/tracking/markup.d.ts.map +1 -0
- package/dist/tracking/markup.js +19 -0
- package/dist/tracking/markup.js.map +1 -0
- package/dist/tracking/react/Experiment.d.ts +23 -0
- package/dist/tracking/react/Experiment.d.ts.map +1 -0
- package/dist/tracking/react/Experiment.js +28 -0
- package/dist/tracking/react/Experiment.js.map +1 -0
- package/dist/tracking/react/TrackingProvider.d.ts +26 -0
- package/dist/tracking/react/TrackingProvider.d.ts.map +1 -0
- package/dist/tracking/react/TrackingProvider.js +45 -0
- package/dist/tracking/react/TrackingProvider.js.map +1 -0
- package/dist/tracking/react/hooks.d.ts +39 -0
- package/dist/tracking/react/hooks.d.ts.map +1 -0
- package/dist/tracking/react/hooks.js +96 -0
- package/dist/tracking/react/hooks.js.map +1 -0
- package/dist/tracking/react/index.d.ts +7 -0
- package/dist/tracking/react/index.d.ts.map +1 -0
- package/dist/tracking/react/index.js +17 -0
- package/dist/tracking/react/index.js.map +1 -0
- package/dist/tracking/testing.d.ts +40 -0
- package/dist/tracking/testing.d.ts.map +1 -0
- package/dist/tracking/testing.js +140 -0
- package/dist/tracking/testing.js.map +1 -0
- package/dist/tracking/ui/ExperimentCard.d.ts +47 -0
- package/dist/tracking/ui/ExperimentCard.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentCard.js +305 -0
- package/dist/tracking/ui/ExperimentCard.js.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts +53 -0
- package/dist/tracking/ui/ExperimentsOverlay.d.ts.map +1 -0
- package/dist/tracking/ui/ExperimentsOverlay.js +359 -0
- package/dist/tracking/ui/ExperimentsOverlay.js.map +1 -0
- package/dist/tracking/ui/anchor.d.ts +107 -0
- package/dist/tracking/ui/anchor.d.ts.map +1 -0
- package/dist/tracking/ui/anchor.js +108 -0
- package/dist/tracking/ui/anchor.js.map +1 -0
- package/dist/tracking/ui/card.d.ts +87 -0
- package/dist/tracking/ui/card.d.ts.map +1 -0
- package/dist/tracking/ui/card.js +287 -0
- package/dist/tracking/ui/card.js.map +1 -0
- package/dist/tracking/ui/client.d.ts +42 -0
- package/dist/tracking/ui/client.d.ts.map +1 -0
- package/dist/tracking/ui/client.js +123 -0
- package/dist/tracking/ui/client.js.map +1 -0
- package/dist/tracking/ui/index.d.ts +20 -0
- package/dist/tracking/ui/index.d.ts.map +1 -0
- package/dist/tracking/ui/index.js +54 -0
- package/dist/tracking/ui/index.js.map +1 -0
- package/dist/tracking/ui/types.d.ts +111 -0
- package/dist/tracking/ui/types.d.ts.map +1 -0
- package/dist/tracking/ui/types.js +8 -0
- package/dist/tracking/ui/types.js.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts +31 -0
- package/dist/tracking/ui/useExperimentsAdmin.d.ts.map +1 -0
- package/dist/tracking/ui/useExperimentsAdmin.js +136 -0
- package/dist/tracking/ui/useExperimentsAdmin.js.map +1 -0
- package/package.json +1 -1
- package/src/index.tsx +5 -0
- package/src/stories/tracking.tsx +197 -0
- package/src/tracking/browser/attach.ts +186 -0
- package/src/tracking/browser/env.ts +78 -0
- package/src/tracking/core/consent.ts +37 -0
- package/src/tracking/core/engagement.ts +47 -0
- package/src/tracking/core/experiments.ts +258 -0
- package/src/tracking/core/hash.ts +26 -0
- package/src/tracking/core/ids.ts +25 -0
- package/src/tracking/core/observable.ts +23 -0
- package/src/tracking/core/queue.ts +131 -0
- package/src/tracking/core/sections.ts +166 -0
- package/src/tracking/core/storage.ts +69 -0
- package/src/tracking/core/tracker.ts +417 -0
- package/src/tracking/core/types.ts +162 -0
- package/src/tracking/core/utm.ts +23 -0
- package/src/tracking/index.ts +72 -0
- package/src/tracking/markup.ts +17 -0
- package/src/tracking/react/Experiment.stories.tsx +174 -0
- package/src/tracking/react/Experiment.tsx +44 -0
- package/src/tracking/react/TrackingProvider.tsx +56 -0
- package/src/tracking/react/hooks.ts +106 -0
- package/src/tracking/react/index.ts +13 -0
- package/src/tracking/testing.ts +108 -0
- package/src/tracking/ui/ExperimentCard.stories.tsx +236 -0
- package/src/tracking/ui/ExperimentCard.tsx +491 -0
- package/src/tracking/ui/ExperimentsOverlay.stories.tsx +148 -0
- package/src/tracking/ui/ExperimentsOverlay.tsx +423 -0
- package/src/tracking/ui/anchor.ts +180 -0
- package/src/tracking/ui/card.ts +323 -0
- package/src/tracking/ui/client.ts +117 -0
- package/src/tracking/ui/index.ts +71 -0
- package/src/tracking/ui/types.ts +104 -0
- package/src/tracking/ui/useExperimentsAdmin.ts +103 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { ReqoreControlGroup, ReqoreP, ReqorePanel, ReqoreTag, ReqoreTagGroup } from '@qoretechnologies/reqore';
|
|
2
|
+
import { StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { expect, within } from 'storybook/test';
|
|
5
|
+
import { StoryMeta } from '../../types';
|
|
6
|
+
import { KEYS, type IExperimentDefinition } from '../core/types';
|
|
7
|
+
import { createMemoryTracker, type IMemoryTrackerOptions } from '../testing';
|
|
8
|
+
import { Experiment } from './Experiment';
|
|
9
|
+
import { useExperiment } from './hooks';
|
|
10
|
+
import { TrackingProvider } from './TrackingProvider';
|
|
11
|
+
|
|
12
|
+
const KEY = 'signup-cta';
|
|
13
|
+
|
|
14
|
+
const RUNNING: IExperimentDefinition = {
|
|
15
|
+
key: KEY,
|
|
16
|
+
status: 'running',
|
|
17
|
+
variants: [
|
|
18
|
+
{ key: 'a', weight: 0.5, control: true },
|
|
19
|
+
{ key: 'b', weight: 0.5 },
|
|
20
|
+
],
|
|
21
|
+
traffic: 1,
|
|
22
|
+
trigger: null,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** `createMemoryTracker`'s clock starts here; the cached definitions are fresh for it. */
|
|
26
|
+
const CLOCK = Date.UTC(2026, 8, 29, 10, 0, 0);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The definitions as the tracker caches them after a fetch, so the first render already
|
|
30
|
+
* has them (a real app gets the same from its cache on every visit after the first).
|
|
31
|
+
*/
|
|
32
|
+
const cached = (experiments: IExperimentDefinition[]) => ({
|
|
33
|
+
[KEYS.experimentsCache]: JSON.stringify({ at: CLOCK, property: 'test', experiments }),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** A visitor id whose GrowthBook hash for `signup-cta` lands in version B at 50/50. */
|
|
37
|
+
const VISITOR_IN_B = 'visitor-3';
|
|
38
|
+
|
|
39
|
+
const Version = ({ letter, label }: { letter: string; label: string }) => (
|
|
40
|
+
<ReqorePanel label={`Version ${letter}`} flat>
|
|
41
|
+
<ReqoreP>{label}</ReqoreP>
|
|
42
|
+
</ReqorePanel>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const VARIANTS = {
|
|
46
|
+
a: <Version letter='A' label='The control: what every visitor sees without the experiment.' />,
|
|
47
|
+
b: <Version letter='B' label='The challenger: shown only to visitors assigned to it.' />,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Shows why this visitor sees what they see (the same assignment `<Experiment>` uses). */
|
|
51
|
+
const Reason = () => {
|
|
52
|
+
const { variant, assignment } = useExperiment(KEY, { variants: ['a', 'b'] });
|
|
53
|
+
return (
|
|
54
|
+
<ReqoreTagGroup>
|
|
55
|
+
<ReqoreTag labelKey='Shown' label={variant.toUpperCase()} />
|
|
56
|
+
<ReqoreTag labelKey='Reason' label={assignment.reason} />
|
|
57
|
+
<ReqoreTag labelKey='Counts for the test' label={assignment.enrolled ? 'yes' : 'no'} />
|
|
58
|
+
</ReqoreTagGroup>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const Demo = ({ tracking }: { tracking?: IMemoryTrackerOptions }) => {
|
|
63
|
+
const [memory] = useState(() => (tracking ? createMemoryTracker(tracking) : null));
|
|
64
|
+
return (
|
|
65
|
+
<TrackingProvider tracker={memory?.tracker ?? null} location='/'>
|
|
66
|
+
<ReqoreControlGroup vertical fluid>
|
|
67
|
+
<Reason />
|
|
68
|
+
<Experiment id={KEY} variants={VARIANTS} />
|
|
69
|
+
</ReqoreControlGroup>
|
|
70
|
+
</TrackingProvider>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const meta = {
|
|
75
|
+
title: 'Tracking/Experiment',
|
|
76
|
+
component: Demo,
|
|
77
|
+
} as StoryMeta<typeof Demo>;
|
|
78
|
+
|
|
79
|
+
export default meta;
|
|
80
|
+
export type Story = StoryObj<typeof meta>;
|
|
81
|
+
|
|
82
|
+
const expectShown = async (canvasElement: HTMLElement, variant: string, reason: string) => {
|
|
83
|
+
const canvas = within(canvasElement);
|
|
84
|
+
await expect(await canvas.findByText(`Version ${variant.toUpperCase()}`)).toBeInTheDocument();
|
|
85
|
+
const wrapper = canvasElement.querySelector(`[data-experiment="${KEY}"]`) as HTMLElement;
|
|
86
|
+
await expect(wrapper.dataset.variant).toBe(variant);
|
|
87
|
+
await expect(wrapper.dataset.experimentReason).toBe(reason);
|
|
88
|
+
await expect(canvas.getByText(reason)).toBeInTheDocument();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const ControlWithoutTracker: Story = {
|
|
92
|
+
parameters: {
|
|
93
|
+
docs: {
|
|
94
|
+
description: {
|
|
95
|
+
story:
|
|
96
|
+
'Renders an experiment with no tracker behind it (as before the visitor answered the consent prompt). Shows version A, the control, with the reason `no-consent`.',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
play: async ({ canvasElement }) => {
|
|
101
|
+
await expectShown(canvasElement, 'a', 'no-consent');
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const AssignedVariant: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
tracking: { consent: 'granted', vid: VISITOR_IN_B, seed: cached([RUNNING]) },
|
|
108
|
+
},
|
|
109
|
+
parameters: {
|
|
110
|
+
docs: {
|
|
111
|
+
description: {
|
|
112
|
+
story:
|
|
113
|
+
'Renders a running 50/50 experiment for a visitor who allowed tracking and whose id hashes into version B. Shows version B, reason `assigned`, counted for the test.',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
play: async ({ canvasElement }) => {
|
|
118
|
+
await expectShown(canvasElement, 'b', 'assigned');
|
|
119
|
+
await expect(within(canvasElement).getByText('yes')).toBeInTheDocument();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const NotEnrolled: Story = {
|
|
124
|
+
args: {
|
|
125
|
+
tracking: { consent: 'granted', vid: VISITOR_IN_B, seed: cached([{ ...RUNNING, traffic: 0 }]) },
|
|
126
|
+
},
|
|
127
|
+
parameters: {
|
|
128
|
+
docs: {
|
|
129
|
+
description: {
|
|
130
|
+
story:
|
|
131
|
+
'Renders a running experiment with a traffic share of 0 for a visitor who allowed tracking. Shows version A, reason `not-enrolled`, not counted for the test.',
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
play: async ({ canvasElement }) => {
|
|
136
|
+
await expectShown(canvasElement, 'a', 'not-enrolled');
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const PreviewLink: Story = {
|
|
141
|
+
args: {
|
|
142
|
+
tracking: { page: { search: `?qa_variant=${KEY}:b` }, seed: cached([RUNNING]) },
|
|
143
|
+
},
|
|
144
|
+
parameters: {
|
|
145
|
+
docs: {
|
|
146
|
+
description: {
|
|
147
|
+
story:
|
|
148
|
+
'Renders the experiment opened through a `?qa_variant=signup-cta:b` preview link, without consent. Shows version B with the reason `preview`, never counted.',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
play: async ({ canvasElement }) => {
|
|
153
|
+
await expectShown(canvasElement, 'b', 'preview');
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const ConcludedWinner: Story = {
|
|
158
|
+
args: {
|
|
159
|
+
tracking: {
|
|
160
|
+
seed: cached([{ key: KEY, status: 'concluded', winner: 'b' } as IExperimentDefinition]),
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
parameters: {
|
|
164
|
+
docs: {
|
|
165
|
+
description: {
|
|
166
|
+
story:
|
|
167
|
+
'Renders an experiment that ended with version B as the winner, for a visitor who never answered the consent prompt. Shows version B to everybody, reason `concluded`.',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
play: async ({ canvasElement }) => {
|
|
172
|
+
await expectShown(canvasElement, 'b', 'concluded');
|
|
173
|
+
},
|
|
174
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { useExperiment } from './hooks';
|
|
3
|
+
|
|
4
|
+
export interface IExperimentProps {
|
|
5
|
+
/** The experiment key, as in the admin API (`signup-cta`). */
|
|
6
|
+
id: string;
|
|
7
|
+
/** One element per variant key; the control's key must be among them. */
|
|
8
|
+
variants: Record<string, ReactNode>;
|
|
9
|
+
/** The control's key (default `a`). */
|
|
10
|
+
control?: string;
|
|
11
|
+
/** Trigger section (`data-track-section` id) when the definition names none. */
|
|
12
|
+
trigger?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders the visitor's variant: `<Experiment id="signup-cta" variants={{ a: <A />, b: <B /> }} />`.
|
|
17
|
+
* Without consent, without a running definition, or outside the traffic share it
|
|
18
|
+
* renders the control and logs nothing. An ended (concluded) experiment renders its winner
|
|
19
|
+
* for everybody. An in-app switch (`experiments.setEditorOverride`) shows one version per
|
|
20
|
+
* experiment (never logged); a `?qa_variant=signup-cta:b` link previews one for QA.
|
|
21
|
+
*
|
|
22
|
+
* DOM: a `display: contents` wrapper with `data-experiment`, `data-variant` (shown),
|
|
23
|
+
* `data-variants` (every key, comma-separated) and `data-experiment-reason`.
|
|
24
|
+
*/
|
|
25
|
+
export const Experiment = ({ id, variants, control = 'a', trigger }: IExperimentProps) => {
|
|
26
|
+
const keys = Object.keys(variants);
|
|
27
|
+
const { variant, assignment } = useExperiment(id, { control, trigger, variants: keys });
|
|
28
|
+
const shown = variants[variant] !== undefined ? variant : control;
|
|
29
|
+
// DOM markers, so an editor overlay can find every experiment on the page. `display: contents`
|
|
30
|
+
// gives the wrapper no box, so the page lays out exactly as without it.
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
style={CONTENTS}
|
|
34
|
+
data-experiment={id}
|
|
35
|
+
data-variant={shown}
|
|
36
|
+
data-variants={keys.join(',')}
|
|
37
|
+
data-experiment-reason={assignment.reason}
|
|
38
|
+
>
|
|
39
|
+
{variants[shown] ?? null}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const CONTENTS = { display: 'contents' } as const;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createContext, useContext, useLayoutEffect, useMemo, useRef, type ReactNode, type MutableRefObject } from 'react';
|
|
2
|
+
import type { IAttached } from '../browser/attach';
|
|
3
|
+
import type { Tracker } from '../core/tracker';
|
|
4
|
+
|
|
5
|
+
interface ITrackingContext {
|
|
6
|
+
tracker: Tracker | null;
|
|
7
|
+
/** The page wiring; set before any child's passive effect runs. */
|
|
8
|
+
attached: MutableRefObject<IAttached | null>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const TrackingContext = createContext<ITrackingContext>({ tracker: null, attached: { current: null } });
|
|
12
|
+
|
|
13
|
+
export interface ITrackingProviderProps {
|
|
14
|
+
tracker: Tracker | null;
|
|
15
|
+
/** Attaches the tracker to the page (listeners, observers); omitted in stories and tests. */
|
|
16
|
+
attach?: () => IAttached;
|
|
17
|
+
/** The current route (`pathname + search`): each change is a new page view. */
|
|
18
|
+
location: string;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mount once, at the app shell. Without a provider (or with `tracker={null}`)
|
|
24
|
+
* everything still renders: consent reads `unknown`, experiments serve the control.
|
|
25
|
+
*/
|
|
26
|
+
export const TrackingProvider = ({ tracker, attach, location, children }: ITrackingProviderProps) => {
|
|
27
|
+
const attached = useRef<IAttached | null>(null);
|
|
28
|
+
|
|
29
|
+
// Layout effects: every layout effect runs before any passive effect, so the
|
|
30
|
+
// tracker is attached and started before a child's `useExperiment` effect.
|
|
31
|
+
useLayoutEffect(() => {
|
|
32
|
+
if (!tracker) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
attached.current = attach?.() ?? null;
|
|
35
|
+
tracker.start();
|
|
36
|
+
} catch {
|
|
37
|
+
/* never into the page */
|
|
38
|
+
}
|
|
39
|
+
return () => {
|
|
40
|
+
attached.current?.detach();
|
|
41
|
+
attached.current = null;
|
|
42
|
+
};
|
|
43
|
+
}, [tracker, attach]);
|
|
44
|
+
|
|
45
|
+
useLayoutEffect(() => {
|
|
46
|
+
tracker?.pageview();
|
|
47
|
+
}, [tracker, location]);
|
|
48
|
+
|
|
49
|
+
const value = useMemo(() => ({ tracker, attached }), [tracker]);
|
|
50
|
+
return <TrackingContext.Provider value={value}>{children}</TrackingContext.Provider>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const useTrackingContext = () => useContext(TrackingContext);
|
|
54
|
+
|
|
55
|
+
/** The tracker, or null outside a provider. Call `goal`, `custom`, `record` on it. */
|
|
56
|
+
export const useTracker = () => useContext(TrackingContext).tracker;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import type { IAssignment } from '../core/experiments';
|
|
3
|
+
import { Observable } from '../core/observable';
|
|
4
|
+
import type { TConsentState } from '../core/types';
|
|
5
|
+
import { useTrackingContext } from './TrackingProvider';
|
|
6
|
+
|
|
7
|
+
const noopSubscribe = () => () => undefined;
|
|
8
|
+
|
|
9
|
+
/** The visitor's consent and the two answers. `unknown` outside a provider. */
|
|
10
|
+
export const useConsent = () => {
|
|
11
|
+
const { tracker } = useTrackingContext();
|
|
12
|
+
const state = useSyncExternalStore<TConsentState>(
|
|
13
|
+
tracker ? tracker.consent.state.subscribe : noopSubscribe,
|
|
14
|
+
() => (tracker ? tracker.consent.get() : 'unknown'),
|
|
15
|
+
() => 'unknown',
|
|
16
|
+
);
|
|
17
|
+
const grant = useCallback(() => tracker?.grant(), [tracker]);
|
|
18
|
+
const deny = useCallback(() => tracker?.deny(), [tracker]);
|
|
19
|
+
return { state, grant, deny, available: !!tracker };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Whether the app asked to reopen its consent prompt (a "Cookie settings" link). */
|
|
23
|
+
export const consentPanel = new Observable<boolean>(false);
|
|
24
|
+
export const openConsentSettings = () => consentPanel.set(true);
|
|
25
|
+
export const closeConsentSettings = () => consentPanel.set(false);
|
|
26
|
+
export const useConsentPanelOpen = () => useSyncExternalStore(consentPanel.subscribe, consentPanel.get, consentPanel.get);
|
|
27
|
+
|
|
28
|
+
export interface IUseExperimentOptions {
|
|
29
|
+
/** The control's key (default `a`): served whenever the visitor is not assigned. */
|
|
30
|
+
control?: string;
|
|
31
|
+
/** Variant keys this code can render; an assignment outside it falls back to the control. */
|
|
32
|
+
variants?: string[];
|
|
33
|
+
/** Trigger section when the experiment definition names none. */
|
|
34
|
+
trigger?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface IUseExperiment {
|
|
38
|
+
variant: string;
|
|
39
|
+
assignment: IAssignment;
|
|
40
|
+
/** Put on the element whose being seen counts as exposure, when there is no trigger section. */
|
|
41
|
+
triggerRef: (el: Element | null) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The variant this visitor sees. Decided once when the component mounts and kept
|
|
46
|
+
* for the page view (no flicker when definitions or consent arrive later: the
|
|
47
|
+
* next page view picks them up). Two exceptions, both for an editor: the in-app
|
|
48
|
+
* version switch (`setEditorOverride`) applies at once, and `experiments.refresh()`
|
|
49
|
+
* (after a winner is accepted or a version removed) decides again. Logs the exposure once per page view when the
|
|
50
|
+
* trigger is seen, and only for enrolled visitors (never for a preview or a winner).
|
|
51
|
+
*/
|
|
52
|
+
export const useExperiment = (key: string, options: IUseExperimentOptions = {}): IUseExperiment => {
|
|
53
|
+
const { tracker, attached } = useTrackingContext();
|
|
54
|
+
const control = options.control ?? 'a';
|
|
55
|
+
const known = options.variants;
|
|
56
|
+
const decide = useCallback((): IAssignment => {
|
|
57
|
+
const a: IAssignment = tracker
|
|
58
|
+
? tracker.decide(key, control)
|
|
59
|
+
: { key, variant: control, enrolled: false, reason: 'no-consent', trigger: null };
|
|
60
|
+
return known && !known.includes(a.variant) ? { ...a, variant: control, enrolled: false } : a;
|
|
61
|
+
// `known` is the variant keys of the call site: constant for its lifetime.
|
|
62
|
+
}, [tracker, key, control]);
|
|
63
|
+
const [mounted] = useState<IAssignment>(decide);
|
|
64
|
+
const store = tracker?.experiments;
|
|
65
|
+
const revision = useSyncExternalStore(store ? store.revision.subscribe : noopSubscribe, () => store?.revision.get() ?? 0, () => 0);
|
|
66
|
+
const editorVariant = useSyncExternalStore(
|
|
67
|
+
store ? store.editorOverrides.subscribe : noopSubscribe,
|
|
68
|
+
() => store?.editorOverrideOf(key),
|
|
69
|
+
() => undefined,
|
|
70
|
+
);
|
|
71
|
+
// Windows of the same app (an editor and its preview frame) share the switch through localStorage.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!store || typeof window === 'undefined') return undefined;
|
|
74
|
+
const onStorage = () => store.reloadEditorOverrides();
|
|
75
|
+
window.addEventListener('storage', onStorage);
|
|
76
|
+
return () => window.removeEventListener('storage', onStorage);
|
|
77
|
+
}, [store]);
|
|
78
|
+
// Same object while no switch or refresh changed it, so the exposure watch is not re-created.
|
|
79
|
+
const [mountedSwitch] = useState(editorVariant);
|
|
80
|
+
const assignment = useMemo(
|
|
81
|
+
() => (revision === 0 && editorVariant === mountedSwitch ? mounted : decide()),
|
|
82
|
+
[revision, editorVariant, mountedSwitch, mounted, decide],
|
|
83
|
+
);
|
|
84
|
+
const element = useRef<Element | null>(null);
|
|
85
|
+
const triggerRef = useCallback((el: Element | null) => {
|
|
86
|
+
element.current = el;
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!tracker || !assignment.enrolled) return undefined;
|
|
91
|
+
let trigger = assignment.trigger ?? options.trigger ?? null;
|
|
92
|
+
let unobserve: (() => void) | undefined;
|
|
93
|
+
if (!trigger && element.current && attached.current) {
|
|
94
|
+
trigger = `experiment:${key}`;
|
|
95
|
+
unobserve = attached.current.observeElement(element.current, trigger);
|
|
96
|
+
}
|
|
97
|
+
const unwatch = tracker.watchExposure({ ...assignment, trigger });
|
|
98
|
+
return () => {
|
|
99
|
+
unwatch();
|
|
100
|
+
unobserve?.();
|
|
101
|
+
};
|
|
102
|
+
// A new assignment only comes from a switch or a refresh; options.trigger is a constant at the call site.
|
|
103
|
+
}, [tracker, assignment]);
|
|
104
|
+
|
|
105
|
+
return { variant: assignment.variant, assignment, triggerRef };
|
|
106
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { TrackingProvider, useTracker, useTrackingContext } from './TrackingProvider';
|
|
2
|
+
export { Experiment } from './Experiment';
|
|
3
|
+
export {
|
|
4
|
+
closeConsentSettings,
|
|
5
|
+
consentPanel,
|
|
6
|
+
openConsentSettings,
|
|
7
|
+
useConsent,
|
|
8
|
+
useConsentPanelOpen,
|
|
9
|
+
useExperiment,
|
|
10
|
+
} from './hooks';
|
|
11
|
+
export type { IUseExperiment, IUseExperimentOptions } from './hooks';
|
|
12
|
+
export type { ITrackingProviderProps } from './TrackingProvider';
|
|
13
|
+
export type { IExperimentProps } from './Experiment';
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { memoryStorage, type IStorage } from './core/storage';
|
|
2
|
+
import { Tracker, type IPageInfo } from './core/tracker';
|
|
3
|
+
import { resolveConfig, storageKeys, type IBatch, type IExperimentDefinition, type ITrackingConfig, type TStorageKeys } from './core/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A tracker with no browser behind it: memory storage, a fake clock and timers you
|
|
7
|
+
* drive, a transport that records batches. For unit tests and Storybook stories.
|
|
8
|
+
* Defaults: property `test`, the default storage prefix (`qa.`), page `https://example.com/`.
|
|
9
|
+
*/
|
|
10
|
+
export interface IMemoryTracker {
|
|
11
|
+
tracker: Tracker;
|
|
12
|
+
/** Batches handed to the transport, parsed. */
|
|
13
|
+
sent: { batch: IBatch; mode: 'fetch' | 'beacon' }[];
|
|
14
|
+
local: IStorage;
|
|
15
|
+
session: IStorage;
|
|
16
|
+
/** The storage keys under the config's prefix. */
|
|
17
|
+
keys: TStorageKeys;
|
|
18
|
+
page: IPageInfo;
|
|
19
|
+
/** Moves the clock forward and runs every timer that is due. */
|
|
20
|
+
advance: (ms: number) => Promise<void>;
|
|
21
|
+
now: () => number;
|
|
22
|
+
/** Makes the next transport calls fail (network down). */
|
|
23
|
+
setOffline: (offline: boolean) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IMemoryTrackerOptions {
|
|
27
|
+
/** Any config field; `property`, `endpoint` and `experimentsEndpoint` have test defaults. */
|
|
28
|
+
config?: Partial<ITrackingConfig>;
|
|
29
|
+
consent?: 'granted' | 'denied';
|
|
30
|
+
/** The running-experiments answer: a list, a function (read on every load), or 'fail' (no endpoint). */
|
|
31
|
+
experiments?: IExperimentDefinition[] | (() => unknown[]) | 'fail';
|
|
32
|
+
page?: Partial<IPageInfo>;
|
|
33
|
+
vid?: string;
|
|
34
|
+
start?: number;
|
|
35
|
+
/** localStorage contents before the tracker starts (applied last). */
|
|
36
|
+
seed?: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const createMemoryTracker = (options: IMemoryTrackerOptions = {}): IMemoryTracker => {
|
|
40
|
+
let clock = options.start ?? Date.UTC(2026, 8, 29, 10, 0, 0);
|
|
41
|
+
let offline = false;
|
|
42
|
+
const timers = new Map<number, { at: number; fn: () => void }>();
|
|
43
|
+
let nextTimer = 1;
|
|
44
|
+
const local = memoryStorage();
|
|
45
|
+
const session = memoryStorage();
|
|
46
|
+
const config = resolveConfig({
|
|
47
|
+
property: 'test',
|
|
48
|
+
endpoint: '/collect',
|
|
49
|
+
experimentsEndpoint: '/experiments',
|
|
50
|
+
...options.config,
|
|
51
|
+
});
|
|
52
|
+
const keys = storageKeys(config.storagePrefix);
|
|
53
|
+
if (options.consent) {
|
|
54
|
+
local.set(keys.consent, options.consent);
|
|
55
|
+
local.set(keys.consentVersion, String(config.consentVersion));
|
|
56
|
+
}
|
|
57
|
+
if (options.vid) local.set(keys.vid, options.vid);
|
|
58
|
+
Object.entries(options.seed ?? {}).forEach(([k, v]) => local.set(k, v));
|
|
59
|
+
const page: IPageInfo = {
|
|
60
|
+
origin: 'https://example.com',
|
|
61
|
+
path: '/',
|
|
62
|
+
search: '',
|
|
63
|
+
title: 'Example',
|
|
64
|
+
referrer: '',
|
|
65
|
+
lang: 'en-US',
|
|
66
|
+
viewport: [1440, 900],
|
|
67
|
+
device: 'desktop',
|
|
68
|
+
...options.page,
|
|
69
|
+
};
|
|
70
|
+
const sent: IMemoryTracker['sent'] = [];
|
|
71
|
+
const tracker = new Tracker(config, {
|
|
72
|
+
local,
|
|
73
|
+
session,
|
|
74
|
+
now: () => clock,
|
|
75
|
+
setTimer: (fn, ms) => {
|
|
76
|
+
const id = nextTimer++;
|
|
77
|
+
timers.set(id, { at: clock + ms, fn });
|
|
78
|
+
return id;
|
|
79
|
+
},
|
|
80
|
+
clearTimer: (h) => void timers.delete(h as number),
|
|
81
|
+
page: () => page,
|
|
82
|
+
transport: {
|
|
83
|
+
send: async (body, mode) => {
|
|
84
|
+
if (offline) return false;
|
|
85
|
+
sent.push({ batch: JSON.parse(body) as IBatch, mode });
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
fetchJson: async () => {
|
|
90
|
+
if (!options.experiments || options.experiments === 'fail') throw new Error('no experiments endpoint');
|
|
91
|
+
return { experiments: typeof options.experiments === 'function' ? options.experiments() : options.experiments };
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const advance = async (ms: number) => {
|
|
95
|
+
const until = clock + ms;
|
|
96
|
+
for (;;) {
|
|
97
|
+
const due = Array.from(timers.entries()).filter(([, t]) => t.at <= until).sort((a, b) => a[1].at - b[1].at)[0];
|
|
98
|
+
if (!due) break;
|
|
99
|
+
timers.delete(due[0]);
|
|
100
|
+
clock = Math.max(clock, due[1].at);
|
|
101
|
+
due[1].fn();
|
|
102
|
+
await Promise.resolve();
|
|
103
|
+
}
|
|
104
|
+
clock = until;
|
|
105
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
106
|
+
};
|
|
107
|
+
return { tracker, sent, local, session, keys, page, advance, now: () => clock, setOffline: (o) => (offline = o) };
|
|
108
|
+
};
|