Skip to content

UXF migration prompt fires on every reload for fresh Profile-only wallets — naive IDB-name probe shared between legacy and Profile cache #331

Description

@vrogojin

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

  1. Open https://sphere-telco-test.dyndns.org/ in a private window.
  2. 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.
  3. Reload the page.
  4. The migration-choice modal pops with the title "Legacy & UXF profiles detected". There is no legacy wallet to migrate.
  5. 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

  • Reload the dev page at https://sphere-telco-test.dyndns.org/ with a fresh Profile-only wallet (no legacy IDB written by a prior wallet) and the migration modal does NOT appear.
  • Reload after dismissing — modal stays hidden (existing session-skip behavior preserved).
  • Reload with a real legacy wallet (no UXF profile) → modal appears with the legacy-only variant.
  • Reload with both stores carrying material data → modal appears with the both variant (radio choices).
  • No flicker on boot — the modal does not appear for a frame while the boot-time probes are still pending.
  • Unit tests cover the (probes × skip/persisted) visibility matrix, including the regression case (hasLegacyData=false, hasProfileData=true).

Files to touch

  • src/components/uxf/UxfMigrationPrompt.tsx — gate on useSphereContext() probes.
  • src/config/uxf.ts — delete detectStorageState + the now-unused constants, leave a tombstone comment.
  • tests/unit/components/uxf/UxfMigrationPrompt.test.tsx (new) — visibility matrix + the regression case.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions