-
-
- ,
- )
- expect(signals).toHaveLength(2)
- expect(signals[0].aborted).toBe(true)
- expect(signals[1].aborted).toBe(false)
- result.unmount()
- expect(signals[1].aborted).toBe(true)
- })
-})
diff --git a/apps/web/src/app/providers/backend-connection-provider.tsx b/apps/web/src/app/providers/backend-connection-provider.tsx
deleted file mode 100644
index 76395d6..0000000
--- a/apps/web/src/app/providers/backend-connection-provider.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { useQueryClient } from '@tanstack/react-query'
-import { LoaderCircle, WifiOff } from 'lucide-react'
-import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react'
-
-import { Button } from '@/components/ui/button'
-import {
- backendConnection,
- type BackendConnection,
-} from '@/lib/backend-connection'
-import { isTemporaryApiError } from '@/lib/is-temporary-api-error'
-import { currentUserQueryKey } from '@/features/auth/api/get-current-user'
-
-import { BackendConnectionContext } from './backend-connection-context'
-
-export function BackendConnectionProvider({
- children,
- connection = backendConnection,
-}: {
- children: ReactNode
- connection?: BackendConnection
-}) {
- const client = useQueryClient()
- // The controller owns asynchronous work outside React. This hook connects its
- // immutable snapshots to rendering without duplicating that state in useState.
- const snapshot = useSyncExternalStore(
- connection.subscribe,
- connection.getSnapshot,
- )
- // A ref remembers which recovery this consumer has handled without causing
- // another render. Starting with the current count ignores past recovery events.
- const previousRecovery = useRef(snapshot.recoveryCount)
-
- useEffect(() => {
- // This effect owns browser listeners/timers. Subscription alone only reads
- // the store; start/stop make its lifecycle follow the global provider.
- connection.start()
- return () => connection.stop()
- }, [connection])
-
- useEffect(() => {
- if (previousRecovery.current === snapshot.recoveryCount) return
- // Mark the event handled before refetching, since those requests can publish
- // more connection snapshots through the Axios interceptors.
- previousRecovery.current = snapshot.recoveryCount
- // Preserve cached data and forms. Only mounted, failed reads are eligible;
- // 401/404/business errors and mutations cannot be replayed by recovery.
- void client.refetchQueries({
- // An active query has a mounted observer. Refetching inactive entries
- // would load screens the user is not viewing and increase request volume.
- type: 'active',
- predicate: (query) =>
- query.state.status === 'error' &&
- isTemporaryApiError(query.state.error),
- })
- }, [client, snapshot.recoveryCount])
-
- useEffect(() => {
- if (snapshot.status !== 'ready') return
- // Health never renews a JWT. Once connected, recheck a stale, previously
- // authenticated profile so the private layout can handle a genuine 401.
- // This effect has a different responsibility from error recovery above:
- // previously successful cached auth data can become invalid without an error.
- void client.refetchQueries({
- queryKey: currentUserQueryKey,
- type: 'active',
- predicate: (query) => query.state.status === 'success' && query.isStale(),
- })
- }, [client, snapshot.status])
-
- // Notices describe connectivity without replacing children, so visible data
- // and unsaved form state survive while a new round is in progress.
- const message =
- snapshot.status === 'offline'
- ? 'You are offline. Check your internet connection.'
- : snapshot.status === 'unavailable'
- ? 'Could not connect. Try again.'
- : snapshot.status === 'checking' && snapshot.showWaiting
- ? 'Waiting for the server…'
- : null
-
- return (
-
- {children}
- {message ? (
-
- ) : null}
-
- )
-}
diff --git a/apps/web/src/features/auth/components/login-form.spec.tsx b/apps/web/src/features/auth/components/login-form.spec.tsx
index 68a368a..dc01d12 100644
--- a/apps/web/src/features/auth/components/login-form.spec.tsx
+++ b/apps/web/src/features/auth/components/login-form.spec.tsx
@@ -1,4 +1,4 @@
-import { screen, waitFor } from '@testing-library/react'
+import { screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { Route, Routes } from 'react-router'
import { describe, expect, it } from 'vitest'
@@ -30,15 +30,13 @@ async function completeLoginForm(
}
describe('LoginForm', () => {
- it('waits for the shared health request before sending credentials once', async () => {
- const healthGate = deferred()
+ it('signs in directly even when the health endpoint is unavailable', async () => {
let healthCount = 0
let postCount = 0
server.use(
- http.get(apiUrl('/health'), async () => {
+ http.get(apiUrl('/health'), () => {
healthCount += 1
- await healthGate.promise
- return HttpResponse.json({ status: 'ok' })
+ return new HttpResponse(null, { status: 503 })
}),
http.post(apiUrl('/auth/login'), () => {
postCount += 1
@@ -46,22 +44,11 @@ describe('LoginForm', () => {
}),
)
const { user } = renderLoginForm()
- try {
- await waitFor(() => expect(healthCount).toBe(1))
- await completeLoginForm(user)
- await user.click(screen.getByRole('button', { name: 'Sign in' }))
- const pending = await screen.findByRole('button', {
- name: 'Signing in...',
- })
- expect(pending).toBeDisabled()
- await user.click(pending)
- expect(postCount).toBe(0)
- expect(healthCount).toBe(1)
- } finally {
- healthGate.resolve(undefined)
- }
- await screen.findByRole('heading', { name: 'Home destination' })
+ await completeLoginForm(user)
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(await screen.findByRole('heading', { name: 'Home destination' })).toBeVisible()
expect(postCount).toBe(1)
+ expect(healthCount).toBe(0)
})
it('exposes labeled fields and blocks an empty submission', async () => {
let postCount = 0
diff --git a/apps/web/src/features/auth/hooks/use-login.ts b/apps/web/src/features/auth/hooks/use-login.ts
index 3b247b4..87c1398 100644
--- a/apps/web/src/features/auth/hooks/use-login.ts
+++ b/apps/web/src/features/auth/hooks/use-login.ts
@@ -4,22 +4,15 @@ import { toast } from 'sonner'
import { login } from '../api/login'
import { currentUserQueryKey } from '../api/get-current-user'
import { getApiErrorMessage } from '@/lib/get-api-error-message'
-import { useBackendConnection } from '@/app/providers/backend-connection-context'
-import type { LoginFormData } from '../types/auth'
export const useLogin = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
- const connection = useBackendConnection()
return useMutation({
- mutationFn: async (data: LoginFormData) => {
- // Zod has validated the form. Share the warmup round before sending the
- // credentials once; isPending includes this wait and blocks double clicks.
- if (!(await connection.ensureReady()))
- throw new Error('Connection unavailable.')
- return login(data)
- },
+ // The mutation owns the POST and its pending state. Submit validated data
+ // directly; a separate health request cannot guarantee this write succeeds.
+ mutationFn: login,
retry: false,
onSuccess: (user) => {
@@ -31,8 +24,7 @@ export const useLogin = () => {
},
onError: (error) => {
- // The hook owns operation feedback; the provider owns connection notices.
- // Neither resets the form, allowing a deliberate retry with the same input.
+ // Keep entered values available so the user can deliberately retry.
toast.error(
getApiErrorMessage(
error,
diff --git a/apps/web/src/features/auth/hooks/use-register.ts b/apps/web/src/features/auth/hooks/use-register.ts
index 30dcfac..d9598ff 100644
--- a/apps/web/src/features/auth/hooks/use-register.ts
+++ b/apps/web/src/features/auth/hooks/use-register.ts
@@ -4,23 +4,14 @@ import { toast } from 'sonner'
import { getApiErrorMessage } from '@/lib/get-api-error-message'
import { registerUser } from '../api/register'
-import { useBackendConnection } from '@/app/providers/backend-connection-context'
-import type { RegisterFormData } from '../types/auth'
export function useRegister() {
const navigate = useNavigate()
- const connection = useBackendConnection()
return useMutation({
- mutationFn: async (data: RegisterFormData) => {
- // Validation has already produced RegisterFormData. Readiness belongs
- // inside the mutation so its pending state covers both warmup and the POST.
- if (!(await connection.ensureReady()))
- throw new Error('Connection unavailable.')
- return registerUser(data)
- },
- // Creating an account changes server state. Even after recovery, a lost
- // response must not cause this POST to be sent again automatically.
+ // The mutation owns the POST and its pending state. Submit validated data
+ // directly; a separate health request cannot guarantee this write succeeds.
+ mutationFn: registerUser,
retry: false,
onSuccess: () => {
// Registration does not authenticate automatically; the user must sign in
diff --git a/apps/web/src/features/auth/pages/register-page.spec.tsx b/apps/web/src/features/auth/pages/register-page.spec.tsx
index bd8647e..e021b46 100644
--- a/apps/web/src/features/auth/pages/register-page.spec.tsx
+++ b/apps/web/src/features/auth/pages/register-page.spec.tsx
@@ -1,7 +1,7 @@
import { screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { Route, Routes } from 'react-router'
-import { beforeEach, describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { currentUserQueryKey } from '@/features/auth/api/get-current-user'
import { deferred } from '@/test/deferred'
@@ -32,10 +32,26 @@ async function completeForm(
}
describe('RegisterPage', () => {
- beforeEach(() => {
+
+ it('registers directly even when the health endpoint is unavailable', async () => {
+ let healthCount = 0
+ let postCount = 0
server.use(
- http.get(apiUrl('/health'), () => HttpResponse.json({ status: 'ok' })),
+ http.get(apiUrl('/health'), () => {
+ healthCount += 1
+ return new HttpResponse(null, { status: 503 })
+ }),
+ http.post(apiUrl('/users'), () => {
+ postCount += 1
+ return HttpResponse.json(createUser(), { status: 201 })
+ }),
)
+ const { user } = renderRegisterPage()
+ await completeForm(user)
+ await user.click(screen.getByRole('button', { name: 'Create account' }))
+ expect(await screen.findByRole('heading', { name: 'Login destination' })).toBeVisible()
+ expect(postCount).toBe(1)
+ expect(healthCount).toBe(0)
})
it('shows field validation and attaches mismatch to confirmation', async () => {
diff --git a/apps/web/src/lib/backend-connection.spec.ts b/apps/web/src/lib/backend-connection.spec.ts
deleted file mode 100644
index ab169aa..0000000
--- a/apps/web/src/lib/backend-connection.spec.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-import { afterEach, describe, expect, it, vi } from 'vitest'
-
-import { deferred } from '@/test/deferred'
-
-import {
- BackendConnection,
- CONNECTION_INTERVAL,
- HEALTH_TIMEOUT,
-} from './backend-connection'
-
-const connections: BackendConnection[] = []
-function createConnection(
- probe = vi
- .fn<(signal: AbortSignal) => Promise>()
- .mockResolvedValue(undefined),
-) {
- const connection = new BackendConnection(probe)
- connections.push(connection)
- return { connection, probe }
-}
-
-afterEach(() => {
- connections.forEach((connection) => connection.stop())
- connections.length = 0
- vi.restoreAllMocks()
-})
-
-describe('backend connection supervision', () => {
- it('shares a pending round across checks and form readiness', async () => {
- const gate = deferred()
- const { connection, probe } = createConnection(vi.fn(() => gate.promise))
- const first = connection.check()
- const second = connection.check(true)
- const form = connection.ensureReady()
- expect(first).toBe(second)
- expect(probe).toHaveBeenCalledTimes(1)
- gate.resolve(undefined)
- await expect(form).resolves.toBe(true)
- expect(connection.getSnapshot().status).toBe('ready')
- })
-
- it('reveals waiting only after 1.5 seconds and clears it after success', async () => {
- vi.useFakeTimers()
- const gate = deferred()
- const { connection } = createConnection(vi.fn(() => gate.promise))
- const round = connection.check()
- await vi.advanceTimersByTimeAsync(1_499)
- expect(connection.getSnapshot().showWaiting).toBe(false)
- await vi.advanceTimersByTimeAsync(1)
- expect(connection.getSnapshot().showWaiting).toBe(true)
- gate.resolve(undefined)
- await round
- expect(connection.getSnapshot().showWaiting).toBe(false)
- })
-
- it('limits each round to three attempts separated by two and five seconds', async () => {
- vi.useFakeTimers()
- const { connection, probe } = createConnection(
- vi.fn().mockRejectedValue(new Error('Unavailable')),
- )
- const round = connection.check()
- await vi.advanceTimersByTimeAsync(1_999)
- expect(probe).toHaveBeenCalledTimes(1)
- await vi.advanceTimersByTimeAsync(1)
- expect(probe).toHaveBeenCalledTimes(2)
- await vi.advanceTimersByTimeAsync(5_000)
- await expect(round).resolves.toBe(false)
- expect(probe).toHaveBeenCalledTimes(3)
- expect(connection.getSnapshot().status).toBe('unavailable')
- await connection.check()
- expect(probe).toHaveBeenCalledTimes(3)
- await vi.advanceTimersByTimeAsync(3_000)
- const retry = connection.check()
- await vi.advanceTimersByTimeAsync(7_000)
- await retry
- expect(probe).toHaveBeenCalledTimes(6)
- })
-
- it('aborts each slow probe at 22 seconds and eventually reports unavailable', async () => {
- vi.useFakeTimers()
- const signals: AbortSignal[] = []
- const { connection } = createConnection(
- vi.fn(
- (signal: AbortSignal) =>
- new Promise((_resolve, reject) => {
- signals.push(signal)
- signal.addEventListener(
- 'abort',
- () => reject(new Error('Aborted')),
- { once: true },
- )
- }),
- ),
- )
- const round = connection.check()
- await vi.advanceTimersByTimeAsync(HEALTH_TIMEOUT * 3 + 7_000)
- await expect(round).resolves.toBe(false)
- expect(signals).toHaveLength(3)
- expect(signals.every((signal) => signal.aborted)).toBe(true)
- expect(vi.getTimerCount()).toBe(0)
- })
-
- it('pings every five minutes while visible and stops scheduling while hidden', async () => {
- vi.useFakeTimers()
- const { connection, probe } = createConnection()
- connection.start()
- await connection.ensureReady()
- await vi.advanceTimersByTimeAsync(CONNECTION_INTERVAL)
- expect(probe).toHaveBeenCalledTimes(2)
- vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
- document.dispatchEvent(new Event('visibilitychange'))
- await vi.advanceTimersByTimeAsync(CONNECTION_INTERVAL * 4)
- expect(probe).toHaveBeenCalledTimes(2)
- })
-
- it.each(['focus', 'pageshow', 'online'])(
- 'checks a stale connection on %s',
- async (event) => {
- vi.useFakeTimers()
- const { connection, probe } = createConnection()
- connection.start()
- await connection.ensureReady()
- vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
- document.dispatchEvent(new Event('visibilitychange'))
- await vi.advanceTimersByTimeAsync(CONNECTION_INTERVAL)
- vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
- window.dispatchEvent(new Event(event))
- await connection.ensureReady()
- expect(probe).toHaveBeenCalledTimes(2)
- },
- )
-
- it('rechecks after returning to a visible tab and deduplicates nearby events', async () => {
- vi.useFakeTimers()
- const gate = deferred()
- const { connection, probe } = createConnection()
- connection.start()
- await connection.ensureReady()
- window.dispatchEvent(new Event('focus'))
- expect(probe).toHaveBeenCalledTimes(1)
- vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
- document.dispatchEvent(new Event('visibilitychange'))
- await vi.advanceTimersByTimeAsync(CONNECTION_INTERVAL * 4)
- probe.mockImplementation(() => gate.promise)
- vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
- document.dispatchEvent(new Event('visibilitychange'))
- window.dispatchEvent(new Event('focus'))
- window.dispatchEvent(new Event('pageshow'))
- expect(probe).toHaveBeenCalledTimes(2)
- gate.resolve(undefined)
- await connection.ensureReady()
- })
-
- it('reconnects immediately after internet loss even with a recent successful contact', async () => {
- const { connection, probe } = createConnection()
- connection.start()
- await connection.ensureReady()
- vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
- window.dispatchEvent(new Event('offline'))
- expect(connection.getSnapshot().status).toBe('offline')
- await expect(connection.ensureReady()).resolves.toBe(false)
- vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
- window.dispatchEvent(new Event('online'))
- await connection.ensureReady()
- expect(probe).toHaveBeenCalledTimes(2)
- expect(connection.getSnapshot().recoveryCount).toBe(1)
- })
-
- it('cancels probes and timers on stop and supports StrictMode mounting again', async () => {
- vi.useFakeTimers()
- const stale = deferred()
- const { connection, probe } = createConnection(vi.fn(() => stale.promise))
- connection.start()
- const oldRound = connection.ensureReady()
- const signal = probe.mock.calls[0][0]
- connection.stop()
- expect(signal.aborted).toBe(true)
- expect(vi.getTimerCount()).toBe(0)
- probe.mockResolvedValue(undefined)
- connection.start()
- await expect(connection.ensureReady()).resolves.toBe(true)
- stale.resolve(undefined)
- await expect(oldRound).resolves.toBe(false)
- expect(connection.getSnapshot().status).toBe('ready')
- expect(connection.getSnapshot().recoveryCount).toBe(0)
- })
-
- it('uses ordinary successful API traffic to avoid unnecessary pings', async () => {
- vi.useFakeTimers()
- const { connection, probe } = createConnection()
- connection.start()
- await connection.ensureReady()
- await vi.advanceTimersByTimeAsync(CONNECTION_INTERVAL - 1_000)
- connection.recordSuccess()
- await vi.advanceTimersByTimeAsync(1_000)
- expect(probe).toHaveBeenCalledTimes(1)
- })
-
- it('sends an uncached public health request without credentials', async () => {
- const fetch = vi
- .fn()
- .mockResolvedValue(new Response(JSON.stringify({ status: 'ok' })))
- vi.stubGlobal('fetch', fetch)
- const connection = new BackendConnection()
- connections.push(connection)
- await expect(connection.check()).resolves.toBe(true)
- expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/health'), {
- cache: 'no-store',
- credentials: 'omit',
- signal: expect.any(AbortSignal),
- })
- })
-
- it('rejects a successful HTML loading response from the hosting provider', async () => {
- vi.useFakeTimers()
- const fetch = vi
- .fn()
- .mockImplementation(async () => new Response('Loading'))
- vi.stubGlobal('fetch', fetch)
- const connection = new BackendConnection()
- connections.push(connection)
- const round = connection.check()
- await vi.advanceTimersByTimeAsync(7_000)
- await expect(round).resolves.toBe(false)
- expect(fetch).toHaveBeenCalledTimes(3)
- })
-})
diff --git a/apps/web/src/lib/backend-connection.ts b/apps/web/src/lib/backend-connection.ts
deleted file mode 100644
index 23f346b..0000000
--- a/apps/web/src/lib/backend-connection.ts
+++ /dev/null
@@ -1,321 +0,0 @@
-import { API_URL } from '@/api/api-url'
-
-export const CONNECTION_INTERVAL = 5 * 60_000
-export const HEALTH_TIMEOUT = 22_000
-// Browser events can arrive together; this cooldown limits automatic rounds,
-// while explicit user retries can bypass it. It does not delay attempts inside a round.
-const AUTOMATIC_COOLDOWN = 10_000
-const WAITING_DELAY = 1_500
-const RETRY_DELAYS = [2_000, 5_000]
-
-export interface ConnectionSnapshot {
- status: 'idle' | 'checking' | 'ready' | 'unavailable' | 'offline'
- showWaiting: boolean
- recoveryCount: number
-}
-
-type HealthProbe = (signal: AbortSignal) => Promise
-
-async function probeHealth(signal: AbortSignal) {
- // The deployed Vite app reaches Nest through the same-origin gateway.
- // Omit cookies explicitly; this request must neither depend on nor renew auth.
- const response = await fetch(`${API_URL}/health`, {
- cache: 'no-store',
- credentials: 'omit',
- signal,
- })
- if (!response.ok) throw new Error('Health request failed.')
- // Only Nest's response proves the application is listening; an HTML fallback
- // or a gateway error must not count as a healthy API.
- const body: unknown = await response.json()
- if (
- !body ||
- typeof body !== 'object' ||
- !('status' in body) ||
- body.status !== 'ok'
- ) {
- throw new Error('Unexpected health response.')
- }
-}
-
-function pause(duration: number, signal: AbortSignal): Promise {
- return new Promise((resolve) => {
- // Cancellation must also interrupt retry delays, not just HTTP requests.
- // Resolve instead of rejecting: runRound checks the signal before continuing.
- const finish = () => {
- clearTimeout(timer)
- signal.removeEventListener('abort', finish)
- resolve()
- }
- const timer = setTimeout(finish, duration)
- signal.addEventListener('abort', finish, { once: true })
- // Abort events are not replayed to listeners registered after cancellation.
- if (signal.aborted) finish()
- })
-}
-
-/**
- * External store: browser events, forms and React all share one health round.
- * React only subscribes to snapshots; this class owns timers and cancellation.
- */
-export class BackendConnection {
- // Snapshots describe UI state; mutable request/timer details stay private.
- // recoveryCount identifies recovery events separately from ordinary successes.
- private snapshot: ConnectionSnapshot = {
- status: 'idle',
- showWaiting: false,
- recoveryCount: 0,
- }
- private listeners = new Set<() => void>()
- // One flight represents the whole round, including its retry delays. Forms
- // and browser events can await this same promise without starting more pings.
- private flight: {
- controller: AbortController
- promise: Promise
- } | null = null
- private timer: ReturnType | undefined
- private waitingTimer: ReturnType | undefined
- // null means "no contact yet"; zero is a valid timestamp in a test clock.
- // Success controls freshness, while attempt time controls automatic throttling.
- private lastSuccess: number | null = null
- private lastAttempt: number | null = null
- private needsRecovery = false
- private started = false
- private readonly probe: HealthProbe
-
- constructor(probe: HealthProbe = probeHealth) {
- // Inject only the network operation. Tests can control its result while
- // exercising the actual scheduling, retries, and cancellation logic.
- this.probe = probe
- }
-
- // useSyncExternalStore needs the same object until state actually changes.
- // Creating a new object here would make an unchanged store appear updated.
- getSnapshot = () => this.snapshot
-
- subscribe = (listener: () => void) => {
- this.listeners.add(listener)
- return () => {
- this.listeners.delete(listener)
- }
- }
-
- private update(status: ConnectionSnapshot['status'], showWaiting = false) {
- // Replace the snapshot before notifying subscribers so every reader sees
- // the new state. Mutating the old object would hide the change from React.
- this.snapshot = { ...this.snapshot, status, showWaiting }
- this.listeners.forEach((listener) => listener())
- }
-
- private usable() {
- // onLine is only a browser hint. It allows a check; the health response is
- // what proves server availability. Hidden tabs do not schedule background work.
- return navigator.onLine && document.visibilityState !== 'hidden'
- }
-
- private schedule = () => {
- clearTimeout(this.timer)
- if (this.started && this.usable()) {
- // A one-shot timeout schedules again after the round settles. Unlike a
- // fixed interval, it does not repeatedly fire during a long cold start.
- this.timer = setTimeout(() => {
- void this.check().finally(this.schedule)
- }, CONNECTION_INTERVAL)
- }
- }
-
- private resume = () => {
- // Restart the timer and ask for a check; check itself decides whether the
- // last contact is fresh, another round exists, or the cooldown still applies.
- this.schedule()
- if (this.usable()) void this.check()
- }
-
- private offline = () => {
- // A recent success describes the old network connection. Discard it and
- // the cooldown so coming back online can trigger a check immediately.
- this.lastSuccess = null
- this.lastAttempt = null
- this.needsRecovery = true
- this.cancelRound()
- clearTimeout(this.timer)
- this.update('offline')
- }
-
- start() {
- if (this.started) return
- this.started = true
- // These events cover different return paths: a tab becoming visible, a
- // window regaining focus, a restored page, and a recovered network link.
- document.addEventListener('visibilitychange', this.resume)
- window.addEventListener('focus', this.resume)
- window.addEventListener('pageshow', this.resume)
- window.addEventListener('online', this.resume)
- window.addEventListener('offline', this.offline)
- if (!navigator.onLine) this.offline()
- else this.resume()
- }
-
- stop() {
- this.started = false
- document.removeEventListener('visibilitychange', this.resume)
- window.removeEventListener('focus', this.resume)
- window.removeEventListener('pageshow', this.resume)
- window.removeEventListener('online', this.resume)
- window.removeEventListener('offline', this.offline)
- clearTimeout(this.timer)
- this.cancelRound()
- // StrictMode may immediately mount again. An intentional cancellation must
- // not count as a failure or prevent the next mount from checking promptly.
- this.lastAttempt = null
- this.lastSuccess = null
- this.needsRecovery = false
- this.update('idle')
- }
-
- private cancelRound() {
- this.flight?.controller.abort()
- // Release ownership immediately. An aborted fetch may reject later; a new
- // mount or reconnection must be able to start a replacement before that.
- this.flight = null
- clearTimeout(this.waitingTimer)
- }
-
- recordSuccess = () => {
- this.lastSuccess = Date.now()
- // An ordinary request also proves reachability. Let an ongoing health round
- // finish before notifying recovery, so events cannot race with that round.
- if (!this.flight && navigator.onLine) this.ready()
- }
-
- recordFailure = () => {
- // Axios reports only temporary errors here. Invalidate reachability without
- // touching authentication or query data; each consumer still receives its error.
- this.lastSuccess = null
- this.needsRecovery = true
- if (!navigator.onLine) return this.offline()
- if (!this.flight) {
- this.update('unavailable')
- if (this.started && this.usable()) void this.check()
- }
- }
-
- private ready() {
- this.lastSuccess = Date.now()
- if (this.needsRecovery) {
- // Count a recovery once after a detected failure. Incrementing for every
- // successful ping would needlessly refetch queries during normal use.
- this.snapshot = {
- ...this.snapshot,
- recoveryCount: this.snapshot.recoveryCount + 1,
- }
- this.needsRecovery = false
- }
- this.update('ready')
- }
-
- check = (force = false): Promise => {
- if (!navigator.onLine) {
- this.offline()
- return Promise.resolve(false)
- }
- // Even a forced check shares ongoing work. "Force" bypasses scheduling
- // restrictions below, not deduplication or the browser's offline state.
- if (this.flight) return this.flight.promise
- const now = Date.now()
- if (!force) {
- if (!this.usable()) return Promise.resolve(false)
- if (
- this.lastSuccess !== null &&
- now - this.lastSuccess < CONNECTION_INTERVAL
- )
- return Promise.resolve(true)
- if (
- this.lastAttempt !== null &&
- now - this.lastAttempt < AUTOMATIC_COOLDOWN
- )
- return Promise.resolve(false)
- }
-
- this.lastAttempt = now
- const controller = new AbortController()
- this.update('checking')
- // Delay only the notice, never the network request. Fast responses remain
- // unobtrusive; a slow round exposes waiting without blocking page rendering.
- this.waitingTimer = setTimeout(
- () => this.update('checking', true),
- WAITING_DELAY,
- )
- const promise = this.runRound(controller)
- this.flight = { controller, promise }
- return promise
- }
-
- ensureReady = async (): Promise => {
- // Forms use this gate instead of inspecting status themselves: "ready"
- // alone can be outdated after a long absence. Await an existing round first.
- if (this.flight) return this.flight.promise
- if (
- this.snapshot.status === 'ready' &&
- navigator.onLine &&
- this.lastSuccess !== null &&
- Date.now() - this.lastSuccess < CONNECTION_INTERVAL
- )
- return true
- // A deliberate submission can request a fresh round immediately. Returning
- // false lets the mutation stop before it sends any credentials or account data.
- return this.check(true)
- }
-
- private async runRound(controller: AbortController): Promise {
- try {
- for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt += 1) {
- if (controller.signal.aborted) return false
- // The round controller lives across attempts; each attempt gets a new
- // deadline controller so one timeout does not cancel subsequent retries.
- const timeout = new AbortController()
- const timer = setTimeout(() => timeout.abort(), HEALTH_TIMEOUT)
- const clearDeadline = () => clearTimeout(timer)
- controller.signal.addEventListener('abort', clearDeadline, {
- once: true,
- })
- try {
- // Either signal can stop the request: cleanup cancels the round,
- // while the deadline cancels only this attempt, including body parsing.
- await this.probe(AbortSignal.any([controller.signal, timeout.signal]))
- // Cleanup may race with a successful response. A cancelled round must
- // not announce availability after another round has taken ownership.
- if (controller.signal.aborted) return false
- this.ready()
- return true
- } catch {
- // Intentional cancellation ends quietly; a failed attempt or timeout
- // falls through to a delay and retry. Neither proves session expiration.
- if (controller.signal.aborted) return false
- } finally {
- clearTimeout(timer)
- controller.signal.removeEventListener('abort', clearDeadline)
- }
- if (attempt < RETRY_DELAYS.length)
- await pause(RETRY_DELAYS[attempt], controller.signal)
- }
- // Only exhausted attempts publish a final health failure. The next
- // successful contact will then notify consumers that recovery occurred.
- this.needsRecovery = true
- this.update('unavailable')
- return false
- } finally {
- // A cancelled round can finish after a replacement has started. It must
- // never clear the replacement's timers or publish a stale result.
- if (this.flight?.controller === controller) {
- this.flight = null
- clearTimeout(this.waitingTimer)
- this.schedule()
- }
- }
- }
-}
-
-// The module instance outlives individual routes. Provider subscriptions and
-// Axios interceptors therefore observe the same connection history and flight.
-export const backendConnection = new BackendConnection()
diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts
index 5785662..3a0e975 100644
--- a/apps/web/src/lib/query-client.ts
+++ b/apps/web/src/lib/query-client.ts
@@ -8,9 +8,8 @@ export const queryClient = new QueryClient({
// This does not mean data is removed from the cache after that time;
// only that it becomes stale and can be refetched if needed
staleTime: 30_000,
- // Connection supervision already reacts to focus. Disable Query's broad
- // focus refetch so returning to a cold API does not reload every stale query.
- refetchOnWindowFocus: false,
+ // Query's default focus/reconnect behavior refreshes stale mounted reads
+ // directly, including the session profile, without background health pings.
retry: retryRead,
},
// Losing a response does not tell us whether a write reached the database.
diff --git a/apps/web/src/routes/require-user.ts b/apps/web/src/routes/require-user.ts
index f50865b..887bf8a 100644
--- a/apps/web/src/routes/require-user.ts
+++ b/apps/web/src/routes/require-user.ts
@@ -20,8 +20,7 @@ export function createAuthLoaders(client: QueryClient) {
return await client.query({
queryKey: currentUserQueryKey,
queryFn: getCurrentUser,
- // Let the route error boundary coordinate recovery with the shared
- // health round instead of adding a separate loader retry sequence.
+ // The route error boundary offers a deliberate retry of this read.
retry: false,
})
} catch (error) {
@@ -34,7 +33,7 @@ export function createAuthLoaders(client: QueryClient) {
}
// Propagate temporary failures to the route error boundary. Treating them
- // as "no user" would send a valid session to login during a cold start.
+ // as "no user" would send a valid session to login during an outage.
throw error
}
}
diff --git a/apps/web/src/routes/route-error.tsx b/apps/web/src/routes/route-error.tsx
index 7711387..5686c52 100644
--- a/apps/web/src/routes/route-error.tsx
+++ b/apps/web/src/routes/route-error.tsx
@@ -1,45 +1,13 @@
-import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { Link, useRevalidator, useRouteError } from 'react-router'
-import { useBackendConnection } from '@/app/providers/backend-connection-context'
import { Button } from '@/components/ui/button'
import { isTemporaryApiError } from '@/lib/is-temporary-api-error'
export function RouteError() {
const error = useRouteError()
- const connection = useBackendConnection()
- const snapshot = useSyncExternalStore(
- connection.subscribe,
- connection.getSnapshot,
- )
- const previousRecovery = useRef(snapshot.recoveryCount)
const revalidator = useRevalidator()
- // Router loading starts after the connection gate. Track the gate separately
- // so the retry button stays disabled throughout both phases, not just the read.
- const [isReconnecting, setIsReconnecting] = useState(false)
const temporary = isTemporaryApiError(error)
-
- useEffect(() => {
- if (previousRecovery.current === snapshot.recoveryCount) return
- previousRecovery.current = snapshot.recoveryCount
- // Loaders have no mounted query observer, so the provider's active-query
- // recovery cannot reach them. Router revalidation retries the failed read.
- if (temporary) void revalidator.revalidate()
- }, [revalidator, snapshot.recoveryCount, temporary])
-
- async function retry() {
- setIsReconnecting(true)
- try {
- // For connectivity failures, share or start a health round before reading
- // again. Other route errors can be retried directly; health cannot fix them.
- if (!temporary || (await connection.ensureReady()))
- await revalidator.revalidate()
- } finally {
- // Restore the button state even when no connection was established. The
- // route error remains available, rather than navigating away or losing auth.
- setIsReconnecting(false)
- }
- }
+ const isRetrying = revalidator.state === 'loading'
return (
@@ -50,13 +18,15 @@ export function RouteError() {
: 'Please try again in a moment.'}
diff --git a/apps/web/src/routes/router.spec.tsx b/apps/web/src/routes/router.spec.tsx
index 6ad6f71..19eb1b1 100644
--- a/apps/web/src/routes/router.spec.tsx
+++ b/apps/web/src/routes/router.spec.tsx
@@ -1,8 +1,7 @@
-import { screen, waitFor } from '@testing-library/react'
+import { screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
-import { beforeEach, describe, expect, it } from 'vitest'
+import { describe, expect, it } from 'vitest'
-import { deferred } from '@/test/deferred'
import { createProjectFixture } from '@/test/factories/project'
import { createTechnicalEntry } from '@/test/factories/technical-entry'
import { createUser } from '@/test/factories/user'
@@ -54,11 +53,6 @@ function useSignedInApplication() {
}
describe('application routing', () => {
- beforeEach(() => {
- server.use(
- http.get(apiUrl('/health'), () => HttpResponse.json({ status: 'ok' })),
- )
- })
it('renders the public landing page without checking a session', async () => {
let sessionRequestCount = 0
@@ -86,54 +80,45 @@ describe('application routing', () => {
['/', 'Keep the reasoning behind your code.'],
['/login', 'Sign in'],
['/register', 'Create account'],
- ])('renders %s and starts warming the API without waiting', async (route, heading) => {
- const healthGate = deferred()
+ ])('renders %s without probing API health', async (route, heading) => {
let healthRequestCount = 0
useGuestSession()
server.use(
- http.get(apiUrl('/health'), async ({ request }) => {
- expect(request.cache).toBe('no-store')
+ http.get(apiUrl('/health'), () => {
healthRequestCount += 1
- await healthGate.promise
return HttpResponse.json({ status: 'ok' })
}),
)
- try {
- renderApp(route)
+ renderApp(route)
- expect(await screen.findByRole('heading', { name: heading })).toBeVisible()
- await waitFor(() => expect(healthRequestCount).toBe(1))
- if (route !== '/') {
- expect(screen.getByRole('button', { name: heading })).toBeEnabled()
- }
- } finally {
- healthGate.resolve(undefined)
+ expect(await screen.findByRole('heading', { name: heading })).toBeVisible()
+ expect(healthRequestCount).toBe(0)
+ if (route !== '/') {
+ expect(screen.getByRole('button', { name: heading })).toBeEnabled()
}
})
- it.each([
- ['/', 'Keep the reasoning behind your code.'],
- ['/login', 'Sign in'],
- ['/register', 'Create account'],
- ])('keeps %s usable when the warmup request fails', async (route, heading) => {
- let healthRequestCount = 0
- useGuestSession()
+ it('retries a failed private loader directly without a health gate', async () => {
+ useSignedInApplication()
+ let available = false
+ let healthRequests = 0
server.use(
http.get(apiUrl('/health'), () => {
- healthRequestCount += 1
- return HttpResponse.error()
+ healthRequests += 1
+ return new HttpResponse(null, { status: 503 })
}),
+ http.get(apiUrl('/users/me'), () => available
+ ? HttpResponse.json(createUser())
+ : new HttpResponse(null, { status: 503 })),
)
-
- renderApp(route)
-
- expect(await screen.findByRole('heading', { name: heading })).toBeVisible()
- await waitFor(() => expect(healthRequestCount).toBe(1))
- expect(screen.queryByRole('alert')).not.toBeInTheDocument()
- if (route !== '/') {
- expect(screen.getByRole('button', { name: heading })).toBeEnabled()
- }
+ const { user, router } = renderApp('/account')
+ expect(await screen.findByRole('heading', { name: 'Could not load this page' })).toBeVisible()
+ expect(router.state.location.pathname).toBe('/account')
+ available = true
+ await user.click(screen.getByRole('button', { name: 'Try loading again' }))
+ expect(await screen.findByRole('heading', { name: 'User account' })).toBeVisible()
+ expect(healthRequests).toBe(0)
})
it.each([
diff --git a/apps/web/src/test/mocks/server.ts b/apps/web/src/test/mocks/server.ts
index 262f5ae..c1d21a9 100644
--- a/apps/web/src/test/mocks/server.ts
+++ b/apps/web/src/test/mocks/server.ts
@@ -1,9 +1,4 @@
import { setupServer } from 'msw/node'
-import { http, HttpResponse } from 'msw'
-import { apiUrl } from './urls'
-// Global connection supervision is infrastructure shared by every UI. Feature
-// endpoints still require explicit handlers; connection tests override health.
-export const server = setupServer(
- http.get(apiUrl('/health'), () => HttpResponse.json({ status: 'ok' })),
-)
+// Every API request needs an explicit handler so unexpected traffic fails.
+export const server = setupServer()
diff --git a/apps/web/src/test/render-with-providers.tsx b/apps/web/src/test/render-with-providers.tsx
index b00eae3..495d6b3 100644
--- a/apps/web/src/test/render-with-providers.tsx
+++ b/apps/web/src/test/render-with-providers.tsx
@@ -6,7 +6,6 @@ import { MemoryRouter } from 'react-router'
import { Toaster } from 'sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
-import { BackendConnectionProvider } from '@/app/providers/backend-connection-provider'
import { createTestQueryClient } from './query-client'
@@ -21,7 +20,7 @@ export function renderWithProviders(
- {ui}
+ {ui}
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
index ccfa647..f23431c 100644
--- a/apps/web/src/test/setup.ts
+++ b/apps/web/src/test/setup.ts
@@ -9,7 +9,6 @@ import { server } from './mocks/server'
import { createMatchMediaController } from './match-media'
import { disposeTestQueryClients } from './query-client'
import { disposeTestRouters } from './router-registry'
-import { backendConnection } from '@/lib/backend-connection'
const unexpectedRequests: string[] = []
@@ -39,7 +38,6 @@ afterEach(async () => {
// Unmount first so active observers cannot start another refetch while the
// shared test resources are being disposed.
cleanup()
- backendConnection.stop()
disposeTestRouters()
await disposeTestQueryClients()
server.resetHandlers()
diff --git a/docs/README.md b/docs/README.md
index 4b80dff..f22e440 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,7 +12,7 @@ Use this table as your starting point. Most questions are addressed in the liste
| How is the database modeled, and why are tables related this way? | [`decisions/database.md`](decisions/database.md) |
| What does the API implement today? | [`usecases/README.md`](usecases/README.md) |
| What does the frontend implement today? | [`../apps/web/README.md`](../apps/web/README.md) |
-| How does the app handle a sleeping or unavailable API? | [`guides/backend-connection.md`](guides/backend-connection.md) |
+| How does the app handle API requests and temporary failures? | [`guides/backend-connection.md`](guides/backend-connection.md) |
| Where is the historical/planned MVP specification? | [`usecases/cases.md`](usecases/cases.md) |
| Where are the API models and frontend flow diagrams? | [`diagrams/README.md`](diagrams/README.md) |
| What remains to be implemented in the backend? | [`backlog/backend.md`](backlog/backend.md) |
diff --git a/docs/guides/backend-connection.md b/docs/guides/backend-connection.md
index 8c36a6f..47f4c2e 100644
--- a/docs/guides/backend-connection.md
+++ b/docs/guides/backend-connection.md
@@ -1,164 +1,96 @@
-# API availability and cold starts
-
-Implemented on October 8, 2026. The unified Vercel deployment serves the
-public domain; see [the deployment guide](vercel-deployment.md).
-
-## Architecture and purpose
-
-DevLog serves a React + Vite application and calls its API with Axios. In a
-hosted build, the browser sends a same-origin `GET /api/health` through the
-gateway's private service binding to Nest's existing `/api/health` endpoint.
-Local development can still call Nest directly through `VITE_API_URL`.
-The request omits credentials, uses `cache: 'no-store'`, and requires Nest's
-`{ "status": "ok" }` response. An HTTP 200 hosting-provider loading page is not
-considered a successful check. Nest also returns `Cache-Control: no-store`.
-This is a liveness check; it does not verify database availability.
-
-This supervision began while the API ran on Render's sleeping Free service.
-On Vercel it remains useful for temporary network or function failures, but
-periodic checks also consume gateway and API requests. It does not guarantee
-that a cold function starts faster.
-
-## Responsibilities and data flow
-
-- `lib/backend-connection.ts` owns connection state, health rounds, timers,
- browser events, cancellation, and the shared `ensureReady()` promise.
-- `BackendConnectionProvider`, mounted in `AppProviders`, subscribes with
- `useSyncExternalStore`. It displays accessible connection notices on all
- routes and recovers failed active queries without clearing the cache.
-- Axios records successful API contacts, including HTTP 4xx responses that
- prove reachability. Temporary network errors and HTTP 502/503/504 invalidate
- that contact. Ordinary successful traffic can replace an unnecessary ping.
-- `useLogin` and `useRegister` receive validated form data and await
- `ensureReady()` before sending it once. React Query's mutation pending state
- includes the health wait, so the existing forms block duplicate submissions
- and retain entered values on failure.
-- TanStack Query owns the general read retry policy. Axios applies the read
- timeout. Keeping retries in one layer avoids multiplying attempts.
-- The router shows a skeleton during initial session validation and an error
- page with manual retry. Failed loaders have no active query observer, so the
- error boundary revalidates them after a detected recovery.
-
-The controller is an external store because timers and in-flight requests must
-remain shared across navigation and forms. A new controller can be injected in
-tests without mocking React or relying on production's singleton state.
-
-## Scheduling and limits
-
-| Control | Value |
-| --- | --- |
-| Periodic check while visible and online | 5 minutes |
-| Automatic event cooldown | 10 seconds |
-| Health attempts per round | At most 3 |
-| Delays before the second and third attempt | 2 seconds, 5 seconds |
-| Deadline per health attempt | 22 seconds, including response body |
-| Delay before displaying the waiting notice | 1.5 seconds |
-| Axios GET/HEAD timeout, unless explicitly configured | 15 seconds |
-| General QueryClient read retry | At most one retry for temporary errors |
-| Mutation retries | Disabled |
-
-A fully timed-out health round can take up to 73 seconds. Initial reads and
-health checks run in parallel; a failed route can recover when the health round
-finishes. Some feature queries and authentication loaders already set
-`retry: false`; they retain that policy and use explicit or connection recovery.
-Axios API helpers called outside QueryClient do not automatically retry.
-
-`visibilitychange`, focus, `pageshow`, and internet reconnection check the last
-successful contact. Nearby events and forms share the current round. Hiding a
-tab cancels the periodic timer; returning checks again when contact is stale.
-Losing internet aborts the current round and invalidates the previous contact.
-Unmounting removes listeners, aborts the round, and clears timers. Cancellation
-from React StrictMode does not turn into an unavailable-server notification.
-
-## Recovery, authentication, and writes
-
-Connection recovery refetches only active queries with temporary errors.
-Inactive queries, successful cached collections, 401/404/business errors, and
-mutations are not replayed. Query data is retained; each feature still owns its
-loading/error presentation. Existing section error screens can replace their
-content while cached data remains available for a later successful read.
-
-Separately, after a successful connection check, a stale, previously successful
-active profile query is rechecked. This detects actual session expiration.
-`ProtectedSession` clears user-scoped cache and returns to login only after the
-profile receives a 401. Initial protected-route loaders already redirect only
-on 401. Timeout, offline, 502, 503, and 504 never trigger logout or remove the
-authentication cookie. Health checks do not renew the JWT.
-
-Writes keep their existing timeout behavior and a single send. A lost response
-can mean a write succeeded even if the browser saw a failure. Automatic replay
-would risk duplicates. If stronger guarantees become necessary, implement
-server-side idempotency keys before introducing automatic write retries.
-
-HTTP 5xx messages use the operation's public fallback; validation messages from
-4xx responses remain visible. Internal infrastructure details belong in server
-logs, rather than form toasts or the route error page.
-
-## Local configuration and deployment
-
-No new environment variable is required for connection supervision. A hosted
-build defaults to `/api`; if set, `VITE_API_URL` must include `/api` because
-Vite embeds it during build. Local cross-origin development requires the Vite
-origin in `CORS_ALLOWED_ORIGINS`. The hosted gateway path is same-origin.
-
-| Local file | Purpose |
-| --- | --- |
-| Root `.env` | Docker database credentials and ports |
-| `apps/api/.env` | Development database, API port, JWT, and CORS |
-| `apps/api/.env.test` | Separate test database on port 5433 and API on 3001 |
-| `apps/web/.env` | Public API base URL on port 3000 |
-
-These files are ignored by Git. The development JWT secret is randomly generated.
-The API reads `JWT_EXPIRES_IN_SECONDS`, not `JWT_EXPIRES_IN`; the updated examples
-use 3600 seconds, matching the existing authentication cookie lifetime.
-Test CORS origins are explicit because wildcards are rejected outside development.
-
-The frontend performs no periodic check when hidden. Closed browsers,
-suspended devices, and discarded tabs cannot trigger checks. A first cold start
-remains possible. The five-minute visible-tab check was retained for this
-migration to avoid changing form and recovery behavior at the same time; it
-costs additional Vercel service requests and can be removed in a separate,
-tested change if measurements show little recovery benefit.
+# API requests and failure recovery
+
+The frontend sends API requests directly. Following the Vercel migration, the
+browser no longer warms the API, periodically polls health, shows global
+connection notices, or waits for a health check before login and registration.
+The removed supervision originated with Render's sleeping Free service.
+
+## Request flow and responsibilities
+
+- `api/http.ts` configures the base URL, browser credentials, JSON headers, and
+ a 15-second timeout for GET/HEAD requests unless a caller provides a deadline.
+ It does not track a separate global connection state.
+- Feature request helpers call their endpoints directly. `useLogin` and
+ `useRegister` submit validated data through their mutation. The mutation's
+ pending state covers the POST, blocks duplicate submissions, and preserves
+ the form draft after a failure.
+- TanStack Query owns cached server data and read retries. The shared policy
+ permits one retry for network/timeouts and HTTP 502/503/504; authentication
+ loaders and features with `retry: false` keep their explicit policy.
+- Stale active queries use TanStack Query's default refetch on tab visibility
+ and internet reconnection. Returning to the app can therefore refresh stale
+ profile and feature data directly, without first checking health. Successful
+ data remains cached; there is no independent connection recovery controller.
+- The router shows its loading screen during initial session validation and
+ a page error when that read fails. **Try loading again** revalidates the
+ failed loader directly, with the button disabled while it is loading.
+
+A health response cannot guarantee that the next authenticated request or
+write will succeed. Removing that prerequisite avoids an extra round trip and
+allows business requests to succeed even if the health endpoint is unavailable.
+It also removes periodic gateway/API traffic from visible idle tabs.
+
+## Sessions and writes
+
+Only a confirmed HTTP 401 from the session request clears user-scoped data and
+returns the user to login. Network errors, timeouts, and server failures preserve
+the session and cache. `ProtectedSession` still observes the shared profile
+query, including a stale profile refetched when the user returns to the tab.
+
+Writes are never retried automatically. A lost response can mean the database
+already accepted the operation; replaying it could create duplicates. The user
+can review the result and deliberately retry. Stronger automatic guarantees
+would require server-side idempotency support.
+
+HTTP 5xx responses use the operation's public fallback message, while useful
+4xx validation messages remain visible. Internal infrastructure details stay in
+server logs rather than form notifications or the route error page.
+
+## Health endpoint and Vercel trade-offs
+
+`GET /api/health` remains available for monitoring, deployment checks, and local
+test-server readiness. It returns Nest's `{ "status": "ok" }` response with
+`Cache-Control: no-store`. This is a liveness check, not a database readiness
+check. The frontend does not call it automatically.
+
+The API uses Vercel's container runtime. [Vercel's scale-in behavior](https://vercel.com/docs/functions/runtimes/container#scale-in-behavior)
+still allows idle containers to scale down, so removing browser supervision
+cannot eliminate cold-start latency. An initial request may take longer or
+reach its existing deadline. The trade-off is simpler request handling and
+fewer service invocations, without claiming a deployment latency improvement.
+
+See [the unified deployment guide](vercel-deployment.md) for same-origin `/api`
+routing through the gateway. `VITE_API_URL` remains the public build-time
+configuration for local cross-origin development; no configuration change is
+required by this removal.
## Validation
-Unit/component tests cover shared rounds, retries, deadlines, event scheduling,
-offline recovery, StrictMode cleanup, safe query recovery, form gating, and
-public error messages. Playwright uses an intercepted API to test navigation,
-failed private-route recovery, offline preservation, and real 401 handling.
-These browser tests verify application behavior, not production cold-start
-latency or deployment cookies.
+Component tests cover public routes without health probes, direct login and
+registration while health is unavailable, pending submission, preserved drafts,
+manual loader recovery, and the distinction between HTTP 401 and outages.
+Shared-client tests protect read deadlines and single-send writes.
-Local validation results for this delivery:
-
-- Web lint, production build, and browser-test TypeScript checks passed.
-- Vitest: 416 of 424 tests passed. The remaining eight failures also reproduce
- in a temporary snapshot of the unchanged `HEAD`: six in
- `technical-entry-detail-page.spec.tsx` and two in `project-detail-page.spec.tsx`.
- They are pre-existing fixture/UI expectations outside connection supervision.
-- Playwright: all eight connection and route-access scenarios passed in Chromium.
-- API environment configuration: four unit tests passed; the public health
- HTTP contract passed its existing end-to-end test without a live database.
+Browser tests in `apps/web/e2e/mocked/api-availability.spec.ts` cover navigation,
+a lost login response followed by deliberate retry, direct private-route
+recovery, idle/browser events without health polling, and a stale profile's
+401 response on return. These intercept API responses; they establish frontend
+behavior rather than real deployment latency or server cookie correctness.
From the repository root:
```bash
-pnpm --filter web test
+NODE_OPTIONS=--no-experimental-webstorage pnpm --filter web test
pnpm --filter web lint
pnpm --filter web build
pnpm --filter web typecheck:e2e
-PLAYWRIGHT_BROWSERS_PATH=apps/web/.playwright-browsers pnpm --filter web exec playwright install chromium
-pnpm --filter web test:e2e e2e/mocked/api-warmup.spec.ts
-pnpm --filter api exec prisma generate
-pnpm --filter api test:e2e health.e2e-spec.ts
+pnpm --filter web test:e2e e2e/mocked/api-availability.spec.ts
```
-On this machine, Node 26's experimental Web Storage conflicts with jsdom.
-Run Vitest with `NODE_OPTIONS=--no-experimental-webstorage` when using that runtime.
-For deployment, verify an API cold start, direct private navigation, returning
-after an idle period, internet loss during a form draft, and expired JWTs.
-Compare observed timing before and after deployment before claiming latency gains.
+Node 26's experimental Web Storage conflicts with jsdom, so the Vitest command
+uses the existing compatibility option. After deployment, check an idle first
+request, direct protected navigation, temporary connection loss with a draft,
+and an expired session before drawing conclusions about production timing.
-Topics worth studying: external stores, cancellation with `AbortController`,
-liveness versus readiness, HTTP idempotency, query observers versus router
-loaders, and session expiry versus network availability.
+Topics worth studying: liveness versus readiness, HTTP idempotency, query
+freshness and focus refetching, and session expiry versus network availability.