From 20edd31abbd0dd1bce6ca9ceaadc814c94dfcf57 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 20 Jul 2026 06:07:28 +0100 Subject: [PATCH] fix(ui): play the intro on backend-confirmed fresh nodes despite stale browser flags neode_intro_seen / neode_onboarding_complete are per-origin browser state: after a reinstall (or another node on a DHCP-recycled IP) they describe the previous node and muted a genuinely fresh install's intro. Root boots now always ask the backend; a confirmed-fresh answer plays the intro and clears the stale flag. Co-Authored-By: Claude Fable 5 --- neode-ui/src/App.vue | 19 ++++++-- .../src/utils/__tests__/introSplash.test.ts | 46 +++++++++++++++++++ neode-ui/src/utils/introSplash.ts | 11 ++++- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index db124e89..2f2aaa17 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -424,10 +424,14 @@ onMounted(async () => { const { IS_DEMO } = await import('@/composables/useDemoIntro') if (IS_DEMO && bootPath === '/') replayRequested = true let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null - const splashCandidate = !seenIntro - && (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')) + // Root boots always ask the backend — even when this browser thinks it has + // seen the intro. Both `neode_intro_seen` and `neode_onboarding_complete` + // are per-origin browser state: after a reinstall (or another node coming + // up on a DHCP-recycled IP) they describe the PREVIOUS node and would mute + // a fresh install's intro / misroute it to login. + const splashCandidate = fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot') - if (splashCandidate && onboardingComplete !== true) { + if (splashCandidate) { try { const { checkOnboardingStatus } = await import('@/composables/useOnboarding') // Bound the pre-splash status check: its retry ladder can spend ~30s @@ -437,10 +441,17 @@ onMounted(async () => { // the splash play (a fresh install IS the slow-backend case; onboarded // nodes answer in milliseconds, so their suppression path is intact). // handleSplashComplete re-checks with full retries after the intro. - onboardingComplete = await Promise.race([ + const live = await Promise.race([ checkOnboardingStatus(), new Promise((resolve) => setTimeout(() => resolve(null), 2500)), ]) + if (live !== null) onboardingComplete = live + if (live === false && seenIntro) { + // Backend-confirmed fresh node behind a browser with a stale flag — + // drop it so this boot (and every later one) plays the intro. + try { localStorage.removeItem('neode_intro_seen') } catch { /* noop */ } + seenIntro = false + } } catch { onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null } diff --git a/neode-ui/src/utils/__tests__/introSplash.test.ts b/neode-ui/src/utils/__tests__/introSplash.test.ts index bad1d133..c454f822 100644 --- a/neode-ui/src/utils/__tests__/introSplash.test.ts +++ b/neode-ui/src/utils/__tests__/introSplash.test.ts @@ -38,4 +38,50 @@ describe('shouldShowIntroSplash', () => { replayRequested: true, })).toBe(true) }) + + it('a confirmed-fresh node plays the intro despite a stale per-origin seenIntro flag (reinstall / DHCP-recycled IP)', () => { + expect(shouldShowIntroSplash({ + seenIntro: true, + routePath: '/', + fromBoot: false, + onboardingComplete: false, + })).toBe(true) + }) + + it('a confirmed-fresh node plays the intro on the boot-screen handoff too', () => { + expect(shouldShowIntroSplash({ + seenIntro: true, + routePath: '/login', + fromBoot: true, + onboardingComplete: false, + })).toBe(true) + }) + + it('stale seenIntro still suppresses when the backend answer is unknown', () => { + expect(shouldShowIntroSplash({ + seenIntro: true, + routePath: '/', + fromBoot: false, + onboardingComplete: null, + })).toBe(false) + }) + + it('fresh node on a deep route without boot handoff stays suppressed', () => { + expect(shouldShowIntroSplash({ + seenIntro: false, + routePath: '/onboarding/seed', + fromBoot: false, + onboardingComplete: false, + })).toBe(false) + }) + + it('boot dev mode never root-boots into the intro', () => { + expect(shouldShowIntroSplash({ + seenIntro: false, + routePath: '/', + fromBoot: false, + devMode: 'boot', + onboardingComplete: false, + })).toBe(false) + }) }) diff --git a/neode-ui/src/utils/introSplash.ts b/neode-ui/src/utils/introSplash.ts index 5b116de1..dc14ef05 100644 --- a/neode-ui/src/utils/introSplash.ts +++ b/neode-ui/src/utils/introSplash.ts @@ -10,10 +10,19 @@ export interface IntroSplashDecisionInput { export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean { if (input.replayRequested) return true + + const isDirectRoute = input.routePath !== '/' + // A node the backend CONFIRMS has never completed onboarding always gets + // the full intro on a root boot. `seenIntro` is per-origin browser state — + // after a reinstall (or a DHCP-recycled IP), the browser still carries the + // previous node's flag at the same origin, which silently muted the intro + // on genuinely fresh installs. + if (input.onboardingComplete === false && (input.fromBoot || (!isDirectRoute && input.devMode !== 'boot'))) { + return true + } if (input.seenIntro) return false if (input.onboardingComplete === true) return false - const isDirectRoute = input.routePath !== '/' if (input.fromBoot) return true if (input.devMode === 'boot') return false return !isDirectRoute