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;