fix: retain uncertain optimistic mutations
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,10 @@ describe("async UI state matrix", () => {
|
||||
[{ data: ["value"], isFetching: true }, "refreshing"],
|
||||
[{ data: ["value"], isStale: true, isDegraded: true }, "stale-degraded"],
|
||||
[{ data: ["value"], isMutationPending: true }, "mutation-pending"],
|
||||
[
|
||||
{ data: ["value"], hasMutationEffectUnknown: true },
|
||||
"mutation-effect-unknown",
|
||||
],
|
||||
[{ data: ["value"], hasMutationConflict: true }, "mutation-conflict"],
|
||||
])("derives overlay state %#", (signals, indicator) => {
|
||||
expect(deriveAsyncState(signals).indicator).toBe(indicator);
|
||||
@@ -35,16 +39,55 @@ describe("async UI state matrix", () => {
|
||||
data: ["value"],
|
||||
isFetching: true,
|
||||
isMutationPending: true,
|
||||
hasMutationEffectUnknown: true,
|
||||
hasMutationConflict: true,
|
||||
});
|
||||
expect(state.indicator).toBe("mutation-conflict");
|
||||
expect(state.indicator).toBe("mutation-effect-unknown");
|
||||
expect(state.overlay).toMatchObject({
|
||||
refreshing: false,
|
||||
mutationPending: false,
|
||||
mutationConflict: true,
|
||||
mutationEffectUnknown: true,
|
||||
mutationConflict: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unknown mutation effects with reconciliation-only actions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const retry = vi.fn();
|
||||
const reconcile = vi.fn();
|
||||
const state = deriveAsyncState({
|
||||
data: ["value"],
|
||||
hasMutationEffectUnknown: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<AsyncSurface
|
||||
state={state}
|
||||
onRetry={retry}
|
||||
onReconcileUnknownEffect={reconcile}
|
||||
>
|
||||
existing content
|
||||
</AsyncSurface>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"변경 결과를 확인할 수 없습니다.",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("existing content").closest("section"),
|
||||
).toHaveAttribute("aria-busy", "false");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "다시 시도" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "변경됨으로 확인" }));
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
|
||||
);
|
||||
expect(reconcile).toHaveBeenNthCalledWith(1, "APPLIED");
|
||||
expect(reconcile).toHaveBeenNthCalledWith(2, "NOT_APPLIED");
|
||||
expect(retry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps content visible while a non-blocking refresh runs", () => {
|
||||
const state = deriveAsyncState({ data: ["value"], isFetching: true });
|
||||
render(<AsyncSurface state={state}>existing content</AsyncSurface>);
|
||||
|
||||
@@ -281,6 +281,7 @@ describe("reference feature page states", () => {
|
||||
"CONFLICT",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "NOT_APPLIED" },
|
||||
),
|
||||
});
|
||||
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
||||
@@ -290,6 +291,112 @@ describe("reference feature page states", () => {
|
||||
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
|
||||
});
|
||||
|
||||
it("blocks resubmit and exposes only reconciliation for an unknown create effect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "MAYBE_APPLIED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
await user.type(
|
||||
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
|
||||
"Unknown result",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("변경 결과를 확인할 수 없습니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
).toHaveValue("Unknown result");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "변경되지 않음으로 확인" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "저장" })).toBeEnabled(),
|
||||
);
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /새 항목 이름/ }),
|
||||
).toHaveValue("Unknown result");
|
||||
});
|
||||
|
||||
it("settles the form after confirming an unknown create was applied", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "MAYBE_APPLIED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
const name = await screen.findByRole("textbox", {
|
||||
name: /새 항목 이름/,
|
||||
});
|
||||
await user.type(name, "Already created");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "변경됨으로 확인" }),
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
||||
expect(name).toHaveValue("");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("treats an applied-confirmed failure as a settled create", async () => {
|
||||
const user = userEvent.setup();
|
||||
const createResource = vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: createFailure(
|
||||
"SERVER_FAILURE",
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
0,
|
||||
{ effect: "APPLIED_CONFIRMED" },
|
||||
),
|
||||
}));
|
||||
renderReference(
|
||||
inputWith({ createResource }),
|
||||
"/examples/reference-resources/new",
|
||||
);
|
||||
const name = await screen.findByRole("textbox", {
|
||||
name: /새 항목 이름/,
|
||||
});
|
||||
await user.type(name, "Committed despite response");
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("저장했습니다.");
|
||||
expect(name).toHaveValue("");
|
||||
expect(
|
||||
screen.queryByText("저장하지 못했습니다. 잠시 후 다시 시도해 주세요."),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "저장" }));
|
||||
expect(createResource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
|
||||
const user = userEvent.setup();
|
||||
const listResources = vi
|
||||
|
||||
@@ -4,5 +4,6 @@ export const invalidPendingConflict: AsyncOverlay = {
|
||||
refreshing: false,
|
||||
staleDegraded: false,
|
||||
mutationPending: true,
|
||||
mutationEffectUnknown: false,
|
||||
mutationConflict: true,
|
||||
};
|
||||
|
||||
@@ -71,6 +71,71 @@ describe("revision-safe optimistic layer runtime", () => {
|
||||
expect(client.getQueryData(key)).toEqual(["server", "pending"]);
|
||||
});
|
||||
|
||||
it("keeps later committed layers projected while an earlier effect is uncertain", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "resources"];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const selectedScope = scope();
|
||||
const runtime = createOptimisticLayerRuntime(client);
|
||||
const append = (previous: unknown, input: string) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
];
|
||||
const first = runtime.begin(
|
||||
key,
|
||||
"first",
|
||||
append,
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
const second = runtime.begin(
|
||||
key,
|
||||
"second",
|
||||
append,
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
|
||||
first?.markUncertain();
|
||||
first?.commit();
|
||||
first?.rollback();
|
||||
second?.commit();
|
||||
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
|
||||
|
||||
first?.reconcile("NOT_APPLIED");
|
||||
expect(client.getQueryData(key)).toEqual(["base", "second"]);
|
||||
});
|
||||
|
||||
it("collapses uncertain and later committed layers in order when reconciled as applied", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "resources"];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const selectedScope = scope();
|
||||
const runtime = createOptimisticLayerRuntime(client);
|
||||
const append = (previous: unknown, input: string) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
];
|
||||
const first = runtime.begin(
|
||||
key,
|
||||
"first",
|
||||
append,
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
const second = runtime.begin(
|
||||
key,
|
||||
"second",
|
||||
append,
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
|
||||
first?.markUncertain();
|
||||
second?.commit();
|
||||
first?.reconcile("APPLIED");
|
||||
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
|
||||
|
||||
first?.reconcile("NOT_APPLIED");
|
||||
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
|
||||
});
|
||||
|
||||
it("removes scoped data instead of restoring it after scope expiry", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "resources"];
|
||||
@@ -87,4 +152,24 @@ describe("revision-safe optimistic layer runtime", () => {
|
||||
layer?.rollback();
|
||||
expect(client.getQueryData(key)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not restore an uncertain layer after its scope expires", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "resources"];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const selectedScope = scope();
|
||||
const runtime = createOptimisticLayerRuntime(client);
|
||||
const layer = runtime.begin(
|
||||
key,
|
||||
"uncertain",
|
||||
(previous, input) => [...(previous as string[]), input],
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
layer?.markUncertain();
|
||||
|
||||
selectedScope.expire();
|
||||
layer?.reconcile("APPLIED");
|
||||
|
||||
expect(client.getQueryData(key)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user