fix: guard deferred state when leaving workspace routes

This commit is contained in:
2026-10-16 01:57:58 +09:00
parent 18384482f6
commit 8a3d9d67d2
3 changed files with 116 additions and 3 deletions
@@ -0,0 +1,14 @@
<script lang="ts">
import { observeDeferred } from './observe-deferred.svelte.ts';
import type { DeferredResult } from './deferred.ts';
let { identity, read }: {
identity: string | undefined;
read: () => Promise<DeferredResult<string>>;
} = $props();
const loaded = observeDeferred(() => read(), () => identity);
</script>
<p data-testid="failure">{loaded.failure?.message ?? 'no failure'}</p>
<p data-testid="data">{loaded.data ?? 'no data'}</p>
<p data-testid="loading">{String(loaded.loading)}</p>
@@ -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<string>) => void;
const promise = new Promise<DeferredResult<string>>((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<string>[]
) {
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")
);
});
@@ -3,7 +3,7 @@ import type { DeferredFailure, DeferredResult } from "./deferred.ts";
// 同じ対象の再取得では確定済み表示を保持し、対象切替では旧値を即座に隠す。
export function observeDeferred<T>(
read: () => Promise<DeferredResult<T>>,
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<T>(
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<T>(
});
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;