fix(tauri-app): NavRail click → view switch via callback prop

The `bind:current={view}` pattern in NavRail did not propagate
clicks to the parent's $state. Svelte 5's $bindable on this
runtime is flakey and on this particular build (Tauri 2.11 +
svelte 5.x) the setter was never invoked when a button was
clicked, so the view state stayed 'home' no matter which rail
button the user pressed. The user reported 'search tut sich nix
genau auch bei compose etc.'

Replace with explicit callback prop:
  let { view = 'home', on_select } = $props();
  onclick={() => on_select?.(item.id)}
The parent then mutates its own `view` rune directly via the
arrow function — Svelte tracks this unconditionally regardless of
runtime-specific bindable semantics.

Includes a vitest regression test that mounts a real Svelte
component harness (jsdom) and asserts that click events on each
rail button flip the parent's `view` and update the .active
class — so any future regression is caught in CI rather than at
the Tauri app window.
This commit is contained in:
tomdebone
2026-07-06 22:59:29 +02:00
parent a60b612f68
commit 1c75d56134
7 changed files with 656 additions and 12 deletions
+6 -1
View File
@@ -373,7 +373,12 @@
</div>
{:else}
<div class="shell">
<NavRail bind:current={view} />
<NavRail
{view}
on_select={(v) => {
view = v;
}}
/>
<div class="main">
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
{#if view === "home"}
@@ -1,10 +1,18 @@
<script lang="ts">
// Same shape as the bindable NavRail but with an explicit `on_select`
// callback. Mirrors the recommended fix in the task: don't rely on
// `$bindable`, use a callback prop to bubble state changes up to
// the parent.
type View = "home" | "compose" | "profile" | "search";
// `$bindable<T>(default)` is the typed form; on older Svelte 5 builds
// the generic caused `current = item.id` to not propagate to the parent.
// Use the untyped form and rely on the destructure type for inference.
let { current = $bindable("home") }: { current: View } = $props();
let {
view = "home",
on_select,
}: {
view?: View;
on_select?: (v: View) => void;
} = $props();
const items: Array<{ id: View; label: string; key: string; icon: string }> = [
{ id: "home", label: "home", key: "g h", icon: "home" },
@@ -18,10 +26,10 @@
{#each items as item}
<button
class="rail__btn"
class:active={current === item.id}
onclick={() => (current = item.id)}
class:active={view === item.id}
onclick={() => on_select?.(item.id)}
title={`${item.label} (${item.key})`}
aria-current={current === item.id ? "page" : undefined}
aria-current={view === item.id ? "page" : undefined}
>
<span class="icon">
{#if item.icon === "home"}
@@ -0,0 +1,60 @@
// End-to-end NavRail test: verify that clicks flip the parent's view
// state AND the active button class follows. We observe the DOM —
// that's what the running Tauri webview shows, and it's what the user
// sees.
//
// Pattern: `let view = $state("home")` in the harness, then the
// `on_select` callback updates it (same shape as App.svelte).
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mount, unmount, tick } from "svelte";
import NavRailHarness from "./NavRailHarness.svelte";
let target: HTMLDivElement;
let app: ReturnType<typeof mount> | null = null;
beforeEach(() => {
target = document.createElement("div");
document.body.appendChild(target);
});
afterEach(() => {
if (app) unmount(app);
target.remove();
});
async function mountHarness() {
app = mount(NavRailHarness, { target });
await tick();
}
describe("NavRail (callback-prop pattern)", () => {
it("renders 4 buttons with home active", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
expect(btns.length).toBe(4);
expect(btns[0].classList.contains("active")).toBe(true);
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
});
it("flips view + active class on click", async () => {
await mountHarness();
const btns = target.querySelectorAll("button.rail__btn");
btns[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("search");
expect(btns[3].classList.contains("active")).toBe(true);
expect(btns[0].classList.contains("active")).toBe(false);
btns[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("compose");
expect(btns[1].classList.contains("active")).toBe(true);
btns[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(btns[2].classList.contains("active")).toBe(true);
});
});
@@ -0,0 +1,18 @@
<script lang="ts">
// Test the callback-prop pattern (no bindable). Same shape the user
// would see if we replaced the `bind:current={view}` in App.svelte
// with `<NavRail view={view} on_select={(v) => view = v} />`.
import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "search";
let view: View = $state("home");
</script>
<NavRail
{view}
on_select={(v) => {
view = v;
}}
/>
<span data-testid="view-value">{view}</span>