diff --git a/apps/web/README.md b/apps/web/README.md index f67dce2..95fa906 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -170,11 +170,11 @@ API must be running and allow the Vite origin in `CORS_ALLOWED_ORIGINS`. See [unified Vercel deployment](../../docs/guides/vercel-deployment.md) for the hosted request path and private binding. -API warmup and reconnection run globally, including protected routes. Slow checks -show a waiting notice; failed active reads can recover while session data and -form drafts remain available. Login and registration wait for availability -before a single submission. See [API availability and cold starts](../../docs/guides/backend-connection.md) -for responsibilities, retry limits, trade-offs, and deployment checks. +API requests run directly, including login and registration. The browser does not +warm the API or poll health; `/api/health` remains available for monitoring and +deployment checks. Read timeouts, bounded temporary-error retries, direct page +retry, and session preservation remain. See [API requests and failure recovery](../../docs/guides/backend-connection.md) +for responsibilities, Vercel trade-offs, and validation. ## Running diff --git a/apps/web/e2e/mocked/api-availability.spec.ts b/apps/web/e2e/mocked/api-availability.spec.ts new file mode 100644 index 0000000..cd748f4 --- /dev/null +++ b/apps/web/e2e/mocked/api-availability.spec.ts @@ -0,0 +1,122 @@ +import { expect, test } from '@playwright/test' + +import { authenticatedApi, guestApi, mockApi } from './mock-api' + +test('public navigation never probes API health', async ({ page }) => { + const assertNoUnexpected = await mockApi(page, guestApi) + const healthRequests: string[] = [] + page.on('request', (request) => { + if (new URL(request.url()).pathname === '/api/health') healthRequests.push(request.url()) + }) + await page.goto('/') + await expect(page.getByRole('heading', { name: 'Keep the reasoning behind your code.' })).toBeVisible() + await page.getByRole('link', { name: 'Sign in', exact: true }).click() + await expect(page.getByRole('button', { name: 'Sign in' })).toBeEnabled() + await page.getByRole('link', { name: 'Go to the DevLog home page' }).click() + await page.getByRole('link', { name: 'Create account', exact: true }).click() + await expect(page.getByRole('button', { name: 'Create account' })).toBeEnabled() + expect(healthRequests).toEqual([]) + assertNoUnexpected() +}) + +test('signs in directly while health is unavailable and never replays a lost write', async ({ page }) => { + let loginCount = 0 + let signedIn = false + let loseResponse = true + const assertNoUnexpected = await mockApi(page, (request, url) => { + if (url.pathname === '/api/health') return { status: 503, body: {} } + return signedIn ? authenticatedApi(request, url) : guestApi(request, url) + }) + await page.route('http://localhost:3000/api/auth/login', async (route) => { + if (route.request().method() === 'OPTIONS') return route.fallback() + loginCount += 1 + if (loseResponse) return route.abort('failed') + signedIn = true + await route.fulfill({ + json: { id: '11111111-1111-4111-8111-111111111111', name: 'Ada Lovelace', email: 'ada@example.com' }, + headers: { + 'access-control-allow-origin': 'http://localhost:4173', + 'access-control-allow-credentials': 'true', + }, + }) + }) + await page.goto('/login') + await page.getByRole('textbox', { name: 'E-mail' }).fill('ada@example.com') + await page.getByLabel('Password').fill('valid-password') + await page.getByRole('button', { name: 'Sign in' }).click() + await expect(page.getByText('Could not sign in. Check your credentials and try again.')).toBeVisible() + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeEnabled() + expect(loginCount).toBe(1) + await expect(page.getByLabel('Password')).toHaveValue('valid-password') + loseResponse = false + await page.getByRole('button', { name: 'Sign in', exact: true }).click() + await expect(page).toHaveURL(/\/dashboard$/) + await expect(page.getByRole('heading', { name: 'Welcome back, Ada.' })).toBeVisible() + expect(loginCount).toBe(2) + assertNoUnexpected() +}) + +test('retries a direct private visit without a health gate or sign out', async ({ page }) => { + let available = false + let sessionReads = 0 + const assertNoUnexpected = await mockApi(page, (request, url) => { + if (url.pathname === '/api/health') return { status: 503, body: {} } + if (url.pathname === '/api/users/me') { + sessionReads += 1 + if (!available) return { status: 503, body: { message: 'Internal service details' } } + } + return authenticatedApi(request, url) + }) + await page.goto('/dashboard') + await expect(page.getByRole('heading', { name: 'Could not load this page' })).toBeVisible() + await expect(page).toHaveURL(/\/dashboard$/) + await expect(page.getByText('Internal service details')).toHaveCount(0) + available = true + await page.getByRole('button', { name: 'Try loading again' }).click() + await expect(page.getByRole('heading', { name: 'Welcome back, Ada.' })).toBeVisible() + expect(sessionReads).toBeGreaterThanOrEqual(2) + assertNoUnexpected() +}) + +test('idle time and browser events preserve the account without health polling', async ({ page, context }) => { + let healthRequests = 0 + const assertNoUnexpected = await mockApi(page, (request, url) => { + if (url.pathname === '/api/health') healthRequests += 1 + return authenticatedApi(request, url) + }) + await page.clock.install() + await page.goto('/account') + await expect(page.getByRole('heading', { name: 'User account' })).toBeVisible() + await page.clock.runFor(10 * 60_000) + await context.setOffline(true) + await expect(page.getByText('Ada Lovelace', { exact: true }).first()).toBeVisible() + await context.setOffline(false) + await page.evaluate(() => { + window.dispatchEvent(new Event('focus')) + window.dispatchEvent(new Event('pageshow')) + window.dispatchEvent(new Event('visibilitychange')) + }) + await expect(page).toHaveURL(/\/account$/) + await expect(page.getByRole('heading', { name: 'User account' })).toBeVisible() + expect(healthRequests).toBe(0) + await expect(page.getByRole('complementary', { name: 'Connection status' })).toHaveCount(0) + assertNoUnexpected() +}) + +test('a stale profile is checked directly on return and only a 401 ends the session', async ({ page }) => { + let expired = false + const assertNoUnexpected = await mockApi(page, (request, url) => { + if (expired && url.pathname === '/api/users/me') return { status: 401, body: { message: 'Unauthorized' } } + return authenticatedApi(request, url) + }) + await page.clock.install() + await page.goto('/account') + await expect(page.getByRole('heading', { name: 'User account' })).toBeVisible() + expired = true + await page.clock.runFor(31_000) + // TanStack Query listens for tab visibility to refresh stale active reads. + await page.evaluate(() => window.dispatchEvent(new Event('visibilitychange'))) + await expect(page).toHaveURL(/\/login$/) + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeVisible() + assertNoUnexpected() +}) diff --git a/apps/web/e2e/mocked/api-warmup.spec.ts b/apps/web/e2e/mocked/api-warmup.spec.ts deleted file mode 100644 index d95306b..0000000 --- a/apps/web/e2e/mocked/api-warmup.spec.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { expect, test } from '@playwright/test' - -import { authenticatedApi, guestApi, mockApi } from './mock-api' - -test('shares one global warmup across landing, login, and registration', async ({ - page, -}) => { - const assertNoUnexpected = await mockApi(page, guestApi) - let releaseHealth!: () => void - const healthGate = new Promise((resolve) => { - releaseHealth = resolve - }) - let healthRequestCount = 0 - let healthResponseCount = 0 - - await page.route('http://localhost:3000/api/health', async (route) => { - expect(route.request().method()).toBe('GET') - healthRequestCount += 1 - await healthGate - await route.fulfill({ - json: { status: 'ok' }, - headers: { 'access-control-allow-origin': 'http://localhost:4173' }, - }) - healthResponseCount += 1 - }) - - try { - await page.goto('/') - await expect( - page.getByRole('heading', { - name: 'Keep the reasoning behind your code.', - }), - ).toBeVisible() - await expect.poll(() => healthRequestCount).toBe(1) - - await page.getByRole('link', { name: 'Sign in', exact: true }).click() - await expect(page.getByRole('button', { name: 'Sign in' })).toBeEnabled() - await expect.poll(() => healthRequestCount).toBe(1) - - await page.getByRole('link', { name: 'Go to the DevLog home page' }).click() - await expect( - page.getByRole('heading', { - name: 'Keep the reasoning behind your code.', - }), - ).toBeVisible() - await expect.poll(() => healthRequestCount).toBe(1) - - await page - .getByRole('link', { name: 'Create account', exact: true }) - .click() - await expect( - page.getByRole('button', { name: 'Create account' }), - ).toBeEnabled() - await expect.poll(() => healthRequestCount).toBe(1) - } finally { - releaseHealth() - await expect.poll(() => healthResponseCount).toBe(healthRequestCount) - } - - assertNoUnexpected() -}) - -test('waits for availability before signing in and never replays a write', async ({ - page, -}) => { - let signedIn = false - let loginCount = 0 - let healthy = false - const assertNoUnexpected = await mockApi(page, (request, url) => { - if (request.method() === 'POST' && url.pathname === '/api/auth/login') { - loginCount += 1 - signedIn = true - return { - body: { - id: '11111111-1111-4111-8111-111111111111', - name: 'Ada Lovelace', - email: 'ada@example.com', - }, - } - } - return signedIn ? authenticatedApi(request, url) : guestApi(request, url) - }) - await page.route('http://localhost:3000/api/health', async (route) => { - await route.fulfill({ - status: healthy ? 200 : 503, - json: healthy ? { status: 'ok' } : { message: 'Unavailable' }, - headers: { 'access-control-allow-origin': 'http://localhost:4173' }, - }) - }) - - await page.goto('/login') - await page.getByRole('textbox', { name: 'E-mail' }).fill('ada@example.com') - await page.getByLabel('Password').fill('valid-password') - await page.getByRole('button', { name: 'Sign in' }).click() - await expect( - page.getByRole('button', { name: 'Signing in...' }), - ).toBeDisabled() - await expect(page.getByText('Waiting for the server…')).toBeVisible() - await expect(page.getByText('Could not connect. Try again.')).toBeVisible({ - timeout: 10_000, - }) - expect(loginCount).toBe(0) - await expect(page.getByLabel('Password')).toHaveValue('valid-password') - - healthy = true - await page.getByRole('button', { name: 'Sign in', exact: true }).click() - await expect(page).toHaveURL(/\/dashboard$/) - await expect( - page.getByRole('heading', { name: 'Welcome back, Ada.' }), - ).toBeVisible() - expect(loginCount).toBe(1) - assertNoUnexpected() -}) - -test('recovers a direct private visit after a temporary failure without signing out', async ({ - page, -}) => { - let available = false - let sessionReads = 0 - let releaseHealth!: () => void - const gate = new Promise((resolve) => { - releaseHealth = resolve - }) - const assertNoUnexpected = await mockApi(page, (request, url) => { - if (url.pathname === '/api/users/me') { - sessionReads += 1 - if (!available) - return { status: 503, body: { message: 'Internal service details' } } - } - return authenticatedApi(request, url) - }) - await page.route('http://localhost:3000/api/health', async (route) => { - await gate - await route.fulfill({ - json: { status: 'ok' }, - headers: { 'access-control-allow-origin': 'http://localhost:4173' }, - }) - }) - - await page.goto('/dashboard') - await expect( - page.getByRole('heading', { name: 'Could not load this page' }), - ).toBeVisible() - await expect(page).toHaveURL(/\/dashboard$/) - await expect(page.getByText('Internal service details')).toHaveCount(0) - available = true - releaseHealth() - await expect( - page.getByRole('heading', { name: 'Welcome back, Ada.' }), - ).toBeVisible() - expect(sessionReads).toBeGreaterThanOrEqual(2) - assertNoUnexpected() -}) - -test('preserves a visible account across internet loss and reconnection', async ({ - page, - context, -}) => { - const assertNoUnexpected = await mockApi(page, authenticatedApi) - await page.goto('/account') - await expect( - page.getByRole('heading', { name: 'User account' }), - ).toBeVisible() - await context.setOffline(true) - await expect( - page.getByText('You are offline. Check your internet connection.'), - ).toBeVisible() - await expect( - page.getByText('Ada Lovelace', { exact: true }).first(), - ).toBeVisible() - await context.setOffline(false) - await expect( - page.getByText('You are offline. Check your internet connection.'), - ).toHaveCount(0) - await expect(page).toHaveURL(/\/account$/) - assertNoUnexpected() -}) - -test('returns to sign in only after a stale session is rejected with 401', async ({ - page, -}) => { - let expired = false - const assertNoUnexpected = await mockApi(page, (request, url) => { - if (expired && url.pathname === '/api/users/me') - return { status: 401, body: { message: 'Unauthorized' } } - return authenticatedApi(request, url) - }) - await page.clock.install() - await page.goto('/account') - await expect( - page.getByRole('heading', { name: 'User account' }), - ).toBeVisible() - expired = true - await page.clock.runFor(5 * 60_000) - await expect(page).toHaveURL(/\/login$/) - await expect( - page.getByRole('button', { name: 'Sign in', exact: true }), - ).toBeVisible() - assertNoUnexpected() -}) diff --git a/apps/web/e2e/mocked/mock-api.ts b/apps/web/e2e/mocked/mock-api.ts index 848e4f3..fd0c7c0 100644 --- a/apps/web/e2e/mocked/mock-api.ts +++ b/apps/web/e2e/mocked/mock-api.ts @@ -75,7 +75,6 @@ export function authenticatedApi(request: Request, url: URL): Reply | undefined if (request.method() !== 'GET') return undefined const path = url.pathname - if (path === '/api/health') return { body: { status: 'ok' } } if (path === '/api/users/me') return { body: user } if (path === '/api/project') return { body: { data: [project], meta: { ...meta, total: 1 } } } if (path === '/api/project/environments') return { body: { data: [], meta } } @@ -94,9 +93,6 @@ export function authenticatedApi(request: Request, url: URL): Reply | undefined } export function guestApi(request: Request, url: URL): Reply | undefined { - if (request.method() === 'GET' && url.pathname === '/api/health') { - return { body: { status: 'ok' } } - } if (request.method() === 'GET' && url.pathname === '/api/users/me') { return { status: 401, body: { message: 'Unauthorized' } } } diff --git a/apps/web/e2e/vercel-same-origin.spec.ts b/apps/web/e2e/vercel-same-origin.spec.ts index e112d34..e4171dd 100644 --- a/apps/web/e2e/vercel-same-origin.spec.ts +++ b/apps/web/e2e/vercel-same-origin.spec.ts @@ -6,14 +6,14 @@ test('the deployed registration form calls its own API', async ({ page, baseURL const email = 'same-origin-check@example.invalid' const apiRequests: string[] = [] - // Readiness can start when the page mounts, before the form is submitted. + // Track the guest-session check and form submission across navigation. page.on('request', (request) => { if (new URL(request.url()).pathname.startsWith('/api/')) { apiRequests.push(request.url()) } }) - // The health request reaches the real deployment. Intercept the write so + // The guest-session request reaches the deployment. Intercept the write so // this browser regression test never creates a user in the shared database. await page.route('**/api/users', async (route) => { await route.fulfill({ @@ -31,7 +31,7 @@ test('the deployed registration form calls its own API', async ({ page, baseURL await page.getByRole('button', { name: 'Create account' }).click() await expect(page).toHaveURL(/\/login$/) - expect(apiRequests.filter((url) => new URL(url).pathname === '/api/health')).toHaveLength(1) + expect(apiRequests.filter((url) => new URL(url).pathname === '/api/health')).toHaveLength(0) expect(apiRequests.filter((url) => new URL(url).pathname === '/api/users')).toHaveLength(1) expect(apiRequests.every((url) => new URL(url).origin === deploymentOrigin)).toBe(true) }) diff --git a/apps/web/src/api/http.ts b/apps/web/src/api/http.ts index 9dd66b1..cef807f 100644 --- a/apps/web/src/api/http.ts +++ b/apps/web/src/api/http.ts @@ -1,6 +1,4 @@ import axios from 'axios' -import { backendConnection } from '@/lib/backend-connection' -import { isTemporaryApiError } from '@/lib/is-temporary-api-error' import { API_URL } from './api-url' @@ -24,27 +22,3 @@ api.interceptors.request.use((config) => { } return config }) - -api.interceptors.response.use( - (response) => { - // Business traffic also reaches the server, so a successful read or write - // can refresh connection history without an additional health request. - backendConnection.recordSuccess() - return response - }, - (error: unknown) => { - if (isTemporaryApiError(error)) backendConnection.recordFailure() - else if ( - axios.isAxiosError(error) && - error.response && - error.response.status < 500 - ) { - // A 401 proves the server is reachable, but says nothing about health of - // the session. Authentication consumers still receive the original error. - backendConnection.recordSuccess() - } - // Reporting connectivity is a side effect, not error handling for the - // operation. Propagate the same error to query, mutation, and auth consumers. - return Promise.reject(error) - }, -) diff --git a/apps/web/src/app/providers/app-providers.tsx b/apps/web/src/app/providers/app-providers.tsx index 487192d..465b0d9 100644 --- a/apps/web/src/app/providers/app-providers.tsx +++ b/apps/web/src/app/providers/app-providers.tsx @@ -4,7 +4,6 @@ import { Toaster } from 'sonner' import { TooltipProvider } from '@/components/ui/tooltip' import { queryClient as productionQueryClient } from '@/lib/query-client' -import { BackendConnectionProvider } from './backend-connection-provider' // In DEV, asynchronously imports ReactQueryDevtools; otherwise, sets it to null const ReactQueryDevtools = import.meta.env.DEV @@ -29,7 +28,7 @@ export function AppProviders({ return ( - {children} + {children} {showDevtools && ReactQueryDevtools ? ( diff --git a/apps/web/src/app/providers/backend-connection-context.ts b/apps/web/src/app/providers/backend-connection-context.ts deleted file mode 100644 index 86ef091..0000000 --- a/apps/web/src/app/providers/backend-connection-context.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createContext, useContext } from 'react' - -import { backendConnection } from '@/lib/backend-connection' - -// Context exposes commands on the shared controller. Components that need live -// status must also subscribe to its snapshots; reading context alone is not reactive. -export const BackendConnectionContext = createContext(backendConnection) - -export function useBackendConnection() { - return useContext(BackendConnectionContext) -} diff --git a/apps/web/src/app/providers/backend-connection-provider.spec.tsx b/apps/web/src/app/providers/backend-connection-provider.spec.tsx deleted file mode 100644 index adc6a67..0000000 --- a/apps/web/src/app/providers/backend-connection-provider.spec.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { QueryClientProvider, useQuery } from '@tanstack/react-query' -import { act, render, screen, waitFor } from '@testing-library/react' -import { AxiosError, type AxiosResponse } from 'axios' -import { StrictMode } from 'react' -import { describe, expect, it, vi } from 'vitest' - -import { BackendConnection } from '@/lib/backend-connection' -import { createTestQueryClient } from '@/test/query-client' - -import { BackendConnectionProvider } from './backend-connection-provider' - -function temporaryError() { - return new AxiosError('Unavailable', undefined, undefined, undefined, { - status: 503, - } as AxiosResponse) -} - -function Read({ name, load }: { name: string; load: () => Promise }) { - const { data, isError } = useQuery({ queryKey: [name], queryFn: load }) - return ( -

- {name}: {data ?? (isError ? 'Failed' : 'Loading')} -

- ) -} - -describe('global connection provider', () => { - it('recovers only active reads with temporary errors and leaves writes and other cache entries alone', async () => { - const client = createTestQueryClient() - const connection = new BackendConnection(async () => {}) - const activeRead = vi - .fn<() => Promise>() - .mockRejectedValueOnce(temporaryError()) - .mockResolvedValue('Recovered') - const forbiddenRead = vi - .fn<() => Promise>() - .mockRejectedValue( - new AxiosError('Unauthorized', undefined, undefined, undefined, { - status: 401, - } as AxiosResponse), - ) - const inactiveRead = vi - .fn<() => Promise>() - .mockRejectedValue(temporaryError()) - const healthyRead = vi - .fn<() => Promise>() - .mockResolvedValue('Existing data') - const write = vi.fn().mockRejectedValue(temporaryError()) - await client - .query({ queryKey: ['inactive'], queryFn: inactiveRead }) - .catch(() => {}) - await client - .getMutationCache() - .build(client, { mutationFn: write }) - .execute(undefined) - .catch(() => {}) - - render( - - - - - - - , - ) - await screen.findByText('active: Failed') - await screen.findByText('forbidden: Failed') - await screen.findByText('healthy: Existing data') - await act(async () => { - connection.recordFailure() - await connection.check(true) - }) - expect(await screen.findByText('active: Recovered')).toBeVisible() - expect(activeRead).toHaveBeenCalledTimes(2) - expect(forbiddenRead).toHaveBeenCalledTimes(1) - expect(inactiveRead).toHaveBeenCalledTimes(1) - expect(healthyRead).toHaveBeenCalledTimes(1) - expect(write).toHaveBeenCalledTimes(1) - }) - - it('keeps mounted content and cached data visible when offline and recovers on reconnect', async () => { - const client = createTestQueryClient() - client.setQueryData(['saved'], 'Saved value') - const connection = new BackendConnection(async () => {}) - render( - - -

Draft in progress

-
-
, - ) - await act(async () => { - await connection.ensureReady() - }) - vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false) - act(() => window.dispatchEvent(new Event('offline'))) - expect(screen.getByRole('status')).toHaveTextContent('You are offline.') - expect(screen.getByText('Draft in progress')).toBeVisible() - expect(client.getQueryData(['saved'])).toBe('Saved value') - vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) - act(() => window.dispatchEvent(new Event('online'))) - await waitFor(() => - expect(screen.queryByRole('status')).not.toBeInTheDocument(), - ) - }) - - it('survives StrictMode and aborts the replaced round and the unmounted round', async () => { - const signals: AbortSignal[] = [] - const connection = new BackendConnection( - (signal) => - new Promise((_resolve, reject) => { - signals.push(signal) - signal.addEventListener( - 'abort', - () => reject(new Error('Cancelled')), - { once: true }, - ) - }), - ) - const result = render( - - - -

Public content

-
-
-
, - ) - 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.