From c61b4d66921e7fe5d7d1e2a988e96785f6aedd9f Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 21 Oct 2026 01:04:35 +0900 Subject: fix(review-1.3): render home tiles before layout revalidation --- src/features/workspaces/ui/WorkspacesHome.svelte | 16 +++- src/features/workspaces/ui/WorkspacesHome.test.ts | 106 +++++++++++++++++++++- 2 files changed, 118 insertions(+), 4 deletions(-) (limited to 'src/features/workspaces/ui') 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(); 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((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(); -- cgit v1.2.3