perf(invoices): the invoice editor renders on the first paint from the session cache (#1937)

"Ny faktura" was the slowest form to fill in: the list page lazy-loaded
NewInvoiceDialog, which lazy-loaded InvoiceEditor (ssr:false), which then
issued four requests on mount (customers, articles, chart of accounts,
company settings) and hid the ENTIRE form behind a spinner until the
customers query alone resolved, even though the other three had landed.
Reopening the dialog paid all of it again.

- InvoiceEditor reads customers, articles, posting accounts and settings
  from lib/reference-data (seeded by the dashboard layout). The whole-form
  spinner gate is gone; the customer picker shows "Hämtar kunder ..." only
  while the list is genuinely uncached. Company settings are applied once
  per editor instance through a guarded effect, so a background
  revalidation can never re-run the create-mode prefills over notes or a
  reference the user has typed. Inline customer/article creation
  invalidates the shared cache (awaited, so the new option resolves before
  the line points at it). Customers now come through /api/customers, which
  masks the personnummer column; nothing in the editor rendered it.
- NewInvoiceDialog imports the editor statically: the dialog is itself a
  next/dynamic chunk on the list page, so this is one deferred chunk
  download when the dialog opens instead of two sequential ones.
- New strings: invoice_editor.loading_customers (sv + en).

Per "Ny faktura": 2 sequential chunk loads -> 1; blocking mount requests
4 -> 0 (cached) with every field populated on the first render.
raw-reference-fetch ratchet: 46 -> 45 files. SendInvoiceDialog and
PaymentBookingDialog (init() flows) stay in the baseline for a later PR.

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:43:09 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 567fae654c
commit 40e773548c
5 changed files with 85 additions and 96 deletions
+75 -80
View File
@@ -59,6 +59,8 @@ import CustomerForm from '@/components/customers/CustomerForm'
import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog'
import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { useAccounts, useArticles, useCompanySettings, useCustomers } from '@/lib/reference-data/hooks'
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import {
@@ -87,7 +89,7 @@ import {
buildSelfBilledPayload,
hasDimensionValues,
} from '@/lib/invoices/editor-payload'
import type { Customer, Currency, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
import type { Customer, Currency, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem } from '@/types'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
@@ -377,8 +379,41 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
type FormData = z.infer<typeof schema>
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
// Reference data from the session cache (lib/reference-data): customers,
// articles, posting accounts and company settings are read from SWR
// instead of four fetches per mount, so a cached editor renders every
// field populated on the first paint and reopening the dialog costs no
// requests. Customers come through /api/customers, which masks the
// personnummer column; nothing here rendered it.
const { customers: cachedCustomers, isLoading: customersLoading, error: customersError } = useCustomers()
// Archived customers (v1 API soft-delete) are not offered in the picker
// (/api/customers filters archived_at IS NULL). An existing draft or copied
// invoice may still point at one (archiving only refuses when open invoices
// exist, drafts do not count), so that single row is fetched on its own and
// kept in the list, or the select would render blank.
const keepCustomerId = initial?.customer_id ?? copyInitial?.customer_id ?? null
const [keptCustomer, setKeptCustomer] = useState<Customer | null>(null)
useEffect(() => {
if (!keepCustomerId || customersLoading) return
if (cachedCustomers.some((c) => c.id === keepCustomerId)) return
let cancelled = false
fetch(`/api/customers/${encodeURIComponent(keepCustomerId)}`)
.then((res) => (res.ok ? res.json() : null))
.then((json: { data?: Customer } | null) => {
if (!cancelled && json?.data) setKeptCustomer(json.data)
})
.catch(() => {
// The picker simply shows no selection for a customer that no longer resolves.
})
return () => {
cancelled = true
}
}, [keepCustomerId, customersLoading, cachedCustomers])
const customers = useMemo(() => {
if (!keptCustomer || cachedCustomers.some((c) => c.id === keptCustomer.id)) return cachedCustomers
return [...cachedCustomers, keptCustomer].sort((a, b) => a.name.localeCompare(b.name))
}, [cachedCustomers, keptCustomer])
const { settings: companySettings } = useCompanySettings()
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSavingDraft, setIsSavingDraft] = useState(false)
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
@@ -403,11 +438,18 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const [numberPreview, setNumberPreview] = useState<string | null>(null)
const [logoUrl, setLogoUrl] = useState<string | null>(null)
// Artikelregister: active articles for the line picker + which line is mid quick-create.
const [articles, setArticles] = useState<ArticleOption[]>([])
const { articles: articleRows } = useArticles()
// Numeric-aware order by article number ('2' before '10', unnumbered last):
// the picker should follow the user's own numbering, not the alphabet.
const articles = useMemo(() => sortArticles(articleRows as ArticleOption[]), [articleRows])
const [savingArticleIndex, setSavingArticleIndex] = useState<number | null>(null)
// Active balance-sheet and revenue accounts for the optional per-line
// posting override, plus which rows currently show that picker.
const [postingAccounts, setPostingAccounts] = useState<BASAccount[]>([])
const { accounts: activeAccounts } = useAccounts()
const postingAccounts = useMemo(
() => activeAccounts.filter((account) => account.account_class >= 1 && account.account_class <= 3),
[activeAccounts],
)
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
// Dimension tagging (kostnadsställe/projekt, dimensions PR7). Affordances
// render only when company_settings.dimensions_enabled: a UI-visibility
@@ -608,37 +650,13 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}, [customers, setValue])
useEffect(() => {
if (!company?.id) return
fetchCustomers()
fetchDefaultNotes()
fetchArticles()
fetchRevenueAccounts()
}, [company?.id])
async function fetchArticles() {
if (!company?.id) return
const { data } = await supabase
.from('articles')
.select('id, article_number, name, unit, price_excl_vat, vat_rate, revenue_account, currency, housework_type')
.eq('company_id', company.id)
.eq('active', true)
// Numeric-aware order by article number ('2' before '10', unnumbered last):
// the picker should follow the user's own numbering, not the alphabet.
setArticles(sortArticles((data ?? []) as ArticleOption[]))
}
async function fetchRevenueAccounts() {
if (!company?.id) return
try {
const res = await fetch('/api/bookkeeping/accounts')
const body = await res.json()
const accounts = ((body?.data as BASAccount[]) || [])
.filter((account) => account.account_class >= 1 && account.account_class <= 3)
setPostingAccounts(accounts)
} catch {
// Non-fatal: the override picker degrades to free 4-digit entry.
}
}
if (!customersError) return
toast({
title: t('load_customers_failed_title'),
description: t('load_customers_failed_description'),
variant: 'destructive',
})
}, [customersError, toast, t])
// Apply a chosen article's defaults onto a line. Selecting "none" detaches the
// article link (and its account override) but keeps the typed text/price so the
@@ -762,7 +780,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
throw new Error(getErrorMessage(result, { context: 'article', statusCode: response.status }))
}
const created = result.data as ArticleOption
setArticles((prev) => sortArticles([...prev, created]))
// Every article picker on the page reads the shared cache: refresh it
// (awaited, so the new option resolves before the line points at it).
await invalidateReferenceData('ref:articles')
setValue(`items.${index}.article_id`, created.id, { shouldDirty: true })
toast({ title: t('article_saved_title'), description: created.name })
} catch (error) {
@@ -913,13 +933,16 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
})
}
async function fetchDefaultNotes() {
if (!company?.id) return
const { data } = await supabase
.from('company_settings')
.select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered, dimensions_enabled, invoice_payment_links_enabled')
.eq('company_id', company.id)
.single()
// Apply the company settings ONCE per editor instance. The settings row is
// a live SWR value: a background revalidation must not re-run the
// create-mode prefills below over notes or a reference the user has since
// typed. The gates read each time (vatRegistered, dimensions, payment
// links) are cheap and deliberately re-applied too, they are not user input.
const settingsAppliedRef = useRef(false)
useEffect(() => {
const data = companySettings
if (!data || settingsAppliedRef.current) return
settingsAppliedRef.current = true
if (data?.invoice_default_notes) {
setDefaultNotes(data.invoice_default_notes)
if (!isEditMode && !isCopyMode) {
@@ -950,7 +973,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
setDimensionsEnabled(data?.dimensions_enabled === true)
// Gates the payment-link section (opt-in on the invoice settings page).
setPaymentLinksEnabled(data?.invoice_payment_links_enabled === true)
}
// The initial-mode flags and the draft's own rounding are fixed for the
// editor's lifetime; setValue is stable (react-hook-form).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companySettings])
// First-invoice detection (issue #520): captured at page load so the
// post-create flow can offer the logo prompt for genuinely first-time
@@ -1040,31 +1066,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}, [watchCustomerId, customers, setValue])
async function fetchCustomers() {
if (!company?.id) return
// Archived customers (v1 API soft-delete) are not offered in the picker.
// An existing draft or copied invoice may still point at one (archiving
// only refuses when open invoices exist, drafts do not count), so that
// single row is kept in the list or the select would render blank.
const keepCustomerId = initial?.customer_id ?? copyInitial?.customer_id ?? null
const base = supabase.from('customers').select('*').eq('company_id', company.id)
const query = keepCustomerId
? base.or(`archived_at.is.null,id.eq.${keepCustomerId}`)
: base.is('archived_at', null)
const { data, error } = await query.order('name', { ascending: true })
if (error) {
toast({
title: t('load_customers_failed_title'),
description: t('load_customers_failed_description'),
variant: 'destructive',
})
} else {
setCustomers(data || [])
}
setIsLoading(false)
}
async function handleCreateCustomer(data: CreateCustomerInput) {
setIsCreatingCustomer(true)
@@ -1088,7 +1089,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
description: t('customer_created_description', { name: data.name }),
})
pendingCustomerRef.current = result.data
setCustomers(prev => [...prev, result.data])
// The selection effect above picks the pending customer up as soon as
// the refreshed shared list contains it.
await invalidateReferenceData('ref:customers')
setIsCreateCustomerOpen(false)
}
@@ -1797,14 +1800,6 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
const titleText = isEditMode
? t('title_edit')
: isCopyMode
@@ -2006,7 +2001,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
sliver; give the zero-customer state real content. */}
{customers.length === 0 && (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{t('no_customers_yet')}
{customersLoading ? t('loading_customers') : t('no_customers_yet')}
</div>
)}
{customers.map((customer) => (
+7 -14
View File
@@ -1,6 +1,5 @@
'use client'
import dynamic from 'next/dynamic'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
@@ -16,19 +15,13 @@ import {
type InvoiceCopySource,
} from '@/lib/invoices/copy-invoice'
// Deferred: the editor (and its framer-motion dependency) is a large chunk
// that would otherwise ship with the invoice LIST bundle: it is only needed
// once this dialog actually opens.
const InvoiceEditor = dynamic(() => import('@/components/invoices/InvoiceEditor'), {
ssr: false,
loading: () => (
<div className="space-y-4 p-6">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
),
})
// The editor (and its framer-motion dependency) is a large chunk that must
// not ship with the invoice LIST bundle. This dialog is itself loaded with
// next/dynamic by app/(dashboard)/invoices/page.tsx, so a static import here
// keeps the editor in the dialog's deferred chunk: ONE chunk download when
// "Ny faktura" opens instead of the dialog chunk followed by a second,
// dependent editor chunk (and then the editor's own data fetches).
import InvoiceEditor from '@/components/invoices/InvoiceEditor'
interface Props {
open: boolean
+1
View File
@@ -3758,6 +3758,7 @@
"bank_add_now": "Add now",
"customer_card_title": "Customer",
"select_customer_placeholder": "Select customer",
"loading_customers": "Loading customers ...",
"no_customers_yet": "No customers yet",
"create_customer": "Create customer",
"create_customer_dialog_title": "Add customer",
+1
View File
@@ -3758,6 +3758,7 @@
"bank_add_now": "Lägg till nu",
"customer_card_title": "Kund",
"select_customer_placeholder": "Välj kund",
"loading_customers": "Hämtar kunder ...",
"no_customers_yet": "Inga kunder än",
"create_customer": "Skapa kund",
"create_customer_dialog_title": "Lägg till kund",
+1 -2
View File
@@ -34,7 +34,7 @@
]
},
"rawReferenceFetch": {
"count": 46,
"count": 45,
"files": [
"app/(dashboard)/assets/[id]/dispose/page.tsx",
"app/(dashboard)/bookkeeping/year-end/page.tsx",
@@ -62,7 +62,6 @@
"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",