fix: guard deferred state when leaving workspace routes
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user