From 822408f2d247d2ab98d86ec374c46dee8b5125f4 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Tue, 4 Aug 2026 10:46:00 -0300 Subject: [PATCH 1/6] fix(onboarding): don't seed a demo flag into a project that has flags Visiting /getting-started created show_demo_button in whichever project came back first, whether or not the customer had ever onboarded. Features are project-level, so it appeared in every environment of that project, including production, alongside a new Onboarding tag. The Getting Started nav link is ungated, so any customer could trigger this by clicking it. ensureFlag already computed isFirstFeature for analytics; it now also decides whether to create anything. An empty project still gets the demo flag, an established one gets nothing. That leaves the tour with no flag to teach with, so the page says so and points at the project's own flags instead of walking someone through connecting a project that is already connected. Copy and treatment of that state are provisional. Co-Authored-By: Claude Opus 5 (1M context) --- .../OnboardingFlow/OnboardingFlow.tsx | 23 +++++++++ .../bootstrap/__tests__/demoFlag.test.ts | 49 +++++++++++++++++++ .../pages/onboarding/bootstrap/demoFlag.ts | 23 +++++++++ .../onboarding/hooks/bootstrapOnboarding.ts | 35 ++++++------- .../hooks/useEnsureOnboardingResources.ts | 4 ++ 5 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts create mode 100644 frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index 7f8f8acdcfc7..7902605f2836 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -1,6 +1,7 @@ import React, { FC, useState } from 'react' import { useHistory } from 'react-router-dom' import Button from 'components/base/forms/Button' +import Link from 'components/base/link' import Icon from 'components/icons/Icon' import OnboardingHeader from 'components/pages/onboarding/OnboardingHeader' import ThemeToggle from 'components/ThemeToggle' @@ -30,6 +31,7 @@ const OnboardingFlow: FC = () => { environment, environmentKey, featureName: bootstrappedFeatureName, + hasDemoFlag, organisationId, organisationName, projectId, @@ -197,6 +199,27 @@ const OnboardingFlow: FC = () => { ) } + // ensureFlag seeded nothing, so there is no flag to tour with. + if (!hasDemoFlag) { + return ( +
+

You’re already set up

+

+ {projectDisplayName} already has flags, so we haven’t added a demo + one. +

+
+ + + View flags in {projectDisplayName} + +
+
+ ) + } + return (
diff --git a/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts b/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts new file mode 100644 index 000000000000..b66b992272dd --- /dev/null +++ b/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts @@ -0,0 +1,49 @@ +import { ProjectFlag, Tag } from 'common/types/responses' +import { + DEMO_FLAG_NAME, + findDemoFlag, + shouldSeedDemoFlag, +} from 'components/pages/onboarding/bootstrap/demoFlag' + +const flag = (name: string, tags: number[] = []): ProjectFlag => + ({ id: name.length, name, tags } as ProjectFlag) + +const onboardingTag = { id: 7, label: 'Onboarding' } as Tag + +describe('shouldSeedDemoFlag', () => { + it('seeds into an empty project', () => { + expect(shouldSeedDemoFlag([])).toBe(true) + }) + + it('seeds nothing once the project has flags of its own', () => { + expect(shouldSeedDemoFlag([flag('checkout_v2')])).toBe(false) + }) +}) + +describe('findDemoFlag', () => { + it('finds a previous run by its tag, whatever it was renamed to', () => { + const renamed = flag('my_own_name', [onboardingTag.id]) + expect(findDemoFlag([flag('checkout_v2'), renamed], onboardingTag)).toBe( + renamed, + ) + }) + + it('prefers the tag over the name when both are present', () => { + const tagged = flag('renamed_demo', [onboardingTag.id]) + const named = flag(DEMO_FLAG_NAME) + expect(findDemoFlag([named, tagged], onboardingTag)).toBe(tagged) + }) + + it('falls back to the name when the tag is missing', () => { + const demo = flag(DEMO_FLAG_NAME) + expect(findDemoFlag([flag('checkout_v2'), demo], undefined)).toBe(demo) + }) + + it('finds nothing in a project that never ran the tour', () => { + expect(findDemoFlag([flag('checkout_v2')], onboardingTag)).toBeUndefined() + }) + + it('finds nothing in an empty project', () => { + expect(findDemoFlag([], onboardingTag)).toBeUndefined() + }) +}) diff --git a/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts b/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts new file mode 100644 index 000000000000..c5800f8658db --- /dev/null +++ b/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts @@ -0,0 +1,23 @@ +import { ProjectFlag, Tag } from 'common/types/responses' + +export const DEMO_FLAG_NAME = 'show_demo_button' + +export const ONBOARDING_TAG = { + color: '#3cb371', + description: 'Created during onboarding', + label: 'Onboarding', +} + +// A previous run's flag. Tag first: renaming is a delete and recreate, so the +// name alone is not reliable. +export const findDemoFlag = ( + flags: ProjectFlag[], + onboardingTag?: Tag, +): ProjectFlag | undefined => + (onboardingTag && flags.find((f) => f.tags?.includes(onboardingTag.id))) || + flags.find((f) => f.name === DEMO_FLAG_NAME) + +// Only seed into an empty project: features are project-level, so an unwanted +// flag shows up in every environment, production included. +export const shouldSeedDemoFlag = (flags: ProjectFlag[]): boolean => + !flags.length diff --git a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts b/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts index 3192bc96ba5e..db12124be4f6 100644 --- a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts +++ b/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts @@ -16,6 +16,12 @@ import { ProjectSummary, Tag, } from 'common/types/responses' +import { + DEMO_FLAG_NAME, + ONBOARDING_TAG, + findDemoFlag, + shouldSeedDemoFlag, +} from 'components/pages/onboarding/bootstrap/demoFlag' import { SmartDefaults } from './useSmartDefaults' import { createOrganisationViaAccountStore } from './createOrganisationViaAccountStore' import API from 'project/api' @@ -23,17 +29,10 @@ import Constants from 'common/constants' type Store = ReturnType -const FLAG_NAME = 'show_demo_button' const DEFAULT_ORG_NAME = 'My organisation' const DEFAULT_PROJECT_NAME = 'My first project' const DEV_ENVIRONMENT_NAME = 'Development' const PROD_ENVIRONMENT_NAME = 'Production' -const ONBOARDING_TAG = { - color: '#3cb371', - description: 'Created during onboarding', - label: 'Onboarding', -} - type ExistingOrg = { id: number; name: string } export type BootstrapInput = { @@ -47,6 +46,9 @@ export type OnboardingBootstrap = { project: ProjectSummary environment: Environment featureName: string + // False when the project already had flags, so we seeded nothing and the tour + // has no flag of its own to teach with. + hasDemoFlag: boolean } async function ensureOrganisation( @@ -154,20 +156,20 @@ async function ensureFlag( }), ) .unwrap() + const results = flags?.results ?? [] const onboardingTag = await findOnboardingTag(store, project.id) - const existing = - (onboardingTag && - flags?.results?.find((f) => f.tags?.includes(onboardingTag.id))) || - flags?.results?.find((f) => f.name === FLAG_NAME) + const existing = findDemoFlag(results, onboardingTag) if (existing) { return existing } - const isFirstFeature = !flags?.results?.length + if (!shouldSeedDemoFlag(results)) { + return undefined + } const created = await store .dispatch( projectFlagService.endpoints.createProjectFlag.initiate({ body: { - name: FLAG_NAME, + name: DEMO_FLAG_NAME, project: project.id, type: 'STANDARD', } as Req['createProjectFlag']['body'], @@ -175,9 +177,7 @@ async function ensureFlag( }), ) .unwrap() - if (isFirstFeature) { - API.trackEvent(Constants.events.CREATE_FIRST_FEATURE) - } + API.trackEvent(Constants.events.CREATE_FIRST_FEATURE) return created } @@ -227,7 +227,8 @@ export async function bootstrapOnboarding( AppActions.refreshOrganisation() return { environment, - featureName: flag?.name ?? FLAG_NAME, + featureName: flag?.name ?? DEMO_FLAG_NAME, + hasDemoFlag: !!flag, organisationId: organisation.id, organisationName: organisation.name, project, diff --git a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts index 574ae4f0f2bd..77d071d86ba7 100644 --- a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts +++ b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts @@ -15,6 +15,7 @@ export type OnboardingResources = { organisationName: string projectName: string featureName: string + hasDemoFlag: boolean caseSensitive: boolean environment: Environment | null environmentKey: string @@ -46,6 +47,7 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { const [organisationName, setOrganisationName] = useState('') const [projectName, setProjectName] = useState('') const [featureName, setFeatureName] = useState('') + const [hasDemoFlag, setHasDemoFlag] = useState(true) // Whether the project enforces lower-case feature names; drives the same name // normalisation the create-feature modal applies (see the header). const [caseSensitive, setCaseSensitive] = useState(false) @@ -77,6 +79,7 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { setEnvironment(res.environment) setEnvironmentKey(res.environment.api_key) setFeatureName(res.featureName) + setHasDemoFlag(res.hasDemoFlag) setStatus('ready') }) .catch((e) => { @@ -91,6 +94,7 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { environmentKey, error, featureName, + hasDemoFlag, organisationId, organisationName, projectId, From 58fe0f437bf9993a1c5095b5efd05b9e8b0de72e Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 5 Aug 2026 20:14:58 -0300 Subject: [PATCH 2/6] chore(onboarding): move the non-hooks out of the hooks folder bootstrapOnboarding and createOrganisationViaAccountStore are plain functions, so hooks/ described three of its nine files wrongly. Both belong to the provisioning that runs before the tour, which is what the new bootstrap/ folder holds. Paths and one comment only; no logic changes. --- .../onboarding/{hooks => bootstrap}/bootstrapOnboarding.ts | 7 +++---- .../createOrganisationViaAccountStore.ts | 0 .../pages/onboarding/hooks/useEnsureOnboardingResources.ts | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) rename frontend/web/components/pages/onboarding/{hooks => bootstrap}/bootstrapOnboarding.ts (96%) rename frontend/web/components/pages/onboarding/{hooks => bootstrap}/createOrganisationViaAccountStore.ts (100%) diff --git a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts similarity index 96% rename from frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts rename to frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts index db12124be4f6..35f1fdf4eb1a 100644 --- a/frontend/web/components/pages/onboarding/hooks/bootstrapOnboarding.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts @@ -21,8 +21,8 @@ import { ONBOARDING_TAG, findDemoFlag, shouldSeedDemoFlag, -} from 'components/pages/onboarding/bootstrap/demoFlag' -import { SmartDefaults } from './useSmartDefaults' +} from './demoFlag' +import { SmartDefaults } from 'components/pages/onboarding/hooks/useSmartDefaults' import { createOrganisationViaAccountStore } from './createOrganisationViaAccountStore' import API from 'project/api' import Constants from 'common/constants' @@ -46,8 +46,7 @@ export type OnboardingBootstrap = { project: ProjectSummary environment: Environment featureName: string - // False when the project already had flags, so we seeded nothing and the tour - // has no flag of its own to teach with. + // False when the project already had flags, so nothing was seeded. hasDemoFlag: boolean } diff --git a/frontend/web/components/pages/onboarding/hooks/createOrganisationViaAccountStore.ts b/frontend/web/components/pages/onboarding/bootstrap/createOrganisationViaAccountStore.ts similarity index 100% rename from frontend/web/components/pages/onboarding/hooks/createOrganisationViaAccountStore.ts rename to frontend/web/components/pages/onboarding/bootstrap/createOrganisationViaAccountStore.ts diff --git a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts index 77d071d86ba7..00c395793334 100644 --- a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts +++ b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts @@ -5,7 +5,7 @@ import useSelectedOrganisation from 'common/hooks/useSelectedOrganisation' import { useGetProfileQuery } from 'common/services/useProfile' import { Environment } from 'common/types/responses' import { useSmartDefaults } from './useSmartDefaults' -import { bootstrapOnboarding } from './bootstrapOnboarding' +import { bootstrapOnboarding } from 'components/pages/onboarding/bootstrap/bootstrapOnboarding' export type OnboardingResourcesStatus = 'creating' | 'ready' | 'error' From 533a77fd2f2b19ffb897376903e1bd7d2c3a020e Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 12 Aug 2026 15:27:09 -0300 Subject: [PATCH 3/6] fix(onboarding): only tour a flag we created The flow adopted any flag it recognised, wherever it lived. The tag lookup matched on label alone, so a customer's own tag labelled Onboarding picked up one of their flags, and a recognised flag was toured even in a project full of their own work. The tour then offers a toggle that writes a feature state, and a rename that is a create plus a delete. The new flag carries only name, description, project, tags and type, so the environment values, segment overrides, identity overrides and variations stay behind on the deleted one, and an SDK reading by name starts getting defaults. Match the tag on its description too, and carry on only while our flag is the only one in the project. One flag is what a mid-tour refresh finds, so a run in progress still resumes. Co-Authored-By: Claude Opus 5 (1M context) --- .../bootstrap/__tests__/demoFlag.test.ts | 40 +++++++++++++++++-- .../bootstrap/bootstrapOnboarding.ts | 15 ++++--- .../pages/onboarding/bootstrap/demoFlag.ts | 15 +++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts b/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts index b66b992272dd..39605a319689 100644 --- a/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts @@ -1,14 +1,34 @@ import { ProjectFlag, Tag } from 'common/types/responses' import { DEMO_FLAG_NAME, + ONBOARDING_TAG, + canResumeDemoFlag, findDemoFlag, + findOnboardingTag, shouldSeedDemoFlag, } from 'components/pages/onboarding/bootstrap/demoFlag' const flag = (name: string, tags: number[] = []): ProjectFlag => ({ id: name.length, name, tags } as ProjectFlag) -const onboardingTag = { id: 7, label: 'Onboarding' } as Tag +const onboardingTag = { id: 7, ...ONBOARDING_TAG } as Tag + +describe('findOnboardingTag', () => { + it('finds the tag a previous run created', () => { + expect( + findOnboardingTag([{ id: 3, label: 'Backend' } as Tag, onboardingTag]), + ).toBe(onboardingTag) + }) + + it('ignores a tag the customer labelled Onboarding themselves', () => { + const theirs = { + description: 'Flags behind our signup flow', + id: 9, + label: 'Onboarding', + } as Tag + expect(findOnboardingTag([theirs])).toBeUndefined() + }) +}) describe('shouldSeedDemoFlag', () => { it('seeds into an empty project', () => { @@ -29,14 +49,14 @@ describe('findDemoFlag', () => { }) it('prefers the tag over the name when both are present', () => { - const tagged = flag('renamed_demo', [onboardingTag.id]) + const tagged = flag('renamed_by_hand', [onboardingTag.id]) const named = flag(DEMO_FLAG_NAME) expect(findDemoFlag([named, tagged], onboardingTag)).toBe(tagged) }) it('falls back to the name when the tag is missing', () => { - const demo = flag(DEMO_FLAG_NAME) - expect(findDemoFlag([flag('checkout_v2'), demo], undefined)).toBe(demo) + const seeded = flag(DEMO_FLAG_NAME) + expect(findDemoFlag([flag('checkout_v2'), seeded], undefined)).toBe(seeded) }) it('finds nothing in a project that never ran the tour', () => { @@ -47,3 +67,15 @@ describe('findDemoFlag', () => { expect(findDemoFlag([], onboardingTag)).toBeUndefined() }) }) + +describe('canResumeDemoFlag', () => { + it('resumes the run a refresh interrupted, where ours is the only flag', () => { + const seeded = flag(DEMO_FLAG_NAME, [onboardingTag.id]) + expect(canResumeDemoFlag([seeded], seeded)).toBe(true) + }) + + it('stops once the project holds flags of its own', () => { + const seeded = flag(DEMO_FLAG_NAME, [onboardingTag.id]) + expect(canResumeDemoFlag([seeded, flag('checkout_v2')], seeded)).toBe(false) + }) +}) diff --git a/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts index 35f1fdf4eb1a..b0e6b3726b96 100644 --- a/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts @@ -19,7 +19,9 @@ import { import { DEMO_FLAG_NAME, ONBOARDING_TAG, + canResumeDemoFlag, findDemoFlag, + findOnboardingTag, shouldSeedDemoFlag, } from './demoFlag' import { SmartDefaults } from 'components/pages/onboarding/hooks/useSmartDefaults' @@ -46,7 +48,8 @@ export type OnboardingBootstrap = { project: ProjectSummary environment: Environment featureName: string - // False when the project already had flags, so nothing was seeded. + // False when the project holds flags that aren't ours, so there is nothing + // here we may seed, toggle or rename. hasDemoFlag: boolean } @@ -134,14 +137,14 @@ async function ensureEnvironments( .unwrap() } -async function findOnboardingTag( +async function fetchOnboardingTag( store: Store, projectId: number, ): Promise { const tags = await store .dispatch(tagService.endpoints.getTags.initiate({ projectId })) .unwrap() - return tags?.find((t) => t.label === ONBOARDING_TAG.label) + return findOnboardingTag(tags ?? []) } async function ensureFlag( @@ -156,10 +159,10 @@ async function ensureFlag( ) .unwrap() const results = flags?.results ?? [] - const onboardingTag = await findOnboardingTag(store, project.id) + const onboardingTag = await fetchOnboardingTag(store, project.id) const existing = findDemoFlag(results, onboardingTag) if (existing) { - return existing + return canResumeDemoFlag(results, existing) ? existing : undefined } if (!shouldSeedDemoFlag(results)) { return undefined @@ -187,7 +190,7 @@ async function ensureOnboardingTag( ): Promise { try { const tag = - (await findOnboardingTag(store, project.id)) ?? + (await fetchOnboardingTag(store, project.id)) ?? (await store .dispatch( tagService.endpoints.createTag.initiate({ diff --git a/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts b/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts index c5800f8658db..301f3e125c01 100644 --- a/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts @@ -8,6 +8,14 @@ export const ONBOARDING_TAG = { label: 'Onboarding', } +// Description too: a customer's own tag labelled Onboarding is not ours. +export const findOnboardingTag = (tags: Tag[]): Tag | undefined => + tags.find( + (t) => + t.label === ONBOARDING_TAG.label && + t.description === ONBOARDING_TAG.description, + ) + // A previous run's flag. Tag first: renaming is a delete and recreate, so the // name alone is not reliable. export const findDemoFlag = ( @@ -21,3 +29,10 @@ export const findDemoFlag = ( // flag shows up in every environment, production included. export const shouldSeedDemoFlag = (flags: ProjectFlag[]): boolean => !flags.length + +// The tour toggles and renames what it finds, so only carry on while ours is +// the only flag here. One flag is also what a mid-tour refresh finds. +export const canResumeDemoFlag = ( + flags: ProjectFlag[], + flag: ProjectFlag, +): boolean => flags.length === 1 && flags[0].id === flag.id From e85a053790ec664af379f9e5644a94511d2c8507 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 12 Aug 2026 15:27:09 -0300 Subject: [PATCH 4/6] fix(onboarding): stop polling when there is no flag to tour The connection hook runs before the already-set-up early return, so that screen polled onboarding-status every 5s for a first evaluation it never waits on. Pass an empty key, which the hook already skips. Co-Authored-By: Claude Opus 5 (1M context) --- .../pages/onboarding/OnboardingFlow/OnboardingFlow.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index 7902605f2836..8b4ff7d41b7a 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -67,8 +67,9 @@ const OnboardingFlow: FC = () => { }) // Real first-evaluation signal: polls the environment's onboarding - // status until Edge reports its first SDK evaluation. - const connection = useOnboardingConnection(environmentKey) + // status until Edge reports its first SDK evaluation. No flag means no tour + // to wait for, and an empty key skips the poll. + const connection = useOnboardingConnection(hasDemoFlag ? environmentKey : '') // Session-only: a reload resets the checklist. Fine for onboarding. const [installCopied, setInstallCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false) From 5ff882bef52aed486f598a45f9a39f8d5074518b Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Wed, 12 Aug 2026 15:40:18 -0300 Subject: [PATCH 5/6] fix(onboarding): don't fail onboarding when the tag lookup fails The lookup ends in .unwrap(), and unlike ensureOnboardingTag it wasn't guarded, so a transient tags request rejected ensureFlag and dropped the user on the "we couldn't set up your workspace" screen. Treat it as optional: without the tag we still match our flag by name. Co-Authored-By: Claude Opus 5 (1M context) --- .../pages/onboarding/bootstrap/bootstrapOnboarding.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts index b0e6b3726b96..b5bf917e2cc2 100644 --- a/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts @@ -159,7 +159,10 @@ async function ensureFlag( ) .unwrap() const results = flags?.results ?? [] - const onboardingTag = await fetchOnboardingTag(store, project.id) + // Not fatal: without the tag we fall back to matching on the name. + const onboardingTag = await fetchOnboardingTag(store, project.id).catch( + () => undefined, + ) const existing = findDemoFlag(results, onboardingTag) if (existing) { return canResumeDemoFlag(results, existing) ? existing : undefined From 1815f7d809fc908f848dc7c8b4c686a3518cb453 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 13 Aug 2026 09:53:00 -0300 Subject: [PATCH 6/6] fix(onboarding): recognise our own flag instead of refusing to tour The seed and resume rules were standing in for identification: the flow could not tell its own flag from a customer's, so it declined to tour a project holding anything else and dropped the user on an already-set-up screen. Creating a flag in a project is not the harm; mutating one the customer already had is. Identify it properly instead. The flag we create now carries a description, which the tour's rename copies over, so it survives the one feature that changes the name. Recognition is the tag, then that description, then the name for flags seeded before this. That leaves nothing for the already-set-up screen to say: we either recognise our flag and tour it, or create one. A name clash on create means our flag is here and we failed to recognise it, so adopt it rather than fail. Co-Authored-By: Claude Opus 5 (1M context) --- .../OnboardingFlow/OnboardingFlow.scss | 9 +++ .../OnboardingFlow/OnboardingFlow.tsx | 45 +++-------- .../bootstrap/__tests__/demoFlag.test.ts | 55 +++++-------- .../bootstrap/bootstrapOnboarding.ts | 81 +++++++++++-------- .../pages/onboarding/bootstrap/demoFlag.ts | 26 +++--- .../hooks/useEnsureOnboardingResources.ts | 4 - 6 files changed, 99 insertions(+), 121 deletions(-) diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.scss b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.scss index 9bee42832fe9..174a56a5d3b2 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.scss +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.scss @@ -7,4 +7,13 @@ // Top padding gives the theme-toggle row room to breathe from the very edge; // sides + bottom keep the reading column off the edges. padding: 24px 24px 40px; + + // The terminal states are one short block, so centre them rather than + // leaving them stranded at the top of an otherwise empty page. + &--message { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 70vh; + } } diff --git a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx index 8b4ff7d41b7a..5ed5bbf5629e 100644 --- a/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx +++ b/frontend/web/components/pages/onboarding/OnboardingFlow/OnboardingFlow.tsx @@ -1,6 +1,7 @@ import React, { FC, useState } from 'react' import { useHistory } from 'react-router-dom' import Button from 'components/base/forms/Button' +import EmptyState from 'components/EmptyState' import Link from 'components/base/link' import Icon from 'components/icons/Icon' import OnboardingHeader from 'components/pages/onboarding/OnboardingHeader' @@ -31,7 +32,6 @@ const OnboardingFlow: FC = () => { environment, environmentKey, featureName: bootstrappedFeatureName, - hasDemoFlag, organisationId, organisationName, projectId, @@ -67,9 +67,8 @@ const OnboardingFlow: FC = () => { }) // Real first-evaluation signal: polls the environment's onboarding - // status until Edge reports its first SDK evaluation. No flag means no tour - // to wait for, and an empty key skips the poll. - const connection = useOnboardingConnection(hasDemoFlag ? environmentKey : '') + // status until Edge reports its first SDK evaluation. + const connection = useOnboardingConnection(environmentKey) // Session-only: a reload resets the checklist. Fine for onboarding. const [installCopied, setInstallCopied] = useState(false) const [snippetCopied, setSnippetCopied] = useState(false) @@ -181,7 +180,7 @@ const OnboardingFlow: FC = () => { if (status === 'creating') { return ( -
+
) @@ -190,33 +189,15 @@ const OnboardingFlow: FC = () => { // Bootstrap failed (e.g. a plan org cap). Recoverable; a reload re-runs it. if (status === 'error') { return ( -
-

We couldn’t set up your workspace

-

- Something went wrong creating your starter project. Please try again. -

- -
- ) - } - - // ensureFlag seeded nothing, so there is no flag to tour with. - if (!hasDemoFlag) { - return ( -
-

You’re already set up

-

- {projectDisplayName} already has flags, so we haven’t added a demo - one. -

-
- - - View flags in {projectDisplayName} - -
+
+ window.location.reload()}>Try again + } + />
) } diff --git a/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts b/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts index 39605a319689..080af5a5c3b7 100644 --- a/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/__tests__/demoFlag.test.ts @@ -1,15 +1,16 @@ import { ProjectFlag, Tag } from 'common/types/responses' import { + DEMO_FLAG_DESCRIPTION, DEMO_FLAG_NAME, ONBOARDING_TAG, - canResumeDemoFlag, findDemoFlag, findOnboardingTag, - shouldSeedDemoFlag, } from 'components/pages/onboarding/bootstrap/demoFlag' -const flag = (name: string, tags: number[] = []): ProjectFlag => - ({ id: name.length, name, tags } as ProjectFlag) +const flag = ( + name: string, + { description = '', tags = [] }: { description?: string; tags?: number[] } = {}, +): ProjectFlag => ({ description, id: name.length, name, tags } as ProjectFlag) const onboardingTag = { id: 7, ...ONBOARDING_TAG } as Tag @@ -30,33 +31,29 @@ describe('findOnboardingTag', () => { }) }) -describe('shouldSeedDemoFlag', () => { - it('seeds into an empty project', () => { - expect(shouldSeedDemoFlag([])).toBe(true) - }) - - it('seeds nothing once the project has flags of its own', () => { - expect(shouldSeedDemoFlag([flag('checkout_v2')])).toBe(false) - }) -}) - describe('findDemoFlag', () => { it('finds a previous run by its tag, whatever it was renamed to', () => { - const renamed = flag('my_own_name', [onboardingTag.id]) - expect(findDemoFlag([flag('checkout_v2'), renamed], onboardingTag)).toBe( - renamed, - ) + const renamed = flag('my_own_name', { tags: [onboardingTag.id] }) + expect( + findDemoFlag([flag('checkout_v2'), renamed], onboardingTag), + ).toBe(renamed) }) - it('prefers the tag over the name when both are present', () => { - const tagged = flag('renamed_by_hand', [onboardingTag.id]) + it('prefers the tag over anything else when both are present', () => { + const tagged = flag('renamed_by_hand', { tags: [onboardingTag.id] }) const named = flag(DEMO_FLAG_NAME) expect(findDemoFlag([named, tagged], onboardingTag)).toBe(tagged) }) - it('falls back to the name when the tag is missing', () => { - const seeded = flag(DEMO_FLAG_NAME) - expect(findDemoFlag([flag('checkout_v2'), seeded], undefined)).toBe(seeded) + it('falls back to our description when the tag is gone', () => { + // The rename carries the description over, so it outlives the name. + const renamed = flag('my_own_name', { description: DEMO_FLAG_DESCRIPTION }) + expect(findDemoFlag([flag('checkout_v2'), renamed], undefined)).toBe(renamed) + }) + + it('falls back to the name for a flag seeded before we set a description', () => { + const legacy = flag(DEMO_FLAG_NAME) + expect(findDemoFlag([flag('checkout_v2'), legacy], undefined)).toBe(legacy) }) it('finds nothing in a project that never ran the tour', () => { @@ -67,15 +64,3 @@ describe('findDemoFlag', () => { expect(findDemoFlag([], onboardingTag)).toBeUndefined() }) }) - -describe('canResumeDemoFlag', () => { - it('resumes the run a refresh interrupted, where ours is the only flag', () => { - const seeded = flag(DEMO_FLAG_NAME, [onboardingTag.id]) - expect(canResumeDemoFlag([seeded], seeded)).toBe(true) - }) - - it('stops once the project holds flags of its own', () => { - const seeded = flag(DEMO_FLAG_NAME, [onboardingTag.id]) - expect(canResumeDemoFlag([seeded, flag('checkout_v2')], seeded)).toBe(false) - }) -}) diff --git a/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts index b5bf917e2cc2..c1ef8e11e842 100644 --- a/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/bootstrapOnboarding.ts @@ -17,12 +17,11 @@ import { Tag, } from 'common/types/responses' import { + DEMO_FLAG_DESCRIPTION, DEMO_FLAG_NAME, ONBOARDING_TAG, - canResumeDemoFlag, findDemoFlag, findOnboardingTag, - shouldSeedDemoFlag, } from './demoFlag' import { SmartDefaults } from 'components/pages/onboarding/hooks/useSmartDefaults' import { createOrganisationViaAccountStore } from './createOrganisationViaAccountStore' @@ -48,9 +47,6 @@ export type OnboardingBootstrap = { project: ProjectSummary environment: Environment featureName: string - // False when the project holds flags that aren't ours, so there is nothing - // here we may seed, toggle or rename. - hasDemoFlag: boolean } async function ensureOrganisation( @@ -147,43 +143,61 @@ async function fetchOnboardingTag( return findOnboardingTag(tags ?? []) } -async function ensureFlag( +async function listFlags( store: Store, project: ProjectSummary, -): Promise { +): Promise { const flags = await store .dispatch( - projectFlagService.endpoints.getProjectFlags.initiate({ - project: `${project.id}`, - }), + projectFlagService.endpoints.getProjectFlags.initiate( + { project: `${project.id}` }, + { forceRefetch: true }, + ), ) .unwrap() - const results = flags?.results ?? [] - // Not fatal: without the tag we fall back to matching on the name. + return flags?.results ?? [] +} + +async function ensureFlag( + store: Store, + project: ProjectSummary, +): Promise { + // Not fatal: without the tag we fall back to the flag's own description. const onboardingTag = await fetchOnboardingTag(store, project.id).catch( () => undefined, ) - const existing = findDemoFlag(results, onboardingTag) + const existing = findDemoFlag(await listFlags(store, project), onboardingTag) if (existing) { - return canResumeDemoFlag(results, existing) ? existing : undefined - } - if (!shouldSeedDemoFlag(results)) { - return undefined + return existing } - const created = await store - .dispatch( - projectFlagService.endpoints.createProjectFlag.initiate({ - body: { - name: DEMO_FLAG_NAME, - project: project.id, - type: 'STANDARD', - } as Req['createProjectFlag']['body'], - project_id: project.id, - }), + try { + const created = await store + .dispatch( + projectFlagService.endpoints.createProjectFlag.initiate({ + body: { + description: DEMO_FLAG_DESCRIPTION, + name: DEMO_FLAG_NAME, + project: project.id, + type: 'STANDARD', + } as Req['createProjectFlag']['body'], + project_id: project.id, + }), + ) + .unwrap() + API.trackEvent(Constants.events.CREATE_FIRST_FEATURE) + return created + } catch (e) { + // Names are unique per project, so a clash means our flag is already here + // and we failed to recognise it: someone edited its description, or removed + // the tag. Take it rather than dropping the user out of the tour. + const clash = (await listFlags(store, project)).find( + (f) => f.name === DEMO_FLAG_NAME, ) - .unwrap() - API.trackEvent(Constants.events.CREATE_FIRST_FEATURE) - return created + if (!clash) { + throw e + } + return clash + } } async function ensureOnboardingTag( @@ -226,14 +240,11 @@ export async function bootstrapOnboarding( const project = await ensureProject(store, organisation.id, input.defaults) const environment = await ensureEnvironments(store, project) const flag = await ensureFlag(store, project) - if (flag) { - await ensureOnboardingTag(store, project, flag) - } + await ensureOnboardingTag(store, project, flag) AppActions.refreshOrganisation() return { environment, - featureName: flag?.name ?? DEMO_FLAG_NAME, - hasDemoFlag: !!flag, + featureName: flag.name, organisationId: organisation.id, organisationName: organisation.name, project, diff --git a/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts b/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts index 301f3e125c01..07018ffa158b 100644 --- a/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts +++ b/frontend/web/components/pages/onboarding/bootstrap/demoFlag.ts @@ -2,9 +2,14 @@ import { ProjectFlag, Tag } from 'common/types/responses' export const DEMO_FLAG_NAME = 'show_demo_button' +// Set on the flag we create, and carried over by the tour's rename, so it +// identifies our flag after the name has changed. Also tells anyone looking at +// their flag list why it is there. +export const DEMO_FLAG_DESCRIPTION = 'Created during onboarding' + export const ONBOARDING_TAG = { color: '#3cb371', - description: 'Created during onboarding', + description: DEMO_FLAG_DESCRIPTION, label: 'Onboarding', } @@ -16,23 +21,14 @@ export const findOnboardingTag = (tags: Tag[]): Tag | undefined => t.description === ONBOARDING_TAG.description, ) -// A previous run's flag. Tag first: renaming is a delete and recreate, so the -// name alone is not reliable. +// Our flag, in descending order of how much the signal is worth. The tour +// renames by delete and recreate, carrying the tags and description over, so +// the name is the one thing that doesn't survive it: it only identifies flags +// seeded before we set a description. export const findDemoFlag = ( flags: ProjectFlag[], onboardingTag?: Tag, ): ProjectFlag | undefined => (onboardingTag && flags.find((f) => f.tags?.includes(onboardingTag.id))) || + flags.find((f) => f.description === DEMO_FLAG_DESCRIPTION) || flags.find((f) => f.name === DEMO_FLAG_NAME) - -// Only seed into an empty project: features are project-level, so an unwanted -// flag shows up in every environment, production included. -export const shouldSeedDemoFlag = (flags: ProjectFlag[]): boolean => - !flags.length - -// The tour toggles and renames what it finds, so only carry on while ours is -// the only flag here. One flag is also what a mid-tour refresh finds. -export const canResumeDemoFlag = ( - flags: ProjectFlag[], - flag: ProjectFlag, -): boolean => flags.length === 1 && flags[0].id === flag.id diff --git a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts index 00c395793334..445f97a2d940 100644 --- a/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts +++ b/frontend/web/components/pages/onboarding/hooks/useEnsureOnboardingResources.ts @@ -15,7 +15,6 @@ export type OnboardingResources = { organisationName: string projectName: string featureName: string - hasDemoFlag: boolean caseSensitive: boolean environment: Environment | null environmentKey: string @@ -47,7 +46,6 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { const [organisationName, setOrganisationName] = useState('') const [projectName, setProjectName] = useState('') const [featureName, setFeatureName] = useState('') - const [hasDemoFlag, setHasDemoFlag] = useState(true) // Whether the project enforces lower-case feature names; drives the same name // normalisation the create-feature modal applies (see the header). const [caseSensitive, setCaseSensitive] = useState(false) @@ -79,7 +77,6 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { setEnvironment(res.environment) setEnvironmentKey(res.environment.api_key) setFeatureName(res.featureName) - setHasDemoFlag(res.hasDemoFlag) setStatus('ready') }) .catch((e) => { @@ -94,7 +91,6 @@ export const useEnsureOnboardingResources = (): OnboardingResources => { environmentKey, error, featureName, - hasDemoFlag, organisationId, organisationName, projectId,