From 8a3d9d67d262a67c603df669fd1a3d7bfaacaf6a Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 16 Oct 2026 01:57:58 +0900 Subject: [PATCH] fix: guard deferred state when leaving workspace routes --- .../navigation/ObserveDeferredHarness.svelte | 14 +++ .../observe-deferred.browser.spec.ts | 91 +++++++++++++++++++ .../navigation/observe-deferred.svelte.ts | 14 ++- 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 web/workspace/src/lib/workspace/navigation/ObserveDeferredHarness.svelte create mode 100644 web/workspace/src/lib/workspace/navigation/observe-deferred.browser.spec.ts diff --git a/web/workspace/src/lib/workspace/navigation/ObserveDeferredHarness.svelte b/web/workspace/src/lib/workspace/navigation/ObserveDeferredHarness.svelte new file mode 100644 index 000000000..0ff8ec8af --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/ObserveDeferredHarness.svelte @@ -0,0 +1,14 @@ + + +

{loaded.failure?.message ?? 'no failure'}

+

{loaded.data ?? 'no data'}

+

{String(loaded.loading)}

diff --git a/web/workspace/src/lib/workspace/navigation/observe-deferred.browser.spec.ts b/web/workspace/src/lib/workspace/navigation/observe-deferred.browser.spec.ts new file mode 100644 index 000000000..b42ff90b2 --- /dev/null +++ b/web/workspace/src/lib/workspace/navigation/observe-deferred.browser.spec.ts @@ -0,0 +1,91 @@ +// @vitest-environment happy-dom +import { cleanup, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import { tick } from "svelte"; +import ObserveDeferredHarness from "./ObserveDeferredHarness.svelte"; +import { type DeferredResult, settle } from "./deferred.ts"; + +afterEach(cleanup); + +function gate() { + let resolve!: (value: DeferredResult) => void; + const promise = new Promise>((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +test("missing route identity neither dereferences empty state nor reads removed layout data", () => { + const read = vi.fn(() => { + throw new Error("layout data is no longer available"); + }); + const view = render(ObserveDeferredHarness, { identity: undefined, read }); + expect(view.getByTestId("failure").textContent).toBe("no failure"); + expect(view.getByTestId("data").textContent).toBe("no data"); + expect(read).not.toHaveBeenCalled(); +}); + +test("leaving a loaded Workspace hides its data and can enter another Workspace", async () => { + const view = render(ObserveDeferredHarness, { + identity: "team", + read: () => settle("Team"), + }); + await view.findByText("Team"); + const removed = vi.fn(() => { + throw new Error("removed layout data"); + }); + await view.rerender({ identity: undefined, read: removed }); + expect(view.getByTestId("failure").textContent).toBe("no failure"); + expect(view.getByTestId("data").textContent).toBe("no data"); + expect(removed).not.toHaveBeenCalled(); + await view.rerender({ + identity: "other", + read: () => settle("Other Workspace"), + }); + await view.findByText("Other Workspace"); +}); + +test("late results after leaving a Workspace cannot restore its data or failure", async () => { + for ( + const result of [ + { ok: true, data: "Old Workspace" }, + { + ok: false, + failure: { message: "Old failure", status: 403, redirect: null }, + }, + ] satisfies DeferredResult[] + ) { + const pending = gate(); + const view = render(ObserveDeferredHarness, { + identity: "team", + read: () => pending.promise, + }); + await view.rerender({ identity: undefined, read: () => pending.promise }); + pending.resolve(result); + await tick(); + expect(view.getByTestId("failure").textContent).toBe("no failure"); + expect(view.getByTestId("data").textContent).toBe("no data"); + view.unmount(); + } +}); + +test("same-Workspace refresh retains data, while denied refresh and identity changes hide it", async () => { + const view = render(ObserveDeferredHarness, { + identity: "team", + read: () => settle("Team"), + }); + await view.findByText("Team"); + const refresh = gate(); + await view.rerender({ identity: "team", read: () => refresh.promise }); + expect(view.getByTestId("data").textContent).toBe("Team"); + refresh.resolve({ + ok: false, + failure: { message: "Denied", status: 403, redirect: null }, + }); + await view.findByText("Denied"); + expect(view.getByTestId("data").textContent).toBe("no data"); + await view.rerender({ identity: undefined, read: () => refresh.promise }); + await waitFor(() => + expect(view.getByTestId("failure").textContent).toBe("no failure") + ); +}); diff --git a/web/workspace/src/lib/workspace/navigation/observe-deferred.svelte.ts b/web/workspace/src/lib/workspace/navigation/observe-deferred.svelte.ts index 82458c7aa..2339d8f44 100644 --- a/web/workspace/src/lib/workspace/navigation/observe-deferred.svelte.ts +++ b/web/workspace/src/lib/workspace/navigation/observe-deferred.svelte.ts @@ -3,7 +3,7 @@ import type { DeferredFailure, DeferredResult } from "./deferred.ts"; // 同じ対象の再取得では確定済み表示を保持し、対象切替では旧値を即座に隠す。 export function observeDeferred( read: () => Promise>, - identity: () => string, + identity: () => string | undefined, ) { let settled = $state.raw<{ identity: string; value: T } | null>(null); let failure = $state.raw<{ identity: string; value: DeferredFailure } | null>( @@ -12,6 +12,12 @@ export function observeDeferred( let loading = $state(true); $effect(() => { const key = identity(); + // Leaving a scoped route can remove its params before the layout is destroyed. + if (key === undefined) { + loading = false; + failure = null; + return; + } const promise = read(); let active = true; loading = true; @@ -31,10 +37,12 @@ export function observeDeferred( }); return { get data() { - return settled?.identity === identity() ? settled.value : null; + const key = identity(); + return settled !== null && settled.identity === key ? settled.value : null; }, get failure() { - return failure?.identity === identity() ? failure.value : null; + const key = identity(); + return failure !== null && failure.identity === key ? failure.value : null; }, get loading() { return loading;