Scope: duckbrain → packages/ui (React 19 + TanStack Query v4, Express --auth=apikey daemon)
Scope: duckbrain → packages/ui (React 19 + TanStack Query v4, Express --auth=apikey daemon)
Solution written to ~/DF-0924-05-solution.md. Full content:
The store begins with a hardcoded/persisted namespace (default). AppShell renders <Outlet/> unconditionally, so route pages mount on the very first render. Each page runs useQuery({ queryKey: ['memories', namespace, …] }) as a passive effect. React flushes passive effects child-first, so the parent useEffect that calls setCurrentNamespace(serverValue) runs after the child already dispatched its fetch.
A gate keyed on useNamespaces().isSuccess does not fix this: the query can already be isSuccess on the render preceding the adoption effect. The fix must be keyed on the adoption write, and must structurally prevent route pages from mounting while adoption is pending.
render #1 store.ns = "default" | namespaces: pending | <Outlet/> rendered
└─ commit #1
├─ CHILD page effect → GET /api/memories?namespace=default ← BUG
└─ AppShell effect → setCurrentNamespace("team-alpha") (too late)
packages/ui/src/stores/namespace-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface NamespaceState {
currentNamespace: string;
setCurrentNamespace: (ns: string) => void;
}
export const useNamespaceStore = create<NamespaceState>()(
persist(
(set) => ({
currentNamespace: 'default',
setCurrentNamespace: (currentNamespace) => set({ currentNamespace }),
}),
{ name: 'duckbrain.namespace' },
),
);
packages/ui/src/hooks/use-namespaces.ts
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';
export interface NamespacesResponse {
currentNamespace: string;
items: string[];
}
export function useNamespaces() {
return useQuery({
queryKey: ['namespaces'],
queryFn: () => api.get<NamespacesResponse>('/namespaces'),
staleTime: 0,
refetchOnMount: 'always', // defeat persisted/stale cache as source of truth
retry: false,
});
}
useNamespaceBootStatus'ready' is set inside the same useEffect that calls setCurrentNamespace(serverValue), and adoption only happens from a fetch completed after mount (isFetchedAfterMount), so hydrated/persisted data cannot open the gate.
packages/ui/src/hooks/useNamespaceBootStatus.ts
import { useEffect, useState } from 'react';
import { useNamespaces } from './use-namespaces';
import { useNamespaceStore } from '../stores/namespace-store';
export type NamespaceBootStatus = 'loading' | 'ready' | 'auth-error' | 'error';
function isAuthError(error: unknown): boolean {
if (!error) return false;
const status =
(error as { status?: number }).status ??
(error as { response?: { status?: number } }).response?.status;
if (status === 401 || status === 403) return true;
const message = error instanceof Error ? error.message : String(error);
return /\b401\b|\b403\b|unauthori[sz]ed|forbidden/i.test(message);
}
export function useNamespaceBootStatus(): NamespaceBootStatus {
const setCurrentNamespace = useNamespaceStore((s) => s.setCurrentNamespace);
const [status, setStatus] = useState<NamespaceBootStatus>('loading');
const query = useNamespaces();
// ADOPTION EFFECT — writes the server namespace AND flips the gate in one commit.
useEffect(() => {
if (!query.data || !query.isFetchedAfterMount) return;
setCurrentNamespace(query.data.currentNamespace);
setStatus('ready');
}, [query.data, query.isFetchedAfterMount, setCurrentNamespace]);
// ERROR EFFECT — only while not yet ready; never tears down a working UI.
useEffect(() => {
if (status === 'ready') return;
if (query.isError) {
setStatus(isAuthError(query.error) ? 'auth-error' : 'error');
}
}, [query.isError, query.error, status]);
return status;
}
React 18/19 batches both setters in one commit, so there is no intermediate render with namespace updated but status === 'loading' (or vice-versa). Optional hardening: move bootStatus into the same Zustand store and write both fields in one set(...).
AppShell — don't render the outlet while loadingpackages/ui/src/app/AppShell.tsx
import { Outlet } from 'react-router-dom';
import { useNamespaceBootStatus } from '../hooks/useNamespaceBootStatus';
import { ApiTokenBanner } from '../components/ApiTokenBanner';
import { BootSplash } from '../components/BootSplash';
import { NamespaceErrorBanner } from '../components/NamespaceErrorBanner';
export function AppShell() {
const bootStatus = useNamespaceBootStatus();
// CRITICAL: no <Outlet/> here — route pages cannot mount, so their query
// effects cannot run. This makes the gate structural, not timing-based.
if (bootStatus === 'loading') {
return <BootSplash />;
}
return (
<>
{bootStatus === 'auth-error' && <ApiTokenBanner />}
{bootStatus === 'error' && <NamespaceErrorBanner />}
<Outlet />
</>
);
}
App.tsx wiring unchanged — the gate lives in the layout route (<Route element={<AppShell />}>).
Why this beats child-first ordering: on render #1 status === 'loading' and <Outlet/> isn't rendered, so the route subtree is never instantiated. When /namespaces resolves, the adoption effect flips ready; only the subsequent render mounts children, already carrying the adopted namespace.
packages/ui/src/hooks/use-namespaces.test.tsx — boot-gating blockimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act, cleanup } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { AppShell } from '../app/AppShell';
import { useNamespaceStore } from '../stores/namespace-store';
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
function MemoriesProbe() {
const namespace = useNamespaceStore((s) => s.currentNamespace);
useQuery({
queryKey: ['memories', namespace, 1],
queryFn: async () => (await fetch(`/api/memories?namespace=${namespace}&limit=1`)).json(),
});
return <div data-testid="memories-probe">{namespace}</div>;
}
function KeysProbe() {
const namespace = useNamespaceStore((s) => s.currentNamespace);
useQuery({
queryKey: ['keys', namespace],
queryFn: async () => (await fetch(`/api/keys?namespace=${namespace}`)).json(),
});
return null;
}
function renderApp(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/memories']}>
<Routes>
<Route element={<AppShell />}>
<Route path="/memories" element={<MemoriesProbe />} />
<Route path="/keys" element={<KeysProbe />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('namespace boot gating', () => {
let calls: string[];
let resolveNamespaces!: (r: Response) => void;
let namespacesPromise: Promise<Response>;
beforeEach(() => {
calls = [];
useNamespaceStore.setState({ currentNamespace: 'default' });
namespacesPromise = new Promise((resolve) => (resolveNamespaces = resolve));
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
calls.push(url);
if (url.includes('/namespaces')) return namespacesPromise;
return Promise.resolve(json({ items: [] }));
}),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it('fires zero page queries until /namespaces resolves, then adopts server namespace', async () => {
renderApp();
await act(async () => { await Promise.resolve(); });
expect(calls.filter((u) => u.includes('/namespaces')).length).toBeGreaterThanOrEqual(1);
expect(calls.some((u) => u.includes('/memories'))).toBe(false);
expect(calls.some((u) => /\/keys\b/.test(u))).toBe(false);
expect(screen.queryByTestId('memories-probe')).toBeNull(); // route content not mounted
await act(async () => {
resolveNamespaces(json({ currentNamespace: 'team-alpha', items: ['default', 'team-alpha'] }));
await namespacesPromise;
});
await waitFor(() => expect(screen.getByTestId('memories-probe')).toHaveTextContent('team-alpha'));
await waitFor(() =>
expect(calls.some((u) => u.includes('/memories?namespace=team-alpha&limit=1'))).toBe(true),
);
expect(
calls.some((u) => u.includes('namespace=default') && !u.includes('/namespaces')),
).toBe(false);
});
it('ignores a persisted/stale namespace and waits for the fresh server value', async () => {
useNamespaceStore.setState({ currentNamespace: 'stale-ns' });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(['namespaces'], { currentNamespace: 'stale-ns', items: ['stale-ns'] });
renderApp(client);
await act(async () => { await Promise.resolve(); });
// cached data present, but isFetchedAfterMount === false → still gated
expect(screen.queryByTestId('memories-probe')).toBeNull();
expect(calls.some((u) => u.includes('/memories'))).toBe(false);
await act(async () => {
resolveNamespaces(json({ currentNamespace: 'server-ns', items: ['server-ns'] }));
await namespacesPromise;
});
await waitFor(() =>
expect(calls.some((u) => u.includes('/memories?namespace=server-ns'))).toBe(true),
);
expect(calls.some((u) => u.includes('namespace=stale-ns'))).toBe(false);
});
});
packages/ui/src/components/api-token-banner.test.tsx — auth-error route-content assertionsit('keeps route content mounted on auth-error so token entry + save-refetch works', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes('/namespaces')) {
return Promise.resolve(
new Response(JSON.stringify({ error: 'unauthorized' }), {
status: 401,
headers: { 'content-type': 'application/json' },
}),
);
}
return Promise.resolve(
new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}),
);
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MemoryRouter initialEntries={['/memories']}>
<Routes>
<Route element={<AppShell />}>
<Route path="/memories" element={<div data-testid="memories-probe">route</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getByTestId('api-token-banner')).toBeInTheDocument());
expect(screen.getByTestId('memories-probe')).toBeInTheDocument(); // stays mounted by design
});
Plus a generic-error test asserting namespace-error-banner + route content both render, and page queries use the store value.
pnpm --filter @duckbrain/ui vitest run \
src/hooks/use-namespaces.test.tsx \
src/components/api-token-banner.test.tsx
pnpm --filter @duckbrain/ui test
pnpm --filter @duckbrain/ui build
pnpm -w test
pnpm -w exec tsc -b
pnpm -w exec prettier --check .
pnpm -w integration
| Assertion | Pass condition |
|---|---|
Zero page calls before /namespaces resolves |
no /memories or /keys URL while gate is loading |
| Route content not mounted while loading | memories-probe absent before resolution |
| Post-adoption calls carry adopted namespace | /memories?namespace=team-alpha…, /keys?namespace=team-alpha |
| No pre-adoption call escapes | no page URL contains namespace=default / namespace=stale-ns |
| Persisted-stale namespace | gate stays closed until post-mount fetch completes |
| Auth error | api-token-banner and route content both mounted |
| Generic error | error banner + route content mounted against store value |
| Suite health | vitest, tsc -b, prettier, integration all green |
Manual proof: load /memories → /api/namespaces is the only boot request; page requests appear only after it resolves and carry team-alpha. Force 401 → banner + page render; save token → refetch → requests switch namespace.
const { isSuccess } = useNamespaces(); if (!isSuccess) return <Splash/>; — isSuccess can be true on the render before adoption.<Routes>/<Outlet/> in AppShell — lets child effects run before the parent adoption effect.currentNamespace as authoritative — requires isFetchedAfterMount before ready.auth-error — breaks token-entry + save-refetch recovery; keep route content mounted.# Evidence - Problem class: typescript-react-query-boot-namespace-adoption-gate - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-24T22:30:24.260Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Gate a React-Query boot flow on the ADOPTION EFFECT, not query success. Symptom: a UI that adopts the server-reported current namespace into a store (via a useQuery + useEffect) still fires page-level queries against the pre-adoption namespace because route pages mount on the first render and effects run CHILD-FIRST \u2014 a gate keyed on query.isSuccess flips in the same commit's child-effect phase, after children already fetched. Fix that survived the adversarial judge: derive a boot status ('loading'|'ready'|'auth-error'|'error') from the SAME effect that writes the adoption into the store; 'loading' renders no route pages; 'auth-error' keeps them mounted so the token-entry + save-refetch flow works; other errors render degraded against the store value. Pin the probe as regression tests: mount the real AppShell wiring with a page calling useQuery, assert zero API calls until namespaces resolves, then assert calls carry the adopted namespace; also cover persisted-stale namespace. Verification: packages/ui vitest + build, root suite + tsc + prettier + integration all green on the merged tree.", "environment": "React 19 + TanStack Query v4 SPA (packages/ui) in front of an Express --auth=apikey daemon", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "typescript-react-query-boot-namespace-adoption-gate", "provider": "openrouter", "solved_at": "2026-09-24T22:30:24.260Z", "version": ""}Scope: duckbrain → packages/ui (React 19 + TanStack Query v4, Express --auth=apikey daemon)
Solution written to ~/DF-0924-05-solution.md. Full content:
The store begins with a hardcoded/persisted namespace (default). AppShell renders <Outlet/> unconditionally, so route pages mount on the very first render. Each page runs useQuery({ queryKey: ['memories', namespace, …] }) as a passive effect. React flushes passive effects child-first, so the parent useEffect that calls setCurrentNamespace(serverValue) runs after the child already dispatched its fetch.
A gate keyed on useNamespaces().isSuccess does not fix this: the query can already be isSuccess on the render preceding the adoption effect. The fix must be keyed on the adoption write, and must structurally prevent route pages from mounting while adoption is pending.
render #1 store.ns = "default" | namespaces: pending | <Outlet/> rendered
└─ commit #1
├─ CHILD page effect → GET /api/memories?namespace=default ← BUG
└─ AppShell effect → setCurrentNamespace("team-alpha") (too late)
packages/ui/src/stores/namespace-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface NamespaceState {
currentNamespace: string;
setCurrentNamespace: (ns: string) => void;
}
export const useNamespaceStore = create<NamespaceState>()(
persist(
(set) => ({
currentNamespace: 'default',
setCurrentNamespace: (currentNamespace) => set({ currentNamespace }),
}),
{ name: 'duckbrain.namespace' },
),
);
packages/ui/src/hooks/use-namespaces.ts
import { useQuery } from '@tanstack/react-query';
import { api } from '../api';
export interface NamespacesResponse {
currentNamespace: string;
items: string[];
}
export function useNamespaces() {
return useQuery({
queryKey: ['namespaces'],
queryFn: () => api.get<NamespacesResponse>('/namespaces'),
staleTime: 0,
refetchOnMount: 'always', // defeat persisted/stale cache as source of truth
retry: false,
});
}
useNamespaceBootStatus'ready' is set inside the same useEffect that calls setCurrentNamespace(serverValue), and adoption only happens from a fetch completed after mount (isFetchedAfterMount), so hydrated/persisted data cannot open the gate.
packages/ui/src/hooks/useNamespaceBootStatus.ts
import { useEffect, useState } from 'react';
import { useNamespaces } from './use-namespaces';
import { useNamespaceStore } from '../stores/namespace-store';
export type NamespaceBootStatus = 'loading' | 'ready' | 'auth-error' | 'error';
function isAuthError(error: unknown): boolean {
if (!error) return false;
const status =
(error as { status?: number }).status ??
(error as { response?: { status?: number } }).response?.status;
if (status === 401 || status === 403) return true;
const message = error instanceof Error ? error.message : String(error);
return /\b401\b|\b403\b|unauthori[sz]ed|forbidden/i.test(message);
}
export function useNamespaceBootStatus(): NamespaceBootStatus {
const setCurrentNamespace = useNamespaceStore((s) => s.setCurrentNamespace);
const [status, setStatus] = useState<NamespaceBootStatus>('loading');
const query = useNamespaces();
// ADOPTION EFFECT — writes the server namespace AND flips the gate in one commit.
useEffect(() => {
if (!query.data || !query.isFetchedAfterMount) return;
setCurrentNamespace(query.data.currentNamespace);
setStatus('ready');
}, [query.data, query.isFetchedAfterMount, setCurrentNamespace]);
// ERROR EFFECT — only while not yet ready; never tears down a working UI.
useEffect(() => {
if (status === 'ready') return;
if (query.isError) {
setStatus(isAuthError(query.error) ? 'auth-error' : 'error');
}
}, [query.isError, query.error, status]);
return status;
}
React 18/19 batches both setters in one commit, so there is no intermediate render with namespace updated but status === 'loading' (or vice-versa). Optional hardening: move bootStatus into the same Zustand store and write both fields in one set(...).
AppShell — don't render the outlet while loadingpackages/ui/src/app/AppShell.tsx
import { Outlet } from 'react-router-dom';
import { useNamespaceBootStatus } from '../hooks/useNamespaceBootStatus';
import { ApiTokenBanner } from '../components/ApiTokenBanner';
import { BootSplash } from '../components/BootSplash';
import { NamespaceErrorBanner } from '../components/NamespaceErrorBanner';
export function AppShell() {
const bootStatus = useNamespaceBootStatus();
// CRITICAL: no <Outlet/> here — route pages cannot mount, so their query
// effects cannot run. This makes the gate structural, not timing-based.
if (bootStatus === 'loading') {
return <BootSplash />;
}
return (
<>
{bootStatus === 'auth-error' && <ApiTokenBanner />}
{bootStatus === 'error' && <NamespaceErrorBanner />}
<Outlet />
</>
);
}
App.tsx wiring unchanged — the gate lives in the layout route (<Route element={<AppShell />}>).
Why this beats child-first ordering: on render #1 status === 'loading' and <Outlet/> isn't rendered, so the route subtree is never instantiated. When /namespaces resolves, the adoption effect flips ready; only the subsequent render mounts children, already carrying the adopted namespace.
packages/ui/src/hooks/use-namespaces.test.tsx — boot-gating blockimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act, cleanup } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query';
import { AppShell } from '../app/AppShell';
import { useNamespaceStore } from '../stores/namespace-store';
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
function MemoriesProbe() {
const namespace = useNamespaceStore((s) => s.currentNamespace);
useQuery({
queryKey: ['memories', namespace, 1],
queryFn: async () => (await fetch(`/api/memories?namespace=${namespace}&limit=1`)).json(),
});
return <div data-testid="memories-probe">{namespace}</div>;
}
function KeysProbe() {
const namespace = useNamespaceStore((s) => s.currentNamespace);
useQuery({
queryKey: ['keys', namespace],
queryFn: async () => (await fetch(`/api/keys?namespace=${namespace}`)).json(),
});
return null;
}
function renderApp(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/memories']}>
<Routes>
<Route element={<AppShell />}>
<Route path="/memories" element={<MemoriesProbe />} />
<Route path="/keys" element={<KeysProbe />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('namespace boot gating', () => {
let calls: string[];
let resolveNamespaces!: (r: Response) => void;
let namespacesPromise: Promise<Response>;
beforeEach(() => {
calls = [];
useNamespaceStore.setState({ currentNamespace: 'default' });
namespacesPromise = new Promise((resolve) => (resolveNamespaces = resolve));
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
calls.push(url);
if (url.includes('/namespaces')) return namespacesPromise;
return Promise.resolve(json({ items: [] }));
}),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it('fires zero page queries until /namespaces resolves, then adopts server namespace', async () => {
renderApp();
await act(async () => { await Promise.resolve(); });
expect(calls.filter((u) => u.includes('/namespaces')).length).toBeGreaterThanOrEqual(1);
expect(calls.some((u) => u.includes('/memories'))).toBe(false);
expect(calls.some((u) => /\/keys\b/.test(u))).toBe(false);
expect(screen.queryByTestId('memories-probe')).toBeNull(); // route content not mounted
await act(async () => {
resolveNamespaces(json({ currentNamespace: 'team-alpha', items: ['default', 'team-alpha'] }));
await namespacesPromise;
});
await waitFor(() => expect(screen.getByTestId('memories-probe')).toHaveTextContent('team-alpha'));
await waitFor(() =>
expect(calls.some((u) => u.includes('/memories?namespace=team-alpha&limit=1'))).toBe(true),
);
expect(
calls.some((u) => u.includes('namespace=default') && !u.includes('/namespaces')),
).toBe(false);
});
it('ignores a persisted/stale namespace and waits for the fresh server value', async () => {
useNamespaceStore.setState({ currentNamespace: 'stale-ns' });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(['namespaces'], { currentNamespace: 'stale-ns', items: ['stale-ns'] });
renderApp(client);
await act(async () => { await Promise.resolve(); });
// cached data present, but isFetchedAfterMount === false → still gated
expect(screen.queryByTestId('memories-probe')).toBeNull();
expect(calls.some((u) => u.includes('/memories'))).toBe(false);
await act(async () => {
resolveNamespaces(json({ currentNamespace: 'server-ns', items: ['server-ns'] }));
await namespacesPromise;
});
await waitFor(() =>
expect(calls.some((u) => u.includes('/memories?namespace=server-ns'))).toBe(true),
);
expect(calls.some((u) => u.includes('namespace=stale-ns'))).toBe(false);
});
});
packages/ui/src/components/api-token-banner.test.tsx — auth-error route-content assertionsit('keeps route content mounted on auth-error so token entry + save-refetch works', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes('/namespaces')) {
return Promise.resolve(
new Response(JSON.stringify({ error: 'unauthorized' }), {
status: 401,
headers: { 'content-type': 'application/json' },
}),
);
}
return Promise.resolve(
new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}),
);
render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<MemoryRouter initialEntries={['/memories']}>
<Routes>
<Route element={<AppShell />}>
<Route path="/memories" element={<div data-testid="memories-probe">route</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
await waitFor(() => expect(screen.getByTestId('api-token-banner')).toBeInTheDocument());
expect(screen.getByTestId('memories-probe')).toBeInTheDocument(); // stays mounted by design
});
Plus a generic-error test asserting namespace-error-banner + route content both render, and page queries use the store value.
pnpm --filter @duckbrain/ui vitest run \
src/hooks/use-namespaces.test.tsx \
src/components/api-token-banner.test.tsx
pnpm --filter @duckbrain/ui test
pnpm --filter @duckbrain/ui build
pnpm -w test
pnpm -w exec tsc -b
pnpm -w exec prettier --check .
pnpm -w integration
| Assertion | Pass condition |
|---|---|
Zero page calls before /namespaces resolves |
no /memories or /keys URL while gate is loading |
| Route content not mounted while loading | memories-probe absent before resolution |
| Post-adoption calls carry adopted namespace | /memories?namespace=team-alpha…, /keys?namespace=team-alpha |
| No pre-adoption call escapes | no page URL contains namespace=default / namespace=stale-ns |
| Persisted-stale namespace | gate stays closed until post-mount fetch completes |
| Auth error | api-token-banner and route content both mounted |
| Generic error | error banner + route content mounted against store value |
| Suite health | vitest, tsc -b, prettier, integration all green |
Manual proof: load /memories → /api/namespaces is the only boot request; page requests appear only after it resolves and carry team-alpha. Force 401 → banner + page render; save token → refetch → requests switch namespace.
const { isSuccess } = useNamespaces(); if (!isSuccess) return <Splash/>; — isSuccess can be true on the render before adoption.<Routes>/<Outlet/> in AppShell — lets child effects run before the parent adoption effect.currentNamespace as authoritative — requires isFetchedAfterMount before ready.auth-error — breaks token-entry + save-refetch recovery; keep route content mounted.# Evidence - Problem class: typescript-react-query-boot-namespace-adoption-gate - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-24T22:30:24.260Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Gate a React-Query boot flow on the ADOPTION EFFECT, not query success. Symptom: a UI that adopts the server-reported current namespace into a store (via a useQuery + useEffect) still fires page-level queries against the pre-adoption namespace because route pages mount on the first render and effects run CHILD-FIRST \u2014 a gate keyed on query.isSuccess flips in the same commit's child-effect phase, after children already fetched. Fix that survived the adversarial judge: derive a boot status ('loading'|'ready'|'auth-error'|'error') from the SAME effect that writes the adoption into the store; 'loading' renders no route pages; 'auth-error' keeps them mounted so the token-entry + save-refetch flow works; other errors render degraded against the store value. Pin the probe as regression tests: mount the real AppShell wiring with a page calling useQuery, assert zero API calls until namespaces resolves, then assert calls carry the adopted namespace; also cover persisted-stale namespace. Verification: packages/ui vitest + build, root suite + tsc + prettier + integration all green on the merged tree.", "environment": "React 19 + TanStack Query v4 SPA (packages/ui) in front of an Express --auth=apikey daemon", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "typescript-react-query-boot-namespace-adoption-gate", "provider": "openrouter", "solved_at": "2026-09-24T22:30:24.260Z", "version": ""}