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>
This commit is contained in:
Jakob Wennberg
2026-08-26 14:14:54 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent a8980a3a41
commit 47fe193c48
16 changed files with 1032 additions and 3 deletions
@@ -0,0 +1,114 @@
/**
* Proof that the raw-reference-fetch ratchet catches the thing and leaves the
* legitimate shapes alone. Offending fixtures live only in these strings and
* in an OS temp directory the end-to-end case creates and deletes.
*/
import { describe, it, expect, afterAll } from 'vitest'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
findRawReferenceFetches,
findRawReferenceFetchesInSource as scan,
isClientSource,
} from '../raw-reference-fetch.mjs'
const tempDirs: string[] = []
afterAll(() => {
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true })
})
const kinds = (source: string) => scan(source).map((f: { kind: string }) => f.kind)
describe('raw-reference-fetch: GET-shaped API calls', () => {
it('flags a bare GET of each reference path, with or without a query string', () => {
expect(kinds(`const res = await fetch('/api/settings')`)).toEqual(['api'])
expect(kinds('fetch(`/api/bookkeeping/fiscal-periods`)')).toEqual(['api'])
expect(kinds(`fetch('/api/bookkeeping/accounts?active=false')`)).toEqual(['api'])
expect(kinds(`fetch('/api/settings/booking-templates', { signal })`)).toEqual(['api'])
expect(kinds(`fetch('/api/cash-accounts?enabled_only=true', { headers: { a: '1' } })`)).toEqual(['api'])
expect(kinds(`fetch(\n '/api/customers',\n { signal: controller.signal },\n)`)).toEqual(['api'])
})
it('ignores writes: they go through the API and then invalidate the cache', () => {
expect(kinds(`fetch('/api/settings', { method: 'PUT', body })`)).toEqual([])
expect(kinds(`fetch('/api/customers', { method: 'POST', headers: { 'content-type': 'x' }, body })`)).toEqual([])
expect(kinds("fetch(`/api/settings/booking-templates/${id}/touch`, { method: 'POST' })")).toEqual([])
expect(kinds(`fetch('/api/articles/' + id, { method: 'DELETE' })`)).toEqual([])
})
it('does not confuse sub-resources or lookalike paths with the reference lists', () => {
expect(kinds("fetch(`/api/settings/booking-templates/${id}`)")).toEqual([])
expect(kinds(`fetch('/api/settings/banking')`)).toEqual([])
expect(kinds(`fetch('/api/customers/abc')`)).toEqual([])
expect(kinds(`fetch('/api/bookkeeping/accounts/bas-catalog')`)).toEqual([])
})
})
describe('raw-reference-fetch: regex safety', () => {
it('scans a pathological near-miss in linear time (no catastrophic backtracking)', () => {
// A fetch call that never closes, padded with the whitespace/comma mix
// the old `\s*,?\s*\)` tail was ambiguous on.
const source = `fetch('/api/settings'${' ,'.repeat(5000)}${' '.repeat(5000)}X`
const start = performance.now()
expect(scan(source)).toEqual([])
expect(performance.now() - start).toBeLessThan(200)
})
})
describe('raw-reference-fetch: use-client detection is linear too', () => {
it('handles a long run of unclosed block comments without backtracking', () => {
const source = `${'/* '.repeat(3000)}'use client'\n`
const start = performance.now()
expect(isClientSource(source)).toBe(false)
expect(performance.now() - start).toBeLessThan(200)
})
it('still sees the directive behind closed comments', () => {
expect(isClientSource(`/* a */ /* b */\n// c\n'use client'\n`)).toBe(true)
})
})
describe('raw-reference-fetch: browser-side table reads', () => {
const clientRead = `'use client'\nimport x from 'y'\nconst { data } = await supabase.from('fiscal_periods').select('*').eq('company_id', id)`
it('flags a select on a reference table only in a use-client file', () => {
expect(kinds(clientRead)).toEqual(['table'])
expect(kinds(clientRead.replace(`'use client'\n`, ''))).toEqual([])
})
it('recognises the directive behind leading comments and double quotes', () => {
expect(isClientSource(`// header\n/* block */\n"use client"\n`)).toBe(true)
expect(isClientSource(`import a from 'b'\n'use client'`)).toBe(false)
})
it('leaves inserts and updates alone', () => {
expect(kinds(`'use client'\nawait supabase.from('fiscal_periods').insert(row)`)).toEqual([])
expect(kinds(`'use client'\nawait supabase.from('company_settings').update(patch).eq('company_id', id)`)).toEqual([])
})
})
describe('raw-reference-fetch: file scan', () => {
it('reports offending files relative to the root and skips sanctioned, api and test files', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'raw-ref-'))
tempDirs.push(root)
const write = (rel: string, content: string) => {
const full = path.join(root, rel)
fs.mkdirSync(path.dirname(full), { recursive: true })
fs.writeFileSync(full, content)
}
write('components/Bad.tsx', `'use client'\nfetch('/api/settings')`)
write('components/Fine.tsx', `'use client'\nfetch('/api/settings', { method: 'PUT' })`)
write('components/__tests__/Bad.test.tsx', `fetch('/api/settings')`)
write('components/Bad.test.ts', `fetch('/api/settings')`)
write('lib/reference-data/fetchers.ts', `fetch('/api/settings')`)
write('app/api/x/route.ts', `fetch('/api/settings')`)
write('app/(dashboard)/reports/page.tsx', `'use client'\nconst r = await fetch('/api/bookkeeping/fiscal-periods')`)
write('lib/server-thing.ts', `await supabase.from('company_settings').select('*')`)
expect(findRawReferenceFetches(root)).toEqual([
'app/(dashboard)/reports/page.tsx',
'components/Bad.tsx',
])
})
})
+60
View File
@@ -32,5 +32,65 @@
"components/salary/NewEmployeeDialog.tsx",
"components/transactions/InvoiceMatchDialog.tsx"
]
},
"rawReferenceFetch": {
"count": 55,
"files": [
"app/(dashboard)/assets/[id]/dispose/page.tsx",
"app/(dashboard)/bookkeeping/year-end/page.tsx",
"app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx",
"app/(dashboard)/customers/page.tsx",
"app/(dashboard)/import/page.tsx",
"app/(dashboard)/invoices/[id]/page.tsx",
"app/(dashboard)/invoices/page.tsx",
"app/(dashboard)/pending/page.tsx",
"app/(dashboard)/salary/employees/[id]/page.tsx",
"app/(dashboard)/salary/runs/[id]/page.tsx",
"app/(dashboard)/supplier-invoices/[id]/page.tsx",
"app/(dashboard)/transactions/page.tsx",
"components/articles/ArticleForm.tsx",
"components/bookkeeping/BookingTemplatePicker.tsx",
"components/bookkeeping/ChartOfAccounts.tsx",
"components/bookkeeping/ChartOfAccountsManager.tsx",
"components/bookkeeping/CorrectionEntryDialog.tsx",
"components/bookkeeping/EditAccountDialog.tsx",
"components/bookkeeping/JournalEntryForm.tsx",
"components/bookkeeping/JournalEntryList.tsx",
"components/bookkeeping/StrikeLinesDialog.tsx",
"components/bookkeeping/TemplateBookDialog.tsx",
"components/common/CashAccountSelector.tsx",
"components/common/FiscalYearSelector.tsx",
"components/common/FyPicker.tsx",
"components/dimensions/types.ts",
"components/extensions/general/ArcimMigrationWorkspace.tsx",
"components/extensions/general/BookDirectlyDialog.tsx",
"components/extensions/general/InvoiceInboxWorkspace.tsx",
"components/extensions/general/TicWorkspace.tsx",
"components/import/BankFileConfirmStep.tsx",
"components/import/FiscalYearGapNotice.tsx",
"components/import/ImportReviewStep.tsx",
"components/import/OpeningBalancePeriodStep.tsx",
"components/invoices/InvoiceEditor.tsx",
"components/invoices/PaymentBookingDialog.tsx",
"components/invoices/SendInvoiceDialog.tsx",
"components/pending-operations/use-account-names.ts",
"components/reports/SkatteverketPanel.tsx",
"components/reports/views/index.tsx",
"components/salary/NewEmployeeDialog.tsx",
"components/settings/BookingTemplatesPanel.tsx",
"components/settings/FiscalPeriodEditor.tsx",
"components/settings/FiscalYearsManager.tsx",
"components/settings/InvoicePaymentAccountsSettings.tsx",
"components/supplier-invoices/use-supplier-invoice-data.ts",
"components/suppliers/SupplierForm.tsx",
"components/transactions/BulkBookDialog.tsx",
"components/transactions/DuplicateBookingDialog.tsx",
"components/transactions/InvoiceMatchDialog.tsx",
"components/transactions/MatchVoucherDialog.tsx",
"components/transactions/QuickReviewDialog.tsx",
"components/transactions/TemplatePicker.tsx",
"components/transactions/TransactionBookingDialog.tsx",
"extensions/general/enable-banking/components/AccountPickerDialog.tsx"
]
}
}
+35 -1
View File
@@ -121,6 +121,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { findSekLabelledFxAmounts } from './format-currency-sek-label.mjs'
import { findRawReferenceFetches } from './raw-reference-fetch.mjs'
import {
findExtensionRouteFindings,
UNGATED_EXTENSION_ROUTES,
@@ -1016,6 +1017,7 @@ const current = {
foldedPublicFlags: findFoldedPublicFlags(),
dialogOverflowRisk: findDialogOverflowRisks(),
directAiClients: findDirectAiClients(),
rawReferenceFetch: findRawReferenceFetches(ROOT),
}
const dialogOverflowFiles = [...new Set(current.dialogOverflowRisk.map((f) => f.file))].sort()
@@ -1037,6 +1039,10 @@ if (isUpdate) {
count: dialogOverflowFiles.length,
files: dialogOverflowFiles,
},
rawReferenceFetch: {
count: current.rawReferenceFetch.length,
files: current.rawReferenceFetch,
},
}
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
console.log(
@@ -1275,6 +1281,31 @@ if (newLedgerScans.length) {
)
}
// 1d. raw-reference-fetch: per-file ratchet. A file outside the baseline set
// that fetches reference data raw (see raw-reference-fetch.mjs) is a NEW
// violation; grandfathered files stay until they move to the hooks. Once the
// baseline reaches 0, delete the entry so any new site is a hard failure.
const rawRefBaseline = new Set(baseline.rawReferenceFetch?.files ?? [])
const newRawRefs = current.rawReferenceFetch.filter((f) => !rawRefBaseline.has(f))
const fixedRawRefs = (baseline.rawReferenceFetch?.files ?? []).filter(
(f) => !current.rawReferenceFetch.includes(f),
)
if (newRawRefs.length) {
failed = true
console.error(
`\n✗ raw-reference-fetch: ${newRawRefs.length} file(s) fetch reference data raw ` +
`(fiscal periods, settings, accounts, cash accounts, dimensions, templates, customers, suppliers, articles):`,
)
newRawRefs.forEach((f) => console.error(` ${f}`))
console.error(
' → read it through the hooks in lib/reference-data/hooks.ts (useFiscalPeriods, useAccounts,\n' +
' useCashAccounts, useCompanySettings, useDimensions, useBookingTemplates, useCustomers,\n' +
' useSuppliers, useArticles) and call invalidateReferenceData() after writes. Those hooks\n' +
' share one session cache and are seeded by the dashboard layout, so the fields render\n' +
' on first paint instead of after another round trip.',
)
}
// 1e3. dialog-overflow-risk: per-file ratchet, a finding in a file outside
// the baseline set is a NEW violation. Grandfathered files stay until fixed.
const dialogOverflowBaseline = new Set(baseline.dialogOverflowRisk?.files ?? [])
@@ -1315,6 +1346,7 @@ if (
fixedAuthFiles.length ||
fixedLedgerScans.length ||
fixedDialogOverflow.length ||
fixedRawRefs.length ||
current.naiveOreRound < baseline.naiveOreRound.count
) {
console.log('\n✓ Progress since baseline:')
@@ -1323,6 +1355,8 @@ if (
console.log(` ledger-scanning-report: -${fixedLedgerScans.length} file(s)`)
if (fixedDialogOverflow.length)
console.log(` dialog-overflow-risk: -${fixedDialogOverflow.length} file(s)`)
if (fixedRawRefs.length)
console.log(` raw-reference-fetch: -${fixedRawRefs.length} file(s)`)
if (current.naiveOreRound < baseline.naiveOreRound.count)
console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`)
console.log(' Run with --update to ratchet the baseline down and lock in the gains.')
@@ -1347,5 +1381,5 @@ if (failed) {
process.exit(1)
}
console.log(
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`,
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`,
)
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* Guard: reference data fetched outside lib/reference-data.
*
* Fiscal periods, company settings, the chart of accounts, cash accounts,
* dimensions, booking templates, customers, suppliers and articles are
* session-cached behind the hooks in lib/reference-data/hooks.ts (seeded
* from the dashboard layout, invalidated after writes). Before that layer
* existed the same lists were fetched raw from 47 / 27 / 14 / 8 / 12 / 5
* independent call sites, uncached, on every mount and every dialog open,
* which is what a customer described as "it takes time before all fields
* load when clicking around" (2026-08-26). This check keeps that number
* going down: an existing raw call site is grandfathered in the baseline,
* a NEW one fails CI.
*
* Two shapes are flagged:
* 1. A GET-shaped `fetch('/api/<reference path>')` anywhere under app/,
* components/, extensions/ or lib/ (a relative URL is client code by
* definition). Writes (`method: 'POST' | 'PUT' | ...`) are fine: they
* go through the API and then call invalidateReferenceData().
* 2. A browser-side `.from('<reference table>').select(` in a file that
* carries the 'use client' directive. Server code reading those tables
* is legitimate and is not scanned.
*
* Sanctioned (RAW_REFERENCE_SANCTIONED): the fetchers themselves, the
* pre-existing SWR settings hook, and the static BAS catalog loader (a
* different, module-cached data set).
*/
import fs from 'node:fs'
import path from 'node:path'
export const REFERENCE_API_PATHS = [
'bookkeeping/fiscal-periods',
'settings/booking-templates',
'settings',
'bookkeeping/accounts',
'cash-accounts',
'dimensions',
'customers',
'suppliers',
'articles',
]
export const REFERENCE_TABLES = [
'fiscal_periods',
'company_settings',
'chart_of_accounts',
'cash_accounts',
]
export const RAW_REFERENCE_SANCTIONED = new Set([
'lib/reference-data/fetchers.ts',
'components/settings/useSettings.ts',
'lib/bookkeeping/bas-catalog-client.ts',
])
const SCAN_DIRS = ['app/(dashboard)', 'components', 'extensions', 'lib']
const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage', '__tests__'])
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
// fetch(`/api/settings`), fetch('/api/settings?x=1'), fetch('/api/settings', { signal })
// The init object may nest one level ({ headers: { ... } }); its text is
// captured so a write method can be excluded. Trailing commas (prettier's
// multi-line call style) are tolerated before the closing paren. Every
// optional whitespace run is anchored by a literal (`,` or `)`) so the
// pattern has no ambiguous backtracking (CodeQL js/redos on the earlier
// `\s*,?\s*\)` shape).
const REFERENCE_URL = String.raw`[\x60'"]/api/(?:${REFERENCE_API_PATHS.map(escape).join('|')})(?:\?[^\x60'"]*)?[\x60'"]`
const INIT_OBJECT = String.raw`\{(?:[^{}]|\{[^{}]*\})*\}`
const API_FETCH_RE = new RegExp(
String.raw`fetch\(\s*${REFERENCE_URL}(?:\s*,\s*(${INIT_OBJECT}))?(?:\s*,)?\s*\)`,
'g',
)
const WRITE_METHOD_RE = /method\s*:\s*[\x60'"](?!GET\b)/i
const TABLE_SELECT_RE = new RegExp(
String.raw`\.from\(\s*['"](?:${REFERENCE_TABLES.map(escape).join('|')})['"]\s*\)\s*\.\s*select\(`,
'g',
)
// Leading whitespace and comments before the directive. A block comment body
// is `(?:[^*]|\*(?!\/))*`, which cannot cross a `*/`, so each iteration of the
// outer star has exactly one parse: the lazy `[\s\S]*?` form let an unclosed
// `/*` be re-split at every later `/*` (CodeQL js/redos).
// Single-character whitespace alternative (not \s+): a `+` inside the outer
// `*` is a nested quantifier on the same character (CodeQL js/redos).
const USE_CLIENT_RE = /^(?:\s|\/\/[^\n]*\n|\/\*(?:[^*]|\*(?!\/))*\*\/)*['"]use client['"]/
export function isClientSource(source) {
return USE_CLIENT_RE.test(source)
}
/**
* Findings for one file's source: `{ kind: 'api' | 'table', index }`.
* Exported for the unit test; the file-level scan below uses it.
*/
export function findRawReferenceFetchesInSource(source) {
const findings = []
for (const match of source.matchAll(API_FETCH_RE)) {
const init = match[1]
if (init && WRITE_METHOD_RE.test(init)) continue
findings.push({ kind: 'api', index: match.index ?? 0 })
}
if (isClientSource(source)) {
for (const match of source.matchAll(TABLE_SELECT_RE)) {
findings.push({ kind: 'table', index: match.index ?? 0 })
}
}
return findings
}
function walk(dir, out) {
if (!fs.existsSync(dir)) return
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full, out)
else if (/\.(?:ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) out.push(full)
}
}
/** Sorted repo-relative paths of files with at least one raw reference fetch. */
export function findRawReferenceFetches(root) {
const files = []
for (const dir of SCAN_DIRS) walk(path.join(root, dir), files)
const offenders = []
for (const file of files) {
const rel = path.relative(root, file).split(path.sep).join('/')
if (rel.startsWith('app/api/') || RAW_REFERENCE_SANCTIONED.has(rel)) continue
const source = fs.readFileSync(file, 'utf8')
if (findRawReferenceFetchesInSource(source).length) offenders.push(rel)
}
return [...new Set(offenders)].sort()
}