Files
Jakob Wennberg 47fe193c48 feat(perf): session-cached reference data layer, server-seeded, with a raw-fetch ratchet (#1932)
* feat(perf): session-cached reference data layer, server-seeded, with a raw-fetch ratchet

Customer report (2026-08-26): "it takes time before all fields load when
clicking around". The cause is on the client: fiscal periods, settings,
accounts, cash accounts, dimensions and templates are fetched raw from 47 /
27 / 14 / 8 / 12 / 5 independent call sites, uncached, on every mount and
every dialog open, each request paying the auth proxy and route wrapper
before its own query. SWR was adopted for exactly this on 2026-07-13 but
reached only three files.

This PR adds the layer; consumers migrate in the follow-ups.

- lib/reference-data/keys.ts: one key builder per data set, company id in
  position 1, null without a company; company_settings keeps the shape
  useCompanySettings already uses so that hook is seeded without a change.
- lib/reference-data/fetchers.ts: browser Supabase for fiscal periods and
  cash accounts (mirroring period.list and listForCompany ordering, pinned
  by tests), /api for the lists whose routes do real work (accounts RPC,
  dimensions ensure, template scoping, customer masking).
- lib/reference-data/hooks.ts: useFiscalPeriods, useCashAccounts,
  useAccounts, useDimensions, useBookingTemplates, useCustomers,
  useSuppliers, useArticles (+ re-exported useCompanySettings); one-minute
  dedupe, keepPreviousData, background revalidation kept on so writes from
  MCP/agents/other tabs surface.
- lib/reference-data/invalidate.ts: invalidateReferenceData(kind) for the
  success path of every client write.
- lib/reference-data/seed.ts + components/providers/ReferenceDataSeed.tsx:
  the dashboard layout fetches fiscal periods and cash accounts in its
  existing batch and hands them, with the settings row it already had, to
  SWR as fallback, so the first form of a session renders its period, bank
  account and settings-driven fields on first paint. getDashboardSettings
  now selects the full row for that (its other consumers read a subset).
  The chart of accounts is not seeded (hundreds of KB for large charts).
- scripts/checks/raw-reference-fetch.mjs, wired into check:guards as a
  per-file ratchet: GET-shaped fetch('/api/<reference path>') anywhere in
  client-facing code and .from('<reference table>').select( in 'use client'
  files. Baselined at 55 files; new sites fail CI; at 0 the entry goes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): anchor every optional whitespace run in the raw-reference-fetch regex

CodeQL js/redos flagged the `\s*,?\s*\)` tail: two adjacent optional
whitespace runs around an optional comma backtrack polynomially on a long
near-miss. The URL and init-object pieces are now named fragments and
every whitespace run is followed by a literal, so there is one way to
match. Behaviour unchanged (same 7 fixtures + baseline count of 55);
a worst-case timing test pins the linear scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): make the use-client directive regex unambiguous (CodeQL js/redos)

An unclosed /* let the lazy comment body be re-split at every later /*.
The body is now (?:[^*]|\*(?!\/))* which cannot cross a */, so the outer
repetition has one parse. Pinned with a 3000-comment worst-case test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: re-trigger checks for the rebased head

No workflow ran for dd560a7af (nor after close/reopen); an empty commit
gives the pull_request event a fresh head. No code change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(checks): single-character whitespace alternative in the use-client detector (CodeQL js/redos)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:14:54 +02:00

162 lines
5.7 KiB
TypeScript

'use client'
/**
* Company-scoped reference data, cached for the session.
*
* Why: forms and pickers used to fetch the same fiscal periods, accounts,
* cash accounts, settings, dimensions and templates on every mount and every
* dialog open (fiscal periods from 47 call sites, settings from 27), each
* request paying the auth proxy + route wrapper before its own query. That
* is the customer-visible "fields load late when clicking around". These
* hooks share one SWR cache entry per company and data set, render from the
* cache (or the server seed, see components/providers/ReferenceDataSeed.tsx)
* on first paint, and revalidate in the background.
*
* Rules:
* - Keys come from lib/reference-data/keys.ts only.
* - After a client write, call invalidateReferenceData() from
* lib/reference-data/invalidate.ts; the dedupe window is bypassed.
* - `revalidateIfStale` stays on so writes made elsewhere (MCP, agent,
* another tab, SIE import) surface within a minute of the next mount.
* - Consumers needing `entity_type` read useCompany().company.entity_type:
* /api/settings falls back to companies.entity_type, this hook does not.
*/
import { useMemo } from 'react'
import useSWR, { type SWRConfiguration } from 'swr'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type {
Article,
BASAccount,
CashAccount,
Customer,
FiscalPeriod,
Supplier,
} from '@/types'
import type { DimensionDto } from '@/components/dimensions/types'
import { refKeys } from './keys'
import {
fetchAccounts,
fetchArticles,
fetchBookingTemplates,
fetchCashAccounts,
fetchCustomers,
fetchDimensions,
fetchFiscalPeriods,
fetchSuppliers,
type BookingTemplateWithUsage,
} from './fetchers'
export { useCompanySettings } from '@/components/settings/useSettings'
export const REFERENCE_SWR_OPTIONS = {
revalidateOnFocus: false,
revalidateOnReconnect: false,
// At most one background refresh per key per minute; mutate() bypasses it.
dedupingInterval: 60_000,
// Never blank a picker while a refresh is in flight.
keepPreviousData: true,
} satisfies SWRConfiguration
const EMPTY: never[] = []
export interface ReferenceListState {
/** True only while the FIRST load for this key is in flight (no cache, no seed). */
isLoading: boolean
error: unknown
/** Re-run the fetch for this key now (bypasses the dedupe window). */
refresh: () => Promise<unknown>
}
function useActiveCompanyId(): string | null {
return useCompanyOptional()?.company?.id ?? null
}
function useReferenceList<T, K extends readonly unknown[] | null>(
key: K,
fetcher: (key: NonNullable<K>) => Promise<T[]>,
): ReferenceListState & { items: T[] } {
const { data, error, isLoading, mutate } = useSWR<T[]>(
key,
fetcher as (key: unknown) => Promise<T[]>,
REFERENCE_SWR_OPTIONS,
)
return {
items: data ?? (EMPTY as T[]),
isLoading: key !== null && isLoading,
error,
refresh: mutate,
}
}
export function useFiscalPeriods(): ReferenceListState & { periods: FiscalPeriod[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.fiscalPeriods(companyId), ([, id]) =>
fetchFiscalPeriods(id),
)
return { periods: items, ...state }
}
export function useCashAccounts(
options: { enabledOnly?: boolean } = {},
): ReferenceListState & { cashAccounts: CashAccount[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.cashAccounts(companyId), ([, id]) =>
fetchCashAccounts(id),
)
const enabledOnly = options.enabledOnly ?? false
// One cache entry serves both variants: the enabled filter is cheap and
// keeping a single key means one seed and one invalidation.
const cashAccounts = useMemo(
() => (enabledOnly ? items.filter((a) => a.enabled) : items),
[items, enabledOnly],
)
return { cashAccounts, ...state }
}
export function useAccounts(activeOnly = true): ReferenceListState & { accounts: BASAccount[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.accounts(companyId, activeOnly), ([, , active]) =>
fetchAccounts(active),
)
return { accounts: items, ...state }
}
export function useDimensions(): ReferenceListState & { dimensions: DimensionDto[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.dimensions(companyId), () => fetchDimensions())
return { dimensions: items, ...state }
}
export function useBookingTemplates(): ReferenceListState & { templates: BookingTemplateWithUsage[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.bookingTemplates(companyId), () =>
fetchBookingTemplates(),
)
return { templates: items, ...state }
}
export function useCustomers(): ReferenceListState & { customers: Customer[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.customers(companyId), () => fetchCustomers())
return { customers: items, ...state }
}
export function useSuppliers(): ReferenceListState & { suppliers: Supplier[] } {
const companyId = useActiveCompanyId()
const { items, ...state } = useReferenceList(refKeys.suppliers(companyId), () => fetchSuppliers())
return { suppliers: items, ...state }
}
export function useArticles(
options: { includeInactive?: boolean } = {},
): ReferenceListState & { articles: Article[] } {
const companyId = useActiveCompanyId()
const includeInactive = options.includeInactive ?? false
const { items, ...state } = useReferenceList(
refKeys.articles(companyId, includeInactive),
([, , inactive]) => fetchArticles(inactive),
)
return { articles: items, ...state }
}