perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline (#1942)

* perf(bundle): drop the BAS chart and the Node crypto polyfill from the shared client baseline

Two chunks rode along in the first-load JS of almost every dashboard route:
the full BAS 2026 chart (315 KB uncompressed, in 81 route manifests) and
the browser polyfill for Node's crypto/vm/Buffer (327 KB, in 26 routes
incl. login and register). Neither was needed on first paint; both got
there through static imports of helpers that happen to live next to code
that needs the data or the builtin.

Node polyfill (4 pure splits, behaviour unchanged, re-exported from the
original modules for server callers):
- lib/auth/bankid-flags.ts: isBankIdEnabled (login, register, security
  settings imported it from bankid.ts, which imports crypto).
- lib/import/bank-file/formats.ts: the format registry + detection (the
  import history imported getFormat from parser.ts, which hashes).
- lib/salary/personnummer-format.ts: parsing/validation/formatting (the
  employee forms reached the encrypting personnummer.ts via tax-column).
- lib/auth/api-key-scopes.ts: scope catalogue, groups, tool map, helpers
  (the API key panel imported STAGING_SCOPES from the key generator).

BAS chart:
- lib/bookkeeping/bas-lazy.ts + use-bas-reference.ts: the chart becomes a
  dynamic import, fetched once per session after first paint; components
  that show BAS names/descriptions call useBasReference() and re-render
  when it lands. Until then (and on the server) only the hardcoded
  account-descriptions answer, so SSR and hydration agree.
- lib/bookkeeping/bas-labels.ts: class/group labels out of bas-reference.ts
  (account-descriptions needed a label and paid for the whole chart).
- lib/bookkeeping/bas-account-numbers.ts (generated, ~11 KB) +
  scripts/generate-bas-account-numbers.ts (--check) + parity test:
  isStandardBASAccountNumber for AddAccountDialog/ChartOfAccountsManager.
- lib/bookkeeping/account-classifier-{heuristic,client}.ts: the BAS-aligned
  heuristic shared by the server classifier and a client variant that uses
  the lazy chart.
- lib/bookkeeping/invoice-accounts.ts: INVOICE_FX_RATE_MISSING,
  InvoiceFxRateMissingError, getRevenueAccount, getOutputVatAccount out of
  invoice-entries.ts, whose engine import pulled account-backfill and the
  chart into SendInvoiceDialog/PaymentBookingDialog.
- CorrectOpeningBalanceDialog re-seeds names when the chart lands;
  OpeningBalanceRowEditor builds its Fuse indexes lazily; the
  ChartOfAccountsManager BAS-katalog tab awaits the chunk.

Tooling:
- scripts/perf/client-import-closure.mjs: static import closure of every
  'use client' module with the shortest chain to a target (file or bare
  specifier); found every path above without a build.
- scripts/checks/client-node-builtin.mjs wired into check:guards: a client
  module reaching a Node builtin is a hard failure (0 today).

Left as is: invoices/[id], its credit page and SendInvoiceDialog still
reach the chart through lib/invoices/issue-credit-note -> invoice-entries
-> engine -> account-backfill; splitting the engine is out of scope here.

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

* fix(perf): unambiguous import-edge regex in the closure walker (CodeQL js/redos)

One quantifier per span: a greedy [^'"]* up to the specifier quote, which it
cannot cross, so a run of whitespace has a single parse. Same edges as
before (multi-line named imports, re-exports, side-effect imports; type-only
imports still skipped).

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 15:07:49 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent e7e4efbfbc
commit 1a41119682
38 changed files with 1235 additions and 533 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { getFormat } from '@/lib/import/bank-file/parser'
import { getFormat } from '@/lib/import/bank-file/formats'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
import { cn, formatDate } from '@/lib/utils'
+31 -25
View File
@@ -1,12 +1,14 @@
'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import Fuse from 'fuse.js'
import Fuse, { type IFuseOptions } from 'fuse.js'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { getBasLoaded } from '@/lib/bookkeeping/bas-lazy'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
export interface EditableRow {
id: string
@@ -36,32 +38,32 @@ interface OpeningBalanceRowEditorProps {
}
// Balance-sheet accounts (class 1-2) drive the primary suggestions; numeric
// queries fall back to the full chart.
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
// queries fall back to the full chart. The chart is a lazily loaded chunk
// (lib/bookkeeping/bas-lazy.ts): the indexes are built on first use after
// it has arrived, and the suggestion list is empty until then.
const FUSE_OPTIONS: IFuseOptions<BASReferenceAccount> = {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
}
let fuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
let fuseInstance: Fuse<BASReferenceAccount> | null = null
function getFuse(): Fuse<BASReferenceAccount> | null {
const chart = getBasLoaded()
if (!chart) return null
if (!fuseInstance) fuseInstance = new Fuse(chart, FUSE_OPTIONS)
return fuseInstance
}
let balanceFuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getBalanceFuse() {
let balanceFuseInstance: Fuse<BASReferenceAccount> | null = null
function getBalanceFuse(): Fuse<BASReferenceAccount> | null {
const chart = getBasLoaded()
if (!chart) return null
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
balanceFuseInstance = new Fuse(
chart.filter((a) => a.account_class === 1 || a.account_class === 2),
FUSE_OPTIONS,
)
}
return balanceFuseInstance
}
@@ -159,12 +161,16 @@ export default function OpeningBalanceRowEditor({
onChangeRef.current({ rows, totals, canSubmit })
}, [rows, totals, canSubmit])
const basReady = useBasReference()
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
if (!fuse) return []
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
// basReady re-runs the search once the chart chunk has arrived.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autocompleteQuery, basReady])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
@@ -208,7 +214,7 @@ export default function OpeningBalanceRowEditor({
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
(rowId: string, account: BASReferenceAccount) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,