@funnelsgrove/runtime 0.1.56 → 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 (54) 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-manifest.validation.d.ts +7 -2
  16. package/dist/runtime/funnel-manifest.validation.js +24 -68
  17. package/dist/runtime/funnel-step-lifecycle.d.ts +35 -0
  18. package/dist/runtime/funnel-step-lifecycle.js +49 -0
  19. package/dist/runtime/funnel-step-metadata.validation.d.ts +7 -0
  20. package/dist/runtime/funnel-step-metadata.validation.js +88 -0
  21. package/dist/runtime/submit-email-capture.d.ts +23 -0
  22. package/dist/runtime/submit-email-capture.js +54 -0
  23. package/dist/runtime/use-funnel-flow-controller.d.ts +58 -5
  24. package/dist/runtime/use-funnel-flow-controller.js +804 -122
  25. package/dist/runtime/use-step-choices.d.ts +58 -0
  26. package/dist/runtime/use-step-choices.js +187 -0
  27. package/dist/sdk/userAnswers.d.ts +1 -0
  28. package/dist/services/api.service.d.ts +10 -0
  29. package/dist/services/api.service.js +32 -0
  30. package/dist/services/funnel-sdk.service.d.ts +17 -0
  31. package/dist/services/funnel-sdk.service.js +13 -0
  32. package/dist/steps/choice-contract.d.ts +33 -0
  33. package/dist/steps/choice-contract.js +302 -0
  34. package/dist/steps/step-contract.d.ts +498 -0
  35. package/dist/steps/step-contract.js +506 -0
  36. package/dist/steps/types.d.ts +7 -5
  37. package/dist/steps/types.js +1 -18
  38. package/dist/testing/funnel-contract-journey-driver.d.ts +35 -0
  39. package/dist/testing/funnel-contract-journey-driver.js +266 -0
  40. package/dist/testing/index.d.ts +1 -0
  41. package/dist/testing/index.js +1 -0
  42. package/dist/validation/fixtures/invalid-reserved-identities.d.ts +249 -0
  43. package/dist/validation/fixtures/invalid-reserved-identities.js +20 -0
  44. package/dist/validation/fixtures/valid-v2-project.d.ts +419 -0
  45. package/dist/validation/fixtures/valid-v2-project.js +228 -0
  46. package/dist/validation/fixtures/valid-v3-project.d.ts +419 -0
  47. package/dist/validation/fixtures/valid-v3-project.js +30 -0
  48. package/dist/validation/funnel-contract-diagnostics.d.ts +49 -0
  49. package/dist/validation/funnel-contract-diagnostics.js +67 -0
  50. package/dist/validation/funnel-contract-validator.d.ts +30 -0
  51. package/dist/validation/funnel-contract-validator.js +1458 -0
  52. package/dist/validation/funnel-manifest-input.normalization.d.ts +67 -0
  53. package/dist/validation/funnel-manifest-input.normalization.js +386 -0
  54. package/package.json +9 -1
@@ -13,6 +13,16 @@ 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
+ };
16
26
  export function resolveFunnelEventAttribution(document, provisionalAttribution) {
17
27
  var _a;
18
28
  return (_a = getFunnelUserAttribution(document)) !== null && _a !== void 0 ? _a : provisionalAttribution;
@@ -31,6 +41,176 @@ export function takeInitialFunnelAttributionMetadata(tracked, attributionReady,
31
41
  const metadata = buildReadyFunnelAttributionEventMetadata(attributionReady, attribution);
32
42
  return { tracked: metadata !== null, metadata };
33
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
+ }
34
214
  export function computeRenderSuspended(input) {
35
215
  var _a;
36
216
  if (input.isPreviewRuntime) {
@@ -89,15 +269,16 @@ export function resolveRenderedExperimentStepId(input) {
89
269
  const variantStepId = resolveRenderableStepId(assignment.routeToStepId);
90
270
  return variantStepId !== null && variantStepId !== void 0 ? variantStepId : safeActiveStepId;
91
271
  }
92
- 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, }) {
93
273
  var _a, _b;
94
274
  const [runtimeMode, setRuntimeMode] = useRuntimeMode();
95
- const [editorModeEnabled, setEditorModeEnabled] = useState(false);
275
+ const [editorModeEnabled, setEditorModeEnabled] = useState(() => isEditorEnabled());
96
276
  const [editorPanelExpanded, setEditorPanelExpanded] = useState(false);
97
277
  const [postHogReady, setPostHogReady] = useState(() => isPostHogReady());
98
278
  const [editorVariantOverrides, setEditorVariantOverrides] = useState({});
99
279
  const shouldLockToInitialStep = lockToInitialStep || isPreviewStepLockRequested();
100
280
  const isPreviewRuntime = useMemo(() => isPreviewFrameRuntime(), []);
281
+ const lifecycleEventsSuppressed = isPreviewRuntime || editorModeEnabled;
101
282
  const resolveRenderableStepId = useCallback((stepId) => {
102
283
  if (!stepId || !isFunnelStepId(stepId)) {
103
284
  return null;
@@ -130,7 +311,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
130
311
  const [activeStepId, setActiveStepId] = useState(safeInitialStepId);
131
312
  const [attributes, setAttributes] = useState(() => (Object.assign({}, (initialAttributes || {}))));
132
313
  const [user, setUser] = useState(() => ({
133
- id: apiService.getOrCreateClientUserId(),
314
+ id: api.getOrCreateClientUserId(),
134
315
  name: '',
135
316
  email: '',
136
317
  attributes: {},
@@ -143,10 +324,21 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
143
324
  const attributesRef = useRef(attributes);
144
325
  const postHogFeatureFlagsRef = useRef({});
145
326
  const prevStepIdRef = useRef(null);
146
- const firstStepViewedTrackedRef = useRef(false);
147
- 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);
148
332
  const currentUserIdRef = useRef(user.id);
149
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);
150
342
  const safeActiveStepId = useMemo(() => { var _a; return (_a = resolveRenderableStepId(activeStepId)) !== null && _a !== void 0 ? _a : safeInitialStepId; }, [activeStepId, resolveRenderableStepId, safeInitialStepId]);
151
343
  const activeStepMeta = stepById[safeActiveStepId];
152
344
  const renderSuspended = useMemo(() => computeRenderSuspended({
@@ -184,6 +376,16 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
184
376
  }), [activeExperimentForStep, editorVariantOverrides, postHogReady, resolveRenderableStepId, safeActiveStepId]);
185
377
  const renderedStepMeta = stepById[renderedStepId];
186
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';
187
389
  const postHogFeatureFlags = useMemo(() => {
188
390
  if (!postHogReady || isPreviewRuntime) {
189
391
  return {};
@@ -193,12 +395,20 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
193
395
  return variantKey ? [[experiment.experimentId, variantKey]] : [];
194
396
  }));
195
397
  }, [funnelExperiments, isPreviewRuntime, postHogReady]);
196
- const goToStep = useCallback((stepId) => {
197
- var _a;
398
+ const resolveNavigationTarget = useCallback((stepId) => {
399
+ var _a, _b;
198
400
  if (shouldLockToInitialStep) {
199
- return;
401
+ return null;
200
402
  }
201
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) => {
202
412
  const stepPath = getPathForStep(safeStepId);
203
413
  const nextLocation = buildHostedStepLocation({
204
414
  currentHref: window.location.href,
@@ -215,16 +425,38 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
215
425
  }
216
426
  }
217
427
  setActiveStepId(safeStepId);
218
- }, [getPathForStep, resolveRenderableStepId, safeInitialStepId, shouldLockToInitialStep]);
428
+ }, [getPathForStep]);
219
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
+ }
220
441
  setAttributes((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
221
- }, []);
442
+ }, [choiceGuardEnvironment, renderedStepId, reportChoiceDiagnostic, stepById, stepContractVersion]);
222
443
  const getAnswer = useCallback((key) => {
223
444
  return attributesRef.current[key];
224
445
  }, []);
225
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
+ }
226
458
  setAttributes((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
227
- }, []);
459
+ }, [choiceGuardEnvironment, renderedStepId, reportChoiceDiagnostic, stepById, stepContractVersion]);
228
460
  useEffect(() => {
229
461
  currentUserIdRef.current = user.id;
230
462
  }, [user.id]);
@@ -251,9 +483,10 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
251
483
  return () => window.clearInterval(intervalId);
252
484
  }, [postHogReady]);
253
485
  const resolveNextStepId = useCallback((stepId, options) => {
486
+ var _a;
254
487
  return resolveNextStepFromContext({
255
488
  stepId,
256
- attributes: attributesRef.current,
489
+ attributes: (_a = options === null || options === void 0 ? void 0 : options.attributes) !== null && _a !== void 0 ? _a : attributesRef.current,
257
490
  safeInitialStepId,
258
491
  resolveRenderableStepId,
259
492
  getConfiguredNextStepId: (currentStepId, context) => resolveConfiguredNextStep(currentStepId, context),
@@ -268,15 +501,10 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
268
501
  resolveRenderableStepId,
269
502
  safeInitialStepId,
270
503
  ]);
271
- const goNext = useCallback(() => {
272
- goToStep(resolveNextStepId(renderedStepId, {
273
- ignoreExperiment: Boolean(activeExperimentForStep && renderedStepId === safeActiveStepId),
274
- }));
275
- }, [activeExperimentForStep, goToStep, renderedStepId, resolveNextStepId, safeActiveStepId]);
276
504
  useEffect(() => {
277
- const localUserId = apiService.getOrCreateClientUserId();
505
+ const localUserId = api.getOrCreateClientUserId();
278
506
  const urlUserAttributes = resolveUrlUserAttributes();
279
- const bootstrapCandidateUserId = apiService.getBootstrapCandidateUserId(localUserId);
507
+ const bootstrapCandidateUserId = api.getBootstrapCandidateUserId(localUserId);
280
508
  const initialAttribution = collectCurrentFunnelAttribution();
281
509
  initialAttributionRef.current = initialAttribution;
282
510
  setUserBootstrapped(isPreviewRuntime);
@@ -297,7 +525,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
297
525
  logger.error('Failed to bootstrap PostHog feature flags:', error);
298
526
  setPostHogReady(true);
299
527
  });
300
- const sessionBootstrap = apiService
528
+ const sessionBootstrap = api
301
529
  .bootstrapSession({
302
530
  userId: bootstrapCandidateUserId || localUserId,
303
531
  email: urlUserAttributes.email || undefined,
@@ -316,7 +544,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
316
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 })));
317
545
  setUserBootstrapped(true);
318
546
  setAttributionReady(true);
319
- void apiService.pingUserContext(sessionUser.id)
547
+ void api.pingUserContext(sessionUser.id)
320
548
  .then((pingedUser) => {
321
549
  if (!pingedUser) {
322
550
  return;
@@ -349,11 +577,11 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
349
577
  logger.error('Failed to identify PostHog user:', error);
350
578
  });
351
579
  return cancelAttributionFailOpen;
352
- }, [isPreviewRuntime]);
353
- const recordStepCompletion = useCallback((stepId, explicitChoices) => {
580
+ }, [api, isPreviewRuntime]);
581
+ const recordStepCompletion = useCallback((intent, options = {}) => {
354
582
  var _a;
355
- const meta = stepById[stepId];
356
- if (!meta) {
583
+ const meta = stepById[intent.visit.stepId];
584
+ if (!meta || lifecycleEventsSuppressed) {
357
585
  return;
358
586
  }
359
587
  const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
@@ -361,8 +589,8 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
361
589
  return;
362
590
  }
363
591
  let choices;
364
- if (explicitChoices) {
365
- choices = explicitChoices;
592
+ if (options.explicitChoices !== undefined) {
593
+ choices = options.explicitChoices;
366
594
  }
367
595
  else {
368
596
  const snapshot = attributesAtStepStart.current;
@@ -380,29 +608,14 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
380
608
  choices,
381
609
  };
382
610
  const stepType = meta.type;
383
- const startedAt = stepStartedAtByIdRef.current[meta.id] || record.completedAt;
611
+ const completionAttributes = Object.assign({}, attributesRef.current);
384
612
  dispatchWindowCustomEvent('funnel:step-completed', record);
385
- if (!isPreviewRuntime) {
386
- const analyticsRuntimeMode = getRuntimeMode();
387
- (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackStepCompleted) === null || _a === void 0 ? void 0 : _a.call(analytics, {
388
- userId: currentUserIdRef.current,
389
- environment: analyticsRuntimeMode,
390
- stepId: record.stepId,
391
- stepName: record.stepName,
392
- stepType,
393
- startedAt,
394
- endedAt: record.completedAt,
395
- selected: record.choices,
396
- metadata: attributionMetadata,
397
- featureFlags: postHogFeatureFlagsRef.current,
398
- });
399
- }
400
613
  setUser((prev) => {
401
614
  var _a, _b;
402
615
  const updatedSteps = [...((_a = prev.completedSteps) !== null && _a !== void 0 ? _a : []), record];
403
- 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 });
404
617
  if (!isPreviewRuntime) {
405
- apiService
618
+ api
406
619
  .updateUser({
407
620
  id: updatedUser.id,
408
621
  name: updatedUser.name,
@@ -418,14 +631,347 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
418
631
  }
419
632
  return updatedUser;
420
633
  });
421
- }, [analytics, attributionReady, 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]);
422
830
  const completeStep = useCallback((stepId, choices) => {
423
- recordStepCompletion(stepId, choices);
424
- }, [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
+ ]);
425
970
  useEffect(() => {
426
971
  var _a, _b, _c;
427
972
  if (safeActiveStepId !== activeStepId) {
428
973
  logger.warn(`[FunnelFlow] Unknown or non-renderable step "${activeStepId}", falling back to "${safeActiveStepId}".`);
974
+ claimCurrentVisit({ type: 'exit', reason: 'replace' });
429
975
  setActiveStepId(safeActiveStepId);
430
976
  return;
431
977
  }
@@ -437,20 +983,32 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
437
983
  return;
438
984
  }
439
985
  if (prevId && prevId !== renderedStepId) {
440
- 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
+ }
441
992
  }
442
993
  const startedAt = new Date().toISOString();
443
- const stepName = (renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.name) || (renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.title) || renderedStepId;
444
- const stepType = renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.type;
445
- stepStartedAtByIdRef.current[renderedStepId] = startedAt;
446
- const analyticsRuntimeMode = getRuntimeMode();
447
- attributesAtStepStart.current = Object.assign({}, attributesRef.current);
448
- prevStepIdRef.current = renderedStepId;
449
994
  const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
450
- if (attributionMetadata === null) {
995
+ if (attributionMetadata === null || !renderedStepMeta) {
451
996
  return;
452
997
  }
453
- if (!isPreviewRuntime) {
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;
1008
+ const analyticsRuntimeMode = getRuntimeMode();
1009
+ attributesAtStepStart.current = Object.assign({}, attributesRef.current);
1010
+ prevStepIdRef.current = renderedStepId;
1011
+ if (!lifecycleEventsSuppressed) {
454
1012
  (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackStepStarted) === null || _a === void 0 ? void 0 : _a.call(analytics, {
455
1013
  userId: currentUserIdRef.current,
456
1014
  environment: analyticsRuntimeMode,
@@ -458,54 +1016,122 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
458
1016
  stepName,
459
1017
  stepType,
460
1018
  startedAt,
461
- metadata: attributionMetadata,
1019
+ stepContractVersion,
1020
+ metadata: Object.assign(Object.assign({}, attributionMetadata), { step_contract_version: stepContractVersion }),
462
1021
  featureFlags: postHogFeatureFlagsRef.current,
463
1022
  });
464
- if (!firstStepViewedTrackedRef.current && safeActiveStepId === defaultStepId) {
465
- const initialAttribution = takeInitialFunnelAttributionMetadata(firstStepViewedTrackedRef.current, attributionReady, initialAttributionRef.current);
466
- firstStepViewedTrackedRef.current = initialAttribution.tracked;
467
- if (!initialAttribution.metadata) {
468
- return;
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
+ });
469
1038
  }
470
- (_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFunnelStarted) === null || _b === void 0 ? void 0 : _b.call(analytics, {
471
- userId: currentUserIdRef.current,
472
- environment: analyticsRuntimeMode,
473
- stepId: renderedStepId,
474
- stepName,
475
- stepType,
476
- occurredAt: startedAt,
477
- metadata: initialAttribution.metadata,
478
- featureFlags: postHogFeatureFlagsRef.current,
479
- });
480
- (_c = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _c === void 0 ? void 0 : _c.call(analytics, {
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, {
481
1096
  userId: currentUserIdRef.current,
482
- environment: analyticsRuntimeMode,
1097
+ environment: getRuntimeMode(),
483
1098
  stepId: renderedStepId,
484
- stepName,
485
- stepType,
486
- occurredAt: startedAt,
487
- metadata: initialAttribution.metadata,
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 }),
488
1104
  featureFlags: postHogFeatureFlagsRef.current,
489
1105
  });
490
1106
  }
491
- }
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);
492
1113
  }, [
493
- activeStepId,
494
1114
  analytics,
495
1115
  attributionReady,
496
1116
  defaultStepId,
497
- isPreviewRuntime,
498
- recordStepCompletion,
1117
+ lifecycleEventsSuppressed,
499
1118
  renderSuspended,
500
1119
  renderedStepId,
501
- renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.name,
502
- renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.title,
503
- renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.type,
1120
+ renderedStepMeta,
504
1121
  safeActiveStepId,
1122
+ stepContractVersion,
505
1123
  ]);
506
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' });
507
1133
  setActiveStepId(safeInitialStepId);
508
- }, [safeInitialStepId]);
1134
+ }, [claimCurrentVisit, safeActiveStepId, safeInitialStepId]);
509
1135
  useEffect(() => {
510
1136
  setEditorModeEnabled(isEditorEnabled());
511
1137
  }, [safeActiveStepId]);
@@ -516,18 +1142,34 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
516
1142
  }, [editorModeEnabled]);
517
1143
  useEffect(() => {
518
1144
  if (shouldLockToInitialStep) {
519
- setActiveStepId(safeInitialStepId);
1145
+ if (safeActiveStepId !== safeInitialStepId) {
1146
+ claimCurrentVisit({ type: 'exit', reason: 'replace' });
1147
+ setActiveStepId(safeInitialStepId);
1148
+ }
520
1149
  return;
521
1150
  }
522
1151
  const syncStepWithPath = () => {
523
1152
  var _a;
524
1153
  const resolvedPathStep = getStepIdFromPath(window.location.pathname);
525
1154
  const resolvedStep = (_a = resolveRenderableStepId(resolvedPathStep)) !== null && _a !== void 0 ? _a : safeInitialStepId;
526
- setActiveStepId(resolvedStep);
1155
+ const targetStepId = resolveNavigationTarget(resolvedStep);
1156
+ if (!targetStepId) {
1157
+ return;
1158
+ }
1159
+ claimCurrentVisit({ type: 'exit', reason: 'back' });
1160
+ setActiveStepId(targetStepId);
527
1161
  };
528
1162
  window.addEventListener('popstate', syncStepWithPath);
529
1163
  return () => window.removeEventListener('popstate', syncStepWithPath);
530
- }, [getStepIdFromPath, resolveRenderableStepId, safeInitialStepId, shouldLockToInitialStep]);
1164
+ }, [
1165
+ claimCurrentVisit,
1166
+ getStepIdFromPath,
1167
+ resolveNavigationTarget,
1168
+ resolveRenderableStepId,
1169
+ safeActiveStepId,
1170
+ safeInitialStepId,
1171
+ shouldLockToInitialStep,
1172
+ ]);
531
1173
  const nextStepId = useMemo(() => resolveNextStepFromContext({
532
1174
  stepId: renderedStepId,
533
1175
  attributes,
@@ -563,7 +1205,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
563
1205
  ignoreExperiment: Boolean(activeExperimentForStep && renderedStepId === safeActiveStepId),
564
1206
  });
565
1207
  const timer = window.setTimeout(() => {
566
- goToStep(autoAdvanceTarget);
1208
+ goToStep(autoAdvanceTarget, { type: 'complete' });
567
1209
  }, autoAdvanceMs);
568
1210
  return () => window.clearTimeout(timer);
569
1211
  }, [
@@ -577,25 +1219,53 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
577
1219
  shouldLockToInitialStep,
578
1220
  ]);
579
1221
  const handleContinue = useCallback(() => {
580
- goToStep(nextStepId);
1222
+ goToStep(nextStepId, { type: 'complete' });
581
1223
  }, [goToStep, nextStepId]);
582
- const goToStepFromContext = useCallback((stepId) => {
1224
+ const goToStepFromContext = useCallback((stepId, outcome) => {
583
1225
  if (!isFunnelStepId(stepId)) {
584
1226
  return;
585
1227
  }
586
- goToStep(stepId);
1228
+ goToStep(stepId, outcome);
587
1229
  }, [goToStep, isFunnelStepId]);
588
1230
  const goChoice = useCallback((choice, stepId) => {
589
1231
  var _a, _b;
590
1232
  const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : renderedStepId;
591
- 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);
592
1248
  const sourceChoiceTargets = getChoiceTargetsForStep(sourceStepId);
593
1249
  const choiceTarget = (_b = resolveRenderableStepId(sourceChoiceTargets === null || sourceChoiceTargets === void 0 ? void 0 : sourceChoiceTargets[choice])) !== null && _b !== void 0 ? _b : resolveNextStepId(sourceStepId);
594
- goToStep(choiceTarget);
595
- }, [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
+ ]);
596
1262
  usePreviewBridge({
597
1263
  activeStepId: safeActiveStepId,
598
- onGoToStep: goToStepFromContext,
1264
+ onGoToStep: (stepId) => {
1265
+ if (isFunnelStepId(stepId)) {
1266
+ goToStep(stepId, { type: 'exit', reason: 'replace' });
1267
+ }
1268
+ },
599
1269
  resolveRenderableStepId,
600
1270
  setRuntimeMode,
601
1271
  shouldLockToInitialStep,
@@ -606,11 +1276,12 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
606
1276
  return;
607
1277
  }
608
1278
  const selectedVariant = experiment.variants.find((variant) => variant.variantKey === nextVariantKey);
609
- if (!selectedVariant) {
1279
+ if (!selectedVariant || selectedVariant.routeToStepId === renderedStepId) {
610
1280
  return;
611
1281
  }
1282
+ claimCurrentVisit({ type: 'exit', reason: 'replace' });
612
1283
  setEditorVariantOverrides((prev) => (Object.assign(Object.assign({}, prev), { [experiment.experimentId]: selectedVariant.variantKey })));
613
- }, []);
1284
+ }, [claimCurrentVisit, renderedStepId]);
614
1285
  const experimentAssignmentForEditor = useCallback((experiment) => {
615
1286
  var _a;
616
1287
  const override = editorVariantOverrides[experiment.experimentId];
@@ -635,30 +1306,40 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
635
1306
  }
636
1307
  return ((_a = experiment.variants[0]) === null || _a === void 0 ? void 0 : _a.variantKey) || '';
637
1308
  }, [editorVariantOverrides, postHogReady]);
638
- const contextValue = useMemo(() => ({
639
- activeStepId: renderedStepId,
640
- featureFlags: postHogFeatureFlags,
641
- isBuilder: isPreviewRuntime,
642
- goToStep: goToStepFromContext,
643
- goNext,
644
- goChoice,
645
- getChoiceTargets: (stepId) => {
646
- var _a;
647
- const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : renderedStepId;
648
- return getChoiceTargetsForStep(sourceStepId);
649
- },
650
- setAnswer,
651
- getAnswer,
652
- answers: attributes,
653
- setAttribute,
654
- attributes,
655
- user,
656
- userBootstrapped,
657
- setUser,
658
- completeStep,
659
- }), [
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
+ }, [
660
1339
  attributes,
661
1340
  completeStep,
1341
+ completeFunnel,
1342
+ commitChoiceCompletion,
662
1343
  getAnswer,
663
1344
  getChoiceTargetsForStep,
664
1345
  goChoice,
@@ -670,6 +1351,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
670
1351
  resolveRenderableStepId,
671
1352
  setAnswer,
672
1353
  setAttribute,
1354
+ submitEmailCapture,
673
1355
  user,
674
1356
  userBootstrapped,
675
1357
  ]);