diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 355d2986d155..766c37ee3411 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -92,6 +92,11 @@ function isSameStep(step, otherStep) { ) } +function truncateFunnelAtFirstZero(funnel) { + const cutoff = funnel.findIndex((entry) => entry.visitors === 0) + return cutoff === -1 ? funnel : funnel.slice(0, cutoff) +} + function truncateFrozenResultsAtIndex(frozenResults, fromIndex) { const next = {} Object.keys(frozenResults).forEach((key) => { @@ -577,6 +582,9 @@ export function FunnelExploration() { const prevDashboardStateRef = useRef(dashboardState) const preloadFiredRef = useRef(false) const funnelFromPreloadRef = useRef(false) + // Set to true when steps are trimmed to match a truncated funnel response. + // Prevents the resulting steps change from triggering a redundant funnel re-fetch. + const funnelTruncatedStepsRef = useRef(false) // Bumped whenever the user actively changes the journey or direction. // Used to discard stale preload-driven candidate fetches that resolve // after the user has already navigated away from the preloaded prefix. @@ -696,6 +704,7 @@ export function FunnelExploration() { prevDashboardStateRef.current = dashboardState let cancelled = false + let pendingFollowUpFetch = false if (!preloadFiredRef.current) { preloadFiredRef.current = true @@ -706,9 +715,14 @@ export function FunnelExploration() { if (cancelled) return if (response && response.funnel && response.funnel.length > 0) { funnelFromPreloadRef.current = true - const preloadedSteps = response.funnel.map(({ step }) => step) - setSteps(preloadedSteps) - setFunnel(response.funnel) + // When dahshboard state changes for an already set journey, + // we might end with some of the trailing steps having zero visitors. + // In such case we should truncate the journey, allow further candidate + // selection (if present) instead of drawing connections + // to non-selectable nodes. + const truncatedFunnel = truncateFunnelAtFirstZero(response.funnel) + setSteps(truncatedFunnel.map(({ step }) => step)) + setFunnel(truncatedFunnel) setFrozenColumnResults(response.candidates) } else { // Nothing to preload, fall back to a plain next-steps fetch @@ -749,8 +763,14 @@ export function FunnelExploration() { const funnelAlreadyLoaded = funnelFromPreloadRef.current funnelFromPreloadRef.current = false + const funnelTruncatedSteps = funnelTruncatedStepsRef.current + funnelTruncatedStepsRef.current = false + const includeFunnel = - journeyChanged && steps.length > 0 && !funnelAlreadyLoaded + journeyChanged && + steps.length > 0 && + !funnelAlreadyLoaded && + !funnelTruncatedSteps if (journeyChanged && steps.length === 0) { setFunnel([]) @@ -766,11 +786,31 @@ export function FunnelExploration() { ) .then((response) => { if (cancelled) return - setActiveColumnResults(response?.next || []) if (includeFunnel) { - setFunnel(response?.funnel || []) + const truncatedFunnel = truncateFunnelAtFirstZero( + response?.funnel || [] + ) + setFunnel(truncatedFunnel) + // The funnel response may be shorter than the current steps either + // because truncateFunnelAtFirstZero cut it, or because the new + // dashboard state (e.g. date range switch) resulted with an empty funnel + // for the existing journey. In both cases the steps must be trimmed + // so the UI doesn't show stale selected steps with no funnel + // backing them. Next step suggestions in this response are for the + // step after the original (untrimmed) journey - skip them and let the + // effect re-run via setSteps to fetch correct suggestions for the new + // active column. Keep activeColumnLoading as true so the spinner + // stays visible while it's getting fetched. + if (truncatedFunnel.length < steps.length) { + funnelTruncatedStepsRef.current = true + setSteps((prev) => prev.slice(0, truncatedFunnel.length)) + setProvisionalFunnelEntries({}) + pendingFollowUpFetch = true + return + } setProvisionalFunnelEntries({}) } + setActiveColumnResults(response?.next || []) }) .catch(() => { if (cancelled) return @@ -778,7 +818,7 @@ export function FunnelExploration() { if (includeFunnel) setFunnel([]) }) .finally(() => { - if (!cancelled) setActiveColumnLoading(false) + if (!cancelled && !pendingFollowUpFetch) setActiveColumnLoading(false) }) setConnectorsKey(randomKey) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index a5ba53294cf3..476b74ff8e53 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -45,10 +45,10 @@ long_random_paths = |> Enum.take(Enum.random(1..20)) |> Enum.join("/") - "/#{path}.html" + "/index/#{path}.html" end -long_random_paths = ["/", "/register", "/login", "/about"] ++ long_random_paths +long_random_paths = ["/", "/register", "/login", "/about", "/index"] ++ long_random_paths long_random_urls = for path <- long_random_paths do