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
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { motion, AnimatePresence, useReducedMotion, animate } from 'framer-motion'
import { RotateCw } from 'lucide-react'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import { useTranslations } from 'next-intl'
import { formatCurrency } from '@/lib/utils'
import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep'
@@ -240,6 +241,9 @@ const PULSE_DUR: Record<Cadence, number> = { weekly: 1.15, monthly: 2.7, irregul
export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; companyName: string }) {
const t = useTranslations('agentKnowledge')
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
const reduce = useReducedMotion() ?? false
const model = useMemo(() => buildModel(deep), [deep])
+7 -3
View File
@@ -15,8 +15,9 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, AlertTriangle } from 'lucide-react'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { isStandardBASAccountNumber } from '@/lib/bookkeeping/bas-account-numbers'
import { classifyAccountClient as classifyAccount } from '@/lib/bookkeeping/account-classifier-client'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import type { BASAccount } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { AccountVatTreatmentSelect } from './AccountVatTreatmentSelect'
@@ -49,6 +50,9 @@ export function AddAccountDialog({
initialAccountNumber,
initialAccountName,
}: AddAccountDialogProps) {
// Loads the BAS chart chunk after mount so classification and the
// standard-account check get the authoritative answer once it lands.
useBasReference()
const [accountNumber, setAccountNumber] = useState('')
const [accountName, setAccountName] = useState('')
const [description, setDescription] = useState('')
@@ -82,7 +86,7 @@ export function AddAccountDialog({
}
}, [open, initialAccountNumber, initialAccountName])
const isBASMatch = accountNumber.length === 4 && isStandardBASAccount(accountNumber)
const isBASMatch = accountNumber.length === 4 && isStandardBASAccountNumber(accountNumber)
const derived = accountNumber.length === 4 ? classifyAccount(accountNumber) : null
async function handleCreate() {
@@ -29,7 +29,9 @@ import {
import { cn } from '@/lib/utils'
import type { BASAccount } from '@/types'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import { isStandardBASAccountNumber } from '@/lib/bookkeeping/bas-account-numbers'
import { ensureBasLoaded } from '@/lib/bookkeeping/bas-lazy'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// ---------------------------------------------------------------------------
@@ -134,6 +136,9 @@ export default function ChartOfAccountsManager() {
(a: { account_number: string; is_active: boolean; is_system_account: boolean }) => [a.account_number, a],
),
)
// The full chart is a lazily loaded chunk: only the BAS-katalog tab pays
// for it, not every route that renders this component's parent.
const BAS_REFERENCE = await ensureBasLoaded()
const merged: ReferenceAccount[] = BAS_REFERENCE.map((ref) => {
const userAccount = userMap.get(ref.account_number)
return {
@@ -561,7 +566,7 @@ export default function ChartOfAccountsManager() {
{t('system_badge')}
</span>
)}
{!isStandardBASAccount(account.account_number) && (
{!isStandardBASAccountNumber(account.account_number) && (
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground">
{t('own_badge')}
</span>
@@ -14,7 +14,8 @@ import { AlertTriangle } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import { getBasLoadedByNumber } from '@/lib/bookkeeping/bas-lazy'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import OpeningBalanceRowEditor, {
type EditableRow,
type OpeningBalanceEditorState,
@@ -33,14 +34,15 @@ let seedIdCounter = 0
// Map the booked IB's lines into editable rows. account_name isn't stored on
// the line, so resolve it from BAS for display (cosmetic: only account_number
// + amounts are sent on save).
// + amounts are sent on save). The chart is a lazily loaded chunk: the
// caller re-seeds once it has arrived.
function seedRowsFromEntry(entry: JournalEntry): EditableRow[] {
const lines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
return lines.map((l) => {
const bas = BAS_REFERENCE.find((a) => a.account_number === l.account_number)
const bas = getBasLoadedByNumber(l.account_number)
return {
id: l.id || `seed_${++seedIdCounter}`,
account_number: l.account_number,
@@ -67,7 +69,10 @@ export default function CorrectOpeningBalanceDialog({
onCorrected,
}: Props) {
const { toast } = useToast()
const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry])
const basReady = useBasReference()
// basReady is a re-seed trigger: names fill in once the chart chunk lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry, basReady])
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -50,6 +50,7 @@ import { useFiscalPeriods } from '@/lib/reference-data/hooks'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import NoDocRequiredToggle from '@/components/bookkeeping/NoDocRequiredToggle'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
@@ -219,6 +220,9 @@ export default function JournalEntryList({
const { canWrite } = useCanWrite()
const company = useCompanyOptional()?.company ?? null
const t = useTranslations('journal_list')
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
const [entries, setEntries] = useState<JournalEntry[]>([])
const [committingId, setCommittingId] = useState<string | null>(null)
// Confirm-before-posting (convention 10): the draft the user is about to
+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,
+1 -1
View File
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react'
import { isMfaRequired } from '@/lib/auth/mfa'
import { isBankIdEnabled } from '@/lib/auth/bankid'
import { isBankIdEnabled } from '@/lib/auth/bankid-flags'
import { isSelfHosted as readSelfHostedFlag } from '@/lib/env/public-flags'
import { AutoLogoutToggle } from '@/components/settings/AutoLogoutToggle'
import { BankIdSettings } from '@/components/settings/BankIdSettings'
@@ -26,6 +26,7 @@ import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import { formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
@@ -162,6 +163,9 @@ export default function NewSupplierInvoiceForm({
const { canWrite } = useCanWrite()
const { toast } = useToast()
const t = useTranslations('supplier_invoice_editor')
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
const ta = useTranslations('accruals')
// When opened from an invoice-inbox item, every redirect should land the
+4
View File
@@ -1,6 +1,7 @@
'use client'
import { getAccountDescription, type AccountType } from '@/lib/bookkeeping/account-descriptions'
import { useBasReference } from '@/lib/bookkeeping/use-bas-reference'
import {
Tooltip,
TooltipTrigger,
@@ -41,6 +42,9 @@ export function AccountNumber({
size = 'default',
className,
}: AccountNumberProps) {
// Loads the BAS chart chunk after mount and re-renders once names and
// descriptions for non-hardcoded accounts are available.
useBasReference()
const desc = getAccountDescription(number)
const displayName = desc?.name ?? name