Skip to content

Commit 38e58b3

Browse files
eduardoboucaspieh
andauthored
fix: make revalidateTags no-op when list of tags is empty (#2727)
* fix: make `revalidateTags` no-op when list of tags is empty * fix: oops * test: add integration test for site-wide purge calls * fix: filter out empty tags * test: correct afterEach import --------- Co-authored-by: pieh <[email protected]>
1 parent 871f7b9 commit 38e58b3

File tree

3 files changed

+111
-8
lines changed

3 files changed

+111
-8
lines changed

src/run/handlers/cache.cts

+15-7
Original file line numberOriginal file lineDiff line numberDiff line change
@@ -348,16 +348,20 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
348
if (requestContext?.didPagesRouterOnDemandRevalidate) {
348
if (requestContext?.didPagesRouterOnDemandRevalidate) {
349
// encode here to deal with non ASCII characters in the key
349
// encode here to deal with non ASCII characters in the key
350
const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}`
350
const tag = `_N_T_${key === '/index' ? '/' : encodeURI(key)}`
351+
const tags = tag.split(/,|%2c/gi).filter(Boolean)
352+
353+
if (tags.length === 0) {
354+
return
355+
}
356+
351
getLogger().debug(`Purging CDN cache for: [${tag}]`)
357
getLogger().debug(`Purging CDN cache for: [${tag}]`)
352
requestContext.trackBackgroundWork(
358
requestContext.trackBackgroundWork(
353-
purgeCache({ tags: tag.split(/,|%2c/gi), userAgent: purgeCacheUserAgent }).catch(
359+
purgeCache({ tags, userAgent: purgeCacheUserAgent }).catch((error) => {
354-
(error) => {
355
// TODO: add reporting here
360
// TODO: add reporting here
356
getLogger()
361
getLogger()
357
.withError(error)
362
.withError(error)
358
.error(`[NetlifyCacheHandler]: Purging the cache for tag ${tag} failed`)
363
.error(`[NetlifyCacheHandler]: Purging the cache for tag ${tag} failed`)
359-
},
364+
}),
360-
),
361
)
365
)
362
}
366
}
363
}
367
}
@@ -380,9 +384,13 @@ export class NetlifyCacheHandler implements CacheHandlerForMultipleVersions {
380
private async doRevalidateTag(tagOrTags: string | string[], ...args: any) {
384
private async doRevalidateTag(tagOrTags: string | string[], ...args: any) {
381
getLogger().withFields({ tagOrTags, args }).debug('NetlifyCacheHandler.revalidateTag')
385
getLogger().withFields({ tagOrTags, args }).debug('NetlifyCacheHandler.revalidateTag')
382

386

383-
const tags = (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags]).flatMap((tag) =>
387+
const tags = (Array.isArray(tagOrTags) ? tagOrTags : [tagOrTags])
384-
tag.split(/,|%2c/gi),
388+
.flatMap((tag) => tag.split(/,|%2c/gi))
385-
)
389+
.filter(Boolean)
390+
391+
if (tags.length === 0) {
392+
return
393+
}
386

394

387
const data: TagManifest = {
395
const data: TagManifest = {
388
revalidatedAt: Date.now(),
396
revalidatedAt: Date.now(),
Original file line numberOriginal file lineDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { unstable_cache } from 'next/cache'
2+
3+
export const dynamic = 'force-dynamic'
4+
5+
const getData = unstable_cache(
6+
async () => {
7+
return {
8+
timestamp: Date.now(),
9+
}
10+
},
11+
[],
12+
{
13+
revalidate: 1,
14+
},
15+
)
16+
17+
export default async function Page() {
18+
const data = await getData()
19+
20+
return <pre>{JSON.stringify(data, null, 2)}</pre>
21+
}

tests/integration/simple-app.test.ts

+75-1
Original file line numberOriginal file lineDiff line numberDiff line change
@@ -4,9 +4,21 @@ import { cp } from 'node:fs/promises'
4
import { createRequire } from 'node:module'
4
import { createRequire } from 'node:module'
5
import { join } from 'node:path'
5
import { join } from 'node:path'
6
import { gunzipSync } from 'node:zlib'
6
import { gunzipSync } from 'node:zlib'
7+
import { HttpResponse, http, passthrough } from 'msw'
8+
import { setupServer } from 'msw/node'
7
import { gt, prerelease } from 'semver'
9
import { gt, prerelease } from 'semver'
8
import { v4 } from 'uuid'
10
import { v4 } from 'uuid'
9-
import { Mock, afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
11+
import {
12+
Mock,
13+
afterAll,
14+
afterEach,
15+
beforeAll,
16+
beforeEach,
17+
describe,
18+
expect,
19+
test,
20+
vi,
21+
} from 'vitest'
10
import { getPatchesToApply } from '../../src/build/content/server.js'
22
import { getPatchesToApply } from '../../src/build/content/server.js'
11
import { type FixtureTestContext } from '../utils/contexts.js'
23
import { type FixtureTestContext } from '../utils/contexts.js'
12
import {
24
import {
@@ -36,9 +48,32 @@ vi.mock('node:fs/promises', async (importOriginal) => {
36
}
48
}
37
})
49
})
38

50

51+
let server: ReturnType<typeof setupServer>
52+
39
// Disable the verbose logging of the lambda-local runtime
53
// Disable the verbose logging of the lambda-local runtime
40
getLogger().level = 'alert'
54
getLogger().level = 'alert'
41

55

56+
const purgeAPI = vi.fn()
57+
58+
beforeAll(() => {
59+
server = setupServer(
60+
http.post('https://api.netlify.com/api/v1/purge', async ({ request }) => {
61+
purgeAPI(await request.json())
62+
63+
return HttpResponse.json({
64+
ok: true,
65+
})
66+
}),
67+
http.all(/.*/, () => passthrough()),
68+
)
69+
server.listen()
70+
})
71+
72+
afterAll(() => {
73+
// Disable API mocking after the tests are done.
74+
server.close()
75+
})
76+
42
beforeEach<FixtureTestContext>(async (ctx) => {
77
beforeEach<FixtureTestContext>(async (ctx) => {
43
// set for each test a new deployID and siteID
78
// set for each test a new deployID and siteID
44
ctx.deployID = generateRandomObjectID()
79
ctx.deployID = generateRandomObjectID()
@@ -48,9 +83,15 @@ beforeEach<FixtureTestContext>(async (ctx) => {
48
// hide debug logs in tests
83
// hide debug logs in tests
49
vi.spyOn(console, 'debug').mockImplementation(() => {})
84
vi.spyOn(console, 'debug').mockImplementation(() => {})
50

85

86+
purgeAPI.mockClear()
87+
51
await startMockBlobStore(ctx)
88
await startMockBlobStore(ctx)
52
})
89
})
53

90

91+
afterEach(() => {
92+
vi.unstubAllEnvs()
93+
})
94+
54
test<FixtureTestContext>('Test that the simple next app is working', async (ctx) => {
95
test<FixtureTestContext>('Test that the simple next app is working', async (ctx) => {
55
await createFixture('simple', ctx)
96
await createFixture('simple', ctx)
56
await runPlugin(ctx)
97
await runPlugin(ctx)
@@ -210,6 +251,39 @@ test<FixtureTestContext>('cacheable route handler is cached on cdn (revalidate=f
210
)
251
)
211
})
252
})
212

253

254+
test<FixtureTestContext>('purge API is not used when unstable_cache cache entry gets stale', async (ctx) => {
255+
await createFixture('simple', ctx)
256+
await runPlugin(ctx)
257+
258+
// set the NETLIFY_PURGE_API_TOKEN to get pass token check and allow fetch call to be made
259+
vi.stubEnv('NETLIFY_PURGE_API_TOKEN', 'mock')
260+
261+
const page1 = await invokeFunction(ctx, {
262+
url: '/unstable_cache',
263+
})
264+
const data1 = load(page1.body)('pre').text()
265+
266+
// allow for cache entry to get stale
267+
await new Promise((res) => setTimeout(res, 2000))
268+
269+
const page2 = await invokeFunction(ctx, {
270+
url: '/unstable_cache',
271+
})
272+
const data2 = load(page2.body)('pre').text()
273+
274+
const page3 = await invokeFunction(ctx, {
275+
url: '/unstable_cache',
276+
})
277+
const data3 = load(page3.body)('pre').text()
278+
279+
expect(purgeAPI, 'Purge API should not be hit').toHaveBeenCalledTimes(0)
280+
expect(
281+
data2,
282+
'Should use stale cache entry for current request and invalidate it in background',
283+
).toBe(data1)
284+
expect(data3, 'Should use updated cache entry').not.toBe(data2)
285+
})
286+
213
test<FixtureTestContext>('cacheable route handler is cached on cdn (revalidate=15)', async (ctx) => {
287
test<FixtureTestContext>('cacheable route handler is cached on cdn (revalidate=15)', async (ctx) => {
214
await createFixture('simple', ctx)
288
await createFixture('simple', ctx)
215
await runPlugin(ctx)
289
await runPlugin(ctx)

0 commit comments

Comments
 (0)