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>(
|
export function observeDeferred<T>(
|
||||||
read: () => Promise<DeferredResult<T>>,
|
read: () => Promise<DeferredResult<T>>,
|
||||||
identity: () => string,
|
identity: () => string | undefined,
|
||||||
) {
|
) {
|
||||||
let settled = $state.raw<{ identity: string; value: T } | null>(null);
|
let settled = $state.raw<{ identity: string; value: T } | null>(null);
|
||||||
let failure = $state.raw<{ identity: string; value: DeferredFailure } | null>(
|
let failure = $state.raw<{ identity: string; value: DeferredFailure } | null>(
|
||||||
@@ -12,6 +12,12 @@ export function observeDeferred<T>(
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const key = identity();
|
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();
|
const promise = read();
|
||||||
let active = true;
|
let active = true;
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -31,10 +37,12 @@ export function observeDeferred<T>(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
get data() {
|
get data() {
|
||||||
return settled?.identity === identity() ? settled.value : null;
|
const key = identity();
|
||||||
|
return settled !== null && settled.identity === key ? settled.value : null;
|
||||||
},
|
},
|
||||||
get failure() {
|
get failure() {
|
||||||
return failure?.identity === identity() ? failure.value : null;
|
const key = identity();
|
||||||
|
return failure !== null && failure.identity === key ? failure.value : null;
|
||||||
},
|
},
|
||||||
get loading() {
|
get loading() {
|
||||||
return loading;
|
return loading;
|
||||||
|
|||||||
Reference in New Issue
Block a user