diff --git a/docs/guide/invalidation/purging.md b/docs/guide/invalidation/purging.md index e0815335..f0944b3c 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 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/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); } }; 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, }; };