Part of the code-health / complexity review of src/. Correctness/perf smell
in the hottest state module.
Summary
In src/lib/state/calculator.svelte.ts the public getters that templates read
on every render — rows, validationSummary, and stpDisplayUnit (around
lines 377–397) — each call a compute*() function on every property access
rather than exposing memoized $derived values.
get rows() { return computeRows(); } // re-parses every row, every read
get stpDisplayUnit() { return getStpDisplayUnit(); } // re-resolves unit, every read
get validationSummary() { return computeValidationSummary(); }
Why this needs doing
computeRows() iterates all input rows and, per row, re-runs parseRow()
(energy parsing + unit conversion + result lookup) and resolveParticleMass().
These getters are read multiple times per render (table body, footer,
validation banner, export wiring), so the same parse work runs several times
for a single frame.
- It side-steps Svelte 5's fine-grained reactivity.
$derived memoizes and only
recomputes when a tracked dependency actually changes; a plain getter cannot,
so we pay full cost on every read regardless of whether inputs changed.
- It's a latent correctness hazard: any code that reads the getter twice and
assumes a stable reference (e.g. === identity checks, or capturing then
comparing) gets a fresh object each time.
The project mandates runes; this is exactly the case $derived exists for.
Scope
- Introduce module-level
$derived / $derived.by values for the three
computations (and any sibling getter in the file with the same pattern — audit
the whole returned object).
- Keep the public getter surface identical (
get rows() returns the derived
value) so no call sites change.
- Confirm the
compute* functions are pure reads of reactive state (no
side-effects); if any mutate state, that must be untangled first.
Acceptance criteria
Files
src/lib/state/calculator.svelte.ts
src/tests/unit/calculator-state.test.ts (add memoization assertion)
AI logging
- CHANGELOG-AI.md entry + short log.
Summary
In
src/lib/state/calculator.svelte.tsthe public getters that templates readon every render —
rows,validationSummary, andstpDisplayUnit(aroundlines 377–397) — each call a
compute*()function on every property accessrather than exposing memoized
$derivedvalues.Why this needs doing
computeRows()iterates all input rows and, per row, re-runsparseRow()(energy parsing + unit conversion + result lookup) and
resolveParticleMass().These getters are read multiple times per render (table body, footer,
validation banner, export wiring), so the same parse work runs several times
for a single frame.
$derivedmemoizes and onlyrecomputes when a tracked dependency actually changes; a plain getter cannot,
so we pay full cost on every read regardless of whether inputs changed.
assumes a stable reference (e.g.
===identity checks, or capturing thencomparing) gets a fresh object each time.
The project mandates runes; this is exactly the case
$derivedexists for.Scope
$derived/$derived.byvalues for the threecomputations (and any sibling getter in the file with the same pattern — audit
the whole returned object).
get rows()returns the derivedvalue) so no call sites change.
compute*functions are pure reads of reactive state (noside-effects); if any mutate state, that must be untangled first.
Acceptance criteria
rows,validationSummary,stpDisplayUnitare backed by$derived.memoized value (add a focused unit test asserting referential stability).
calculator-state.test.ts/calculator-url.test.tspass unchanged.pnpm check,pnpm lint,pnpm testpass.Files
src/lib/state/calculator.svelte.tssrc/tests/unit/calculator-state.test.ts(add memoization assertion)AI logging