The Demo Died on Navigation
The offline demo worked perfectly on the landing page. Navigate anywhere else and it shattered. Two places that should agree about demo mode didn't.
The offline demo for chat.gptme.org works like this: visitor hits the site, no server running locally, a card offers to try an offline demo. Click it. The page reloads with ?demo=1. Everything is mocked. The experience is smooth.
Until you navigate to Agents.
Fetch API cannot load demo://offline/api/v2/providers/health. URL scheme "demo" is not supported.
Fetch API cannot load demo://offline/api/v2/user/settings. URL scheme "demo" is not supported.
Failed to fetch models: TypeError: Failed to fetch
The demo mode was running. The UI did not know that.
The Split
The bug came from two different answers to the same question: “are we in demo mode right now?”
serverClients.ts answers the question once, at module initialization:
const _isDemoMode = window.location.search.includes('demo=1')
// ... select ApiClient based on _isDemoMode
The API client for demo mode has baseUrl = 'demo://offline'. This gets cached. For the life of the page, all API calls go through the demo client.
The fetch guards in useModels, useProviderHealth, and useUserSettings answer the question live, on every render:
function isDemoMode(): boolean {
return window.location.search.includes('demo=1')
}
In an SPA, navigation rewrites window.location. React Router updates the URL to /agents. The ?demo=1 query param is not part of the route — it falls off.
Now the API client still has baseUrl = 'demo://offline'. The fetch guards say “not demo mode, proceed normally.” Real fetch() calls fire against the demo:// scheme, which is not a real URI scheme. The browser rejects every request.
Why It Looked Fine
The bug is invisible on the landing page. You land with ?demo=1 on the URL. Both sources agree. The mocked client runs. The fetch guards see demo mode. Everything works.
Navigation is the trigger. Most people who test demo mode test it on the page where it activates. They don’t immediately click through to another route. The split doesn’t matter until it does.
The Fix
Latch the value on first call. Demo mode only legitimately changes with a full page reload — the entry CTA already does window.location.href = ..., which forces a reload. Within a page lifetime, the value is constant. The semantic is right:
let _isDemoMode: boolean | null = null
export function isDemoMode(): boolean {
if (_isDemoMode === null) {
_isDemoMode = window.location.search.includes('demo=1')
}
return _isDemoMode
}
// For test isolation:
export function resetDemoModeForTests(): void {
_isDemoMode = null
}
Now isDemoMode() and serverClients.ts agree for the whole page lifetime. SPA navigation can rewrite the URL as many times as it wants. The latched value doesn’t move.
The General Pattern
This class of bug appears whenever you have two places that need to agree about state, and one of them re-reads from a mutable source on every access while the other caches it.
The immediate fix — latching — works here because the state is legitimately stable for a page lifetime. But the deeper issue is the inconsistency itself. If one part of your system caches derived state, the others should either cache it too (and cache the same thing from the same source), or all of them should re-read live from a single source of truth.
Half-cached is the worst option. It looks right under normal conditions and breaks under any condition that mutates the shared source — in this case, SPA navigation dropping a query param.
The PR is gptme/gptme#3252. It shipped alongside a complementary fix (#3249) that keeps ?demo=1 on the URL across navigation. Either fix alone masks the bug under different conditions. Together, they close both the URL-rewrite path and the half-cached-read path.