diff options
| author | Adam Malczewski <[email protected]> | 2026-10-21 01:04:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-21 01:04:35 +0900 |
| commit | c61b4d66921e7fe5d7d1e2a988e96785f6aedd9f (patch) | |
| tree | 8130a35661a66d28197f5f52fff036a6be523656 /src/features | |
| parent | b3a539260aa15efd197ce06c1073f0f25fee0c15 (diff) | |
| download | dispatch-web-c61b4d66921e7fe5d7d1e2a988e96785f6aedd9f.tar.gz dispatch-web-c61b4d66921e7fe5d7d1e2a988e96785f6aedd9f.zip | |
fix(review-1.3): render home tiles before layout revalidation
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/workspaces/tile-layout-store.svelte.ts | 34 | ||||
| -rw-r--r-- | src/features/workspaces/tile-layout-store.test.ts | 116 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 16 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.test.ts | 106 |
4 files changed, 258 insertions, 14 deletions
diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts index a910c73..98a5c33 100644 --- a/src/features/workspaces/tile-layout-store.svelte.ts +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -2,6 +2,7 @@ import type { TileLayoutData as ContractTileLayoutData, TileGeometryMode, } from "@dispatch/tile-layout-contract"; +import { createLocalStore } from "../../adapters/local-storage"; import type { TileLayoutHttp } from "./adapter/tile-layout-http"; import { type Cell, @@ -25,7 +26,7 @@ import { * * - **Orientation selection** — `setMode(mode)` (the home page's effect on * the resolved grid geometry) is the boot + orientation-change trigger: - * an orientation CHANGE resets to the local empty default and probes that + * an orientation CHANGE seeds from its cached server layout and probes that * orientation's document fresh. Same-mode calls are a no-op. The store * probes its INITIAL mode on creation (the boot probe). * - **Server-backed** — a successful probe applies the server document; @@ -38,7 +39,7 @@ import { * clients; the client filters by mode). * - **Quiet degrade** — any probe/write failure (backend unreachable, routes * not served) keeps the local state with EPHEMERAL edits (in-memory only — - * the retired localStorage write path is gone); logged once, no error UI. + * only server-confirmed layouts are cached); logged once, no error UI. * - **Sync gating** — `sync(ids)` no-ops while the first probe for an * orientation is in flight (`providerState === "unknown"`): merging the * live workspace list into an unprobed empty document would PUT a wiped @@ -61,7 +62,7 @@ export interface TileLayoutStore { /** * Select the active orientation and probe its document. Same-mode calls * are a no-op (the boot probe fires on creation for the initial mode); - * an orientation CHANGE resets to the local default + probes fresh. + * an orientation CHANGE seeds from its cache + probes fresh. */ setMode(mode: TileGeometryMode): void; /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */ @@ -96,6 +97,10 @@ export interface TileLayoutStore { export interface TileLayoutStoreOptions { /** The injected HTTP edge (the `home-tile-layout` plugin's routes). */ readonly http: TileLayoutHttp; + /** Initial viewport orientation (default wide). */ + readonly initialMode?: TileGeometryMode; + /** Injected localStorage for server-layout snapshots; absent → no cache. */ + readonly storage?: Storage | undefined; /** Edit-PUT debounce window in ms (default 300; tests shrink it). */ readonly debounceMs?: number; /** The quiet-failure hook (default `console.warn` — never an error UI). */ @@ -109,11 +114,16 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; const warn = opts.warn ?? ((message: string) => console.warn(message)); - let mode = $state<TileGeometryMode>("wide"); + const cache = { + wide: createLocalStore<TileLayoutData>("dispatch.tile-layout.wide", { storage: opts.storage }), + tall: createLocalStore<TileLayoutData>("dispatch.tile-layout.tall", { storage: opts.storage }), + }; + const initialMode = opts.initialMode ?? "wide"; + let mode = $state<TileGeometryMode>(initialMode); // The ACTIVE orientation's document — the server's copy once a probe has // landed (sanitized defensively; the backend validates strictly), the - // empty default until then. - let data = $state<TileLayoutData>(sanitizeLayout(null)); + // cached server snapshot (or empty default) until then. Never PUT the seed. + let data = $state<TileLayoutData>(sanitizeLayout(cache[initialMode].load())); let providerState = $state<TileLayoutProviderState>("unknown"); /** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */ @@ -141,6 +151,7 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS function applyServerLayout(next: TileLayoutData): void { lastServerLayout = next; data = next; + cache[mode].save(next); } function clearPutTimer(): void { @@ -154,7 +165,10 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS putTimer = null; // Read the CURRENT document at fire time: a newer edit supersedes the // debounced snapshot (one in-flight write per window, last wins). - const result = await http.putLayout(mode, { version: 2, tiles: data.tiles }); + const writtenMode = mode; + const result = await http.putLayout(writtenMode, { version: 2, tiles: data.tiles }); + if (result.ok) cache[writtenMode].save(sanitizeLayout(result.layout)); + if (writtenMode !== mode) return; if (result.ok) { notePresent(); // The server's validated echo is the new rollback target. @@ -227,12 +241,12 @@ export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutS setMode(next) { if (next === mode) return; mode = next; - // The document belongs to the OLD orientation now: reset to the local - // default while the probe resolves (never flash the other + // The document belongs to the OLD orientation now: seed from this + // mode's cache while the probe resolves (never flash the other // orientation's arrangement), and drop any queued edit. clearPutTimer(); lastServerLayout = null; - data = sanitizeLayout(null); + data = sanitizeLayout(cache[mode].load()); providerState = "unknown"; void runProbe(); }, diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts index 15f7a74..b5af8b6 100644 --- a/src/features/workspaces/tile-layout-store.test.ts +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -72,9 +72,11 @@ async function flushOnly(): Promise<void> { describe("createTileLayoutStore", () => { beforeEach(() => { vi.useFakeTimers(); + localStorage.clear(); }); afterEach(() => { vi.useRealTimers(); + localStorage.clear(); }); it("the boot probe applies the server document for the initial mode", async () => { @@ -86,6 +88,118 @@ describe("createTileLayoutStore", () => { expect(store.mode).toBe("wide"); }); + it("honors the initial tall mode with exactly one probe and no same-mode reset", async () => { + const http = fakeHttp({ tall: doc(tile("mobile", 0)) }); + const get = vi.spyOn(http, "getLayout"); + const store = createTileLayoutStore({ http, initialMode: "tall" }); + store.setMode("tall"); + await flushOnly(); + expect(get.mock.calls).toEqual([["tall"]]); + expect(store.mode).toBe("tall"); + expect(store.entries).toEqual([tile("mobile", 0)]); + }); + + it("seeds from cache immediately without PUT or caching edits before revalidation", async () => { + const cached = doc(tile("cached", 0, 2, 2)); + localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const http = fakeHttp(); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + http.getLayout = () => + new Promise((resolve) => { + gate.release = resolve; + }); + const store = createTileLayoutStore({ http, initialMode: "tall", storage: localStorage }); + expect(store.entries).toEqual(cached.tiles); + expect(store.providerState).toBe("unknown"); + + store.sync(["cached", "new"]); + store.resize("cached", 1, 1); + await settle(); + expect(http.puts).toEqual([]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(cached); + + const fresh = doc(tile("server", 0)); + gate.release?.({ ok: true, layout: fresh }); + await settle(); + expect(store.entries).toEqual(fresh.tiles); + expect(store.providerState).toBe("present"); + expect(http.puts).toEqual([]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(fresh); + }); + + it("resets to the new mode's cache and re-probes on orientation change", async () => { + const cached = doc(tile("cached-mobile", 0)); + localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + const get = vi.spyOn(http, "getLayout"); + const store = createTileLayoutStore({ http, storage: localStorage }); + await flushOnly(); + + store.setMode("tall"); + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual(cached.tiles); + await flushOnly(); + expect(store.entries).toEqual([]); + expect(get.mock.calls).toEqual([["wide"], ["tall"]]); + }); + + it("caches server GET, WS push, and successful PUT echoes, never unconfirmed edits", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, storage: localStorage }); + const cached = () => JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null"); + await flushOnly(); + expect(cached()).toEqual(doc(tile("a", 0))); + + store.onServerTileLayout("tall", doc(tile("ignored", 0))); + expect(localStorage.getItem("dispatch.tile-layout.tall")).toBeNull(); + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(cached()).toEqual(doc(tile("push", 0))); + + store.resize("push", 2, 2); + expect(cached()).toEqual(doc(tile("push", 0))); + await settle(); + expect(cached()).toEqual(doc(tile("push", 0, 2, 2))); + + http.failNext.put = "network down"; + store.resize("push", 1, 1); + await settle(); + expect(cached()).toEqual(doc(tile("push", 0, 2, 2))); + }); + + it("caches an in-flight PUT echo under its written mode after an orientation change", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)), tall: doc(tile("mobile", 0)) }); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + http.putLayout = () => + new Promise((resolve) => { + gate.release = resolve; + }); + const store = createTileLayoutStore({ http, storage: localStorage }); + await flushOnly(); + store.resize("desktop", 2, 2); + await settle(); + store.setMode("tall"); + await flushOnly(); + gate.release?.({ ok: true, layout: doc(tile("desktop", 0, 2, 2)) }); + await flushOnly(); + expect(store.entries).toEqual([tile("mobile", 0)]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual( + doc(tile("mobile", 0)), + ); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null")).toEqual( + doc(tile("desktop", 0, 2, 2)), + ); + }); + + it.each([ + "invalid JSON", + '{"version":2,"tiles":"bad"}', + ])("ignores malformed cache (%s) and supports storageless contexts", (raw) => { + localStorage.setItem("dispatch.tile-layout.wide", raw); + const store = createTileLayoutStore({ http: fakeHttp(), storage: localStorage }); + expect(store.entries).toEqual([]); + expect(createTileLayoutStore({ http: fakeHttp() }).entries).toEqual([]); + }); + it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => { const http = fakeHttp(); http.docs.set("wide", { junk: true } as unknown as TileLayoutData); @@ -120,6 +234,8 @@ describe("createTileLayoutStore", () => { expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); store.setMode("tall"); + expect(store.entries).toEqual([]); + expect(store.providerState).toBe("unknown"); await flushOnly(); expect(store.mode).toBe("tall"); expect(store.entries.map((t) => t.id)).toEqual(["mobile"]); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 65e1593..196e831 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -12,6 +12,7 @@ import { DEFAULT_VIEWPORT, geometryFor, + mergeLayout, pack, type GridGeometry, type Span, @@ -86,11 +87,15 @@ }); // ── The tile canvas' size decides the geometry (ONE pure function — no - // breakpoint classes). Unmeasured (tests) → the default landscape viewport. + // breakpoint classes). Start from the viewport, matching the boot probe; + // the canvas measurement then refines it (no transient wide reset on phones). + const initialViewport = typeof window === "undefined" + ? DEFAULT_VIEWPORT + : { width: window.innerWidth, height: window.innerHeight }; let regionEl = $state<HTMLDivElement | undefined>(); let size = $state<{ width: number; height: number } | undefined>(); const geometry: GridGeometry = $derived( - geometryFor(size?.width ?? DEFAULT_VIEWPORT.width, size?.height ?? DEFAULT_VIEWPORT.height), + geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.height), ); $effect(() => { @@ -115,7 +120,12 @@ // The grid view: the SAME persisted data packs into either geometry — // positions re-derive here, spans clamp for the RENDER only. - const placements = $derived(pack(tiles.entries, geometry)); + // Render-only fallback: workspace discovery must not wait for layout GET. + // Never sync/commit this provisional arrangement while the probe is unknown. + const entries = $derived(tiles.entries.length > 0 + ? tiles.entries + : mergeLayout(null, store.list.map((ws) => ws.id))); + const placements = $derived(pack(entries, geometry)); const workspacesById = $derived(new Map(store.list.map((ws) => [ws.id, ws]))); // ── Selection + action bar ───────────────────────────────────────────────── diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts index d4316b1..5a66a42 100644 --- a/src/features/workspaces/ui/WorkspacesHome.test.ts +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http"; -import type { TileLayoutHttp } from "../adapter/tile-layout-http"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "../adapter/tile-layout-http"; import type { WorkspaceLookup } from "../logic/create"; import { createTileLayoutStore } from "../tile-layout-store.svelte"; import WorkspacesHome from "./WorkspacesHome.svelte"; @@ -101,6 +101,110 @@ describe("WorkspacesHome (tile canvas)", () => { expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); }); + it.each([ + false, + true, + ])("renders before the layout probe resolves (cached: %s)", async (cached) => { + const layout = memoryLayout(); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + layout.getLayout = vi.fn( + () => + new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }), + ); + const put = vi.spyOn(layout, "putLayout"); + const stored = { + version: 2, + tiles: [ + { id: "b", order: 0, colSpan: 2, rowSpan: 2 }, + { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, + ], + }; + const storage = cached ? localStorage : undefined; + storage?.setItem("dispatch.tile-layout.tall", JSON.stringify(stored)); + const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390); + const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844); + const tiles = createTileLayoutStore({ http: layout, initialMode: "tall", storage }); + try { + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore( + fakeHttp([ws(), ws({ id: "b", title: "B" })]), + ), + onNavigate: vi.fn(), + computers: [], + tiles, + }, + }); + await screen.findByRole("button", { name: "Open workspace A" }); + await screen.findByRole("button", { name: "Open workspace B" }); + expect( + [...document.querySelectorAll("[data-tile-id]")].map((el) => + el.getAttribute("data-tile-id"), + ), + ).toEqual(cached ? ["b", "a"] : ["a", "b"]); + expect(tiles.providerState).toBe("unknown"); + expect(tiles.entries).toEqual(cached ? stored.tiles : []); + expect(layout.getLayout).toHaveBeenCalledExactlyOnceWith("tall"); + expect(put).not.toHaveBeenCalled(); + gate.release?.({ + ok: true, + layout: { + version: 2, + tiles: stored.tiles + .slice() + .reverse() + .map((tile, order) => ({ ...tile, order })), + }, + }); + await waitFor(() => { + expect(tiles.providerState).toBe("present"); + expect(tiles.entries.map((tile) => tile.id)).toEqual(["a", "b"]); + // CSS-grid coordinates determine visual order; keyed DOM nodes may stay put. + expect(document.querySelector('[data-tile-id="a"]')).toHaveStyle("grid-row: 1 / span 1"); + expect(document.querySelector('[data-tile-id="b"]')).toHaveStyle("grid-row: 2 / span 2"); + }); + } finally { + width.mockRestore(); + height.mockRestore(); + storage?.removeItem("dispatch.tile-layout.tall"); + } + }); + + it("re-probes when the canvas changes orientation", async () => { + let measure: ResizeObserverCallback = () => {}; + const observer = vi.spyOn(globalThis, "ResizeObserver").mockImplementation((callback) => { + measure = callback; + return { observe() {}, unobserve() {}, disconnect() {} }; + }); + const layout = memoryLayout(); + const get = vi.spyOn(layout, "getLayout"); + const tiles = createTileLayoutStore({ http: layout }); + try { + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), + onNavigate: vi.fn(), + computers: [], + tiles, + }, + }); + await screen.findByRole("button", { name: "Open workspace A" }); + measure( + [{ contentRect: { width: 390, height: 700 } }] as ResizeObserverEntry[], + {} as ResizeObserver, + ); + await waitFor(() => { + expect(tiles.mode).toBe("tall"); + expect(get.mock.calls).toEqual([["wide"], ["tall"]]); + }); + await screen.findByRole("button", { name: "Open workspace A" }); + } finally { + observer.mockRestore(); + } + }); + it("clicking a tile navigates to the workspace path", async () => { const user = userEvent.setup(); const onNavigate = vi.fn(); |
