From bbdbf88da23b9c5cc8ded7013a5cdbdb90fdc743 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 18:33:26 -0700 Subject: [PATCH 1/3] fix(admin): clear the purge button's loading state after a purge PurgeCacheButton set its loading state when a purge started and never cleared it. The button stays mounted after the dialog closes, so the next time the dialog opened its confirm button was a disabled spinner and the only way out was Cancel - until a reload remounted the component. Reset it in the same `finally` that closes the dialog, so success and failure both leave the control usable. Covered by e2e/tests/purge-controls.spec.ts, which lands with the edit-view purge fix on this branch. --- .../admin/src/components/PurgeCacheButton/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/plugin-rest-cache/admin/src/components/PurgeCacheButton/index.tsx b/packages/plugin-rest-cache/admin/src/components/PurgeCacheButton/index.tsx index af4339d3..fa29c704 100644 --- a/packages/plugin-rest-cache/admin/src/components/PurgeCacheButton/index.tsx +++ b/packages/plugin-rest-cache/admin/src/components/PurgeCacheButton/index.tsx @@ -99,6 +99,10 @@ const PurgeCacheButton = ({ }), }); } finally { + // The button stays mounted after the dialog closes, so a loading state + // left set here is what the next confirmation opens with: a disabled + // spinner as the only action, until a reload remounts it. + setIsLoading(false); setShowConfirmModal(false); } }; From cdb9275b619345b8a753a30b018f710b3879ea49 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 18:33:27 -0700 Subject: [PATCH 2/3] fix(admin): purge the open entry from the edit-view action The edit-view "Purge REST Cache" action sent the document's scalar fields as the route params. A Strapi 5 document's `id` field is the numeric row id, but a REST route's `:id` is the documentId, so the purge built a pattern like `^/api/articles/1\?` that no cache key can match. With clearRelatedCache on - the default - this was invisible: a content type is in its own related set, so every purge wildcards its own routes and the entry went anyway. With clearRelatedCache off, the action reported success and the open entry stayed cached. Send `id: documentId`, the same scope the document service middleware already uses for its own purges, and keep the other scalar fields so a custom route such as `/:slug` is still narrowed to this entry. Single types still send no params and wildcard. The e2e spec asserts the request body as well as the resulting MISS, so it fails on the old code under the default config too, not only with CLEAR_RELATED_CACHE=false. --- e2e/tests/purge-controls.spec.ts | 100 ++++++++++++++++++ .../admin/src/hooks/useCachedDocument.ts | 27 ++++- 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/purge-controls.spec.ts diff --git a/e2e/tests/purge-controls.spec.ts b/e2e/tests/purge-controls.spec.ts new file mode 100644 index 00000000..0adf66eb --- /dev/null +++ b/e2e/tests/purge-controls.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, request, type Page } from '@playwright/test'; + +const ARTICLES = '/admin/content-manager/collection-types/api::article.article'; +const PURGE = 'Purge REST Cache'; + +/** + * Strapi's guided tour overlays the page for a fresh admin and swallows + * clicks. It is Strapi's, not ours, so get it out of the way and move on. + */ +async function skipGuidedTour(page: Page) { + const skip = page.getByRole('dialog').getByRole('button', { name: 'Skip' }); + + try { + await skip.waitFor({ state: 'visible', timeout: 8_000 }); + await skip.click(); + await skip.waitFor({ state: 'hidden' }); + } catch { + // No tour. + } +} + +test.describe('purge controls', () => { + /** + * The confirm button's loading state was set when a purge started and never + * cleared, so the second time the dialog opened its only action was a + * disabled spinner - until a page reload remounted the button. + */ + test('the list-view purge can be used twice without a reload', async ({ page }) => { + await page.goto(ARTICLES); + await skipGuidedTour(page); + + for (let attempt = 1; attempt <= 2; attempt += 1) { + await page.getByRole('button', { name: PURGE }).first().click(); + + const confirm = page.getByRole('alertdialog').getByRole('button', { name: PURGE }); + await expect(confirm, `confirm button on attempt ${attempt}`).toBeEnabled({ + timeout: 5_000, + }); + await confirm.click(); + + await expect(page.getByText('Cache purged successfully').first()).toBeVisible(); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + } + }); + + /** + * The edit view's purge action must remove the cached response for the + * entry that is open: /api/articles/:id, where REST's `:id` is the + * documentId. It used to send the document's own fields as the route + * params, so `:id` was filled with the numeric database row id and matched + * no cache key. + * + * clearRelatedCache - on in the playground - masks that: a content type is a + * member of its own related set, so every purge also wildcards its own + * routes and the entry goes anyway. That is why the request body is asserted + * directly, rather than relying on the entry going missing. Run with + * CLEAR_RELATED_CACHE=false and the MISS assertion below covers it as well. + */ + test('the edit-view purge action purges the open entry', async ({ page, baseURL }) => { + // Cookie-less, so the default hitpass does not bypass the cache. + const anonymous = await request.newContext({ baseURL, storageState: undefined }); + + const list = await anonymous.get('/api/articles'); + const { data } = (await list.json()) as { data: Array<{ documentId: string }> }; + const { documentId } = data[0]; + const entryUrl = `/api/articles/${documentId}`; + + await anonymous.get(entryUrl); + expect((await anonymous.get(entryUrl)).headers()['x-cache']).toBe('HIT'); + + await page.goto(`${ARTICLES}/${documentId}`); + + const purgeRequest = page.waitForRequest( + (req) => req.url().endsWith('/rest-cache/purge') && req.method() === 'POST' + ); + + await page + .getByRole('button', { name: /more (document )?actions/i }) + .last() + .click(); + await page.getByRole('menuitem', { name: PURGE }).click(); + await page.getByRole('alertdialog').getByRole('button', { name: /confirm/i }).click(); + + const sent = (await purgeRequest).postDataJSON() as { + contentType: string; + params?: Record; + }; + + await expect(page.getByText('Cache purged successfully')).toBeVisible(); + + // The entry the editor purged must now be a miss. + expect((await anonymous.get(entryUrl)).headers()['x-cache']).toBe('MISS'); + + // And for the right reason: scoped to this entry's documentId. + expect(sent.contentType).toBe('api::article.article'); + expect(sent.params?.id).toBe(documentId); + + await anonymous.dispose(); + }); +}); diff --git a/packages/plugin-rest-cache/admin/src/hooks/useCachedDocument.ts b/packages/plugin-rest-cache/admin/src/hooks/useCachedDocument.ts index 71de976c..130a82c3 100644 --- a/packages/plugin-rest-cache/admin/src/hooks/useCachedDocument.ts +++ b/packages/plugin-rest-cache/admin/src/hooks/useCachedDocument.ts @@ -29,6 +29,31 @@ const toPurgeParams = (values: unknown): PurgeRequest['params'] => { return params; }; +/** + * The route params that address this entry in the REST API. + * + * `:id` in a Strapi 5 REST route is the documentId - `/api/articles/:id` is + * requested as `/api/articles/`, and that is what the cache key + * holds. The document's own `id` field is the numeric database row id, which + * never appears in a URL, so passing the document through as-is purged a key + * that cannot exist. With clearRelatedCache on that went unnoticed, because a + * content type is related to itself and its routes are wildcarded anyway; with + * it off, the edit-view purge removed nothing for the entry it was opened on. + * + * The other scalar fields stay, so a custom route such as `/api/articles/:slug` + * is still narrowed to this entry. The server's document service middleware + * scopes its own purges the same way, with `{ id: documentId }`. + */ +const toEntryParams = (values: unknown, documentId?: string): PurgeRequest['params'] => { + const params = toPurgeParams(values) ?? {}; + + if (documentId) { + params.id = documentId; + } + + return params; +}; + export interface CachedDocument { /** * Whether a purge control should be offered for the document currently open. @@ -79,7 +104,7 @@ export const useCachedDocument = ({ // Nothing has been written yet, so nothing can be cached. isEligible: Boolean(documentId) && config !== undefined, config, - params: isSingleType ? {} : toPurgeParams(document), + params: isSingleType ? {} : toEntryParams(document, documentId), isSingleType, }; }; From 4c8605df6895361f60884a1d83b5fa1d12e12779 Mon Sep 17 00:00:00 2001 From: derrickmehaffy Date: Mon, 12 Oct 2026 18:45:17 -0700 Subject: [PATCH 3/3] docs: params.id in a purge request is the documentId The routes reference showed `"params": { "id": 42 }`. A REST route's `:id` is the documentId, so a numeric id builds a pattern no cache key matches - the same mistake the edit-view action made. The purging guide already used a documentId in its example; both now say so in the field description. --- docs/guide/invalidation/purging.md | 2 +- docs/guide/reference/routes.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guide/invalidation/purging.md b/docs/guide/invalidation/purging.md index a85a7f61..75adaaa2 100644 --- a/docs/guide/invalidation/purging.md +++ b/docs/guide/invalidation/purging.md @@ -52,7 +52,7 @@ path is `/rest-cache/purge`. It requires an authenticated admin user holding | Field | Type | Required | Meaning | | --- | --- | --- | --- | | `contentType` | `string` | yes | The uid to purge. Must be a content type you configured. | -| `params` | `object` | no | Route parameters to substitute, e.g. `{ id }`. Omit to purge the routes that take no parameters. | +| `params` | `object` | no | Route parameters to substitute, e.g. `{ id }`, where `id` is the documentId as it appears in the URL - not the numeric database id. Omit to purge the routes that take no parameters. | | `wildcard` | `boolean` | no | Ignore `params` and match any value for every route parameter. | | Status | Meaning | diff --git a/docs/guide/reference/routes.md b/docs/guide/reference/routes.md index b6e01cad..f9a946eb 100644 --- a/docs/guide/reference/routes.md +++ b/docs/guide/reference/routes.md @@ -33,7 +33,7 @@ endpoint does not make someone an operator entitled to credentials. ```json { "contentType": "api::article.article", - "params": { "id": 42 }, + "params": { "id": "lxr8ai1cs0a1234" }, "wildcard": false } ``` @@ -41,7 +41,7 @@ endpoint does not make someone an operator entitled to credentials. | Field | Type | Required | Description | | --- | --- | --- | --- | | `contentType` | `string` | yes | The uid to purge. `400` if it is not a cached content type. | -| `params` | `object` | no | Route parameters used to narrow the purge. | +| `params` | `object` | no | Route parameters used to narrow the purge. `id` is the **documentId**, as it appears in `/api/articles/:id` - not the numeric database id. | | `wildcard` | `boolean` | no | Ignore `params` and clear everything for the content type. | ### GET /rest-cache/stats