Summary
On feat/telco-webrtc-calls, the "Legacy & UXF profiles detected" modal (from `src/components/uxf/UxfMigrationPrompt.tsx`) pops on every page reload — including for users who have no legacy wallet at all and whose UXF OrbitDB profile was created fresh. The user is asked to choose between merging, loading legacy only, or loading UXF only, when there is no legacy data to merge or choose from in the first place.
Symptom
- Open https://sphere-telco-test.dyndns.org/ in a private window.
- Create a fresh wallet via the onboarding flow. The boot lands in Profile mode (UXF build default;
walletMode === 'profile') and the OrbitDB Profile is the only token store with data.
- Reload the page.
- The migration-choice modal pops with the title "Legacy & UXF profiles detected". There is no legacy wallet to migrate.
- Dismiss via Skip → modal stays away for the rest of the session. Reload again → modal pops again. Choose Merge → modal goes away permanently, but every prior reload already showed it.
Root cause
The modal's visibility gate in UxfMigrationPrompt.tsx:141-150 reads:
useEffect(() => {
const skipped = sessionStorage.getItem(UXF_MIGRATION_SKIP_KEY);
const persisted = localStorage.getItem(UXF_MIGRATION_CHOICE_KEY);
if (skipped || persisted) return;
detectStorageState().then(({ hasLegacy, hasUxf }) => {
if (hasLegacy && hasUxf) setVariant('both');
else if (hasLegacy) setVariant('legacy-only');
});
}, []);
detectStorageState() in src/config/uxf.ts:13-30 decides via raw IndexedDB database name presence:
const dbs = await indexedDB.databases();
const names = dbs.map(d => d.name ?? '');
const hasLegacy = names.some(n => LEGACY_DB_NAMES.includes(...)); // ['sphere-storage']
const hasUxf = names.some(n => UXF_DB_PATTERN.test(n)); // /^orbitdb/i
This is unsound. From SphereContext.ts:13-16:
Note: identity / mnemonic always lives in the legacy sphere-storage KV — even under Profile mode, because the Profile local cache uses the same IndexedDB name.
And confirmed in SphereProvider.tsx:631-639:
under UXF mode, the Profile-backed ProfileStorageProvider's local cache shares the SAME sphere-storage IndexedDB as the legacy IndexedDBStorageProvider… That means a wallet created FRESH under Profile on a previous boot also shows up via Sphere.exists(legacyStorage) on the next boot.
Net effect: once any wallet (legacy or fresh Profile) has booted in the browser, the sphere-storage IDB exists. detectStorageState() then always returns hasLegacy=true. Combined with the (correctly-present) OrbitDB databases that satisfy hasUxf=true, the prompt always lands on the 'both' variant on every reload until the user records a persisted UXF_MIGRATION_CHOICE_KEY.
The session-skip key (UXF_MIGRATION_SKIP_KEY) is session-scoped, so even after dismissal the modal returns the next time the tab is reopened.
Why the banner does NOT have this bug
The same component family has a sibling — UxfBanner.tsx — that decides which buttons (Migrate / Switch / Merge) to render. The banner consumes hasLegacyData / hasProfileData from SphereContext, both of which run through hasMaterialContent via SphereProvider.runProbes. That predicate distinguishes "the sphere-storage IDB exists as a Profile-mode local cache (no user tokens)" from "it carries real legacy tokens".
So the banner-row UI on the dashboard is correct (the Migrate button does not appear for fresh Profile-only wallets), but the modal that mounts via main.tsx:44 duplicates a much weaker decision and gets it wrong.
Proposed fix
Replace the modal's detectStorageState() call with the same hasLegacyData / hasProfileData flags the banner already uses. Sketch:
export function UxfMigrationPrompt() {
const { walletExists, hasLegacyData, hasProfileData } = useSphereContext();
const [variant, setVariant] = useState<PromptVariant | null>(null);
useEffect(() => {
if (sessionStorage.getItem(UXF_MIGRATION_SKIP_KEY)) { setVariant(null); return; }
if (localStorage.getItem(UXF_MIGRATION_CHOICE_KEY)) { setVariant(null); return; }
if (!walletExists) { setVariant(null); return; }
// Probes pending — defer, otherwise we'd assert hasLegacyData=false
// for one render and falsely hide the modal for users who DO have
// a legacy wallet.
if (hasLegacyData === null || hasProfileData === null) { setVariant(null); return; }
if (hasLegacyData && hasProfileData) setVariant('both');
else if (hasLegacyData) setVariant('legacy-only');
else setVariant(null);
}, [walletExists, hasLegacyData, hasProfileData]);
// …
}
Then delete detectStorageState, LEGACY_DB_NAMES, and UXF_DB_PATTERN from src/config/uxf.ts — the prompt is their only caller, and leaving the unsound helper in the config invites a future regression. Drop a tombstone comment explaining why a shallow IDB-name probe is the wrong shape for this decision.
Visibility matrix after the fix
walletExists |
hasLegacyData |
hasProfileData |
Variant |
Notes |
| false |
any |
any |
none |
pre-onboarding |
| true |
null |
null |
none |
probes pending — defer |
| true |
false |
false |
none |
identity exists, neither store has tokens |
| true |
false |
true |
none |
the bug — was 'both', should be 'none' |
| true |
true |
false |
legacy-only |
unchanged |
| true |
true |
true |
both |
unchanged |
Acceptance criteria
Files to touch
Out of scope
- The separate "Migrate" button on the dashboard
UxfBanner is currently a no-op as documented in the memory note "UXF migration primitive — deferred follow-up" (sphere.telco emits sphere:uxf-migration-choice but sphere-sdk has no listener / legacy→UXF profile primitive). That is a different defect — track it independently.
- The boot-time auto-migration in
SphereProvider (runProfileMigration) is also out of scope; the modal sits in front of that path but doesn't gate it.
References
Summary
On
feat/telco-webrtc-calls, the "Legacy & UXF profiles detected" modal (from `src/components/uxf/UxfMigrationPrompt.tsx`) pops on every page reload — including for users who have no legacy wallet at all and whose UXF OrbitDB profile was created fresh. The user is asked to choose between merging, loading legacy only, or loading UXF only, when there is no legacy data to merge or choose from in the first place.Symptom
walletMode === 'profile') and the OrbitDB Profile is the only token store with data.Root cause
The modal's visibility gate in
UxfMigrationPrompt.tsx:141-150reads:detectStorageState()insrc/config/uxf.ts:13-30decides via raw IndexedDB database name presence:This is unsound. From
SphereContext.ts:13-16:And confirmed in
SphereProvider.tsx:631-639:Net effect: once any wallet (legacy or fresh Profile) has booted in the browser, the
sphere-storageIDB exists.detectStorageState()then always returnshasLegacy=true. Combined with the (correctly-present) OrbitDB databases that satisfyhasUxf=true, the prompt always lands on the'both'variant on every reload until the user records a persistedUXF_MIGRATION_CHOICE_KEY.The session-skip key (
UXF_MIGRATION_SKIP_KEY) is session-scoped, so even after dismissal the modal returns the next time the tab is reopened.Why the banner does NOT have this bug
The same component family has a sibling —
UxfBanner.tsx— that decides which buttons (Migrate / Switch / Merge) to render. The banner consumeshasLegacyData/hasProfileDatafromSphereContext, both of which run throughhasMaterialContentviaSphereProvider.runProbes. That predicate distinguishes "thesphere-storageIDB exists as a Profile-mode local cache (no user tokens)" from "it carries real legacy tokens".So the banner-row UI on the dashboard is correct (the Migrate button does not appear for fresh Profile-only wallets), but the modal that mounts via
main.tsx:44duplicates a much weaker decision and gets it wrong.Proposed fix
Replace the modal's
detectStorageState()call with the samehasLegacyData/hasProfileDataflags the banner already uses. Sketch:Then delete
detectStorageState,LEGACY_DB_NAMES, andUXF_DB_PATTERNfromsrc/config/uxf.ts— the prompt is their only caller, and leaving the unsound helper in the config invites a future regression. Drop a tombstone comment explaining why a shallow IDB-name probe is the wrong shape for this decision.Visibility matrix after the fix
walletExistshasLegacyDatahasProfileDataAcceptance criteria
legacy-onlyvariant.bothvariant (radio choices).hasLegacyData=false, hasProfileData=true).Files to touch
src/components/uxf/UxfMigrationPrompt.tsx— gate onuseSphereContext()probes.src/config/uxf.ts— deletedetectStorageState+ the now-unused constants, leave a tombstone comment.tests/unit/components/uxf/UxfMigrationPrompt.test.tsx(new) — visibility matrix + the regression case.Out of scope
UxfBanneris currently a no-op as documented in the memory note "UXF migration primitive — deferred follow-up" (sphere.telco emitssphere:uxf-migration-choicebut sphere-sdk has no listener / legacy→UXF profile primitive). That is a different defect — track it independently.SphereProvider(runProfileMigration) is also out of scope; the modal sits in front of that path but doesn't gate it.References
src/components/uxf/UxfMigrationPrompt.tsx— the modal.src/config/uxf.ts— the unsound probe.src/sdk/SphereContext.ts— the sharedsphere-storagerationale and the correct probe shape (hasLegacyData/hasProfileData).src/sdk/SphereProvider.tsx#L466-L548—runProbes(), the content-aware probe runner.src/sdk/utils/tokenStorageProbe.ts—hasMaterialContent, the single source of truth used by both the banner and the boot-time probes.src/components/uxf/UxfBanner.tsx— sibling component that already does the decision correctly.