@funnelsgrove/runtime 0.1.55 → 0.1.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/components/FunnelContext.d.ts +5 -1
  2. package/dist/components/FunnelContext.js +2 -1
  3. package/dist/components/ManageSubscriptionScreen.js +4 -1
  4. package/dist/components/SubscriptionHandoffScreen.d.ts +2 -1
  5. package/dist/components/SubscriptionHandoffScreen.js +11 -6
  6. package/dist/config/funnel.manifest.types.d.ts +4 -2
  7. package/dist/generated/step-contract-hash.d.ts +1 -0
  8. package/dist/generated/step-contract-hash.js +2 -0
  9. package/dist/index.d.ts +12 -0
  10. package/dist/index.js +10 -0
  11. package/dist/migrations/step-contract-v2.d.ts +50 -0
  12. package/dist/migrations/step-contract-v2.js +310 -0
  13. package/dist/migrations/step-contract-v3.d.ts +41 -0
  14. package/dist/migrations/step-contract-v3.js +26 -0
  15. package/dist/runtime/funnel-attribution.d.ts +1 -0
  16. package/dist/runtime/funnel-attribution.js +24 -0
  17. package/dist/runtime/funnel-manifest.validation.d.ts +7 -2
  18. package/dist/runtime/funnel-manifest.validation.js +24 -68
  19. package/dist/runtime/funnel-step-lifecycle.d.ts +35 -0
  20. package/dist/runtime/funnel-step-lifecycle.js +49 -0
  21. package/dist/runtime/funnel-step-metadata.validation.d.ts +7 -0
  22. package/dist/runtime/funnel-step-metadata.validation.js +88 -0
  23. package/dist/runtime/submit-email-capture.d.ts +23 -0
  24. package/dist/runtime/submit-email-capture.js +54 -0
  25. package/dist/runtime/use-funnel-flow-controller.d.ts +71 -5
  26. package/dist/runtime/use-funnel-flow-controller.js +850 -116
  27. package/dist/runtime/use-step-choices.d.ts +58 -0
  28. package/dist/runtime/use-step-choices.js +187 -0
  29. package/dist/sdk/userAnswers.d.ts +1 -0
  30. package/dist/services/api.service.d.ts +10 -0
  31. package/dist/services/api.service.js +32 -0
  32. package/dist/services/funnel-sdk.service.d.ts +17 -0
  33. package/dist/services/funnel-sdk.service.js +13 -0
  34. package/dist/steps/choice-contract.d.ts +33 -0
  35. package/dist/steps/choice-contract.js +302 -0
  36. package/dist/steps/step-contract.d.ts +498 -0
  37. package/dist/steps/step-contract.js +506 -0
  38. package/dist/steps/types.d.ts +7 -5
  39. package/dist/steps/types.js +1 -18
  40. package/dist/testing/funnel-contract-journey-driver.d.ts +35 -0
  41. package/dist/testing/funnel-contract-journey-driver.js +266 -0
  42. package/dist/testing/index.d.ts +1 -0
  43. package/dist/testing/index.js +1 -0
  44. package/dist/validation/fixtures/invalid-reserved-identities.d.ts +249 -0
  45. package/dist/validation/fixtures/invalid-reserved-identities.js +20 -0
  46. package/dist/validation/fixtures/valid-v2-project.d.ts +419 -0
  47. package/dist/validation/fixtures/valid-v2-project.js +228 -0
  48. package/dist/validation/fixtures/valid-v3-project.d.ts +419 -0
  49. package/dist/validation/fixtures/valid-v3-project.js +30 -0
  50. package/dist/validation/funnel-contract-diagnostics.d.ts +49 -0
  51. package/dist/validation/funnel-contract-diagnostics.js +67 -0
  52. package/dist/validation/funnel-contract-validator.d.ts +30 -0
  53. package/dist/validation/funnel-contract-validator.js +1458 -0
  54. package/dist/validation/funnel-manifest-input.normalization.d.ts +67 -0
  55. package/dist/validation/funnel-manifest-input.normalization.js +386 -0
  56. package/package.json +9 -1
@@ -8,14 +8,218 @@ import { buildHostedStepLocation, dispatchWindowCustomEvent, } from './browser-h
8
8
  import { isPreviewStepLockRequested, resolveNextStepFromContext, shouldRunAutoAdvanceTimer, } from './funnel-flow.js';
9
9
  import { usePreviewBridge } from './preview-bridge.js';
10
10
  import { resolveExperimentAssignment, resolveForcedExperimentAssignment, } from './experiment-assignment.js';
11
- import { collectCurrentFunnelAttribution } from './funnel-attribution.js';
11
+ import { buildFunnelAttributionEventMetadata, collectCurrentFunnelAttribution, getFunnelUserAttribution, } from './funnel-attribution.js';
12
12
  import { resolveUrlUserAttributes } from './url-user-attributes.js';
13
13
  import { bootstrapPostHog, identifyPostHog, isPostHogReady, resolveExperimentVariant, } from './posthog-flags.js';
14
14
  import { getRuntimeMode, isEditorEnabled, useRuntimeMode } from '../services/runtime-mode.service.js';
15
15
  import { isPreviewFrameRuntime } from '../services/preview-frame.service.js';
16
+ import { FUNNEL_STEP_CONTRACTS, LEGACY_UNVERSIONED_STEP_CONTRACT_VERSION, } from '../steps/step-contract.js';
17
+ import { isDevelopmentRuntime } from '../config/env.config.js';
18
+ import { consumeChoiceCompletionMarker, enforceChoiceBypassGuard, registerInternalChoiceCommit, runAtomicChoiceCommit, } from './use-step-choices.js';
19
+ import { createFunnelStepLifecycleState, finishVisit, startVisit, } from './funnel-step-lifecycle.js';
20
+ import { createEmailCaptureSingleFlight, submitEmailCapture as submitEmailCapturePrimitive, } from './submit-email-capture.js';
21
+ const isTerminalCompletion = (stepMeta, outcome) => {
22
+ var _a;
23
+ return (outcome.type === 'complete'
24
+ && Boolean(stepMeta && ((_a = FUNNEL_STEP_CONTRACTS[stepMeta.type]) === null || _a === void 0 ? void 0 : _a.terminal) === true));
25
+ };
26
+ export function resolveFunnelEventAttribution(document, provisionalAttribution) {
27
+ var _a;
28
+ return (_a = getFunnelUserAttribution(document)) !== null && _a !== void 0 ? _a : provisionalAttribution;
29
+ }
30
+ export function buildReadyFunnelAttributionEventMetadata(attributionReady, attribution) {
31
+ return attributionReady ? buildFunnelAttributionEventMetadata(attribution) : null;
32
+ }
33
+ export function scheduleAttributionFailOpen(onReady, timeoutMs) {
34
+ const timeoutId = setTimeout(onReady, timeoutMs);
35
+ return () => clearTimeout(timeoutId);
36
+ }
37
+ export function takeInitialFunnelAttributionMetadata(tracked, attributionReady, attribution) {
38
+ if (tracked) {
39
+ return { tracked, metadata: null };
40
+ }
41
+ const metadata = buildReadyFunnelAttributionEventMetadata(attributionReady, attribution);
42
+ return { tracked: metadata !== null, metadata };
43
+ }
44
+ const EXIT_REASONS = new Set(['back', 'replace', 'cancel', 'abandon']);
45
+ const MAX_NAVIGATION_SELECTED_KEYS = 128;
46
+ const MAX_NAVIGATION_SELECTED_KEY_LENGTH = 128;
47
+ const MAX_NAVIGATION_SELECTED_STRING_LENGTH = 4096;
48
+ const MAX_NAVIGATION_SELECTED_DEPTH = 16;
49
+ const MAX_NAVIGATION_SELECTED_NODES = 512;
50
+ const INVALID_SELECTED_VALUE = Symbol('invalid-selected-value');
51
+ const copyOwnDataProperties = (input, maxKeys) => {
52
+ try {
53
+ const keys = Reflect.ownKeys(input);
54
+ if (keys.length > maxKeys || keys.some((key) => typeof key !== 'string')) {
55
+ return null;
56
+ }
57
+ const copy = Object.create(null);
58
+ for (const key of keys) {
59
+ const descriptor = Object.getOwnPropertyDescriptor(input, key);
60
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
61
+ return null;
62
+ }
63
+ Object.defineProperty(copy, key, {
64
+ configurable: false,
65
+ enumerable: true,
66
+ value: descriptor.value,
67
+ writable: false,
68
+ });
69
+ }
70
+ return Object.freeze(copy);
71
+ }
72
+ catch (_a) {
73
+ return null;
74
+ }
75
+ };
76
+ const canonicalizeSelected = (input) => {
77
+ let nodes = 0;
78
+ const ancestors = new WeakSet();
79
+ const visit = (value, depth) => {
80
+ nodes += 1;
81
+ if (nodes > MAX_NAVIGATION_SELECTED_NODES || depth > MAX_NAVIGATION_SELECTED_DEPTH) {
82
+ return INVALID_SELECTED_VALUE;
83
+ }
84
+ if (value === null || typeof value === 'boolean') {
85
+ return value;
86
+ }
87
+ if (typeof value === 'number') {
88
+ return Number.isFinite(value) ? value : INVALID_SELECTED_VALUE;
89
+ }
90
+ if (typeof value === 'string') {
91
+ return value.length <= MAX_NAVIGATION_SELECTED_STRING_LENGTH
92
+ ? value
93
+ : INVALID_SELECTED_VALUE;
94
+ }
95
+ if (typeof value !== 'object' || ancestors.has(value)) {
96
+ return INVALID_SELECTED_VALUE;
97
+ }
98
+ const isArray = Array.isArray(value);
99
+ const prototype = Object.getPrototypeOf(value);
100
+ if (isArray ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) {
101
+ return INVALID_SELECTED_VALUE;
102
+ }
103
+ ancestors.add(value);
104
+ try {
105
+ const keys = Reflect.ownKeys(value);
106
+ if (keys.some((key) => typeof key !== 'string')) {
107
+ return INVALID_SELECTED_VALUE;
108
+ }
109
+ if (isArray) {
110
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length');
111
+ const length = lengthDescriptor && 'value' in lengthDescriptor
112
+ ? lengthDescriptor.value
113
+ : null;
114
+ if (!Number.isSafeInteger(length)
115
+ || length < 0
116
+ || length > MAX_NAVIGATION_SELECTED_NODES
117
+ || keys.length !== length + 1) {
118
+ return INVALID_SELECTED_VALUE;
119
+ }
120
+ const copy = [];
121
+ for (let index = 0; index < length; index += 1) {
122
+ const key = String(index);
123
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
124
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
125
+ return INVALID_SELECTED_VALUE;
126
+ }
127
+ const nested = visit(descriptor.value, depth + 1);
128
+ if (nested === INVALID_SELECTED_VALUE) {
129
+ return INVALID_SELECTED_VALUE;
130
+ }
131
+ copy.push(nested);
132
+ }
133
+ return Object.freeze(copy);
134
+ }
135
+ if (keys.length > MAX_NAVIGATION_SELECTED_KEYS) {
136
+ return INVALID_SELECTED_VALUE;
137
+ }
138
+ const copy = Object.create(null);
139
+ for (const key of keys) {
140
+ if (key.length > MAX_NAVIGATION_SELECTED_KEY_LENGTH) {
141
+ return INVALID_SELECTED_VALUE;
142
+ }
143
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
144
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
145
+ return INVALID_SELECTED_VALUE;
146
+ }
147
+ const nested = visit(descriptor.value, depth + 1);
148
+ if (nested === INVALID_SELECTED_VALUE) {
149
+ return INVALID_SELECTED_VALUE;
150
+ }
151
+ Object.defineProperty(copy, key, {
152
+ configurable: false,
153
+ enumerable: true,
154
+ value: nested,
155
+ writable: false,
156
+ });
157
+ }
158
+ return Object.freeze(copy);
159
+ }
160
+ finally {
161
+ ancestors.delete(value);
162
+ }
163
+ };
164
+ try {
165
+ const selected = visit(input, 0);
166
+ return selected === INVALID_SELECTED_VALUE || selected === null || Array.isArray(selected)
167
+ ? null
168
+ : selected;
169
+ }
170
+ catch (_a) {
171
+ return null;
172
+ }
173
+ };
174
+ function resolveNavigationOutcome(input, stepContractVersion) {
175
+ if (input === undefined) {
176
+ return stepContractVersion === LEGACY_UNVERSIONED_STEP_CONTRACT_VERSION
177
+ ? { type: 'complete' }
178
+ : null;
179
+ }
180
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
181
+ return null;
182
+ }
183
+ const record = copyOwnDataProperties(input, 2);
184
+ if (!record) {
185
+ return null;
186
+ }
187
+ try {
188
+ const keys = Object.keys(record);
189
+ if (record.type === 'complete') {
190
+ if (keys.some((key) => key !== 'type' && key !== 'selected')) {
191
+ return null;
192
+ }
193
+ if (record.selected === undefined) {
194
+ return { type: 'complete' };
195
+ }
196
+ if (record.selected === null || typeof record.selected !== 'object' || Array.isArray(record.selected)) {
197
+ return null;
198
+ }
199
+ const selected = canonicalizeSelected(record.selected);
200
+ return selected ? { type: 'complete', selected } : null;
201
+ }
202
+ if (record.type === 'exit'
203
+ && keys.every((key) => key === 'type' || key === 'reason')
204
+ && typeof record.reason === 'string'
205
+ && EXIT_REASONS.has(record.reason)) {
206
+ return { type: 'exit', reason: record.reason };
207
+ }
208
+ }
209
+ catch (_a) {
210
+ return null;
211
+ }
212
+ return null;
213
+ }
16
214
  export function computeRenderSuspended(input) {
17
215
  var _a;
18
- if (input.isPreviewRuntime || input.postHogReady) {
216
+ if (input.isPreviewRuntime) {
217
+ return false;
218
+ }
219
+ if (input.attributionReady === false) {
220
+ return true;
221
+ }
222
+ if (input.postHogReady) {
19
223
  return false;
20
224
  }
21
225
  const activeExperiment = input.experiments.find((experiment) => experiment.stepId === input.activeStepId);
@@ -65,15 +269,16 @@ export function resolveRenderedExperimentStepId(input) {
65
269
  const variantStepId = resolveRenderableStepId(assignment.routeToStepId);
66
270
  return variantStepId !== null && variantStepId !== void 0 ? variantStepId : safeActiveStepId;
67
271
  }
68
- export function useFunnelFlowController({ analytics, initialStepId, initialAttributes, lockToInitialStep = false, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }) {
272
+ export function useFunnelFlowController({ api = apiService, analytics, stepContractVersion, initialStepId, initialAttributes, lockToInitialStep = false, defaultStepId, stepSequence, stepById, stepComponentById, funnelExperiments, getPathForStep, getStepIdFromPath, getSequentialNextStepId, getChoiceTargetsForStep, isFunnelStepId, resolveConfiguredNextStep, resolveRuntimeInitialStepId, }) {
69
273
  var _a, _b;
70
274
  const [runtimeMode, setRuntimeMode] = useRuntimeMode();
71
- const [editorModeEnabled, setEditorModeEnabled] = useState(false);
275
+ const [editorModeEnabled, setEditorModeEnabled] = useState(() => isEditorEnabled());
72
276
  const [editorPanelExpanded, setEditorPanelExpanded] = useState(false);
73
277
  const [postHogReady, setPostHogReady] = useState(() => isPostHogReady());
74
278
  const [editorVariantOverrides, setEditorVariantOverrides] = useState({});
75
279
  const shouldLockToInitialStep = lockToInitialStep || isPreviewStepLockRequested();
76
280
  const isPreviewRuntime = useMemo(() => isPreviewFrameRuntime(), []);
281
+ const lifecycleEventsSuppressed = isPreviewRuntime || editorModeEnabled;
77
282
  const resolveRenderableStepId = useCallback((stepId) => {
78
283
  if (!stepId || !isFunnelStepId(stepId)) {
79
284
  return null;
@@ -106,7 +311,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
106
311
  const [activeStepId, setActiveStepId] = useState(safeInitialStepId);
107
312
  const [attributes, setAttributes] = useState(() => (Object.assign({}, (initialAttributes || {}))));
108
313
  const [user, setUser] = useState(() => ({
109
- id: apiService.getOrCreateClientUserId(),
314
+ id: api.getOrCreateClientUserId(),
110
315
  name: '',
111
316
  email: '',
112
317
  attributes: {},
@@ -114,21 +319,35 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
114
319
  completedSteps: [],
115
320
  }));
116
321
  const [userBootstrapped, setUserBootstrapped] = useState(isPreviewRuntime);
322
+ const [attributionReady, setAttributionReady] = useState(isPreviewRuntime);
117
323
  const attributesAtStepStart = useRef({});
118
324
  const attributesRef = useRef(attributes);
119
325
  const postHogFeatureFlagsRef = useRef({});
120
326
  const prevStepIdRef = useRef(null);
121
- const firstStepViewedTrackedRef = useRef(false);
122
- const stepStartedAtByIdRef = useRef({});
327
+ const funnelStartedTrackedRef = useRef(false);
328
+ const firstStepViewedVisitRef = useRef(null);
329
+ const firstStepClickedVisitRef = useRef(null);
330
+ // One controller mount is one funnel run. A full reload intentionally begins a new run.
331
+ const funnelCompletionClaimedRef = useRef(false);
123
332
  const currentUserIdRef = useRef(user.id);
333
+ const initialAttributionRef = useRef(null);
334
+ const choiceCompletionMarkerRef = useRef(null);
335
+ const choiceCommitInFlightRef = useRef(null);
336
+ const choiceNavigationRetryRef = useRef(null);
337
+ const stepLifecycleStateRef = useRef(createFunnelStepLifecycleState());
338
+ const activeVisitTokenRef = useRef(null);
339
+ const emailCaptureSingleFlightRef = useRef(createEmailCaptureSingleFlight());
340
+ const pendingEmailCaptureNavigationRef = useRef(null);
341
+ const appliedInitialStepIdRef = useRef(safeInitialStepId);
124
342
  const safeActiveStepId = useMemo(() => { var _a; return (_a = resolveRenderableStepId(activeStepId)) !== null && _a !== void 0 ? _a : safeInitialStepId; }, [activeStepId, resolveRenderableStepId, safeInitialStepId]);
125
343
  const activeStepMeta = stepById[safeActiveStepId];
126
344
  const renderSuspended = useMemo(() => computeRenderSuspended({
127
345
  activeStepId: safeActiveStepId,
346
+ attributionReady,
128
347
  postHogReady,
129
348
  experiments: funnelExperiments,
130
349
  isPreviewRuntime,
131
- }), [funnelExperiments, isPreviewRuntime, postHogReady, safeActiveStepId]);
350
+ }), [attributionReady, funnelExperiments, isPreviewRuntime, postHogReady, safeActiveStepId]);
132
351
  const resolveExperimentVariantKey = useCallback((experiment) => {
133
352
  var _a;
134
353
  const override = editorVariantOverrides[experiment.experimentId];
@@ -157,6 +376,16 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
157
376
  }), [activeExperimentForStep, editorVariantOverrides, postHogReady, resolveRenderableStepId, safeActiveStepId]);
158
377
  const renderedStepMeta = stepById[renderedStepId];
159
378
  const activeStepComponent = stepComponentById[renderedStepId];
379
+ const reportChoiceDiagnostic = useCallback((diagnostic) => {
380
+ logger.error(`[FunnelFlow] ${diagnostic.code}: ${diagnostic.reason}`);
381
+ dispatchWindowCustomEvent('funnel:contract-diagnostic', {
382
+ code: diagnostic.code,
383
+ stepId: diagnostic.stepId,
384
+ reason: diagnostic.reason,
385
+ repair: diagnostic.repair,
386
+ });
387
+ }, []);
388
+ const choiceGuardEnvironment = isDevelopmentRuntime() ? 'development' : 'production';
160
389
  const postHogFeatureFlags = useMemo(() => {
161
390
  if (!postHogReady || isPreviewRuntime) {
162
391
  return {};
@@ -166,12 +395,20 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
166
395
  return variantKey ? [[experiment.experimentId, variantKey]] : [];
167
396
  }));
168
397
  }, [funnelExperiments, isPreviewRuntime, postHogReady]);
169
- const goToStep = useCallback((stepId) => {
170
- var _a;
398
+ const resolveNavigationTarget = useCallback((stepId) => {
399
+ var _a, _b;
171
400
  if (shouldLockToInitialStep) {
172
- return;
401
+ return null;
173
402
  }
174
403
  const safeStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : safeInitialStepId;
404
+ const activeVisitStepId = (_b = activeVisitTokenRef.current) === null || _b === void 0 ? void 0 : _b.stepId;
405
+ return safeStepId === safeActiveStepId
406
+ || safeStepId === renderedStepId
407
+ || safeStepId === activeVisitStepId
408
+ ? null
409
+ : safeStepId;
410
+ }, [renderedStepId, resolveRenderableStepId, safeActiveStepId, safeInitialStepId, shouldLockToInitialStep]);
411
+ const navigateToStep = useCallback((safeStepId) => {
175
412
  const stepPath = getPathForStep(safeStepId);
176
413
  const nextLocation = buildHostedStepLocation({
177
414
  currentHref: window.location.href,
@@ -188,16 +425,38 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
188
425
  }
189
426
  }
190
427
  setActiveStepId(safeStepId);
191
- }, [getPathForStep, resolveRenderableStepId, safeInitialStepId, shouldLockToInitialStep]);
428
+ }, [getPathForStep]);
192
429
  const setAnswer = useCallback((key, value) => {
430
+ if (!enforceChoiceBypassGuard({
431
+ operation: 'setAnswer',
432
+ activeStepId: renderedStepId,
433
+ key: String(key),
434
+ stepById,
435
+ stepContractVersion,
436
+ environment: choiceGuardEnvironment,
437
+ reportDiagnostic: reportChoiceDiagnostic,
438
+ })) {
439
+ return;
440
+ }
193
441
  setAttributes((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
194
- }, []);
442
+ }, [choiceGuardEnvironment, renderedStepId, reportChoiceDiagnostic, stepById, stepContractVersion]);
195
443
  const getAnswer = useCallback((key) => {
196
444
  return attributesRef.current[key];
197
445
  }, []);
198
446
  const setAttribute = useCallback((key, value) => {
447
+ if (!enforceChoiceBypassGuard({
448
+ operation: 'setAttribute',
449
+ activeStepId: renderedStepId,
450
+ key,
451
+ stepById,
452
+ stepContractVersion,
453
+ environment: choiceGuardEnvironment,
454
+ reportDiagnostic: reportChoiceDiagnostic,
455
+ })) {
456
+ return;
457
+ }
199
458
  setAttributes((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
200
- }, []);
459
+ }, [choiceGuardEnvironment, renderedStepId, reportChoiceDiagnostic, stepById, stepContractVersion]);
201
460
  useEffect(() => {
202
461
  currentUserIdRef.current = user.id;
203
462
  }, [user.id]);
@@ -224,9 +483,10 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
224
483
  return () => window.clearInterval(intervalId);
225
484
  }, [postHogReady]);
226
485
  const resolveNextStepId = useCallback((stepId, options) => {
486
+ var _a;
227
487
  return resolveNextStepFromContext({
228
488
  stepId,
229
- attributes: attributesRef.current,
489
+ attributes: (_a = options === null || options === void 0 ? void 0 : options.attributes) !== null && _a !== void 0 ? _a : attributesRef.current,
230
490
  safeInitialStepId,
231
491
  resolveRenderableStepId,
232
492
  getConfiguredNextStepId: (currentStepId, context) => resolveConfiguredNextStep(currentStepId, context),
@@ -241,21 +501,18 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
241
501
  resolveRenderableStepId,
242
502
  safeInitialStepId,
243
503
  ]);
244
- const goNext = useCallback(() => {
245
- goToStep(resolveNextStepId(renderedStepId, {
246
- ignoreExperiment: Boolean(activeExperimentForStep && renderedStepId === safeActiveStepId),
247
- }));
248
- }, [activeExperimentForStep, goToStep, renderedStepId, resolveNextStepId, safeActiveStepId]);
249
504
  useEffect(() => {
250
- const localUserId = apiService.getOrCreateClientUserId();
505
+ const localUserId = api.getOrCreateClientUserId();
251
506
  const urlUserAttributes = resolveUrlUserAttributes();
252
- const bootstrapCandidateUserId = apiService.getBootstrapCandidateUserId(localUserId);
507
+ const bootstrapCandidateUserId = api.getBootstrapCandidateUserId(localUserId);
253
508
  const initialAttribution = collectCurrentFunnelAttribution();
509
+ initialAttributionRef.current = initialAttribution;
254
510
  setUserBootstrapped(isPreviewRuntime);
255
511
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
256
512
  if (isPreviewRuntime) {
257
513
  return;
258
514
  }
515
+ const cancelAttributionFailOpen = scheduleAttributionFailOpen(() => setAttributionReady(true), 5000);
259
516
  const postHogBootstrap = bootstrapPostHog({
260
517
  apiKey: POSTHOG_PROJECT_API_KEY,
261
518
  apiHost: POSTHOG_API_HOST,
@@ -268,7 +525,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
268
525
  logger.error('Failed to bootstrap PostHog feature flags:', error);
269
526
  setPostHogReady(true);
270
527
  });
271
- const sessionBootstrap = apiService
528
+ const sessionBootstrap = api
272
529
  .bootstrapSession({
273
530
  userId: bootstrapCandidateUserId || localUserId,
274
531
  email: urlUserAttributes.email || undefined,
@@ -276,15 +533,18 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
276
533
  attribution: initialAttribution,
277
534
  })
278
535
  .then((sessionUser) => {
536
+ cancelAttributionFailOpen();
279
537
  const sessionAttributes = Object.keys(sessionUser.attributes || {}).length > 0
280
538
  ? sessionUser.attributes
281
539
  : null;
282
540
  if (sessionAttributes) {
283
541
  setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
284
542
  }
543
+ initialAttributionRef.current = resolveFunnelEventAttribution(sessionUser.document, initialAttribution);
285
544
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: sessionUser.id || prev.id, name: sessionUser.name || prev.name, email: sessionUser.email || prev.email, document: sessionUser.document, attributes: sessionAttributes ? Object.assign(Object.assign({}, sessionAttributes), prev.attributes) : prev.attributes })));
286
545
  setUserBootstrapped(true);
287
- void apiService.pingUserContext(sessionUser.id)
546
+ setAttributionReady(true);
547
+ void api.pingUserContext(sessionUser.id)
288
548
  .then((pingedUser) => {
289
549
  if (!pingedUser) {
290
550
  return;
@@ -301,8 +561,10 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
301
561
  return sessionUser;
302
562
  })
303
563
  .catch((error) => {
564
+ cancelAttributionFailOpen();
304
565
  logger.error('Failed to bootstrap user session:', error);
305
566
  setUserBootstrapped(false);
567
+ setAttributionReady(true);
306
568
  return null;
307
569
  });
308
570
  Promise.all([postHogBootstrap, sessionBootstrap])
@@ -314,16 +576,21 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
314
576
  .catch((error) => {
315
577
  logger.error('Failed to identify PostHog user:', error);
316
578
  });
317
- }, [isPreviewRuntime]);
318
- const recordStepCompletion = useCallback((stepId, explicitChoices) => {
579
+ return cancelAttributionFailOpen;
580
+ }, [api, isPreviewRuntime]);
581
+ const recordStepCompletion = useCallback((intent, options = {}) => {
319
582
  var _a;
320
- const meta = stepById[stepId];
321
- if (!meta) {
583
+ const meta = stepById[intent.visit.stepId];
584
+ if (!meta || lifecycleEventsSuppressed) {
585
+ return;
586
+ }
587
+ const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
588
+ if (attributionMetadata === null) {
322
589
  return;
323
590
  }
324
591
  let choices;
325
- if (explicitChoices) {
326
- choices = explicitChoices;
592
+ if (options.explicitChoices !== undefined) {
593
+ choices = options.explicitChoices;
327
594
  }
328
595
  else {
329
596
  const snapshot = attributesAtStepStart.current;
@@ -341,28 +608,14 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
341
608
  choices,
342
609
  };
343
610
  const stepType = meta.type;
344
- const startedAt = stepStartedAtByIdRef.current[meta.id] || record.completedAt;
611
+ const completionAttributes = Object.assign({}, attributesRef.current);
345
612
  dispatchWindowCustomEvent('funnel:step-completed', record);
346
- if (!isPreviewRuntime) {
347
- const analyticsRuntimeMode = getRuntimeMode();
348
- (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackStepCompleted) === null || _a === void 0 ? void 0 : _a.call(analytics, {
349
- userId: currentUserIdRef.current,
350
- environment: analyticsRuntimeMode,
351
- stepId: record.stepId,
352
- stepName: record.stepName,
353
- stepType,
354
- startedAt,
355
- endedAt: record.completedAt,
356
- selected: record.choices,
357
- featureFlags: postHogFeatureFlagsRef.current,
358
- });
359
- }
360
613
  setUser((prev) => {
361
614
  var _a, _b;
362
615
  const updatedSteps = [...((_a = prev.completedSteps) !== null && _a !== void 0 ? _a : []), record];
363
- const updatedUser = Object.assign(Object.assign({}, prev), { attributes: Object.assign(Object.assign({}, prev.attributes), attributesRef.current), document: prev.document, completedSteps: updatedSteps });
616
+ const updatedUser = Object.assign(Object.assign({}, prev), { attributes: Object.assign(Object.assign({}, prev.attributes), completionAttributes), document: prev.document, completedSteps: updatedSteps });
364
617
  if (!isPreviewRuntime) {
365
- apiService
618
+ api
366
619
  .updateUser({
367
620
  id: updatedUser.id,
368
621
  name: updatedUser.name,
@@ -378,14 +631,347 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
378
631
  }
379
632
  return updatedUser;
380
633
  });
381
- }, [analytics, isPreviewRuntime, stepById]);
634
+ if (!isPreviewRuntime) {
635
+ const analyticsRuntimeMode = getRuntimeMode();
636
+ try {
637
+ (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackStepCompleted) === null || _a === void 0 ? void 0 : _a.call(analytics, Object.assign(Object.assign({ userId: currentUserIdRef.current, environment: analyticsRuntimeMode, stepId: record.stepId, stepName: record.stepName, stepType, startedAt: intent.visit.startedAt, endedAt: record.completedAt, stepContractVersion }, (options.omitSelected ? {} : { selected: record.choices })), { metadata: Object.assign(Object.assign(Object.assign({}, attributionMetadata), options.metadata), { step_contract_version: stepContractVersion }), featureFlags: postHogFeatureFlagsRef.current }));
638
+ }
639
+ catch (error) {
640
+ logger.error('Failed to track step completion:', error);
641
+ }
642
+ }
643
+ }, [analytics, api, attributionReady, isPreviewRuntime, lifecycleEventsSuppressed, stepById, stepContractVersion]);
644
+ const emitStepExit = useCallback((intent) => {
645
+ var _a;
646
+ const meta = stepById[intent.visit.stepId];
647
+ if (!meta || lifecycleEventsSuppressed) {
648
+ return;
649
+ }
650
+ const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
651
+ if (attributionMetadata === null) {
652
+ return;
653
+ }
654
+ (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackStepExited) === null || _a === void 0 ? void 0 : _a.call(analytics, {
655
+ userId: currentUserIdRef.current,
656
+ environment: getRuntimeMode(),
657
+ stepId: meta.id,
658
+ stepName: meta.name || meta.title,
659
+ stepType: meta.type,
660
+ startedAt: intent.visit.startedAt,
661
+ endedAt: new Date().toISOString(),
662
+ exitReason: intent.reason,
663
+ stepContractVersion,
664
+ metadata: Object.assign(Object.assign({}, attributionMetadata), { step_contract_version: stepContractVersion }),
665
+ featureFlags: postHogFeatureFlagsRef.current,
666
+ });
667
+ }, [analytics, attributionReady, lifecycleEventsSuppressed, stepById, stepContractVersion]);
668
+ const claimActiveVisit = useCallback((stepId, outcome, options = {}) => {
669
+ const token = activeVisitTokenRef.current;
670
+ if (!token || token.stepId !== stepId) {
671
+ return false;
672
+ }
673
+ const safeOutcome = resolveNavigationOutcome(outcome, stepContractVersion);
674
+ if (!safeOutcome) {
675
+ return false;
676
+ }
677
+ if (isTerminalCompletion(stepById[stepId], safeOutcome)
678
+ && options.allowTerminalCompletion !== true) {
679
+ logger.warn('[FunnelFlow] Terminal completion must use completeFunnel().');
680
+ return false;
681
+ }
682
+ const intent = finishVisit(stepLifecycleStateRef.current, token.visitId, safeOutcome);
683
+ if (!intent) {
684
+ return false;
685
+ }
686
+ if (intent.type === 'exit') {
687
+ emitStepExit(intent);
688
+ return true;
689
+ }
690
+ recordStepCompletion(intent, Object.assign(Object.assign({}, (safeOutcome.type === 'complete' && safeOutcome.selected !== undefined
691
+ ? { explicitChoices: Object.assign({}, safeOutcome.selected) }
692
+ : {})), { metadata: options.metadata, omitSelected: options.omitSelected }));
693
+ return true;
694
+ }, [emitStepExit, recordStepCompletion, stepById, stepContractVersion]);
695
+ const claimCurrentVisit = useCallback((outcome) => {
696
+ const token = activeVisitTokenRef.current;
697
+ return token ? claimActiveVisit(token.stepId, outcome) : false;
698
+ }, [claimActiveVisit]);
699
+ const goToStep = useCallback((stepId, outcome) => {
700
+ const resolvedOutcome = resolveNavigationOutcome(outcome, stepContractVersion);
701
+ const targetStepId = resolveNavigationTarget(stepId);
702
+ if (!resolvedOutcome || !targetStepId) {
703
+ return;
704
+ }
705
+ if (isTerminalCompletion(stepById[renderedStepId], resolvedOutcome)) {
706
+ logger.warn('[FunnelFlow] Terminal completion navigation must use completeFunnel().');
707
+ return;
708
+ }
709
+ claimCurrentVisit(resolvedOutcome);
710
+ navigateToStep(targetStepId);
711
+ }, [
712
+ claimCurrentVisit,
713
+ navigateToStep,
714
+ renderedStepId,
715
+ resolveNavigationTarget,
716
+ stepById,
717
+ stepContractVersion,
718
+ ]);
719
+ const goNext = useCallback(() => {
720
+ goToStep(resolveNextStepId(renderedStepId, {
721
+ ignoreExperiment: Boolean(activeExperimentForStep && renderedStepId === safeActiveStepId),
722
+ }), { type: 'complete' });
723
+ }, [activeExperimentForStep, goToStep, renderedStepId, resolveNextStepId, safeActiveStepId]);
724
+ const submitEmailCapture = useCallback((email) => {
725
+ var _a, _b;
726
+ const submittedStepId = renderedStepId;
727
+ const submittedStepMeta = stepById[submittedStepId];
728
+ const submittedVisitId = (_b = (_a = activeVisitTokenRef.current) === null || _a === void 0 ? void 0 : _a.visitId) !== null && _b !== void 0 ? _b : null;
729
+ const submittedUserId = currentUserIdRef.current;
730
+ const navigateManifestTarget = (stepId) => {
731
+ if (!isFunnelStepId(stepId)) {
732
+ return;
733
+ }
734
+ const targetStepId = resolveNavigationTarget(stepId);
735
+ if (targetStepId) {
736
+ navigateToStep(targetStepId);
737
+ }
738
+ };
739
+ return submitEmailCapturePrimitive({
740
+ email,
741
+ isPreview: isPreviewRuntime,
742
+ singleFlight: emailCaptureSingleFlightRef.current,
743
+ retryPendingNavigation: () => {
744
+ var _a;
745
+ const pending = pendingEmailCaptureNavigationRef.current;
746
+ if (!pending) {
747
+ return false;
748
+ }
749
+ if (pending.visitId !== ((_a = activeVisitTokenRef.current) === null || _a === void 0 ? void 0 : _a.visitId)
750
+ || pending.stepId !== submittedStepId) {
751
+ pendingEmailCaptureNavigationRef.current = null;
752
+ return false;
753
+ }
754
+ navigateManifestTarget(pending.targetStepId);
755
+ pendingEmailCaptureNavigationRef.current = null;
756
+ return true;
757
+ },
758
+ persist: (normalizedEmail) => api.captureEmail({
759
+ userId: submittedUserId,
760
+ email: normalizedEmail,
761
+ environment: getRuntimeMode(),
762
+ }),
763
+ applyUser: (capturedUser) => {
764
+ currentUserIdRef.current = capturedUser.id;
765
+ setUser((prev) => (Object.assign(Object.assign(Object.assign({}, prev), capturedUser), { completedSteps: prev.completedSteps })));
766
+ },
767
+ applyPreviewEmail: (normalizedEmail) => {
768
+ setUser((prev) => (Object.assign(Object.assign({}, prev), { email: normalizedEmail })));
769
+ },
770
+ fanOutBrowserDestinations: (eventId) => {
771
+ var _a;
772
+ if (!submittedStepMeta) {
773
+ return;
774
+ }
775
+ (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackEmailCapturedBrowserDestinations) === null || _a === void 0 ? void 0 : _a.call(analytics, {
776
+ eventId,
777
+ stepId: submittedStepMeta.id,
778
+ stepName: submittedStepMeta.name || submittedStepMeta.title,
779
+ stepType: submittedStepMeta.type,
780
+ });
781
+ },
782
+ completeVisit: () => {
783
+ var _a;
784
+ return (submittedVisitId !== null
785
+ && ((_a = activeVisitTokenRef.current) === null || _a === void 0 ? void 0 : _a.visitId) === submittedVisitId
786
+ && claimActiveVisit(submittedStepId, { type: 'complete' }));
787
+ },
788
+ resolveNextStep: () => {
789
+ const targetStepId = resolveNextStepId(submittedStepId, {
790
+ ignoreExperiment: Boolean(activeExperimentForStep && submittedStepId === safeActiveStepId),
791
+ });
792
+ if (submittedVisitId !== null) {
793
+ pendingEmailCaptureNavigationRef.current = {
794
+ visitId: submittedVisitId,
795
+ stepId: submittedStepId,
796
+ targetStepId,
797
+ };
798
+ }
799
+ return targetStepId;
800
+ },
801
+ navigate: (stepId) => {
802
+ navigateManifestTarget(stepId);
803
+ pendingEmailCaptureNavigationRef.current = null;
804
+ },
805
+ });
806
+ }, [
807
+ activeExperimentForStep,
808
+ api,
809
+ analytics,
810
+ claimActiveVisit,
811
+ isFunnelStepId,
812
+ isPreviewRuntime,
813
+ navigateToStep,
814
+ renderedStepId,
815
+ resolveNavigationTarget,
816
+ resolveNextStepId,
817
+ safeActiveStepId,
818
+ stepById,
819
+ ]);
820
+ useEffect(() => {
821
+ const abandonCurrentVisit = (event) => {
822
+ if (event.persisted) {
823
+ return;
824
+ }
825
+ claimCurrentVisit({ type: 'exit', reason: 'abandon' });
826
+ };
827
+ window.addEventListener('pagehide', abandonCurrentVisit);
828
+ return () => window.removeEventListener('pagehide', abandonCurrentVisit);
829
+ }, [claimCurrentVisit]);
382
830
  const completeStep = useCallback((stepId, choices) => {
383
- recordStepCompletion(stepId, choices);
384
- }, [recordStepCompletion]);
831
+ if (!enforceChoiceBypassGuard({
832
+ operation: 'completeStep',
833
+ activeStepId: renderedStepId,
834
+ sourceStepId: stepId,
835
+ stepById,
836
+ stepContractVersion,
837
+ environment: choiceGuardEnvironment,
838
+ reportDiagnostic: reportChoiceDiagnostic,
839
+ })) {
840
+ return;
841
+ }
842
+ claimActiveVisit(stepId, Object.assign({ type: 'complete' }, (choices === undefined ? {} : { selected: choices })));
843
+ }, [
844
+ choiceGuardEnvironment,
845
+ claimActiveVisit,
846
+ renderedStepId,
847
+ reportChoiceDiagnostic,
848
+ stepById,
849
+ stepContractVersion,
850
+ ]);
851
+ const completeFunnel = useCallback((stepId) => {
852
+ var _a, _b;
853
+ const stepMeta = stepById[stepId];
854
+ if (!stepMeta
855
+ || ((_a = FUNNEL_STEP_CONTRACTS[stepMeta.type]) === null || _a === void 0 ? void 0 : _a.terminal) !== true) {
856
+ return;
857
+ }
858
+ const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
859
+ if (attributionMetadata === null
860
+ || !claimActiveVisit(stepId, { type: 'complete' }, { allowTerminalCompletion: true })) {
861
+ return;
862
+ }
863
+ const alreadyCompletedThisRun = funnelCompletionClaimedRef.current;
864
+ funnelCompletionClaimedRef.current = true;
865
+ if (lifecycleEventsSuppressed || alreadyCompletedThisRun) {
866
+ return;
867
+ }
868
+ const occurredAt = new Date().toISOString();
869
+ try {
870
+ (_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFunnelCompleted) === null || _b === void 0 ? void 0 : _b.call(analytics, {
871
+ userId: currentUserIdRef.current,
872
+ environment: getRuntimeMode(),
873
+ stepId: stepMeta.id,
874
+ stepName: stepMeta.name || stepMeta.title,
875
+ stepType: stepMeta.type,
876
+ stepContractVersion,
877
+ occurredAt,
878
+ metadata: Object.assign(Object.assign({}, attributionMetadata), { step_contract_version: stepContractVersion }),
879
+ featureFlags: postHogFeatureFlagsRef.current,
880
+ });
881
+ }
882
+ catch (error) {
883
+ logger.error('Failed to track funnel completion:', error);
884
+ }
885
+ if (!(analytics === null || analytics === void 0 ? void 0 : analytics.flush)) {
886
+ return;
887
+ }
888
+ try {
889
+ const flushResult = analytics.flush();
890
+ void Promise.resolve(flushResult).catch((error) => {
891
+ logger.error('Failed to flush funnel completion:', error);
892
+ });
893
+ }
894
+ catch (error) {
895
+ logger.error('Failed to flush funnel completion:', error);
896
+ }
897
+ }, [
898
+ analytics,
899
+ attributionReady,
900
+ claimActiveVisit,
901
+ lifecycleEventsSuppressed,
902
+ stepById,
903
+ stepContractVersion,
904
+ ]);
905
+ const commitChoiceCompletion = useCallback((stepMeta, result) => {
906
+ runAtomicChoiceCommit({
907
+ activeStepId: renderedStepId,
908
+ activeStepMeta: renderedStepMeta,
909
+ stepMeta,
910
+ result,
911
+ environment: choiceGuardEnvironment,
912
+ getAttributes: () => attributesRef.current,
913
+ writeAttributes: (nextAttributes) => {
914
+ attributesRef.current = nextAttributes;
915
+ setAttributes(nextAttributes);
916
+ },
917
+ isInFlight: () => choiceCommitInFlightRef.current === renderedStepId,
918
+ beginCommit: () => {
919
+ choiceCommitInFlightRef.current = renderedStepId;
920
+ },
921
+ cancelCommit: () => {
922
+ choiceCommitInFlightRef.current = null;
923
+ },
924
+ recordCompletion: (completion) => {
925
+ let claimed;
926
+ try {
927
+ claimed = claimActiveVisit(renderedStepId, Object.assign({ type: 'complete' }, (completion.selected === undefined ? {} : { selected: completion.selected })), {
928
+ metadata: completion.metadata,
929
+ omitSelected: completion.selected === undefined,
930
+ });
931
+ }
932
+ catch (error) {
933
+ choiceNavigationRetryRef.current = renderedStepId;
934
+ throw error;
935
+ }
936
+ if (claimed) {
937
+ choiceNavigationRetryRef.current = null;
938
+ }
939
+ return claimed || choiceNavigationRetryRef.current === renderedStepId;
940
+ },
941
+ markCompletedBeforeNavigation: () => {
942
+ choiceCompletionMarkerRef.current = renderedStepId;
943
+ },
944
+ clearCompletedBeforeNavigation: (markerWasInstalled) => {
945
+ if (markerWasInstalled) {
946
+ choiceNavigationRetryRef.current = renderedStepId;
947
+ }
948
+ if (choiceCompletionMarkerRef.current === renderedStepId) {
949
+ choiceCompletionMarkerRef.current = null;
950
+ }
951
+ },
952
+ resolveNext: (proposedAttributes) => resolveNextStepId(renderedStepId, {
953
+ attributes: proposedAttributes,
954
+ ignoreExperiment: Boolean(activeExperimentForStep && renderedStepId === safeActiveStepId),
955
+ }),
956
+ navigate: (stepId) => goToStep(stepId, { type: 'complete' }),
957
+ reportDiagnostic: reportChoiceDiagnostic,
958
+ });
959
+ }, [
960
+ activeExperimentForStep,
961
+ choiceGuardEnvironment,
962
+ goToStep,
963
+ claimActiveVisit,
964
+ renderedStepId,
965
+ renderedStepMeta,
966
+ reportChoiceDiagnostic,
967
+ resolveNextStepId,
968
+ safeActiveStepId,
969
+ ]);
385
970
  useEffect(() => {
386
971
  var _a, _b, _c;
387
972
  if (safeActiveStepId !== activeStepId) {
388
973
  logger.warn(`[FunnelFlow] Unknown or non-renderable step "${activeStepId}", falling back to "${safeActiveStepId}".`);
974
+ claimCurrentVisit({ type: 'exit', reason: 'replace' });
389
975
  setActiveStepId(safeActiveStepId);
390
976
  return;
391
977
  }
@@ -397,14 +983,32 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
397
983
  return;
398
984
  }
399
985
  if (prevId && prevId !== renderedStepId) {
400
- recordStepCompletion(prevId);
986
+ const marker = consumeChoiceCompletionMarker(choiceCompletionMarkerRef.current, prevId);
987
+ choiceCompletionMarkerRef.current = marker.nextMarker;
988
+ if (marker.consumed) {
989
+ choiceCommitInFlightRef.current = null;
990
+ choiceNavigationRetryRef.current = null;
991
+ }
401
992
  }
402
993
  const startedAt = new Date().toISOString();
403
- const stepName = (renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.name) || (renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.title) || renderedStepId;
404
- const stepType = renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.type;
405
- stepStartedAtByIdRef.current[renderedStepId] = startedAt;
994
+ const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
995
+ if (attributionMetadata === null || !renderedStepMeta) {
996
+ return;
997
+ }
998
+ const startIntent = startVisit(stepLifecycleStateRef.current, { stepId: renderedStepId, startedAt });
999
+ if (!startIntent) {
1000
+ return;
1001
+ }
1002
+ activeVisitTokenRef.current = {
1003
+ visitId: startIntent.visit.visitId,
1004
+ stepId: renderedStepId,
1005
+ };
1006
+ const stepName = renderedStepMeta.name || renderedStepMeta.title;
1007
+ const stepType = renderedStepMeta.type;
406
1008
  const analyticsRuntimeMode = getRuntimeMode();
407
- if (!isPreviewRuntime) {
1009
+ attributesAtStepStart.current = Object.assign({}, attributesRef.current);
1010
+ prevStepIdRef.current = renderedStepId;
1011
+ if (!lifecycleEventsSuppressed) {
408
1012
  (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackStepStarted) === null || _a === void 0 ? void 0 : _a.call(analytics, {
409
1013
  userId: currentUserIdRef.current,
410
1014
  environment: analyticsRuntimeMode,
@@ -412,48 +1016,122 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
412
1016
  stepName,
413
1017
  stepType,
414
1018
  startedAt,
1019
+ stepContractVersion,
1020
+ metadata: Object.assign(Object.assign({}, attributionMetadata), { step_contract_version: stepContractVersion }),
415
1021
  featureFlags: postHogFeatureFlagsRef.current,
416
1022
  });
417
- if (!firstStepViewedTrackedRef.current && safeActiveStepId === defaultStepId) {
418
- firstStepViewedTrackedRef.current = true;
419
- (_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFunnelStarted) === null || _b === void 0 ? void 0 : _b.call(analytics, {
420
- userId: currentUserIdRef.current,
421
- environment: analyticsRuntimeMode,
422
- stepId: renderedStepId,
423
- stepName,
424
- stepType,
425
- occurredAt: startedAt,
426
- featureFlags: postHogFeatureFlagsRef.current,
427
- });
428
- (_c = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _c === void 0 ? void 0 : _c.call(analytics, {
1023
+ if (safeActiveStepId === defaultStepId) {
1024
+ const initialAttribution = takeInitialFunnelAttributionMetadata(funnelStartedTrackedRef.current, attributionReady, initialAttributionRef.current);
1025
+ funnelStartedTrackedRef.current = initialAttribution.tracked;
1026
+ if (initialAttribution.metadata) {
1027
+ (_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFunnelStarted) === null || _b === void 0 ? void 0 : _b.call(analytics, {
1028
+ userId: currentUserIdRef.current,
1029
+ environment: analyticsRuntimeMode,
1030
+ stepId: renderedStepId,
1031
+ stepName,
1032
+ stepType,
1033
+ stepContractVersion,
1034
+ occurredAt: startedAt,
1035
+ metadata: initialAttribution.metadata,
1036
+ featureFlags: postHogFeatureFlagsRef.current,
1037
+ });
1038
+ }
1039
+ if (firstStepViewedVisitRef.current !== startIntent.visit.visitId) {
1040
+ firstStepViewedVisitRef.current = startIntent.visit.visitId;
1041
+ (_c = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _c === void 0 ? void 0 : _c.call(analytics, {
1042
+ userId: currentUserIdRef.current,
1043
+ environment: analyticsRuntimeMode,
1044
+ stepId: renderedStepId,
1045
+ stepName,
1046
+ stepType,
1047
+ stepContractVersion,
1048
+ occurredAt: startedAt,
1049
+ metadata: attributionMetadata,
1050
+ featureFlags: postHogFeatureFlagsRef.current,
1051
+ });
1052
+ }
1053
+ }
1054
+ }
1055
+ }, [
1056
+ activeStepId,
1057
+ analytics,
1058
+ attributionReady,
1059
+ claimCurrentVisit,
1060
+ defaultStepId,
1061
+ isPreviewRuntime,
1062
+ lifecycleEventsSuppressed,
1063
+ renderSuspended,
1064
+ renderedStepId,
1065
+ renderedStepMeta,
1066
+ safeActiveStepId,
1067
+ stepContractVersion,
1068
+ ]);
1069
+ useEffect(() => {
1070
+ if (lifecycleEventsSuppressed
1071
+ || renderSuspended
1072
+ || safeActiveStepId !== defaultStepId
1073
+ || !renderedStepMeta) {
1074
+ return;
1075
+ }
1076
+ const visit = activeVisitTokenRef.current;
1077
+ if (!visit || visit.stepId !== renderedStepId) {
1078
+ return;
1079
+ }
1080
+ const handleFirstStepClick = () => {
1081
+ var _a;
1082
+ const activeVisit = activeVisitTokenRef.current;
1083
+ if (!activeVisit
1084
+ || activeVisit.visitId !== visit.visitId
1085
+ || activeVisit.stepId !== renderedStepId
1086
+ || firstStepClickedVisitRef.current === visit.visitId) {
1087
+ return;
1088
+ }
1089
+ const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
1090
+ if (attributionMetadata === null) {
1091
+ return;
1092
+ }
1093
+ firstStepClickedVisitRef.current = visit.visitId;
1094
+ try {
1095
+ (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepClicked) === null || _a === void 0 ? void 0 : _a.call(analytics, {
429
1096
  userId: currentUserIdRef.current,
430
- environment: analyticsRuntimeMode,
1097
+ environment: getRuntimeMode(),
431
1098
  stepId: renderedStepId,
432
- stepName,
433
- stepType,
434
- occurredAt: startedAt,
1099
+ stepName: renderedStepMeta.name || renderedStepMeta.title,
1100
+ stepType: renderedStepMeta.type,
1101
+ stepContractVersion,
1102
+ occurredAt: new Date().toISOString(),
1103
+ metadata: Object.assign(Object.assign({}, attributionMetadata), { step_contract_version: stepContractVersion }),
435
1104
  featureFlags: postHogFeatureFlagsRef.current,
436
1105
  });
437
1106
  }
438
- }
439
- attributesAtStepStart.current = Object.assign({}, attributesRef.current);
440
- prevStepIdRef.current = renderedStepId;
1107
+ catch (error) {
1108
+ logger.error('Failed to track first step click:', error);
1109
+ }
1110
+ };
1111
+ window.addEventListener('click', handleFirstStepClick, true);
1112
+ return () => window.removeEventListener('click', handleFirstStepClick, true);
441
1113
  }, [
442
- activeStepId,
443
1114
  analytics,
1115
+ attributionReady,
444
1116
  defaultStepId,
445
- isPreviewRuntime,
446
- recordStepCompletion,
1117
+ lifecycleEventsSuppressed,
447
1118
  renderSuspended,
448
1119
  renderedStepId,
449
- renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.name,
450
- renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.title,
451
- renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.type,
1120
+ renderedStepMeta,
452
1121
  safeActiveStepId,
1122
+ stepContractVersion,
453
1123
  ]);
454
1124
  useEffect(() => {
1125
+ if (appliedInitialStepIdRef.current === safeInitialStepId) {
1126
+ return;
1127
+ }
1128
+ appliedInitialStepIdRef.current = safeInitialStepId;
1129
+ if (safeActiveStepId === safeInitialStepId) {
1130
+ return;
1131
+ }
1132
+ claimCurrentVisit({ type: 'exit', reason: 'replace' });
455
1133
  setActiveStepId(safeInitialStepId);
456
- }, [safeInitialStepId]);
1134
+ }, [claimCurrentVisit, safeActiveStepId, safeInitialStepId]);
457
1135
  useEffect(() => {
458
1136
  setEditorModeEnabled(isEditorEnabled());
459
1137
  }, [safeActiveStepId]);
@@ -464,18 +1142,34 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
464
1142
  }, [editorModeEnabled]);
465
1143
  useEffect(() => {
466
1144
  if (shouldLockToInitialStep) {
467
- setActiveStepId(safeInitialStepId);
1145
+ if (safeActiveStepId !== safeInitialStepId) {
1146
+ claimCurrentVisit({ type: 'exit', reason: 'replace' });
1147
+ setActiveStepId(safeInitialStepId);
1148
+ }
468
1149
  return;
469
1150
  }
470
1151
  const syncStepWithPath = () => {
471
1152
  var _a;
472
1153
  const resolvedPathStep = getStepIdFromPath(window.location.pathname);
473
1154
  const resolvedStep = (_a = resolveRenderableStepId(resolvedPathStep)) !== null && _a !== void 0 ? _a : safeInitialStepId;
474
- setActiveStepId(resolvedStep);
1155
+ const targetStepId = resolveNavigationTarget(resolvedStep);
1156
+ if (!targetStepId) {
1157
+ return;
1158
+ }
1159
+ claimCurrentVisit({ type: 'exit', reason: 'back' });
1160
+ setActiveStepId(targetStepId);
475
1161
  };
476
1162
  window.addEventListener('popstate', syncStepWithPath);
477
1163
  return () => window.removeEventListener('popstate', syncStepWithPath);
478
- }, [getStepIdFromPath, resolveRenderableStepId, safeInitialStepId, shouldLockToInitialStep]);
1164
+ }, [
1165
+ claimCurrentVisit,
1166
+ getStepIdFromPath,
1167
+ resolveNavigationTarget,
1168
+ resolveRenderableStepId,
1169
+ safeActiveStepId,
1170
+ safeInitialStepId,
1171
+ shouldLockToInitialStep,
1172
+ ]);
479
1173
  const nextStepId = useMemo(() => resolveNextStepFromContext({
480
1174
  stepId: renderedStepId,
481
1175
  attributes,
@@ -511,7 +1205,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
511
1205
  ignoreExperiment: Boolean(activeExperimentForStep && renderedStepId === safeActiveStepId),
512
1206
  });
513
1207
  const timer = window.setTimeout(() => {
514
- goToStep(autoAdvanceTarget);
1208
+ goToStep(autoAdvanceTarget, { type: 'complete' });
515
1209
  }, autoAdvanceMs);
516
1210
  return () => window.clearTimeout(timer);
517
1211
  }, [
@@ -525,25 +1219,53 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
525
1219
  shouldLockToInitialStep,
526
1220
  ]);
527
1221
  const handleContinue = useCallback(() => {
528
- goToStep(nextStepId);
1222
+ goToStep(nextStepId, { type: 'complete' });
529
1223
  }, [goToStep, nextStepId]);
530
- const goToStepFromContext = useCallback((stepId) => {
1224
+ const goToStepFromContext = useCallback((stepId, outcome) => {
531
1225
  if (!isFunnelStepId(stepId)) {
532
1226
  return;
533
1227
  }
534
- goToStep(stepId);
1228
+ goToStep(stepId, outcome);
535
1229
  }, [goToStep, isFunnelStepId]);
536
1230
  const goChoice = useCallback((choice, stepId) => {
537
1231
  var _a, _b;
538
1232
  const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : renderedStepId;
539
- setAttributes((prev) => writeStepChoice(prev, sourceStepId, choice));
1233
+ const guardSourceStepId = stepId && stepById[stepId] ? stepId : sourceStepId;
1234
+ if (!enforceChoiceBypassGuard({
1235
+ operation: 'goChoice',
1236
+ activeStepId: renderedStepId,
1237
+ sourceStepId: guardSourceStepId,
1238
+ stepById,
1239
+ stepContractVersion,
1240
+ environment: choiceGuardEnvironment,
1241
+ reportDiagnostic: reportChoiceDiagnostic,
1242
+ })) {
1243
+ return;
1244
+ }
1245
+ const nextAttributes = writeStepChoice(attributesRef.current, sourceStepId, choice);
1246
+ attributesRef.current = nextAttributes;
1247
+ setAttributes(nextAttributes);
540
1248
  const sourceChoiceTargets = getChoiceTargetsForStep(sourceStepId);
541
1249
  const choiceTarget = (_b = resolveRenderableStepId(sourceChoiceTargets === null || sourceChoiceTargets === void 0 ? void 0 : sourceChoiceTargets[choice])) !== null && _b !== void 0 ? _b : resolveNextStepId(sourceStepId);
542
- goToStep(choiceTarget);
543
- }, [getChoiceTargetsForStep, goToStep, renderedStepId, resolveNextStepId, resolveRenderableStepId]);
1250
+ goToStep(choiceTarget, { type: 'complete' });
1251
+ }, [
1252
+ choiceGuardEnvironment,
1253
+ getChoiceTargetsForStep,
1254
+ goToStep,
1255
+ renderedStepId,
1256
+ reportChoiceDiagnostic,
1257
+ resolveNextStepId,
1258
+ resolveRenderableStepId,
1259
+ stepById,
1260
+ stepContractVersion,
1261
+ ]);
544
1262
  usePreviewBridge({
545
1263
  activeStepId: safeActiveStepId,
546
- onGoToStep: goToStepFromContext,
1264
+ onGoToStep: (stepId) => {
1265
+ if (isFunnelStepId(stepId)) {
1266
+ goToStep(stepId, { type: 'exit', reason: 'replace' });
1267
+ }
1268
+ },
547
1269
  resolveRenderableStepId,
548
1270
  setRuntimeMode,
549
1271
  shouldLockToInitialStep,
@@ -554,11 +1276,12 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
554
1276
  return;
555
1277
  }
556
1278
  const selectedVariant = experiment.variants.find((variant) => variant.variantKey === nextVariantKey);
557
- if (!selectedVariant) {
1279
+ if (!selectedVariant || selectedVariant.routeToStepId === renderedStepId) {
558
1280
  return;
559
1281
  }
1282
+ claimCurrentVisit({ type: 'exit', reason: 'replace' });
560
1283
  setEditorVariantOverrides((prev) => (Object.assign(Object.assign({}, prev), { [experiment.experimentId]: selectedVariant.variantKey })));
561
- }, []);
1284
+ }, [claimCurrentVisit, renderedStepId]);
562
1285
  const experimentAssignmentForEditor = useCallback((experiment) => {
563
1286
  var _a;
564
1287
  const override = editorVariantOverrides[experiment.experimentId];
@@ -583,30 +1306,40 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
583
1306
  }
584
1307
  return ((_a = experiment.variants[0]) === null || _a === void 0 ? void 0 : _a.variantKey) || '';
585
1308
  }, [editorVariantOverrides, postHogReady]);
586
- const contextValue = useMemo(() => ({
587
- activeStepId: renderedStepId,
588
- featureFlags: postHogFeatureFlags,
589
- isBuilder: isPreviewRuntime,
590
- goToStep: goToStepFromContext,
591
- goNext,
592
- goChoice,
593
- getChoiceTargets: (stepId) => {
594
- var _a;
595
- const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : renderedStepId;
596
- return getChoiceTargetsForStep(sourceStepId);
597
- },
598
- setAnswer,
599
- getAnswer,
600
- answers: attributes,
601
- setAttribute,
602
- attributes,
603
- user,
604
- userBootstrapped,
605
- setUser,
606
- completeStep,
607
- }), [
1309
+ const contextValue = useMemo(() => {
1310
+ const value = {
1311
+ activeStepId: renderedStepId,
1312
+ featureFlags: postHogFeatureFlags,
1313
+ isBuilder: isPreviewRuntime,
1314
+ goToStep: goToStepFromContext,
1315
+ goNext,
1316
+ goChoice,
1317
+ getChoiceTargets: (stepId) => {
1318
+ var _a;
1319
+ const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : renderedStepId;
1320
+ return getChoiceTargetsForStep(sourceStepId);
1321
+ },
1322
+ setAnswer,
1323
+ getAnswer,
1324
+ answers: attributes,
1325
+ setAttribute,
1326
+ attributes,
1327
+ user,
1328
+ userBootstrapped,
1329
+ setUser,
1330
+ submitEmailCapture,
1331
+ completeStep,
1332
+ completeFunnel,
1333
+ };
1334
+ // The WeakMap stores callback identity only; it never reads captured refs during render.
1335
+ // eslint-disable-next-line react-hooks/refs
1336
+ registerInternalChoiceCommit(value, commitChoiceCompletion);
1337
+ return value;
1338
+ }, [
608
1339
  attributes,
609
1340
  completeStep,
1341
+ completeFunnel,
1342
+ commitChoiceCompletion,
610
1343
  getAnswer,
611
1344
  getChoiceTargetsForStep,
612
1345
  goChoice,
@@ -618,6 +1351,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
618
1351
  resolveRenderableStepId,
619
1352
  setAnswer,
620
1353
  setAttribute,
1354
+ submitEmailCapture,
621
1355
  user,
622
1356
  userBootstrapped,
623
1357
  ]);