From 5488559aa32b27136a1942e791af094a8608a0e9 Mon Sep 17 00:00:00 2001 From: Luca Pagliaro Date: Mon, 14 Apr 2025 17:26:01 +0200 Subject: [PATCH 1/7] fix: update current step based on URL change --- .../src/features/onboarding/hooks/useFunnelNavigation.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts index 9c14aad3cad..7d5a6758982 100644 --- a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts +++ b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts @@ -160,10 +160,11 @@ export const useFunnelNavigation = ({ [isFirstStep, step?.transitions], ); + const stepInURL = searchParams.get('stepId'); useEffect( () => { // Check if the URL has a stepId parameter or if there is a session - const stepId = searchParams.get('stepId') ?? session.currentStep; + const stepId = stepInURL ?? session.currentStep; if (!stepId) { return; @@ -174,7 +175,7 @@ export const useFunnelNavigation = ({ }, // only run on mount // eslint-disable-next-line react-hooks/exhaustive-deps - [funnel.id], + [funnel.id, stepInURL], ); return { From 1b70ecb35a9ffca34e57659647a5ba997799465e Mon Sep 17 00:00:00 2001 From: Luca Pagliaro Date: Tue, 15 Apr 2025 10:38:37 +0200 Subject: [PATCH 2/7] fix: rename variable for clarity --- .../src/features/onboarding/hooks/useFunnelNavigation.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts index 7d5a6758982..6c62c2a0028 100644 --- a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts +++ b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts @@ -160,11 +160,11 @@ export const useFunnelNavigation = ({ [isFirstStep, step?.transitions], ); - const stepInURL = searchParams.get('stepId'); + const urlStepId = searchParams.get('stepId'); useEffect( () => { // Check if the URL has a stepId parameter or if there is a session - const stepId = stepInURL ?? session.currentStep; + const stepId = urlStepId ?? session.currentStep; if (!stepId) { return; @@ -175,7 +175,7 @@ export const useFunnelNavigation = ({ }, // only run on mount // eslint-disable-next-line react-hooks/exhaustive-deps - [funnel.id, stepInURL], + [funnel.id, urlStepId], ); return { From b382577937ef2e4f6f781456aed885f62e0850f2 Mon Sep 17 00:00:00 2001 From: Luca Pagliaro Date: Tue, 15 Apr 2025 15:11:16 +0200 Subject: [PATCH 3/7] fix: update effect explanation comment and check for position existence --- .../src/features/onboarding/hooks/useFunnelNavigation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts index 6c62c2a0028..8b24632c8ba 100644 --- a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts +++ b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts @@ -171,9 +171,11 @@ export const useFunnelNavigation = ({ } const newPosition = stepMap[stepId]?.position; - setPosition(newPosition); + if (newPosition) { + setPosition(newPosition); + } }, - // only run on mount + // only run when URL/Funnel changes // eslint-disable-next-line react-hooks/exhaustive-deps [funnel.id, urlStepId], ); From 98d2c5fbe12cbc681646659322787b944966e386 Mon Sep 17 00:00:00 2001 From: Luca Pagliaro Date: Wed, 16 Apr 2025 12:25:22 +0200 Subject: [PATCH 4/7] feat: moved initialStep logic in SSR and simplified navigation logic --- .../onboarding/hooks/useFunnelNavigation.ts | 69 ++++++++++++------- .../onboarding/shared/FunnelStepper.tsx | 16 ++++- packages/webapp/pages/helloworld/index.tsx | 8 +++ 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts index 8b24632c8ba..f0f9e09a647 100644 --- a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts +++ b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts @@ -1,4 +1,4 @@ -import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { useAtom } from 'jotai/react'; import { UNDO } from 'jotai-history'; @@ -10,12 +10,11 @@ import { getFunnelStepByPosition, funnelPositionHistoryAtom, } from '../store/funnelStore'; -import type { FunnelSession } from '../types/funnelBoot'; interface UseFunnelNavigationProps { funnel: FunnelJSON; + initialStepId?: string | null; onNavigation: TrackOnNavigate; - session: FunnelSession; } type Chapters = Array<{ steps: number }>; @@ -32,6 +31,7 @@ interface HeaderNavigation { export interface UseFunnelNavigationReturn { chapters: Chapters; + isReady: boolean; navigate: NavigateFunction; position: FunnelPosition; step: FunnelStep; @@ -74,8 +74,8 @@ function updateURLWithStepId({ export const useFunnelNavigation = ({ funnel, + initialStepId, onNavigation, - session, }: UseFunnelNavigationProps): UseFunnelNavigationReturn => { const router = useRouter(); const searchParams = useSearchParams(); @@ -84,6 +84,8 @@ export const useFunnelNavigation = ({ const [position, setPosition] = useAtom(funnelPositionAtom); const [history, dispatchHistory] = useAtom(funnelPositionHistoryAtom); const isFirstStep = !position.step && !position.chapter; + const isInitialized = useRef(false); + const urlStepId = searchParams.get('stepId'); const chapters: Chapters = useMemo( () => funnel.chapters.map((chapter) => ({ steps: chapter.steps.length })), @@ -92,6 +94,16 @@ export const useFunnelNavigation = ({ const stepMap: StepMap = useMemo(() => getStepMap(funnel), [funnel]); + const setPositionById = useCallback( + (stepId: FunnelStep['id']) => { + const newPosition = stepMap[stepId]?.position; + if (newPosition) { + setPosition(newPosition); + } + }, + [setPosition, stepMap], + ); + const step: FunnelStep = useMemo( () => getFunnelStepByPosition(funnel, position), [funnel, position], @@ -111,8 +123,7 @@ export const useFunnelNavigation = ({ } // update the position in the store - const newPosition = stepMap[to]?.position; - setPosition(newPosition); + setPositionById(to); // track the navigation event onNavigation({ from, to, timeDuration, type }); @@ -128,7 +139,7 @@ export const useFunnelNavigation = ({ pathname, router, searchParams, - setPosition, + setPositionById, step, stepMap, stepTimerStart, @@ -160,25 +171,34 @@ export const useFunnelNavigation = ({ [isFirstStep, step?.transitions], ); - const urlStepId = searchParams.get('stepId'); - useEffect( - () => { - // Check if the URL has a stepId parameter or if there is a session - const stepId = urlStepId ?? session.currentStep; + // On load: Update the initial position in state and URL + useEffect(() => { + if (isInitialized.current) { + return; + } - if (!stepId) { - return; - } + if (initialStepId) { + setPositionById(initialStepId); + } - const newPosition = stepMap[stepId]?.position; - if (newPosition) { - setPosition(newPosition); - } - }, - // only run when URL/Funnel changes - // eslint-disable-next-line react-hooks/exhaustive-deps - [funnel.id, urlStepId], - ); + updateURLWithStepId({ + router, + pathname, + searchParams, + stepId: initialStepId || step.id, + }); + + isInitialized.current = true; + }, [initialStepId, pathname, router, searchParams, setPositionById, step.id]); + + // After load: update the position when the URL's stepId changes + useEffect(() => { + if (!urlStepId || !isInitialized.current) { + return; + } + + setPositionById(urlStepId); + }, [setPositionById, urlStepId]); return { back, @@ -187,5 +207,6 @@ export const useFunnelNavigation = ({ position, skip, step, + isReady: isInitialized.current, }; }; diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx index 1a3b0e4eb57..87304fa9309 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.tsx @@ -38,9 +38,10 @@ import classed from '../../../lib/classed'; export interface FunnelStepperProps { funnel: FunnelJSON; + initialStepId?: string | null; + onComplete?: () => void; session: FunnelSession; showCookieBanner?: boolean; - onComplete?: () => void; } const stepComponentMap = { @@ -78,6 +79,7 @@ const HiddenStep = classed('div', 'hidden'); export const FunnelStepper = ({ funnel, + initialStepId, session, showCookieBanner, onComplete, @@ -90,8 +92,12 @@ export const FunnelStepper = ({ trackOnComplete, trackFunnelEvent, } = useFunnelTracking({ funnel, session }); - const { back, chapters, navigate, position, skip, step } = - useFunnelNavigation({ funnel, onNavigation: trackOnNavigate, session }); + const { back, chapters, navigate, position, skip, step, isReady } = + useFunnelNavigation({ + funnel, + initialStepId, + onNavigation: trackOnNavigate, + }); const { transition: sendTransition } = useStepTransition(session.id); const { showBanner, ...cookieConsentProps } = useFunnelCookies({ defaultOpen: showCookieBanner, @@ -128,6 +134,10 @@ export const FunnelStepper = ({ } }; + if (!isReady) { + return null; + } + return (
= async ({ // Check if the user already accepted cookies const hasAcceptedCookies = allCookies.includes(GdprConsentKey.Marketing); + // Determine the initial step ID + const initialStepId: string | null = + `${query?.stepId}` || boot.data?.funnelState?.session?.currentStep; + // Return props including the dehydrated state return { props: { dehydratedState: dehydrate(queryClient), showCookieBanner: !hasAcceptedCookies, + initialStepId, }, }; }; export default function HelloWorldPage({ dehydratedState, + initialStepId, showCookieBanner, }: PageProps): ReactElement { const { data: funnelBoot } = useFunnelBoot(); @@ -111,6 +118,7 @@ export default function HelloWorldPage({ {!!funnel && !!session.id && ( router.replace('/onboarding')} From 7a8522b6f58f025e58d595ec1a15dd9fec82ce54 Mon Sep 17 00:00:00 2001 From: Luca Pagliaro Date: Wed, 16 Apr 2025 12:28:06 +0200 Subject: [PATCH 5/7] fix: avoid set undefined as initialStepId --- packages/webapp/pages/helloworld/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/webapp/pages/helloworld/index.tsx b/packages/webapp/pages/helloworld/index.tsx index e723b807f8a..08933c1765f 100644 --- a/packages/webapp/pages/helloworld/index.tsx +++ b/packages/webapp/pages/helloworld/index.tsx @@ -75,8 +75,9 @@ export const getServerSideProps: GetServerSideProps = async ({ const hasAcceptedCookies = allCookies.includes(GdprConsentKey.Marketing); // Determine the initial step ID + const queryStepId = query?.stepId as string | undefined; const initialStepId: string | null = - `${query?.stepId}` || boot.data?.funnelState?.session?.currentStep; + queryStepId ?? boot.data?.funnelState?.session?.currentStep; // Return props including the dehydrated state return { From 4ee7439e6d2e725b98e8ce9c97b92b1fefae6096 Mon Sep 17 00:00:00 2001 From: Luca Pagliaro Date: Wed, 16 Apr 2025 12:40:46 +0200 Subject: [PATCH 6/7] refactor: changed from ref.current to state for isReady --- .../onboarding/hooks/useFunnelNavigation.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts index f0f9e09a647..b566c7f33af 100644 --- a/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts +++ b/packages/shared/src/features/onboarding/hooks/useFunnelNavigation.ts @@ -10,6 +10,7 @@ import { getFunnelStepByPosition, funnelPositionHistoryAtom, } from '../store/funnelStore'; +import { useToggle } from '../../../hooks/useToggle'; interface UseFunnelNavigationProps { funnel: FunnelJSON; @@ -85,6 +86,7 @@ export const useFunnelNavigation = ({ const [history, dispatchHistory] = useAtom(funnelPositionHistoryAtom); const isFirstStep = !position.step && !position.chapter; const isInitialized = useRef(false); + const [isReady, setIsReady] = useToggle(false); const urlStepId = searchParams.get('stepId'); const chapters: Chapters = useMemo( @@ -189,7 +191,16 @@ export const useFunnelNavigation = ({ }); isInitialized.current = true; - }, [initialStepId, pathname, router, searchParams, setPositionById, step.id]); + setIsReady(true); + }, [ + initialStepId, + pathname, + router, + searchParams, + setPositionById, + step.id, + setIsReady, + ]); // After load: update the position when the URL's stepId changes useEffect(() => { @@ -207,6 +218,6 @@ export const useFunnelNavigation = ({ position, skip, step, - isReady: isInitialized.current, + isReady, }; }; From e63adae82eaf84d54bcedbe2bcc21e8a78dabe2f Mon Sep 17 00:00:00 2001 From: Luca Pagliaro Date: Wed, 16 Apr 2025 13:21:01 +0200 Subject: [PATCH 7/7] test: set isReady to true in FunnelStepper tests --- .../src/features/onboarding/shared/FunnelStepper.spec.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx b/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx index 608675985d7..66864704121 100644 --- a/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx +++ b/packages/shared/src/features/onboarding/shared/FunnelStepper.spec.tsx @@ -86,6 +86,7 @@ describe('FunnelStepper component', () => { position: { chapter: 0, step: 0 }, chapters: [{ steps: 1 }], step: mockStep, + isReady: true, }); (useFunnelTracking as jest.Mock).mockReturnValue({ @@ -273,6 +274,7 @@ describe('FunnelStepper component', () => { position: { chapter: 0, step: 0 }, chapters: [{ steps: 2 }], step: quizStep, + isReady: true, }); renderComponent(mockFunnelWithMultipleSteps);