Skip to content

fix(desktop): resolve v2 default from local db, not stale localStorage#4176

Merged
saddlepaddle merged 2 commits into
mainfrom
debug-v2-migration-signal
May 7, 2026
Merged

fix(desktop): resolve v2 default from local db, not stale localStorage#4176
saddlepaddle merged 2 commits into
mainfrom
debug-v2-migration-signal

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented May 7, 2026

Summary

Two related bugs:

1. v2 default signal was always wrong. #4115 used localStorage.getItem("tabs-storage") to detect returning users, but tabs state moved off localStorage to app-state.json (lowdb) back in #303 (2025-12-10). The key has been null for every install since, so !hasPriorSupersetUsage() was true for everyone and force-flipped anyone without an explicit toggle into v2.

2. Existing v1 users who opted into v2 got force-walked through onboarding. The gate in _authenticated/layout.tsx only required isV2CloudEnabled && !requiredComplete, with no "is this a fresh user?" check — so any existing user who toggled v2 ON in Experimental Settings was redirected to /setup/providers.

Fix

  • New workspaces.hasAny and projects.hasAny procedures (cheap LIMIT 1 reads on localDb/host.db).
  • v2-local-override store now has two flags, both boolean | null:
    • optInV2 — user-controllable (resolver default or Experimental toggle).
    • isFreshInstall — sticky one-shot detection set by the resolver; never changes from a user toggle.
  • V2DefaultResolver (mounted in RootLayout) runs whenever either flag is null, queries the two hasAny procedures once, and fills in whichever flags are still null with !hasWorkspace && !hasProject. Users who got force-pulled by the broken release but never toggled have no persisted optInV2, so they self-heal back to v1.
  • Onboarding gate now requires isV2CloudEnabled && isFreshInstall === true && !requiredComplete && !isOnSetupRoute — so existing users opting into v2 land on the dashboard, not setup.
  • useIsV2CloudEnabled returns optInV2 === true (null → v1, no flicker before resolution).
  • Deletes the dead hasPriorSupersetUsage.ts.

Test plan

  • Fresh install → defaults to v2 AND walks through onboarding.
  • Returning v1 user (has projects/workspaces) → stays on v1, no onboarding redirect.
  • CLI-only user (projects, no workspaces) → stays on v1, no onboarding.
  • Existing v1 user toggles v2 ON in Experimental Settings → flips to v2, lands on dashboard (NOT /setup/providers).
  • Affected user upgrading from broken release (force-pulled, never toggled) → resolver fills in null optInV2 → back to v1.
  • User who explicitly toggled v2 ON before this fix → keeps v2; resolver backfills isFreshInstall=false; if they hadn't completed onboarding they stop being force-walked through it.
  • Fresh user quits mid-onboarding and relaunches → still routed back to setup (isFreshInstall is sticky).

Summary by CodeRabbit

Release Notes

  • New Features

    • Detect whether any workspace or project exists to drive onboarding defaults.
    • Added a startup resolver that initializes v2 opt-in and fresh-install state.
  • Improvements

    • v2 opt-in state is now tri-state (true/false/null) and resolves explicitly at startup.
    • Hook now returns strict booleans where appropriate and a dedicated fresh-install hook added.
  • Removed

    • Legacy prior-usage detection helper removed.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 690040c3-ab8f-441b-8a6f-d53b6f9857f2

📥 Commits

Reviewing files that changed from the base of the PR and between 1f10963 and aaf74f1.

📒 Files selected for processing (10)
  • apps/desktop/src/lib/trpc/routers/projects/projects.ts
  • apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
  • apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx
  • apps/desktop/src/renderer/components/V2DefaultResolver/index.ts
  • apps/desktop/src/renderer/hooks/useIsFreshInstall.ts
  • apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts
  • apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts
  • apps/desktop/src/renderer/routes/-layout.tsx
  • apps/desktop/src/renderer/routes/_authenticated/layout.tsx
  • apps/desktop/src/renderer/stores/v2-local-override.ts
💤 Files with no reviewable changes (1)
  • apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts
✅ Files skipped from review due to trivial changes (4)
  • apps/desktop/src/renderer/components/V2DefaultResolver/index.ts
  • apps/desktop/src/renderer/routes/-layout.tsx
  • apps/desktop/src/renderer/hooks/useIsFreshInstall.ts
  • apps/desktop/src/lib/trpc/routers/projects/projects.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts
  • apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
  • apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx

📝 Walkthrough

Walkthrough

This PR migrates V2 cloud opt-in initialization from eager, localStorage-based defaults to lazy resolution via database existence checks. New tRPC endpoints expose workspace and project existence; the store adopts a tri-state model; a resolver component populates the opt-in and fresh-install flags on mount; and prior initialization logic is removed.

Changes

V2 Cloud Opt-In Lazy Initialization

Layer / File(s) Summary
State Model
apps/desktop/src/renderer/stores/v2-local-override.ts
optInV2 becomes boolean | null, isFreshInstall: boolean | null and setIsFreshInstall are added; both initialize to null.
Data Source APIs
apps/desktop/src/lib/trpc/routers/projects/projects.ts, apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
Adds projects.hasAny and workspaces.hasAny tRPC public queries that select id with limit(1) and return whether any row exists.
Resolution Component
apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx, apps/desktop/src/renderer/components/V2DefaultResolver/index.ts
V2DefaultResolver mounts, concurrently fetches the two hasAny endpoints, computes freshness, and sets optInV2/isFreshInstall in the store with cancellation and race guards; component renders null.
Hook & Layout Integration
apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts, apps/desktop/src/renderer/hooks/useIsFreshInstall.ts, apps/desktop/src/renderer/routes/-layout.tsx, apps/desktop/src/renderer/routes/_authenticated/layout.tsx
useIsV2CloudEnabled now returns s.optInV2 === true; a useIsFreshInstall hook is added; V2DefaultResolver is inserted into the root layout; authenticated layout uses useIsFreshInstall and refines onboarding redirect conditions.
Cleanup
apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts
Removed hasPriorSupersetUsage() helper and its localStorage-based eager initialization logic.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A tri-state awakens, null at the start,
Resolver listens for rows in the dark,
Endpoints whisper whether tables hold light,
Store sets the path—fresh or legacy—just right,
Hop forward, little app, and play your part!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: replacing stale localStorage-based v2 default detection with a local database lookup.
Description check ✅ Passed The description covers all required sections: clear summary of the two bugs, detailed fix explanation, test plan, and sufficient context for understanding the changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch debug-v2-migration-signal

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts`:
- Around line 98-105: The hasAny procedure currently counts rows regardless of
soft-deletion; update the query inside hasAny to exclude soft-deleted workspaces
by adding a condition that the soft-delete column is null (e.g.,
workspaces.deletedAt is null) to the localDb select-from chain so it only
returns non-deleted rows; modify the query that uses localDb.select({ id:
workspaces.id }).from(workspaces).limit(1).all() inside hasAny to include the
where/filter on workspaces.deletedAt being null.

In
`@apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx`:
- Around line 13-20: The Promise.all([...].then(...)) call can reject and cause
an unhandled rejection; wrap the fetches with error handling by appending a
.catch handler (or convert to async/await with try/catch) around
Promise.all(utils.workspaces.hasAny.fetch(), utils.projects.hasAny.fetch()) so
any rejection is caught, log or report the error, and still call setOptInV2 with
a safe default (e.g., false) unless cancelled; ensure you check cancelled and
useV2LocalOverrideStore.getState().optInV2 in both the success .then and the
.catch paths so the resolver always sets a value or exits cleanly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e9317ef3-b517-4bf7-89c9-ce4c265eb8c2

📥 Commits

Reviewing files that changed from the base of the PR and between 3a47475 and 1f10963.

📒 Files selected for processing (8)
  • apps/desktop/src/lib/trpc/routers/projects/projects.ts
  • apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
  • apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx
  • apps/desktop/src/renderer/components/V2DefaultResolver/index.ts
  • apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts
  • apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts
  • apps/desktop/src/renderer/routes/-layout.tsx
  • apps/desktop/src/renderer/stores/v2-local-override.ts
💤 Files with no reviewable changes (1)
  • apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts

Comment thread apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts
Comment on lines +13 to +20
void Promise.all([
utils.workspaces.hasAny.fetch(),
utils.projects.hasAny.fetch(),
]).then(([hasWorkspace, hasProject]) => {
if (cancelled) return;
if (useV2LocalOverrideStore.getState().optInV2 !== null) return;
setOptInV2(!hasWorkspace && !hasProject);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle resolver fetch failures to avoid unhandled rejections.

If either hasAny.fetch() rejects, the promise chain is unhandled and the resolver silently never sets a value for this run.

Suggested fix
 		void Promise.all([
 			utils.workspaces.hasAny.fetch(),
 			utils.projects.hasAny.fetch(),
-		]).then(([hasWorkspace, hasProject]) => {
-			if (cancelled) return;
-			if (useV2LocalOverrideStore.getState().optInV2 !== null) return;
-			setOptInV2(!hasWorkspace && !hasProject);
-		});
+		])
+			.then(([hasWorkspace, hasProject]) => {
+				if (cancelled) return;
+				if (useV2LocalOverrideStore.getState().optInV2 !== null) return;
+				setOptInV2(!hasWorkspace && !hasProject);
+			})
+			.catch(() => {
+				// Keep unresolved state on failure; retry on next mount.
+			});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void Promise.all([
utils.workspaces.hasAny.fetch(),
utils.projects.hasAny.fetch(),
]).then(([hasWorkspace, hasProject]) => {
if (cancelled) return;
if (useV2LocalOverrideStore.getState().optInV2 !== null) return;
setOptInV2(!hasWorkspace && !hasProject);
});
void Promise.all([
utils.workspaces.hasAny.fetch(),
utils.projects.hasAny.fetch(),
])
.then(([hasWorkspace, hasProject]) => {
if (cancelled) return;
if (useV2LocalOverrideStore.getState().optInV2 !== null) return;
setOptInV2(!hasWorkspace && !hasProject);
})
.catch(() => {
// Keep unresolved state on failure; retry on next mount.
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx`
around lines 13 - 20, The Promise.all([...].then(...)) call can reject and cause
an unhandled rejection; wrap the fetches with error handling by appending a
.catch handler (or convert to async/await with try/catch) around
Promise.all(utils.workspaces.hasAny.fetch(), utils.projects.hasAny.fetch()) so
any rejection is caught, log or report the error, and still call setOptInV2 with
a safe default (e.g., false) unless cancelled; ensure you check cancelled and
useV2LocalOverrideStore.getState().optInV2 in both the success .then and the
.catch paths so the resolver always sets a value or exits cleanly.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 7, 2026

Greptile Summary

This PR replaces the broken hasPriorSupersetUsage heuristic (which checked a stale tabs-storage localStorage key that has been null for every install since #303) with an async resolver that queries the local SQLite DB directly to determine whether any v1 workspaces or projects exist before setting the v2 default.

  • V2DefaultResolver mounts in RootLayout, runs two cheap LIMIT 1 tRPC queries, and writes true/false to the Zustand store once; the store starts as null (unresolved) and useIsV2CloudEnabled treats null as v1 to avoid premature activation.
  • projects.hasAny / workspaces.hasAny are new tRPC procedures using LIMIT 1 selects — efficient and symmetric.
  • hasPriorSupersetUsage.ts is deleted, removing the dead localStorage probe entirely.

Confidence Score: 3/5

The fix is directionally correct and the logic is sound for new installs, but the async fetch path has no error handling — a failed IPC call on startup silently leaves optInV2 as null forever for that session, so a fresh-install user never gets the intended v2 default.

The resolver's Promise.all is wrapped in void with no .catch. On any startup IPC error both hasAny calls reject, the .then is never invoked, and optInV2 stays null for the session. Since useIsV2CloudEnabled treats null as false, a fresh user would open to v1 with no automatic retry. This is not a crash, but it is a quiet failure on the exact path this PR is meant to fix.

apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx — the async fetch needs a .catch fallback to write a default value so optInV2 is never left as null indefinitely.

Important Files Changed

Filename Overview
apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx New component that resolves the v2 default by querying local DB; missing error handling means a failed fetch leaves optInV2 as null indefinitely, silently keeping fresh users on v1.
apps/desktop/src/renderer/stores/v2-local-override.ts Correctly changes initial optInV2 to null (unresolved) and removes the stale hasPriorSupersetUsage dependency; self-healing described in PR description won't apply to users who already persisted true from the broken release.
apps/desktop/src/renderer/hooks/useIsV2CloudEnabled.ts Correctly tightened to optInV2 === true so null (unresolved) maps to v1, preventing premature v2 activation during async resolution.
apps/desktop/src/lib/trpc/routers/projects/projects.ts Adds hasAny procedure with an efficient LIMIT 1 query; straightforward and correct.
apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts Adds hasAny procedure with an efficient LIMIT 1 query; mirrors the projects implementation and is correct.
apps/desktop/src/renderer/routes/-layout.tsx Mounts V2DefaultResolver inside ElectronTRPCProvider but outside AuthProvider, which is correct since hasAny uses publicProcedure.
apps/desktop/src/renderer/lib/hasPriorSupersetUsage.ts Deleted; was checking a stale tabs-storage localStorage key that has been null on every install since the tabs state was migrated to lowdb in #303.

Sequence Diagram

sequenceDiagram
    participant RL as RootLayout
    participant VDR as V2DefaultResolver
    participant Store as v2-local-override (Zustand)
    participant TRPC as electronTrpc
    participant DB as localDb (SQLite)

    RL->>Store: hydrate from localStorage ("v2-local-override-v2")
    Note over Store: optInV2 = null (no prior value)<br/>or true/false (returning user)
    RL->>VDR: mount
    VDR->>Store: read optInV2
    alt "optInV2 !== null"
        VDR-->>VDR: return early (existing value preserved)
    else "optInV2 === null"
        VDR->>TRPC: workspaces.hasAny.fetch()
        VDR->>TRPC: projects.hasAny.fetch()
        TRPC->>DB: SELECT id FROM workspaces LIMIT 1
        TRPC->>DB: SELECT id FROM projects LIMIT 1
        DB-->>TRPC: hasWorkspace, hasProject
        TRPC-->>VDR: [hasWorkspace, hasProject]
        VDR->>Store: "setOptInV2(!hasWorkspace && !hasProject)"
        Store-->>Store: persist to localStorage
    end
    Note over Store: useIsV2CloudEnabled returns optInV2 === true
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/renderer/stores/v2-local-override.ts, line 15-26 (link)

    P2 Self-healing does not apply to users who already ran the broken release

    The PR description states "users who got force-pulled into v2 but never explicitly toggled have no persisted override yet, so they self-heal back to v1 on next launch." However, Zustand's persist middleware writes state to localStorage on every state change. Any user who launched the broken build (commit af7a1a21d) will already have {"state":{"optInV2":true},...} saved under "v2-local-override-v2". On upgrade, the persist middleware hydrates that stored true, so optInV2 !== null, and V2DefaultResolver exits early without querying the DB. Those users stay on v2 rather than self-healing to v1.

    Self-healing only benefits users who (a) cleared their localStorage, or (b) never launched the app under the broken release. If the broken release was already shipped, a persist key rotation (e.g. rename to "v2-local-override-v3") would be needed to force re-resolution for everyone.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/renderer/stores/v2-local-override.ts
    Line: 15-26
    
    Comment:
    **Self-healing does not apply to users who already ran the broken release**
    
    The PR description states "users who got force-pulled into v2 but never explicitly toggled have no persisted override yet, so they self-heal back to v1 on next launch." However, Zustand's `persist` middleware writes state to localStorage on every state change. Any user who launched the broken build (commit `af7a1a21d`) will already have `{"state":{"optInV2":true},...}` saved under `"v2-local-override-v2"`. On upgrade, the persist middleware hydrates that stored `true`, so `optInV2 !== null`, and `V2DefaultResolver` exits early without querying the DB. Those users stay on v2 rather than self-healing to v1.
    
    Self-healing only benefits users who (a) cleared their localStorage, or (b) never launched the app under the broken release. If the broken release was already shipped, a persist key rotation (e.g. rename to `"v2-local-override-v3"`) would be needed to force re-resolution for everyone.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx:13-20
**Unhandled rejection leaves `optInV2` permanently `null`**

The `Promise.all` is prefixed with `void`, which silences the unhandled-rejection warning but means a tRPC error (e.g., IPC hiccup on startup) causes both `hasAny` calls to throw, the `.then` callback never runs, and `optInV2` stays `null` forever for that session. Because `useIsV2CloudEnabled` treats `null` as `false`, a fresh-install user would see v1 indefinitely and never get the intended v2 default — even after the IPC channel recovers — until they manually restart or toggle.

### Issue 2 of 2
apps/desktop/src/renderer/stores/v2-local-override.ts:15-26
**Self-healing does not apply to users who already ran the broken release**

The PR description states "users who got force-pulled into v2 but never explicitly toggled have no persisted override yet, so they self-heal back to v1 on next launch." However, Zustand's `persist` middleware writes state to localStorage on every state change. Any user who launched the broken build (commit `af7a1a21d`) will already have `{"state":{"optInV2":true},...}` saved under `"v2-local-override-v2"`. On upgrade, the persist middleware hydrates that stored `true`, so `optInV2 !== null`, and `V2DefaultResolver` exits early without querying the DB. Those users stay on v2 rather than self-healing to v1.

Self-healing only benefits users who (a) cleared their localStorage, or (b) never launched the app under the broken release. If the broken release was already shipped, a persist key rotation (e.g. rename to `"v2-local-override-v3"`) would be needed to force re-resolution for everyone.

Reviews (1): Last reviewed commit: "fix(desktop): resolve v2 default from lo..." | Re-trigger Greptile

Comment on lines +13 to +20
void Promise.all([
utils.workspaces.hasAny.fetch(),
utils.projects.hasAny.fetch(),
]).then(([hasWorkspace, hasProject]) => {
if (cancelled) return;
if (useV2LocalOverrideStore.getState().optInV2 !== null) return;
setOptInV2(!hasWorkspace && !hasProject);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Unhandled rejection leaves optInV2 permanently null

The Promise.all is prefixed with void, which silences the unhandled-rejection warning but means a tRPC error (e.g., IPC hiccup on startup) causes both hasAny calls to throw, the .then callback never runs, and optInV2 stays null forever for that session. Because useIsV2CloudEnabled treats null as false, a fresh-install user would see v1 indefinitely and never get the intended v2 default — even after the IPC channel recovers — until they manually restart or toggle.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/components/V2DefaultResolver/V2DefaultResolver.tsx
Line: 13-20

Comment:
**Unhandled rejection leaves `optInV2` permanently `null`**

The `Promise.all` is prefixed with `void`, which silences the unhandled-rejection warning but means a tRPC error (e.g., IPC hiccup on startup) causes both `hasAny` calls to throw, the `.then` callback never runs, and `optInV2` stays `null` forever for that session. Because `useIsV2CloudEnabled` treats `null` as `false`, a fresh-install user would see v1 indefinitely and never get the intended v2 default — even after the IPC channel recovers — until they manually restart or toggle.

How can I resolve this? If you propose a fix, please make it concise.

The v2 default introduced in #4115 used `localStorage.getItem("tabs-storage")`
to detect returning users, but tabs state was migrated off localStorage to
app-state.json (lowdb) in #303 (2025-12-10). The key has been null for every
install since, so `!hasPriorSupersetUsage()` evaluated to true for all users
and force-flipped anyone without an explicit toggle into v2 on first launch.

Resolve the default from the canonical signal instead: ≥1 row in `projects`
or `workspaces` in localDb means a returning user. `optInV2` starts `null`,
`V2DefaultResolver` runs both `*.hasAny` queries once and persists the
result. Users who got force-pulled but never toggled have no persisted
override yet, so they self-heal back to v1 on next launch.
The onboarding gate at _authenticated/layout.tsx only checked
isV2CloudEnabled && !requiredComplete, so any existing v1 user who
explicitly toggled v2 ON was force-walked through /setup/* — wrong:
they already have projects/workspaces and should land on the dashboard.

Split the v2 default into two flags on the override store:
- optInV2: user-controllable (resolver default OR experimental toggle).
- isFreshInstall: sticky one-shot detection from the resolver, never
  changed by user toggles.

Onboarding gate now requires isFreshInstall === true. Resolver runs
whenever either flag is null, so users with persisted optInV2 from
the broken release also get isFreshInstall backfilled on next launch.
@saddlepaddle saddlepaddle force-pushed the debug-v2-migration-signal branch from 1f10963 to aaf74f1 Compare May 7, 2026 06:23
@saddlepaddle saddlepaddle merged commit 3c5b3c7 into main May 7, 2026
9 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

saddlepaddle added a commit that referenced this pull request May 7, 2026
…opt-ins

V2DefaultResolver wrote optInV2=true to v2-local-override-v2 for every
fresh-detected install in the ~24h window since #4176 shipped. Those
writes are indistinguishable from explicit user toggles in the store,
so without bumping the key those users would silently stay on v2 even
though they never made a real choice.

Bumping to v2-local-override-v3 clears the field for everyone:
- existing v1 users: no change (their key was already null/absent)
- recently auto-flipped users: land back on v1, can opt in if they want
- explicit opt-ins: have to toggle once more

Acceptable trade-off vs leaving a small group force-stuck on v2.
saddlepaddle added a commit that referenced this pull request May 7, 2026
* fix(desktop): stop auto-opting users into v2 + onboarding

Two recurring complaints on top of each other: fresh installs were
being flipped to v2 without explicit opt-in, and v2 users were then
force-walked through the experimental onboarding flow on first launch.
v2 + onboarding aren't ready to be defaults — only users who explicitly
opt in via Settings → Experimental should land there.

- Delete V2DefaultResolver. Its only job was to auto-set optInV2=true
  on detected-fresh installs. With it gone, optInV2 stays null until
  the user toggles it, and useIsV2CloudEnabled returns false.
- Remove the /setup/* redirect in _authenticated/layout.tsx so fresh
  v2 users land on the dashboard like v1 users.
- Drop isFreshInstall from the store + the useIsFreshInstall hook
  (only consumer was the redirect gate).
- Drop the projects.hasAny / workspaces.hasAny tRPC procedures
  (only consumer was V2DefaultResolver).

Preserved: the optInV2 toggle and "Restart onboarding" action in
Settings → Experimental, the full /setup/* route tree + onboarding
store, and any persisted optInV2=true from explicit toggles.

* fix(desktop): bump v2-local-override persist key to clear stale auto-opt-ins

V2DefaultResolver wrote optInV2=true to v2-local-override-v2 for every
fresh-detected install in the ~24h window since #4176 shipped. Those
writes are indistinguishable from explicit user toggles in the store,
so without bumping the key those users would silently stay on v2 even
though they never made a real choice.

Bumping to v2-local-override-v3 clears the field for everyone:
- existing v1 users: no change (their key was already null/absent)
- recently auto-flipped users: land back on v1, can opt in if they want
- explicit opt-ins: have to toggle once more

Acceptable trade-off vs leaving a small group force-stuck on v2.

* Revert "fix(desktop): bump v2-local-override persist key to clear stale auto-opt-ins"

This reverts 3ca8703. The bump also clears optInV2 for users who
explicitly toggled v2 on and went through onboarding — they'd silently
land back on v1 with no projects visible (v2 workspaces live in cloud,
not v1 local), looking broken. Worse trade-off than leaving the small
~24h auto-flip cohort stuck on v2.
MocA-Love pushed a commit to MocA-Love/superset that referenced this pull request May 8, 2026
superset-sh#4176)

* fix(desktop): resolve v2 default from local db, not stale localStorage

The v2 default introduced in superset-sh#4115 used `localStorage.getItem("tabs-storage")`
to detect returning users, but tabs state was migrated off localStorage to
app-state.json (lowdb) in #303 (2025-12-10). The key has been null for every
install since, so `!hasPriorSupersetUsage()` evaluated to true for all users
and force-flipped anyone without an explicit toggle into v2 on first launch.

Resolve the default from the canonical signal instead: ≥1 row in `projects`
or `workspaces` in localDb means a returning user. `optInV2` starts `null`,
`V2DefaultResolver` runs both `*.hasAny` queries once and persists the
result. Users who got force-pulled but never toggled have no persisted
override yet, so they self-heal back to v1 on next launch.

* fix(desktop): only fresh installs are forced into onboarding

The onboarding gate at _authenticated/layout.tsx only checked
isV2CloudEnabled && !requiredComplete, so any existing v1 user who
explicitly toggled v2 ON was force-walked through /setup/* — wrong:
they already have projects/workspaces and should land on the dashboard.

Split the v2 default into two flags on the override store:
- optInV2: user-controllable (resolver default OR experimental toggle).
- isFreshInstall: sticky one-shot detection from the resolver, never
  changed by user toggles.

Onboarding gate now requires isFreshInstall === true. Resolver runs
whenever either flag is null, so users with persisted optInV2 from
the broken release also get isFreshInstall backfilled on next launch.
MocA-Love pushed a commit to MocA-Love/superset that referenced this pull request May 8, 2026
…h#4177)

* fix(desktop): stop auto-opting users into v2 + onboarding

Two recurring complaints on top of each other: fresh installs were
being flipped to v2 without explicit opt-in, and v2 users were then
force-walked through the experimental onboarding flow on first launch.
v2 + onboarding aren't ready to be defaults — only users who explicitly
opt in via Settings → Experimental should land there.

- Delete V2DefaultResolver. Its only job was to auto-set optInV2=true
  on detected-fresh installs. With it gone, optInV2 stays null until
  the user toggles it, and useIsV2CloudEnabled returns false.
- Remove the /setup/* redirect in _authenticated/layout.tsx so fresh
  v2 users land on the dashboard like v1 users.
- Drop isFreshInstall from the store + the useIsFreshInstall hook
  (only consumer was the redirect gate).
- Drop the projects.hasAny / workspaces.hasAny tRPC procedures
  (only consumer was V2DefaultResolver).

Preserved: the optInV2 toggle and "Restart onboarding" action in
Settings → Experimental, the full /setup/* route tree + onboarding
store, and any persisted optInV2=true from explicit toggles.

* fix(desktop): bump v2-local-override persist key to clear stale auto-opt-ins

V2DefaultResolver wrote optInV2=true to v2-local-override-v2 for every
fresh-detected install in the ~24h window since superset-sh#4176 shipped. Those
writes are indistinguishable from explicit user toggles in the store,
so without bumping the key those users would silently stay on v2 even
though they never made a real choice.

Bumping to v2-local-override-v3 clears the field for everyone:
- existing v1 users: no change (their key was already null/absent)
- recently auto-flipped users: land back on v1, can opt in if they want
- explicit opt-ins: have to toggle once more

Acceptable trade-off vs leaving a small group force-stuck on v2.

* Revert "fix(desktop): bump v2-local-override persist key to clear stale auto-opt-ins"

This reverts 3ca8703. The bump also clears optInV2 for users who
explicitly toggled v2 on and went through onboarding — they'd silently
land back on v1 with no projects visible (v2 workspaces live in cloud,
not v1 local), looking broken. Worse trade-off than leaving the small
~24h auto-flip cohort stuck on v2.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant