Skip to content

fix(console-ui): restore sidebar nav — stop Privy bridge starving App Router transitions#460

Open
Gajesh2007 wants to merge 1 commit into
masterfrom
fix/console-ui-privy-nav-transition-starvation
Open

fix(console-ui): restore sidebar nav — stop Privy bridge starving App Router transitions#460
Gajesh2007 wants to merge 1 commit into
masterfrom
fix/console-ui-privy-nav-transition-starvation

Conversation

@Gajesh2007

@Gajesh2007 Gajesh2007 commented Jun 24, 2026

Copy link
Copy Markdown
Member

Summary

The console-ui sidebar links (Stats, Providers, Earn, Models, Billing, Settings, …) are visible but unclickable in production: clicking does nothing, the URL never changes. This is not the issue #457 tried to fix (that hydration fix is correct, but orthogonal — the sidebar is rendered, on‑screen, and hit‑testable; no overlay, no #418).

Root cause: the Privy auth bridge (PrivyRealProviderPrivyAuthBridge) reports auth state up to the app through an effect keyed on usePrivy()'s login / logout / getAccessToken. The Privy SDK hands back fresh identities for those methods on most renders, so the effect re-fired and called onAuthChange (a setState in PrivyClientProvider) on essentially every render. The rendered output never changed, so it produced no DOM mutations and was invisible, but it perpetually re‑scheduled default‑priority work and starved React's lower‑priority navigation transitions. Under the Next 16 App Router, <Link> / router.push() then silently no‑op (no pushState, no RSC fetch, no error).

It only reproduces in production because Privy is gated on NEXT_PUBLIC_PRIVY_APP_ID (absent in local dev), which is exactly why #457's dev‑only verification couldn't surface it.

Fix: keep a live ref to the Privy handle and expose stable login / logout / getAccessToken (useCallback([])), and key the bridge effect on a stable userId, so onAuthChange fires only on real auth changes. Navigation transitions are no longer starved and SPA navigation works again.

Evidence (production build, headed Chromium, returning‑user localStorage)

Build Click "Stats" history.pushState URL Verdict
master (Privy active) preventDefault runs 0 stays / NO‑OP (broken)
this PR preventDefault runs 1 /stats SPA nav OK

Bisect that pinned it down: a bare <PrivyProvider> (no PrivyAuthBridge) navigates fine (SPA_OK); re‑adding the original bridge reproduces the no‑op. Disabling GA, Datadog, the client route cache (staleTimes), prefetch, a Suspense boundary, and upgrading Next (incl. 16.3.0-canary with the #94144 cache fix) all did not fix it — only stabilizing the bridge does.

Before / After — behavior

flowchart LR
  subgraph Before [Before: nav unclickable]
    A1[Click sidebar Stats] --> B1[next/link onClick<br/>preventDefault]
    B1 --> C1[startTransition router.push]
    C1 --> D1{transition starved by<br/>per-render setState loop}
    D1 --> E1[no pushState / no RSC fetch]
    E1 --> F1[URL stays / — nothing happens]
  end
  subgraph After [After: SPA nav]
    A2[Click sidebar Stats] --> B2[next/link onClick<br/>preventDefault]
    B2 --> C2[startTransition router.push]
    C2 --> D2[transition commits]
    D2 --> E2[pushState + RSC fetch]
    E2 --> F2[URL becomes /stats]
  end
Loading

Before / After — code

flowchart TB
  subgraph Before [Before: PrivyAuthBridge]
    a1[usePrivy returns fresh<br/>login/logout/getAccessToken each render]
    a1 --> a2[effect deps include those identities]
    a2 --> a3[effect re-fires every render]
    a3 --> a4[onAuthChange -> setState in parent]
    a4 --> a5[re-render -> usePrivy -> new identities]
    a5 --> a2
    a5 -.default-priority churn.-> a6[React navigation transition never commits]
  end
  subgraph After [After: PrivyAuthBridge]
    b1[privyRef holds live handle]
    b1 --> b2[login/logout/getAccessToken = useCallback&#40;[]&#41; stable]
    b2 --> b3[effect keyed on ready/authenticated/userId + stable cbs]
    b3 --> b4[onAuthChange fires once per real auth change]
    b4 --> b5[no churn -> transitions commit]
  end
Loading

Test plan

  • cd console-ui && npm run build — passes
  • npx eslint src/ — 0 errors
  • npx vitest run — only the 3 pre-existing provider-dashboard-* failures remain (present on master e8ba9c0f, unrelated to this change); all others pass
  • New regression test __tests__/privy-auth-bridge.test.tsxfails on the old bridge (onAuthChange called per render), passes on the fix
  • Browser re-check (headed Chromium, production build, Privy enabled): clicking Stats navigates to /stats via SPA (3/3); cross-page nav /stats → /models works; mobile (390px) overlay nav works and auto-closes; theme toggle + toasts unaffected
  • Post-merge: confirm Vercel promotes the merge commit to console.darkbloom.dev and re-verify sidebar nav in the live deploy

Made with Cursor


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

… Router transitions

The Privy auth bridge reported auth state up to the app via an effect
keyed on usePrivy()'s login/logout/getAccessToken. The Privy SDK hands
back fresh identities for those methods on most renders, so the effect
re-fired and called onAuthChange (a setState in PrivyClientProvider) on
essentially every render. The rendered output never changed, so it
produced no DOM mutations and was invisible — but it perpetually
re-scheduled default-priority work and starved React's lower-priority
navigation transitions.

Under the Next 16 App Router this made <Link> / router.push() silently
no-op: the sidebar rendered and was hittable, but clicking did nothing
and the URL never changed (no pushState, no RSC fetch, no error). It only
reproduced in production because Privy is gated on NEXT_PUBLIC_PRIVY_APP_ID
(absent in local dev), so #457's dev-only verification could not surface
it; the #457 hydration fix was correct but orthogonal.

Fix: keep a live ref to the Privy handle and expose stable login/logout/
getAccessToken via useCallback([]), and key the bridge effect on a stable
userId, so onAuthChange fires only on real auth changes. Navigation
transitions are no longer starved and SPA nav works again.

Adds a regression test that fails on the old bridge (onAuthChange fired
per-render) and passes on the fix.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
d-inference Ready Ready Preview Jun 24, 2026 1:04am
d-inference-console-ui-dev Ready Ready Preview Jun 24, 2026 1:04am
d-inference-landing Ready Ready Preview Jun 24, 2026 1:04am

Request Review

@github-actions

Copy link
Copy Markdown

These two files fall entirely outside current threat model affected_files coverage, but one of them warrants a threat model update.


Trust boundaries touched

  • TB-001 (Consumer → Coordinator) — indirectly, via Privy JWT issuance
  • TB-004 (Browser → Coordinator BFF) — directly; PrivyRealProvider.tsx is the component that bootstraps Privy authentication in the browser

Neither file appears in any affected_files list today.


Threat relevance

Threat Assessment
T-020 (Mock auth active in production) ℹ️ Neutral — PrivyRealProvider.tsx is the real auth path, not the mock fallback in PrivyClientProvider.tsx. Changes here do not directly touch the MOCK_AUTH branch, but any regression that prevents PrivyRealProvider from mounting correctly would cause the app to fall back to that branch. Reviewers should confirm the component always throws or hard-fails rather than silently yielding an unauthenticated state if Privy initialisation fails.
T-001 (API key theft via XSS) ℹ️ Neutral — no changes to key storage or issuance visible in the diff summary.
T-017 (SSRF via client-controlled coordinator URL) ℹ️ Neutral — no BFF route changes.

New attack surface not covered by an existing threat

PrivyRealProvider.tsx is the component that configures the Privy SDK client-side — it sets the appId, login methods, embedded wallet policy, and any Privy SDK callbacks. This component is not listed in any threat's affected_files, yet it is the root of the entire consumer authentication trust chain (TB-001 / TB-004). Changes here can:

  1. Alter accepted login methods (e.g. adding email OTP, SMS, or social logins) — expanding the consumer identity surface without a corresponding threat model entry.
  2. Change embedded-wallet / JWT configuration — affecting the short-lived Privy JWT the coordinator validates in auth/privy.go.
  3. Add or remove Privy SDK event hooks — a malicious or accidental onSuccess callback could exfiltrate the freshly-issued JWT before it reaches the coordinator.

Recommendation: Add console-ui/src/components/providers/PrivyRealProvider.tsx (and its test file) to the affected_files list of T-020 and T-001 in the threat model. Any future PR touching this component will then receive automatic security scrutiny.


SEC-* findings resolved

None — this PR does not appear to address any open findings.


🔐 Threat model: docs/threat-model.yaml · Updates on each push to this PR

@ethenotethan ethenotethan left a comment

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.

Automated Code Review — Layr-Labs/d-inference#

Verdict: REQUEST_CHANGES

Security — ✅ No issues found

Performance — 1 finding(s) (1 blocking)

  • 🟡 [MEDIUM] console-ui/src/components/providers/PrivyRealProvider.tsx:16 — useRef assignment on every render creates unnecessary work
    • Suggestion: Move privyRef.current = privy assignment inside useEffect or use a different pattern to avoid executing on every render

Type_diligence — 1 finding(s)

  • 🔵 [INFO] console-ui/__tests__/privy-auth-bridge.test.tsx:68 — Unsafe cast to { id?: string } without type assertion check
    • Suggestion: Use type assertion with check: const userId = user && typeof user === 'object' && 'id' in user ? (user as { id?: string }).id : null

Additive_complexity — 2 finding(s)

  • 🔵 [INFO] console-ui/__tests__/privy-auth-bridge.test.tsx:21 — Hoisted counter object adds unnecessary indirection for simple test state
    • Suggestion: Use a simple module-level variable: let usePrivyCalls = 0; instead of { usePrivyCalls: { n: 0 } }
  • 🔵 [INFO] console-ui/src/components/providers/PrivyRealProvider.tsx:33 — Type assertion with object destructuring is more complex than needed
    • Suggestion: Use direct property access: const userId = user?.id ?? null; instead of casting to { id?: string } | null

4 finding(s) total, 1 blocking. Verdict: REQUEST_CHANGES.

🤖 Automated review by Centaur · DAR-186

const privy = usePrivy();
const { ready, authenticated, user, login, logout } = privy;
const { ready, authenticated, user } = privy;

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.

🟡 [MEDIUM] ⚡ useRef assignment on every render creates unnecessary work

💡 Suggestion: Move privyRef.current = privy assignment inside useEffect or use a different pattern to avoid executing on every render

📊 Score: 2×4 = 8 · Category: Repeated work


const state = onAuthChange.mock.calls[0][0];
expect(state.ready).toBe(true);
expect(state.authenticated).toBe(false);

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.

🔵 [INFO] 🏷️ Unsafe cast to { id?: string } without type assertion check

💡 Suggestion: Use type assertion with check: const userId = user && typeof user === 'object' && 'id' in user ? (user as { id?: string }).id : null

📊 Score: 2×3 = 6 · Category: unsafe_cast

// the effect on a stable `userId`, so `onAuthChange` fires only on real auth
// changes.

const { usePrivyCalls } = vi.hoisted(() => ({ usePrivyCalls: { n: 0 } }));

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.

🔵 [INFO] 🧩 Hoisted counter object adds unnecessary indirection for simple test state

💡 Suggestion: Use a simple module-level variable: let usePrivyCalls = 0; instead of { usePrivyCalls: { n: 0 } }

📊 Score: 2×3 = 6 · Category: over-abstraction

const logout = useCallback(() => privyRef.current.logout(), []);

// Identify the user by a stable primitive so an unstable `user` object
// identity from Privy doesn't re-fire this effect every render.

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.

🔵 [INFO] 🧩 Type assertion with object destructuring is more complex than needed

💡 Suggestion: Use direct property access: const userId = user?.id ?? null; instead of casting to { id?: string } | null

📊 Score: 2×2 = 4 · Category: over-abstraction

@blacksmith-sh

blacksmith-sh Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
TestIntegration_ConcurrentRequests View Logs

Fix with Codesmith
Need help on this PR? Tag /codesmith with what you need.

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.

2 participants