Skip to content

Fix Supabase client idle-freeze that bricks the page after tab suspension#38

Merged
cwsanchez merged 2 commits into
mainfrom
cursor/fix-idle-freeze-supabase-client-17e6
Apr 19, 2026
Merged

Fix Supabase client idle-freeze that bricks the page after tab suspension#38
cwsanchez merged 2 commits into
mainfrom
cursor/fix-idle-freeze-supabase-client-17e6

Conversation

@cwsanchez
Copy link
Copy Markdown
Owner

@cwsanchez cwsanchez commented Apr 18, 2026

The bugs

1. Backend stops responding after tab idle

When a user leaves the tab alone for a while, the backend stops responding until the page is refreshed:

  • Leaderboards spin forever instead of loading.
  • The timer still works, but pressing Submit silently does nothing.
  • The user only notices after they've just earned a good score — and refreshing loses it.
  • The browser console often shows Unchecked runtime.lastError: A listener indicated an asynchronous response by returning true, but the message channel closed before a response was received.

2. GET /manifest.json 401 Unauthorized on every page load

Separate issue surfaced during testing — every page load / refresh on the Vercel preview deployment logs two console errors for manifest.json returning 401.

Root causes

For the idle freeze

  1. Supabase JS client idle-freeze. The @supabase/supabase-js client can enter a "zombie" state after tab suspension. Its internal auth lock (navigator.locks or the library's fallback) fails to release, and every subsequent .from(...), .auth.getSession(), .auth.signIn(...), etc. hangs forever — the HTTP request never even leaves the client. This is a well-known upstream bug (see supabase/supabase#36046, supabase-js#2013, supabase-js PR #2228).

  2. The "Unchecked runtime.lastError" console line is a red herring — it's emitted by Chrome browser extensions (MetaMask, password managers, Grammarly, etc.) when their background service worker gets suspended. It's not from this app, but it's a strong signal that the user's browser just went through an idle/suspension cycle, which is exactly what puts the Supabase client into the deadlocked state.

The old mitigations in the repo (no-op auth lock, 60s getSession keep-alive, forceReconnect that only cleared channels + refreshed the session) weren't enough because they didn't actually tear down and rebuild the underlying client instance. submitScore() also had no timeout/retry wrapping — so once the client was wedged, submitting just sat there.

For the manifest.json 401

This is a documented Vercel-preview-deployment quirk: preview URLs are wrapped by Vercel's SSO auth wall, which requires a _vercel_sso_nonce cookie to return assets. By default, browsers fetch manifest.json without credentials, so the cookie isn't sent and Vercel returns its auth-wall HTML with a 401 status. Confirmed by the response: Content-Type: text/html, 14.6 kB size, Server: Vercel, Set-Cookie: _vercel_sso_nonce=.... The <link rel="manifest"> needs crossorigin="use-credentials" so the browser sends cookies. (Does nothing to public / production deployments since the manifest is same-origin there.)

The fix

Idle-freeze fix

  1. Upgrade @supabase/supabase-js from ^2.99.1^2.103.3 to pick up upstream auth-lock fixes.
  2. Rewrite src/lib/supabaseClient.js to export a Proxy over a mutable internal client instance. This lets us fully rebuild the client at runtime without invalidating any consumers that captured the supabase import.
  3. Add checkAndRecoverHealth() — a 3s-timeout auth.getSession() probe. If it hangs, the client is rebuilt end-to-end. onAuthStateChange listeners are transparently rebound onto the new client.
  4. Rewire App.js — replace the naive 60s keep-alive with a comprehensive recovery effect that runs health checks on visibilitychange, focus, pageshow (for bfcache restores), and on a 60s interval.
  5. Harden useLeaderboardStore.submitScore with the same timeout + retry wrapper used by fetches. On persistent failure, queue the submission to localStorage so the score is never silently lost — it auto-retries on focus/reconnect, on the next healthy request, and on next page load.
  6. Clear user-facing feedback: instead of doing nothing when a submit fails, the user now sees Connection issue — score saved and will submit automatically.
  7. Timeout-wrap useAuthStore.initialize so a wedged persisted auth state at boot can't freeze the app.

Manifest 401 fix

  1. public/index.html: add crossorigin="use-credentials" to the <link rel="manifest">. One-liner that makes the browser send cookies with the manifest request, which eliminates the 401 on Vercel preview deployments.
  2. public/manifest.json: while in there, replace the stale CRA boilerplate ("Create React App Sample", theme_color: "#000000") with the actual Stop the Clock! metadata matching the in-app theme.

Tests

Added 9 new unit tests (12 total in the suite, all green):

src/lib/supabaseClient.test.js — directly simulates the bug with a mocked getSession that never resolves, and asserts the client is rebuilt:

PASS src/lib/supabaseClient.test.js
  ✓ proxy forwards method calls to current underlying client
  ✓ recreateClient swaps the underlying instance but the exported supabase ref keeps working
  ✓ onAuthStateChange listeners survive client recreation
  ✓ checkAndRecoverHealth rebuilds the client when getSession hangs   <-- reproduces the bug
  ✓ checkAndRecoverHealth reports healthy when getSession resolves
  ✓ forceReconnect is the legacy alias for recreateClient

src/stores/useLeaderboardStore.test.js — asserts that failed submissions are queued to localStorage, cleared on success, and flushable:

PASS src/stores/useLeaderboardStore.test.js
  ✓ queues the score to localStorage when the read rejects
  ✓ clears pending queue after a successful submit
  ✓ flushPendingSubmit resubmits a queued score

Manual smoke tests

Idle-freeze recovery plumbing — Start/Stop timer works; visibility/focus/pageshow handlers run cleanly; no JS errors in console:

App loads cleanly after Proxy-based client refactor
No JS errors in console (only the expected network errors to the placeholder Supabase URL)
visibilitychange / focus / pageshow event handlers run cleanly, app remains responsive

Manifest 401 fix — hard-refreshed the page after the fix; no manifest errors in console, manifest.json returns 200 OK, and Chrome parses the correct app identity:

Console shows no manifest errors after the fix
manifest.json now returns 200 OK
Chrome correctly parses the manifest showing

Verification notes for the deployed app

After merging, a user who experiences the freeze should now see one of:

  • Automatic recovery after a few seconds of refocusing the tab (the periodic interval + visibility handler will detect the wedge, rebuild the client, and refetch leaderboards).
  • Queued submission if they submit while wedged — they'll see Connection issue — score saved and will submit automatically. The score is written to localStorage (key: stc-pending-submit-v1) and auto-retries on next recovery / on next page load. They will no longer lose a score.

The manifest.json 401 errors will disappear from the console on every deployment (preview and production).

Risk assessment

  • The Proxy re-export keeps every existing supabase.from(...), supabase.auth.*, etc. call path unchanged at the call-site. The proxy forwards method lookups to the current live client, so no other files needed to be touched.
  • The supabase-js bump is a patch-version increase within the same major; the only intentional behavior change is the upstream auth-lock recovery.
  • If, in some pathological environment, the health check falsely detects a freeze, the worst case is a client recreate — which just re-reads the persisted session from localStorage and is safe.
  • The crossorigin="use-credentials" attribute is a no-op for same-origin manifests (public / production deployments), so there's no risk of it breaking non-preview environments.

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

When a user leaves the tab alone for a while, the Supabase JS client
could enter a 'zombie' state (a known upstream bug,
supabase/supabase#36046). After that, every query and auth call would
hang forever: leaderboards would spin indefinitely, and score
submissions would silently do nothing. The only recovery was a full
page refresh — which cost the user their unsubmitted score.

The console error users saw ('Unchecked runtime.lastError: A listener
indicated an asynchronous response by returning true, but the message
channel closed before a response was received') is emitted by
browser extensions whose background scripts get suspended, which is
the same class of event that triggers the Supabase deadlock.

Changes:

- Upgrade @supabase/supabase-js from ^2.99.1 to ^2.103.3 to pick up
  upstream auth-lock fixes (PRs #2228, #2214, #2239 upstream).
- Rewrite src/lib/supabaseClient.js to export a Proxy over a mutable
  internal client instance. This lets us fully rebuild the client
  when it deadlocks without breaking any consumers that captured the
  supabase import.
- Add checkAndRecoverHealth(): probes auth.getSession() with a 3s
  timeout; if it hangs, recreates the client entirely. Preserves any
  onAuthStateChange listeners across recreations.
- App.js: replace the naive 60s getSession keep-alive with a
  comprehensive recovery effect that runs health checks on
  visibilitychange, window focus, bfcache pageshow, and on a 60s
  interval. Auto-refetches leaderboards and flushes any queued
  submit after recovery.
- useLeaderboardStore: wrap submitScore reads and writes in the same
  timeout + retry + health-recover logic as fetches. On persistent
  failure, queue the submission to localStorage so the score is
  NEVER lost — it auto-retries on focus/reconnect and on next load.
  Surface a user-facing message ('Connection issue — score saved
  and will submit automatically') instead of doing nothing.
- useAuthStore: timeout-wrap the initial getSession() call so a
  wedged persisted auth state at boot can't freeze the app.
- Add unit tests for the Proxy indirection, the getSession-hang
  recovery path, listener rebinding on recreate, submit-retry
  queueing, and queue flush behavior.

Co-authored-by: Chris Sanchez <cwsanchez@users.noreply.github.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 18, 2026

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

Project Deployment Actions Updated (UTC)
stop-the-clock Ready Ready Preview, Comment Apr 19, 2026 4:41am

Request Review

… metadata

On protected Vercel preview deployments, fetching manifest.json returns
401 Unauthorized because the browser omits cookies on the uncredentialed
manifest request, so Vercel's SSO wall rejects it. Adding
crossorigin="use-credentials" tells the browser to include the
_vercel_sso_nonce cookie, which clears the 401. No effect on public /
production deployments (manifest is same-origin there).

See: vercel/next.js#62866

Also replace the CRA boilerplate manifest (which still said
'Create React App Sample' / '#000000' theme) with proper Stop the
Clock! metadata matching the in-app theme.

Co-authored-by: Chris Sanchez <cwsanchez@users.noreply.github.com>
@cwsanchez cwsanchez marked this pull request as ready for review April 19, 2026 04:59
@cwsanchez cwsanchez merged commit ea21360 into main Apr 19, 2026
2 checks passed
@cwsanchez cwsanchez deleted the cursor/fix-idle-freeze-supabase-client-17e6 branch April 19, 2026 04:59
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