Skip to content

refactor: Svelte 5 best practices, avoid $effect (#47) - #52

Merged
patrickiel merged 1 commit into
mainfrom
issue-47-svelte5-best-practices
Nov 10, 2026
Merged

patrickiel merged 1 commit into
mainfrom
issue-47-svelte5-best-practices

Conversation

@patrickiel

Copy link
Copy Markdown
Owner

Refactors ui/ and site/ toward Svelte 5 best practices, checked with the Svelte MCP's svelte-autofixer. There should be no behaviour change.

Effects removed

Was Now
ModuleCard reset rename/menu when edit mode ends writable $derived (renamingName, menuOpen) that resets whenever editing changes
ModuleCard cleared previews + menuPage on close writable $deriveds keyed on menuOpen, so every close path is covered, including Popover's Escape and outside click
ModuleCard size tween {@attach tweenSize} on the panel; bind:this for panel and clip removed
Select onOpenChange effect bind:open={() => open, setOpen} plus an unmount report of "closed"
Switch text measurement {@attach measureLines}
SetupWizard input metering {@attach armMetering} on the input step's meter list
Rack trackDevicePixelRatio() effect devicePixelRatio() now uses createSubscriber; reading it subscribes
site Header spin counter the bind:scrollY setter counts the toggles
ToolSidebar use: action {@attach fromAction(...)}

Effects kept, each with a // Effect: justification

Kept because no handler sees the change, and converting them would change behaviour:

  • Rack content-learn disarm and MidiLearn hide. Both write back through a $bindable that App reads to pause MIDI dispatch. What triggers the disarm is an engine push or a host prop with no handler in the component. A plain $derived would keep App's flag stuck on, and would re-arm the learn when the target came back.
  • Rack last-active-lane memory. It records history of engine pushes, because a scene recall or rig load also moves the lane. It is now a plain record rather than $state, since only a handler reads it.
  • Rack pendingSceneId. It latches the first convergence of the engine's echoes. As a $derived, an edit made after the switch settled would bring the spinner back.
  • StrobeDisplay. It feeds the rAF loop's plain locals and wakes the loop. It also has to keep the last reference after the reading drops.
  • Timers, document writes and engine requests: the baseline settle timer, the scene-route re-measure inside its attachment, GainMeter's clip hold, TunerReadout's hold/fade, Popover's open stack, and App's theme and tone3000Verify.

Guard: ui/src/lib/effectGuard.test.ts fails on any $effect in ui/src or site/src without a // Effect: comment directly above it. It runs as part of pnpm test.

Autofixer

  • issues: [] on every .svelte / .svelte.ts in both packages.
  • App.svelte used to fail to parse because of a UTF-8 BOM. The BOM is gone, and the mojibake in its comments (—) is repaired. MockEngine.ts has the same mojibake and is left alone.
  • Converted class: directives to class arrays, with one exception: the module panel. tweenSize toggles module-resizing through classList, and a class array rewrites className whole whenever an entry flips, so it would strip that class mid-tween.
  • Remaining suggestions are left on purpose:
    • the kept effects above;
    • element bind:this refs that handlers use for focus, click or scroll;
    • function-local Maps that never escape into reactive state, where SvelteMap would be wrong.
  • Removed one unused svelte-ignore.

AGENTS.md Code style now carries the rule: prefer $derived / handlers / function bindings / {@attach} / createSubscriber, justify any remaining $effect, check with svelte-autofixer, and keep class: only where classList is also written.

Verification

  • ui/: pnpm format:check, pnpm check (0 errors, 0 warnings) and pnpm test all pass: 784 tests, including the new guard, which was checked to fail on an unjustified effect.
  • site/: pnpm --dir site check passes. site/src/routes/+page.svelte already had a Prettier warning before this branch, and it is untouched here.
  • Manual checks in pnpm dev (MockEngine), with no console errors:
    • module card: insert, options menu, Appearance page with colour preview, Escape clearing the preview, reopening on the main page, inline rename cancelled by Perform→Edit, edit-controls unfold;
    • Select: filter + Enter picks and closes;
    • setup wizard: input step meters armed and detecting a channel;
    • split: routes render; Switch→B→Mix→Switch lands back on B;
    • tuner needle moving.
  • Not checked by hand: strobe mode, gain-meter clip, MIDI learn, scenes, and the Songs setlist Select. Their effects only gained comments, except the setlist Select, which goes through the new setOpen path.

Closes #47

…fect

State that follows other state is now a writable $derived (ModuleCard's
rename, menu, page and previews), a reaction lives in its handler or a
function binding's setter (Select's onOpenChange, the site header's spin
counter), DOM work is an {@Attach} (Switch's line measurement, ModuleCard's
size tween, the setup wizard's input metering, ToolSidebar's action via
fromAction), and devicePixelRatio() subscribes on read through
createSubscriber, so Rack needs no effect to track it.

Every remaining $effect carries a `// Effect:` line saying why it is one,
and src/lib/effectGuard.test.ts fails on one that does not, across ui/ and
site/. class: directives become class arrays, except on the module panel,
whose classList the size tween writes. App.svelte loses the UTF-8 BOM that
made svelte-autofixer fail to parse it, and its mojibake comments are
repaired. AGENTS.md records the rule.

Refs #47
@patrickiel
patrickiel merged commit 59aa96b into main Nov 10, 2026
7 checks passed
@patrickiel
patrickiel deleted the issue-47-svelte5-best-practices branch November 10, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor the Svelte UI to Svelte 5 best practices (avoid $effect), checked with the Svelte MCP

1 participant