The cross-tab lock was taken with ifAvailable, so a tab that found another tab replaying returned from flush() at once. Its callers read that as "the queue is sent": the online trigger then re-seeds every trip from the server and rehydrates the open ones, and the socket refetches after its pre-reconnect flush. All tabs see the online event together, so the tabs that lost the race laid the server's rows from before the replay over the shared Dexie, and over the optimistic edits still on their way, while the winning tab was mid-flush. The lock is now requested without ifAvailable. A tab that finds it held waits, then runs its own pass, which finds nothing or what the holder left behind, so flush() resolves only after the replay and the re-seed chained onto it reads the state after it. The holder's requests are bounded by the client timeout, so the wait is too.
9.3 KiB
CLAUDE.md
Scope: the @trek/client workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root CLAUDE.md for the monorepo picture and the philosophy, server/CLAUDE.md for the API. This file holds the client's commands, invariants and pitfalls; ls the directories for the inventory.
Commands (run from client/)
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build # prebuild generates PWA icons, then vite build
npm run typecheck # tsc --noEmit (CI)
npm run lint # eslint . (lint:check in CI)
npm run lint:pages # enforce the Page pattern (CI gate)
npm run lint:rtl # physical left/right sides may only shrink per file (CI gate; --list shows them, --update lowers the baseline)
npm run lint:size # no source file past 1000 lines; longer ones may only shrink (CI gate; --update lowers the baseline)
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
npm run shots # Playwright screenshot project (shots:promote to accept)
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (CI gate)
Single test: npx vitest run src/store/slices/budgetSlice.test.ts, or npx vitest run -t "optimistically adds the place".
The dev server needs the API on :3001 — run npm run dev at the repo root to start both.
Page pattern (enforced — lint:pages fails otherwise)
Every src/pages/*Page.tsx is a thin wiring container; all state/effects/handlers live in a co-located use<Page>() hook under src/pages/<page>/. The page body must not call React state/effect/memo/ref hooks — only context hooks like useTranslation(). An optional <page>Model.ts holds pure, React-free types and helpers. Full spec in src/pages/PATTERN.md. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
Data flow — offline-first
The layering is component → feature hook → store/slice → repo/ → api/ | Dexie, and writes go through a mutation queue:
src/store/— Zustand.tripStore.tsis composed from slices instore/slices/;slices/remoteEventHandler.tsapplies inbound WebSocket events to local state.src/repo/— per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.src/api/client.ts— the single Axios instance, typed from@trek/shared. New API surface goes in a per-domainapi/<domain>.ts, not the god module.src/db/offlineDb.ts— the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.src/sync/—mutationQueue.ts: offline writes do an optimistic Dexie write (temporary negative id for creates), thenflush()replays REST on reconnect with the mutation's UUID as theX-Idempotency-Keyheader (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked asfailedorconflictholds back the later writes to the sameresource+ entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does).flush()takes the Web Locktrek-mutation-flush:<db name>so only one tab replays at a time; a tab that finds it taken waits for it, soflush()never resolves while another tab is still replaying and a re-seed chained onto it cannot lay older server rows over those writes (the in-tab flag alone where the API is missing). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
Rules for new code
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
- One data path, no layer skips. Never import
api/client(or raw fetch/axios) from a component or modal — especially never write from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent. - Offline-first is the product promise. New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
- No god components or god hooks. The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
- Prefer React 19 idioms:
useOptimistic,useActionState/useFormStatus,use()+ Suspense,useSyncExternalStorefor external subscriptions (hooks/useNetworkModeis the reference). - Optimistic writes must reconcile — rollback + user-visible toast on failure. No
.catch(() => {}), no barecatch {}. - WS remote-event handling must match local slice reducers field-for-field. Divergence is silent, cross-user data loss.
- Subscribe with selectors (
useShallowfor multi-field reads); never whole-store subscriptions;getState()for imperative access in effects. - Connectivity:
isEffectivelyOffline()is the single source of truth — never readnavigator.onLinein feature code. - Rendering security: never interpolate user content into HTML strings (map popups included — build via DOM +
textContent, orescapeHtmlfrom@trek/shared); untrusted markdown getsrehype-sanitize; neverrehype-rawnear it. - No
windowevent buses or global mutablewindowstate. Noanyat boundaries: WS payloads and map renderer props get real types. - Hygiene: error boundaries around new shells/routes (
components/shared/ErrorBoundary.tsx); every async.then(setState)needs a cancelled flag orAbortController; no routineeslint-disable exhaustive-deps(a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate. - Theming: use the semantic Tailwind tokens defined in
tailwind.config.js(bg-surface*,text-content*,border-edge*,bg-accent*, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. OnlyapplyAppearance()insrc/theme/mutates<html>styling (seesrc/theme/README.md).theme:lintcatches literals andbg-[#...]-style classes but not named palette classes — review those by hand.
Big-picture pieces
- Maps (
src/components/Map/): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime byMapViewAuto.tsx. Keep both in sync when changing map features, and keepmapbox-glandmaplibre-glin separate chunks (check:gl-split). The raster prefetcher fetches tilesno-corsso custom tile providers without CORS headers keep working; the vector prefetcher deliberately usescorsbecause it has to read the responses — don't "align" one with the other. - i18n (
src/i18n/TranslationContext.tsx):enis bundled; every other locale is a dynamicimport('@trek/shared/i18n/<locale>')so Vite emits one chunk per locale. Strings live inshared/, never here. - Mobile shell (
src/mobile/): below the phone breakpoint (useIsPhone)App.tsxwraps routes inMobileShelland theM*screens undermobile/screens/take over. A UI change to a domain with anM*twin usually needs both — and the shared-hook rule below. - Plugins UI (
src/components/Plugins/): third-party surfaces render only inside the sandboxedPluginFrameiframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by thetrek-plugin-devskill. - Managed installs (
src/managed/index.tsx): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
Desktop ↔ mobile shells and the SonarQube duplication gate
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows ≤ 3% duplicated lines on the PR's new code, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (useInstanceSettings, useRangeBypass under components/Admin/ are the pattern) and keep only markup in each shell. Full gate rules in the root CLAUDE.md.
Tests
vitest with @vitejs/plugin-react, a custom jsdom environment (tests/environment/), forks pool. Tests live in tests/{unit,integration}/ and co-located as src/**/*.test.{ts,tsx}. msw mocks HTTP, fake-indexeddb backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see store/slices/budgetSlice.test.ts).