@funnelsgrove/runtime 0.1.55 → 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];
@@ -251,11 +278,13 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
251
278
  const urlUserAttributes = resolveUrlUserAttributes();
252
279
  const bootstrapCandidateUserId = apiService.getBootstrapCandidateUserId(localUserId);
253
280
  const initialAttribution = collectCurrentFunnelAttribution();
281
+ initialAttributionRef.current = initialAttribution;
254
282
  setUserBootstrapped(isPreviewRuntime);
255
283
  setUser((prev) => (Object.assign(Object.assign({}, prev), { id: localUserId })));
256
284
  if (isPreviewRuntime) {
257
285
  return;
258
286
  }
287
+ const cancelAttributionFailOpen = scheduleAttributionFailOpen(() => setAttributionReady(true), 5000);
259
288
  const postHogBootstrap = bootstrapPostHog({
260
289
  apiKey: POSTHOG_PROJECT_API_KEY,
261
290
  apiHost: POSTHOG_API_HOST,
@@ -276,14 +305,17 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
276
305
  attribution: initialAttribution,
277
306
  })
278
307
  .then((sessionUser) => {
308
+ cancelAttributionFailOpen();
279
309
  const sessionAttributes = Object.keys(sessionUser.attributes || {}).length > 0
280
310
  ? sessionUser.attributes
281
311
  : null;
282
312
  if (sessionAttributes) {
283
313
  setAttributes((prev) => (Object.assign(Object.assign({}, sessionAttributes), prev)));
284
314
  }
315
+ initialAttributionRef.current = resolveFunnelEventAttribution(sessionUser.document, initialAttribution);
285
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 })));
286
317
  setUserBootstrapped(true);
318
+ setAttributionReady(true);
287
319
  void apiService.pingUserContext(sessionUser.id)
288
320
  .then((pingedUser) => {
289
321
  if (!pingedUser) {
@@ -301,8 +333,10 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
301
333
  return sessionUser;
302
334
  })
303
335
  .catch((error) => {
336
+ cancelAttributionFailOpen();
304
337
  logger.error('Failed to bootstrap user session:', error);
305
338
  setUserBootstrapped(false);
339
+ setAttributionReady(true);
306
340
  return null;
307
341
  });
308
342
  Promise.all([postHogBootstrap, sessionBootstrap])
@@ -314,6 +348,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
314
348
  .catch((error) => {
315
349
  logger.error('Failed to identify PostHog user:', error);
316
350
  });
351
+ return cancelAttributionFailOpen;
317
352
  }, [isPreviewRuntime]);
318
353
  const recordStepCompletion = useCallback((stepId, explicitChoices) => {
319
354
  var _a;
@@ -321,6 +356,10 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
321
356
  if (!meta) {
322
357
  return;
323
358
  }
359
+ const attributionMetadata = buildReadyFunnelAttributionEventMetadata(attributionReady, initialAttributionRef.current);
360
+ if (attributionMetadata === null) {
361
+ return;
362
+ }
324
363
  let choices;
325
364
  if (explicitChoices) {
326
365
  choices = explicitChoices;
@@ -354,6 +393,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
354
393
  startedAt,
355
394
  endedAt: record.completedAt,
356
395
  selected: record.choices,
396
+ metadata: attributionMetadata,
357
397
  featureFlags: postHogFeatureFlagsRef.current,
358
398
  });
359
399
  }
@@ -378,7 +418,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
378
418
  }
379
419
  return updatedUser;
380
420
  });
381
- }, [analytics, isPreviewRuntime, stepById]);
421
+ }, [analytics, attributionReady, isPreviewRuntime, stepById]);
382
422
  const completeStep = useCallback((stepId, choices) => {
383
423
  recordStepCompletion(stepId, choices);
384
424
  }, [recordStepCompletion]);
@@ -404,6 +444,12 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
404
444
  const stepType = renderedStepMeta === null || renderedStepMeta === void 0 ? void 0 : renderedStepMeta.type;
405
445
  stepStartedAtByIdRef.current[renderedStepId] = startedAt;
406
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
+ }
407
453
  if (!isPreviewRuntime) {
408
454
  (_a = analytics === null || analytics === void 0 ? void 0 : analytics.trackStepStarted) === null || _a === void 0 ? void 0 : _a.call(analytics, {
409
455
  userId: currentUserIdRef.current,
@@ -412,10 +458,15 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
412
458
  stepName,
413
459
  stepType,
414
460
  startedAt,
461
+ metadata: attributionMetadata,
415
462
  featureFlags: postHogFeatureFlagsRef.current,
416
463
  });
417
464
  if (!firstStepViewedTrackedRef.current && safeActiveStepId === defaultStepId) {
418
- firstStepViewedTrackedRef.current = true;
465
+ const initialAttribution = takeInitialFunnelAttributionMetadata(firstStepViewedTrackedRef.current, attributionReady, initialAttributionRef.current);
466
+ firstStepViewedTrackedRef.current = initialAttribution.tracked;
467
+ if (!initialAttribution.metadata) {
468
+ return;
469
+ }
419
470
  (_b = analytics === null || analytics === void 0 ? void 0 : analytics.trackFunnelStarted) === null || _b === void 0 ? void 0 : _b.call(analytics, {
420
471
  userId: currentUserIdRef.current,
421
472
  environment: analyticsRuntimeMode,
@@ -423,6 +474,7 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
423
474
  stepName,
424
475
  stepType,
425
476
  occurredAt: startedAt,
477
+ metadata: initialAttribution.metadata,
426
478
  featureFlags: postHogFeatureFlagsRef.current,
427
479
  });
428
480
  (_c = analytics === null || analytics === void 0 ? void 0 : analytics.trackFirstStepViewed) === null || _c === void 0 ? void 0 : _c.call(analytics, {
@@ -432,15 +484,15 @@ export function useFunnelFlowController({ analytics, initialStepId, initialAttri
432
484
  stepName,
433
485
  stepType,
434
486
  occurredAt: startedAt,
487
+ metadata: initialAttribution.metadata,
435
488
  featureFlags: postHogFeatureFlagsRef.current,
436
489
  });
437
490
  }
438
491
  }
439
- attributesAtStepStart.current = Object.assign({}, attributesRef.current);
440
- prevStepIdRef.current = renderedStepId;
441
492
  }, [
442
493
  activeStepId,
443
494
  analytics,
495
+ attributionReady,
444
496
  defaultStepId,
445
497
  isPreviewRuntime,
446
498
  recordStepCompletion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.1.55",
3
+ "version": "0.1.56",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",