From aa2114fda0422d9aeada20ae6366228084c8a9a3 Mon Sep 17 00:00:00 2001 From: Sean Oliver <882952+seanoliver@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:56:57 -0700 Subject: [PATCH] feat(growth): add in-memory first-touch attribution store (GROWTH-656) Replaces the supabase-telemetry-data session cookie with a module-scoped in-memory store so no non-essential device storage occurs before affirmative consent in GDPR regions. --- packages/common/hooks/useFirstTouchStore.tsx | 28 ++++++++++ .../common/telemetry-first-touch-store.ts | 53 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 packages/common/hooks/useFirstTouchStore.tsx create mode 100644 packages/common/telemetry-first-touch-store.ts diff --git a/packages/common/hooks/useFirstTouchStore.tsx b/packages/common/hooks/useFirstTouchStore.tsx new file mode 100644 index 00000000000..4127c846347 --- /dev/null +++ b/packages/common/hooks/useFirstTouchStore.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react' + +import { setFirstTouchData } from '../telemetry-first-touch-store' +import { getSharedTelemetryData } from '../telemetry-utils' + +interface UseFirstTouchStoreProps { + enabled: boolean +} + +/** + * Captures first-touch attribution data (e.g. `document.referrer`, UTM params) + * into an in-memory store on the initial page load — before consent is granted. + * + * The data is read once by PageTelemetry after consent and then cleared. + * Using in-memory storage (instead of a cookie) ensures no non-essential + * device storage before affirmative consent in GDPR regions. + */ +export function useFirstTouchStore({ enabled }: UseFirstTouchStoreProps) { + useEffect(() => { + if (typeof window === 'undefined') return + if (!enabled) return + + const telemetryData = getSharedTelemetryData(window.location.pathname) + setFirstTouchData(telemetryData) + }, [enabled]) +} + +export default useFirstTouchStore diff --git a/packages/common/telemetry-first-touch-store.ts b/packages/common/telemetry-first-touch-store.ts new file mode 100644 index 00000000000..ef6a1ffc4ad --- /dev/null +++ b/packages/common/telemetry-first-touch-store.ts @@ -0,0 +1,53 @@ +/** + * In-memory store for first-touch attribution data. + * + * Replaces the previous `supabase-telemetry-data` session cookie so that + * no non-essential device storage happens before affirmative consent in + * GDPR regions (ePrivacy Directive compliance). + * + * The data is captured on the very first page load (before consent) and + * held in a module-scoped variable. When consent is granted, PageTelemetry + * reads it once for the initial pageview event and then clears it. + * + * Trade-off: data is lost on a hard reload (Cmd+R) before consent, which + * is an accepted edge case (see GROWTH-656). + * + * Module-scope is safe here because both the writer (useFirstTouchStore) + * and reader (PageTelemetry) live in the same client-side bundle — the + * same pattern used by posthogClient and consentState. + */ + +import type { getSharedTelemetryData } from './telemetry-utils' + +export type SharedTelemetryData = ReturnType + +// --------------------------------------------------------------------------- +// Module-scoped singleton (survives SPA navigations, lost on hard reload) +// --------------------------------------------------------------------------- + +let firstTouchData: SharedTelemetryData | null = null + +/** + * Store the first-touch attribution data captured on initial page load. + * Only writes once — subsequent calls are no-ops if data already exists. + */ +export function setFirstTouchData(data: SharedTelemetryData): void { + if (firstTouchData !== null) return + firstTouchData = data +} + +/** + * Read the stored first-touch attribution data. + * Returns null if no data has been captured (e.g. after a hard reload). + */ +export function getFirstTouchData(): SharedTelemetryData | null { + return firstTouchData +} + +/** + * Clear the stored first-touch attribution data. + * Called after the initial pageview event is sent, or when the user opts out. + */ +export function clearFirstTouchData(): void { + firstTouchData = null +}