@funnelsgrove/runtime 0.1.54 → 0.1.56

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.
@@ -15,4 +15,5 @@ export declare const collectCurrentFunnelAttribution: (input?: {
15
15
  posthogProperties?: Record<string, unknown> | null;
16
16
  }) => FunnelUserAttribution;
17
17
  export declare const mergeFunnelUserAttribution: (current: unknown, incoming: FunnelUserAttribution | null | undefined) => FunnelUserAttribution;
18
+ export declare const buildFunnelAttributionEventMetadata: (attribution: FunnelUserAttribution | null | undefined) => Record<string, string>;
18
19
  export declare const getFunnelUserAttribution: (document: unknown) => FunnelUserAttribution | null;
@@ -6,6 +6,22 @@ const INTERNAL_ATTRIBUTION_QUERY_KEYS = new Set([
6
6
  'step',
7
7
  'user_id',
8
8
  ]);
9
+ const EVENT_ATTRIBUTION_CLICK_ID_KEYS = new Set([
10
+ '_fbc',
11
+ '_fbp',
12
+ 'epik',
13
+ 'fbc',
14
+ 'fbclid',
15
+ 'fbp',
16
+ 'gbraid',
17
+ 'gclid',
18
+ 'li_fat_id',
19
+ 'msclkid',
20
+ 'scclid',
21
+ 'ttclid',
22
+ 'twclid',
23
+ 'wbraid',
24
+ ]);
9
25
  const POSTHOG_CONTEXT_MAPPINGS = {
10
26
  $geoip_city_name: 'cityName',
11
27
  $geoip_country_code: 'countryCode',
@@ -41,6 +57,13 @@ const normalizeTouch = (value) => {
41
57
  return [[normalizedKey, normalizedValue]];
42
58
  }));
43
59
  };
60
+ const isEventAttributionKey = (key) => {
61
+ const normalizedKey = key.trim().toLowerCase();
62
+ return normalizedKey.startsWith('utm_') || EVENT_ATTRIBUTION_CLICK_ID_KEYS.has(normalizedKey);
63
+ };
64
+ const normalizeEventAttributionTouch = (value) => {
65
+ return Object.fromEntries(Object.entries(normalizeTouch(value)).filter(([key]) => isEventAttributionKey(key)));
66
+ };
44
67
  const normalizeContext = (value) => {
45
68
  if (!isRecord(value)) {
46
69
  return {};
@@ -219,6 +242,7 @@ export const mergeFunnelUserAttribution = (current, incoming) => {
219
242
  context: deepMergeRecords(currentContext, nextContext),
220
243
  };
221
244
  };
245
+ export const buildFunnelAttributionEventMetadata = (attribution) => normalizeEventAttributionTouch(attribution === null || attribution === void 0 ? void 0 : attribution.firstTouch);
222
246
  export const getFunnelUserAttribution = (document) => {
223
247
  if (!isRecord(document) || !isRecord(document.attribution)) {
224
248
  return null;
@@ -2,6 +2,7 @@ import { type Dispatch, type SetStateAction } from 'react';
2
2
  import type { FunnelContextValue } from '../components/FunnelContext.js';
3
3
  import type { FunnelManifestExperiment } from '../config/funnel.manifest.types.js';
4
4
  import type { FunnelUserAnswers } from '../sdk/userAnswers.js';
5
+ import { type FunnelUserAttribution } from './funnel-attribution.js';
5
6
  import type { FunnelStepMeta } from '../steps/types.js';
6
7
  export type FunnelFlowControllerExperiment<StepId extends string> = FunnelManifestExperiment & {
7
8
  stepId: StepId;
@@ -18,6 +19,7 @@ type FunnelFlowAnalyticsAdapter = {
18
19
  stepName: string;
19
20
  stepType?: string;
20
21
  startedAt: string;
22
+ metadata?: Record<string, unknown>;
21
23
  featureFlags?: Record<string, string>;
22
24
  }) => string | null;
23
25
  trackStepCompleted?: (input: {
@@ -29,6 +31,7 @@ type FunnelFlowAnalyticsAdapter = {
29
31
  startedAt: string;
30
32
  endedAt: string;
31
33
  selected?: Record<string, unknown>;
34
+ metadata?: Record<string, unknown>;
32
35
  featureFlags?: Record<string, string>;
33
36
  }) => string | null;
34
37
  trackFunnelStarted?: (input: {
@@ -38,6 +41,7 @@ type FunnelFlowAnalyticsAdapter = {
38
41
  stepName: string;
39
42
  stepType?: string;
40
43
  occurredAt: string;
44
+ metadata?: Record<string, unknown>;
41
45
  featureFlags?: Record<string, string>;
42
46
  }) => string | null;
43
47
  trackFirstStepViewed?: (input: {
@@ -47,6 +51,7 @@ type FunnelFlowAnalyticsAdapter = {
47
51
  stepName: string;
48
52
  stepType?: string;
49
53
  occurredAt: string;
54
+ metadata?: Record<string, unknown>;
50
55
  featureFlags?: Record<string, string>;
51
56
  }) => string | null;
52
57
  };
@@ -99,8 +104,16 @@ type UseFunnelFlowControllerResult<StepId extends string> = {
99
104
  setRuntimeMode: (mode: 'test' | 'live') => void;
100
105
  showShellContinue: boolean;
101
106
  };
107
+ export declare function resolveFunnelEventAttribution(document: unknown, provisionalAttribution: FunnelUserAttribution | null): FunnelUserAttribution | null;
108
+ export declare function buildReadyFunnelAttributionEventMetadata(attributionReady: boolean, attribution: FunnelUserAttribution | null): Record<string, string> | null;
109
+ export declare function scheduleAttributionFailOpen(onReady: () => void, timeoutMs: number): () => void;
110
+ export declare function takeInitialFunnelAttributionMetadata(tracked: boolean, attributionReady: boolean, attribution: FunnelUserAttribution | null): {
111
+ tracked: boolean;
112
+ metadata: Record<string, string> | null;
113
+ };
102
114
  export declare function computeRenderSuspended(input: {
103
115
  activeStepId: string;
116
+ attributionReady?: boolean;
104
117
  postHogReady: boolean;
105
118
  experiments: ReadonlyArray<{
106
119
  stepId: string;
@@ -8,14 +8,38 @@ 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
+ export function resolveFunnelEventAttribution(document, provisionalAttribution) {
17
+ var _a;
18
+ return (_a = getFunnelUserAttribution(document)) !== null && _a !== void 0 ? _a : provisionalAttribution;
19
+ }
20
+ export function buildReadyFunnelAttributionEventMetadata(attributionReady, attribution) {
21
+ return attributionReady ? buildFunnelAttributionEventMetadata(attribution) : null;
22
+ }
23
+ export function scheduleAttributionFailOpen(onReady, timeoutMs) {
24
+ const timeoutId = setTimeout(onReady, timeoutMs);
25
+ return () => clearTimeout(timeoutId);
26
+ }
27
+ export function takeInitialFunnelAttributionMetadata(tracked, attributionReady, attribution) {
28
+ if (tracked) {
29
+ return { tracked, metadata: null };
30
+ }
31
+ const metadata = buildReadyFunnelAttributionEventMetadata(attributionReady, attribution);
32
+ return { tracked: metadata !== null, metadata };
33
+ }
16
34
  export function computeRenderSuspended(input) {
17
35
  var _a;
18
- if (input.isPreviewRuntime || input.postHogReady) {
36
+ if (input.isPreviewRuntime) {
37
+ return false;
38
+ }
39
+ if (input.attributionReady === false) {
40
+ return true;
41
+ }
42
+ if (input.postHogReady) {
19
43
  return false;
20
44
  }
21
45
  const activeExperiment = input.experiments.find((experiment) => experiment.stepId === input.activeStepId);
@@ -114,6 +138,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
114
138
  completedSteps: [],
115
139
  }));
116
140
  const [userBootstrapped, setUserBootstrapped] = useState(isPreviewRuntime);
141
+ const [attributionReady, setAttributionReady] = useState(isPreviewRuntime);
117
142
  const attributesAtStepStart = useRef({});
118
143
  const attributesRef = useRef(attributes);
119
144
  const postHogFeatureFlagsRef = useRef({});
@@ -121,14 +146,16 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
121
146
  const firstStepViewedTrackedRef = useRef(false);
122
147
  const stepStartedAtByIdRef = useRef({});
123
148
  const currentUserIdRef = useRef(user.id);
149
+ const initialAttributionRef = useRef(null);
124
150
  const safeActiveStepId = useMemo(() => { var _a; return (_a = resolveRenderableStepId(activeStepId)) !== null && _a !== void 0 ? _a : safeInitialStepId; }, [activeStepId, resolveRenderableStepId, safeInitialStepId]);
125
151
  const activeStepMeta = stepById[safeActiveStepId];
126
152
  const renderSuspended = useMemo(() => computeRenderSuspended({
127
153
  activeStepId: safeActiveStepId,
154
+ attributionReady,
128
155
  postHogReady,
129
156
  experiments: funnelExperiments,
130
157
  isPreviewRuntime,
131
- }), [funnelExperiments, isPreviewRuntime, postHogReady, safeActiveStepId]);
158
+ }), [attributionReady, funnelExperiments, isPreviewRuntime, postHogReady, safeActiveStepId]);
132
159
  const resolveExperimentVariantKey = useCallback((experiment) => {
133
160
  var _a;
134
161
  const override = editorVariantOverrides[experiment.experimentId];
@@ -189,22 +216,6 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
189
216
  }
190
217
  setActiveStepId(safeStepId);
191
218
  }, [getPathForStep, resolveRenderableStepId, safeInitialStepId, shouldLockToInitialStep]);
192
- useEffect(() => {
193
- if (!activeExperimentForStep ||
194
- renderSuspended ||
195
- shouldLockToInitialStep ||
196
- renderedStepId === safeActiveStepId) {
197
- return;
198
- }
199
- goToStep(renderedStepId);
200
- }, [
201
- activeExperimentForStep,
202
- goToStep,
203
- renderSuspended,
204
- renderedStepId,
205
- safeActiveStepId,
206
- shouldLockToInitialStep,
207
- ]);
208
219
  const setAnswer = useCallback((key, value) => {
209
220
  setAttributes((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
210
221
  }, []);
@@ -267,11 +278,13 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
267
278
  const urlUserAttributes = resolveUrlUserAttributes();
268
279
  const bootstrapCandidateUserId = apiService.getBootstrapCandidateUserId(localUserId);
269
280
  const initialAttribution = collectCurrentFunnelAttribution();
281
+ initialAttributionRef.current = initialAttribution;
270
282
  setUserBootstrapped(isPreviewRuntime);
271
283
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
272
284
  if (isPreviewRuntime) {
273
285
  return;
274
286
  }
287
+ const cancelAttributionFailOpen = scheduleAttributionFailOpen(() => setAttributionReady(true), 5000);
275
288
  const postHogBootstrap = bootstrapPostHog({
276
289
  apiKey: POSTHOG_PROJECT_API_KEY,
277
290
  apiHost: POSTHOG_API_HOST,
@@ -292,14 +305,17 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
292
305
  attribution: initialAttribution,
293
306
  })
294
307
  .then((sessionUser) => {
308
+ cancelAttributionFailOpen();
295
309
  const sessionAttributes = Object.keys(sessionUser.attributes || {}).length > 0
296
310
  ? sessionUser.attributes
297
311
  : null;
298
312
  if (sessionAttributes) {
299
313
  setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
300
314
  }
315
+ initialAttributionRef.current = resolveFunnelEventAttribution(sessionUser.document, initialAttribution);
301
316
  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 })));
302
317
  setUserBootstrapped(true);
318
+ setAttributionReady(true);
303
319
  void apiService.pingUserContext(sessionUser.id)
304
320
  .then((pingedUser) => {
305
321
  if (!pingedUser) {
@@ -317,8 +333,10 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
317
333
  return sessionUser;
318
334
  })
319
335
  .catch((error) => {
336
+ cancelAttributionFailOpen();
320
337
  logger.error('Failed to bootstrap user session:', error);
321
338
  setUserBootstrapped(false);
339
+ setAttributionReady(true);
322
340
  return null;
323
341
  });
324
342
  Promise.all([postHogBootstrap, sessionBootstrap])
@@ -330,6 +348,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
330
348
  .catch((error) => {
331
349
  logger.error('Failed to identify PostHog user:', error);
332
350
  });
351
+ return cancelAttributionFailOpen;
333
352
  }, [isPreviewRuntime]);
334
353
  const recordStepCompletion = useCallback((stepId, explicitChoices) => {
335
354
  var _a;
@@ -337,6 +356,10 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
337
356
  if (!meta) {
338
357
  return;
339
358
  }
359
+ const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
360
+ if (attributionMetadata === null) {
361
+ return;
362
+ }
340
363
  let choices;
341
364
  if (explicitChoices) {
342
365
  choices = explicitChoices;
@@ -370,6 +393,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
370
393
  startedAt,
371
394
  endedAt: record.completedAt,
372
395
  selected: record.choices,
396
+ metadata: attributionMetadata,
373
397
  featureFlags: postHogFeatureFlagsRef.current,
374
398
  });
375
399
  }
@@ -394,7 +418,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
394
418
  }
395
419
  return updatedUser;
396
420
  });
397
- }, [analytics, isPreviewRuntime, stepById]);
421
+ }, [analytics, attributionReady, isPreviewRuntime, stepById]);
398
422
  const completeStep = useCallback((stepId, choices) => {
399
423
  recordStepCompletion(stepId, choices);
400
424
  }, [recordStepCompletion]);
@@ -420,6 +444,12 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
420
444
  const stepType = renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.type;
421
445
  stepStartedAtByIdRef.current[renderedStepId] = startedAt;
422
446
  const analyticsRuntimeMode = getRuntimeMode();
447
+ attributesAtStepStart.current = Object.assign({}, attributesRef.current);
448
+ prevStepIdRef.current = renderedStepId;
449
+ const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
450
+ if (attributionMetadata === null) {
451
+ return;
452
+ }
423
453
  if (!isPreviewRuntime) {
424
454
  (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackStepStarted) === null || _a === void 0 ? void 0 : _a.call(analytics, {
425
455
  userId: currentUserIdRef.current,
@@ -428,10 +458,15 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
428
458
  stepName,
429
459
  stepType,
430
460
  startedAt,
461
+ metadata: attributionMetadata,
431
462
  featureFlags: postHogFeatureFlagsRef.current,
432
463
  });
433
464
  if (!firstStepViewedTrackedRef.current && safeActiveStepId === defaultStepId) {
434
- firstStepViewedTrackedRef.current = true;
465
+ const initialAttribution = takeInitialFunnelAttributionMetadata(firstStepViewedTrackedRef.current, attributionReady, initialAttributionRef.current);
466
+ firstStepViewedTrackedRef.current = initialAttribution.tracked;
467
+ if (!initialAttribution.metadata) {
468
+ return;
469
+ }
435
470
  (_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFunnelStarted) === null || _b === void 0 ? void 0 : _b.call(analytics, {
436
471
  userId: currentUserIdRef.current,
437
472
  environment: analyticsRuntimeMode,
@@ -439,6 +474,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
439
474
  stepName,
440
475
  stepType,
441
476
  occurredAt: startedAt,
477
+ metadata: initialAttribution.metadata,
442
478
  featureFlags: postHogFeatureFlagsRef.current,
443
479
  });
444
480
  (_c = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _c === void 0 ? void 0 : _c.call(analytics, {
@@ -448,15 +484,15 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
448
484
  stepName,
449
485
  stepType,
450
486
  occurredAt: startedAt,
487
+ metadata: initialAttribution.metadata,
451
488
  featureFlags: postHogFeatureFlagsRef.current,
452
489
  });
453
490
  }
454
491
  }
455
- attributesAtStepStart.current = Object.assign({}, attributesRef.current);
456
- prevStepIdRef.current = renderedStepId;
457
492
  }, [
458
493
  activeStepId,
459
494
  analytics,
495
+ attributionReady,
460
496
  defaultStepId,
461
497
  isPreviewRuntime,
462
498
  recordStepCompletion,
@@ -512,7 +548,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
512
548
  safeActiveStepId,
513
549
  safeInitialStepId,
514
550
  ]);
515
- const choiceTargets = getChoiceTargetsForStep(safeActiveStepId);
551
+ const choiceTargets = getChoiceTargetsForStep(renderedStepId);
516
552
  const actionBar = (_a = renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.actionBar) !== null && _a !== void 0 ? _a : activeStepMeta === null || activeStepMeta === void 0 ? void 0 : activeStepMeta.actionBar;
517
553
  const isAutoAdvanceStep = typeof (actionBar === null || actionBar === void 0 ? void 0 : actionBar.autoAdvanceMs) === 'number';
518
554
  const autoAdvanceMs = actionBar === null || actionBar === void 0 ? void 0 : actionBar.autoAdvanceMs;
@@ -551,12 +587,12 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
551
587
  }, [goToStep, isFunnelStepId]);
552
588
  const goChoice = useCallback((choice, stepId) => {
553
589
  var _a, _b;
554
- const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : safeActiveStepId;
590
+ const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : renderedStepId;
555
591
  setAttributes((prev) => writeStepChoice(prev, sourceStepId, choice));
556
592
  const sourceChoiceTargets = getChoiceTargetsForStep(sourceStepId);
557
593
  const choiceTarget = (_b = resolveRenderableStepId(sourceChoiceTargets === null || sourceChoiceTargets === void 0 ? void 0 : sourceChoiceTargets[choice])) !== null && _b !== void 0 ? _b : resolveNextStepId(sourceStepId);
558
594
  goToStep(choiceTarget);
559
- }, [getChoiceTargetsForStep, goToStep, resolveNextStepId, resolveRenderableStepId, safeActiveStepId]);
595
+ }, [getChoiceTargetsForStep, goToStep, renderedStepId, resolveNextStepId, resolveRenderableStepId]);
560
596
  usePreviewBridge({
561
597
  activeStepId: safeActiveStepId,
562
598
  onGoToStep: goToStepFromContext,
@@ -600,7 +636,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
600
636
  return ((_a = experiment.variants[0]) === null || _a === void 0 ? void 0 : _a.variantKey) || '';
601
637
  }, [editorVariantOverrides, postHogReady]);
602
638
  const contextValue = useMemo(() => ({
603
- activeStepId: safeActiveStepId,
639
+ activeStepId: renderedStepId,
604
640
  featureFlags: postHogFeatureFlags,
605
641
  isBuilder: isPreviewRuntime,
606
642
  goToStep: goToStepFromContext,
@@ -608,7 +644,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
608
644
  goChoice,
609
645
  getChoiceTargets: (stepId) => {
610
646
  var _a;
611
- const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : safeActiveStepId;
647
+ const sourceStepId = (_a = resolveRenderableStepId(stepId)) !== null && _a !== void 0 ? _a : renderedStepId;
612
648
  return getChoiceTargetsForStep(sourceStepId);
613
649
  },
614
650
  setAnswer,
@@ -630,8 +666,8 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
630
666
  goToStepFromContext,
631
667
  isPreviewRuntime,
632
668
  postHogFeatureFlags,
669
+ renderedStepId,
633
670
  resolveRenderableStepId,
634
- safeActiveStepId,
635
671
  setAnswer,
636
672
  setAttribute,
637
673
  user,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.54",
3
+ "version": "0.1.56",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",