From 32a43d71095e06be16c639d295a830e81e12abda Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 4 May 2026 12:27:34 +0200 Subject: [PATCH 1/4] truncate re-computed funnels at zero visitors --- assets/js/dashboard/extra/exploration.js | 36 ++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 355d2986d155..46e901cf5478 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. @@ -706,9 +714,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 +762,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([]) @@ -768,7 +787,14 @@ export function FunnelExploration() { if (cancelled) return setActiveColumnResults(response?.next || []) if (includeFunnel) { - setFunnel(response?.funnel || []) + const truncatedFunnel = truncateFunnelAtFirstZero( + response?.funnel || [] + ) + setFunnel(truncatedFunnel) + if (truncatedFunnel.length < (response?.funnel?.length ?? 0)) { + funnelTruncatedStepsRef.current = true + setSteps((prev) => prev.slice(0, truncatedFunnel.length)) + } setProvisionalFunnelEntries({}) } }) From 29087d33fea88af6cff818747eba0175b0614417 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 5 May 2026 08:31:07 +0200 Subject: [PATCH 2/4] Load suggestions for the active column on funnel trim --- assets/js/dashboard/extra/exploration.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 46e901cf5478..18b2c4d3f5d0 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -704,6 +704,7 @@ export function FunnelExploration() { prevDashboardStateRef.current = dashboardState let cancelled = false + let pendingFollowUpFetch = false if (!preloadFiredRef.current) { preloadFiredRef.current = true @@ -785,18 +786,27 @@ export function FunnelExploration() { ) .then((response) => { if (cancelled) return - setActiveColumnResults(response?.next || []) if (includeFunnel) { const truncatedFunnel = truncateFunnelAtFirstZero( response?.funnel || [] ) setFunnel(truncatedFunnel) if (truncatedFunnel.length < (response?.funnel?.length ?? 0)) { + // The funnel was trimmed - `next` suggestions in the response + // are for the step after the original (untrimmed) journey, not for + // the new active column after trimming. Skip setting them here and + // let re-run the effect via setSteps to fetch the correct + // suggestions. Keep activeColumnLoading as true so the spinner + // stays visible while it's getting fetched. funnelTruncatedStepsRef.current = true setSteps((prev) => prev.slice(0, truncatedFunnel.length)) + setProvisionalFunnelEntries({}) + pendingFollowUpFetch = true + return } setProvisionalFunnelEntries({}) } + setActiveColumnResults(response?.next || []) }) .catch(() => { if (cancelled) return @@ -804,7 +814,7 @@ export function FunnelExploration() { if (includeFunnel) setFunnel([]) }) .finally(() => { - if (!cancelled) setActiveColumnLoading(false) + if (!cancelled && !pendingFollowUpFetch) setActiveColumnLoading(false) }) setConnectorsKey(randomKey) From 65c2d510a56ae5cf80bc8419f3c8528c654c1de4 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 5 May 2026 08:37:57 +0200 Subject: [PATCH 3/4] Ensure wildcard is in seeds --- priv/repo/seeds.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From c524198b90c162479f6e44c3c67d87b2121d97c9 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 5 May 2026 09:15:09 +0200 Subject: [PATCH 4/4] hum --- assets/js/dashboard/extra/exploration.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/assets/js/dashboard/extra/exploration.js b/assets/js/dashboard/extra/exploration.js index 18b2c4d3f5d0..766c37ee3411 100644 --- a/assets/js/dashboard/extra/exploration.js +++ b/assets/js/dashboard/extra/exploration.js @@ -791,13 +791,17 @@ export function FunnelExploration() { response?.funnel || [] ) setFunnel(truncatedFunnel) - if (truncatedFunnel.length < (response?.funnel?.length ?? 0)) { - // The funnel was trimmed - `next` suggestions in the response - // are for the step after the original (untrimmed) journey, not for - // the new active column after trimming. Skip setting them here and - // let re-run the effect via setSteps to fetch the correct - // suggestions. Keep activeColumnLoading as true so the spinner - // stays visible while it's getting fetched. + // 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({})