commit 729fa8b322e181728838385605521e1c9eb3babb
parent 282f611c3feb7eb7b4cde1d865f5c259fceaaf3c
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 23:17:55 +0100
merchant web UI: invalidate editor and catalog caches after mutations
Diffstat:
8 files changed, 285 insertions(+), 10 deletions(-)
diff --git a/packages/taler-merchant-webui/src/api/cache.test.tsx b/packages/taler-merchant-webui/src/api/cache.test.tsx
@@ -0,0 +1,181 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { render } from "preact";
+import { act } from "preact/test-utils";
+import { mutate } from "swr";
+import { seedSWRCache as seed } from "../testing/swr-cache.js";
+import { TemplateType } from "@gnu-taler/taler-util";
+import { useInventory, useCategories } from "./hooks/useInventory.js";
+import { useWebhooks } from "./hooks/useWebhooks.js";
+import { useTemplates } from "./hooks/useTemplates.js";
+import { useTokenFamilies } from "./hooks/useTokenFamilies.js";
+import { invalidateMerchantCache } from "./cache.js";
+import { useHttpLibForTesting } from "./client.js";
+import { FakeHttpLib, noContent, ok } from "../testing/fake-http.js";
+import { signIn, signOut } from "../stores/session.js";
+
+for (const entity of [
+ "product",
+ "webhook",
+ "template",
+ "family",
+ "category",
+] as const) {
+ for (const operation of ["update", "delete", "failure"] as const) {
+ test(`${entity} ${operation} invalidates dependent caches only after success`, async () => {
+ const backend = `https://${entity}-${operation}.example.test/`;
+ const token = "secret-token:test";
+ const key = (name: string, id?: string) => [
+ name,
+ backend,
+ "shop",
+ token,
+ ...(id === undefined ? [] : [id]),
+ ];
+ const resource = {
+ product: "getProductDetails",
+ webhook: "getWebhookDetails",
+ template: "getTemplateDetails",
+ family: "getTokenFamilyDetails",
+ category: "listProducts",
+ }[entity];
+ const affected = [
+ key(resource, entity === "category" ? undefined : "item"),
+ ];
+ if (entity === "product") affected.push(key("listCategories"));
+ if (entity === "category" && operation === "delete")
+ affected.push(key("getProductDetails", "item"));
+ const untouched = [
+ key(resource, "other"),
+ [resource, backend, "other-shop", token, "item"],
+ [resource, backend, "shop", "other-token", "item"],
+ [resource, "https://other.example.test/", "shop", token, "item"],
+ ];
+ // No detail view is mounted: revalidation alone cannot remove these snapshots.
+ for (const k of [...affected, ...untouched])
+ await seed(k, { name: "Old" });
+ const http = new FakeHttpLib().handle((request) => {
+ if (request.method === "GET")
+ return ok({
+ slug: "item",
+ name: "Old",
+ description: "Old",
+ kind: "subscription",
+ valid_after: { t_s: 0 },
+ valid_before: { t_s: "never" },
+ duration: { d_us: 86400000000 },
+ issued: 0,
+ used: 0,
+ });
+ if (operation === "failure") throw new Error("save rejected");
+ return noContent();
+ });
+ const restoreHttp = useHttpLibForTesting(http);
+ signIn("shop", token, backend);
+ const container = document.body.appendChild(
+ document.createElement("div"),
+ );
+ let run!: () => Promise<unknown>;
+ function Probe() {
+ const inventory = useInventory({ enabled: false });
+ const webhooks = useWebhooks();
+ const templates = useTemplates({ enabled: false });
+ const families = useTokenFamilies({ enabled: false });
+ const categories = useCategories({ enabled: false });
+ const deleting = operation === "delete";
+ run = {
+ product: () =>
+ deleting
+ ? inventory.deleteProduct("item")
+ : inventory.updateProduct("item", {
+ price: "EUR:3",
+ categories: [1],
+ }),
+ webhook: () =>
+ deleting
+ ? webhooks.deleteWebhook("item")
+ : webhooks.updateWebhook("item", {
+ id: "item",
+ url: "https://new.example.test/",
+ event: "pay",
+ }),
+ template: () =>
+ deleting
+ ? templates.deleteTemplate("item")
+ : templates.updateTemplate("item", "New", {
+ template_type: TemplateType.FIXED_ORDER,
+ amount: "EUR:3",
+ }),
+ family: () =>
+ deleting
+ ? families.deleteTokenFamily("item")
+ : families.updateTokenFamily("item", { name: "New" }),
+ category: () =>
+ deleting
+ ? categories.deleteCategory(1)
+ : categories.updateCategory(1, "New"),
+ }[entity];
+ return null;
+ }
+ try {
+ await act(async () => render(<Probe />, container));
+ if (operation === "failure")
+ await assert.rejects(run(), /save rejected/);
+ else
+ await act(async () => {
+ await run();
+ });
+ for (const k of affected)
+ assert.deepEqual(
+ await mutate(k),
+ operation === "failure" ? { name: "Old" } : undefined,
+ );
+ // A category edit refreshes every product list variant in this session.
+ for (const k of untouched.filter(
+ (k) =>
+ !(
+ entity === "category" &&
+ k[2] === "shop" &&
+ k[3] === token &&
+ k[1] === backend
+ ),
+ )) {
+ assert.deepEqual(await mutate(k), { name: "Old" });
+ }
+ } finally {
+ act(() => render(null, container));
+ container.remove();
+ restoreHttp();
+ signOut();
+ }
+ });
+ }
+}
+
+test("list invalidation clears all cached filters and pages within the session", async () => {
+ const config = {
+ rootUrl: new URL("https://lists.example.test/"),
+ account: "shop",
+ };
+ for (const resource of ["listOrders", "merchant-incoming-transfers"]) {
+ const keys = ["all", "paid", "refunded"].map((filter) => [
+ resource,
+ config.rootUrl.href,
+ config.account,
+ "token",
+ filter,
+ ]);
+ for (const key of keys) await seed(key, ["old"]);
+ await invalidateMerchantCache(config, "token", resource);
+ for (const key of keys) assert.equal(await mutate(key), undefined);
+ }
+});
diff --git a/packages/taler-merchant-webui/src/api/cache.ts b/packages/taler-merchant-webui/src/api/cache.ts
@@ -0,0 +1,31 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+ */
+
+import { mutate } from "swr";
+import type { BackendConfig } from "./client.js";
+
+/** Clear stale snapshots even when their views are unmounted, and reload active views. */
+export async function invalidateMerchantCache(
+ config: BackendConfig,
+ token: string,
+ resource: string,
+ id?: string,
+): Promise<void> {
+ await mutate(
+ (key) =>
+ Array.isArray(key) &&
+ key[0] === resource &&
+ key[1] === config.rootUrl.href &&
+ key[2] === config.account &&
+ key[3] === token &&
+ (id === undefined || key[4] === id),
+ undefined,
+ { revalidate: true },
+ );
+}
diff --git a/packages/taler-merchant-webui/src/api/hooks/useInventory.ts b/packages/taler-merchant-webui/src/api/hooks/useInventory.ts
@@ -20,6 +20,7 @@ import { merchantClient } from "../client.js";
import { unwrap, unwrapEmpty } from "../failure.js";
import { session } from "../../stores/session.js";
import type { CategoryItem, ProductItem } from "../../types/domain.js";
+import { invalidateMerchantCache } from "../cache.js";
import { getClientConfig } from "./common.js";
import { remoteResource } from "../contracts.js";
import { useTranslation } from "../../context/translation.js";
@@ -151,6 +152,7 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) {
};
const res = await client.addProduct(token as AccessToken, body);
unwrap(res);
+ await invalidateMerchantCache(config, token, "listCategories");
await mutate();
};
@@ -170,6 +172,13 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) {
body,
);
unwrapEmpty(res);
+ await invalidateMerchantCache(
+ config,
+ token,
+ "getProductDetails",
+ productId,
+ );
+ await invalidateMerchantCache(config, token, "listCategories");
await mutate();
};
@@ -180,6 +189,13 @@ export function useInventory({ enabled = true }: { enabled?: boolean } = {}) {
force,
});
unwrapEmpty(res);
+ await invalidateMerchantCache(
+ config,
+ token,
+ "getProductDetails",
+ productId,
+ );
+ await invalidateMerchantCache(config, token, "listCategories");
await mutate();
};
@@ -293,6 +309,7 @@ export function useCategories({ enabled = true }: { enabled?: boolean } = {}) {
{ name },
);
unwrap(res);
+ await invalidateMerchantCache(config, token, "listProducts");
await mutate();
};
@@ -304,6 +321,8 @@ export function useCategories({ enabled = true }: { enabled?: boolean } = {}) {
String(categoryId),
);
unwrap(res);
+ await invalidateMerchantCache(config, token, "listProducts");
+ await invalidateMerchantCache(config, token, "getProductDetails");
await mutate();
};
diff --git a/packages/taler-merchant-webui/src/api/hooks/useTemplates.ts b/packages/taler-merchant-webui/src/api/hooks/useTemplates.ts
@@ -24,6 +24,7 @@ import { captureSessionIdentity, session } from "../../stores/session.js";
import { Duration } from "@gnu-taler/taler-util";
import { templatePayUri } from "../../utils/templates.js";
import type { TemplateItem } from "../../types/domain.js";
+import { invalidateMerchantCache } from "../cache.js";
import { getClientConfig } from "./common.js";
import { remoteResource } from "../contracts.js";
import {
@@ -133,6 +134,12 @@ export function useTemplates({ enabled = true }: { enabled?: boolean } = {}) {
...templateWriteExtras(extra),
});
unwrap(res);
+ await invalidateMerchantCache(
+ config,
+ token,
+ "getTemplateDetails",
+ templateId,
+ );
await mutate();
};
@@ -151,6 +158,12 @@ export function useTemplates({ enabled = true }: { enabled?: boolean } = {}) {
const client = merchantClient(config);
const res = await client.deleteTemplate(token as AccessToken, templateId);
unwrap(res);
+ await invalidateMerchantCache(
+ config,
+ token,
+ "getTemplateDetails",
+ templateId,
+ );
await mutate();
};
diff --git a/packages/taler-merchant-webui/src/api/hooks/useTokenFamilies.ts b/packages/taler-merchant-webui/src/api/hooks/useTokenFamilies.ts
@@ -24,6 +24,7 @@ import {
import { merchantClient } from "../client.js";
import { unwrap, unwrapEmpty } from "../failure.js";
import { session } from "../../stores/session.js";
+import { invalidateMerchantCache } from "../cache.js";
import { getClientConfig } from "./common.js";
import { remoteResource } from "../contracts.js";
import { mapWithConcurrency } from "../concurrency.js";
@@ -223,6 +224,7 @@ export function useTokenFamilies({
const client = merchantClient(config);
const res = await client.deleteTokenFamily(token as AccessToken, slug);
unwrapEmpty(res);
+ await invalidateMerchantCache(config, token, "getTokenFamilyDetails", slug);
await mutate();
};
@@ -245,6 +247,7 @@ export function useTokenFamilies({
body,
);
unwrap(res);
+ await invalidateMerchantCache(config, token, "getTokenFamilyDetails", slug);
await mutate();
};
diff --git a/packages/taler-merchant-webui/src/api/hooks/useWebhooks.ts b/packages/taler-merchant-webui/src/api/hooks/useWebhooks.ts
@@ -20,6 +20,7 @@ import { merchantClient } from "../client.js";
import { unwrap, unwrapEmpty } from "../failure.js";
import { session } from "../../stores/session.js";
import type { WebhookEndpoint } from "../../types/domain.js";
+import { invalidateMerchantCache } from "../cache.js";
import { getClientConfig } from "./common.js";
import { remoteResource } from "../contracts.js";
import { mapWithConcurrency } from "../concurrency.js";
@@ -107,6 +108,12 @@ export function useWebhooks() {
webhookDetails(wh),
);
unwrapEmpty(res);
+ await invalidateMerchantCache(
+ config,
+ token,
+ "getWebhookDetails",
+ webhookId,
+ );
await mutate();
};
@@ -115,6 +122,12 @@ export function useWebhooks() {
const client = merchantClient(config);
const res = await client.deleteWebhook(token as AccessToken, webhookId);
unwrapEmpty(res);
+ await invalidateMerchantCache(
+ config,
+ token,
+ "getWebhookDetails",
+ webhookId,
+ );
await mutate();
};
diff --git a/packages/taler-merchant-webui/src/routes/CreateProductRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateProductRoute.tsx
@@ -26,11 +26,7 @@ import { CreateProductScreen } from "../screens/CreateProductScreen.js";
export function CreateProductRoute({ editId }: { editId?: string }): VNode {
const { createProduct, updateProduct } = useInventory();
- const {
- product,
- resource: productResource,
- mutate: refreshProduct,
- } = useProductDetails(editId);
+ const { product, resource: productResource } = useProductDetails(editId);
const {
categories,
resource: categoriesResource,
@@ -59,11 +55,7 @@ export function CreateProductRoute({ editId }: { editId?: string }): VNode {
configResource={configResource}
payoutCurrencies={payoutCurrencies}
onCreateProduct={createProduct}
- onUpdateProduct={async (id, detail) => {
- await updateProduct(id, detail);
- // The next editor draft must start from the saved product details.
- await refreshProduct();
- }}
+ onUpdateProduct={updateProduct}
onCreateCategory={createCategory}
/>
);
diff --git a/packages/taler-merchant-webui/src/testing/swr-cache.ts b/packages/taler-merchant-webui/src/testing/swr-cache.ts
@@ -0,0 +1,23 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+ */
+
+import { mutate, unstable_serialize } from "swr";
+import { cache } from "swr/_internal";
+
+// The Node renderer disables React layout effects. Supply the original key
+// metadata that SWR records when a real browser mounts a cache reader.
+export async function seedSWRCache(
+ key: unknown[],
+ value: unknown,
+): Promise<void> {
+ await mutate(key, value, false);
+ const serialized = unstable_serialize(key);
+ const entry = { ...cache.get(serialized), _k: key };
+ cache.set(serialized, entry);
+}