Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/invalidation/purging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/reference/routes.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ endpoint does not make someone an operator entitled to credentials.
```json
{
"contentType": "api::article.article",
"params": { "id": 42 },
"params": { "id": "lxr8ai1cs0a1234" },
"wildcard": false
}
```

| 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
Expand Down
100 changes: 100 additions & 0 deletions e2e/tests/purge-controls.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
};

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<documentId>`, 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.
Expand Down Expand Up @@ -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,
};
};
Loading