@trieb.work/nextjs-turbo-redis-cache 1.16.2 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/.github/workflows/ci.yml +11 -2
  2. package/.github/workflows/release.yml +8 -157
  3. package/ARCHITECTURE.md +86 -47
  4. package/README.md +91 -25
  5. package/dist/index.d.mts +40 -1
  6. package/dist/index.d.ts +40 -1
  7. package/dist/index.js +264 -96
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +261 -95
  10. package/dist/index.mjs.map +1 -1
  11. package/package.json +5 -7
  12. package/release.config.cjs +6 -25
  13. package/src/CacheComponentsHandler.ts +141 -111
  14. package/src/RedisStringsHandler.ts +37 -25
  15. package/src/SyncedMap.ts +16 -2
  16. package/src/index.ts +7 -0
  17. package/src/utils/cacheTtl.ts +71 -0
  18. package/src/utils/compareAndUnlink.ts +29 -0
  19. package/src/utils/redisConnection.ts +17 -0
  20. package/src/utils/tagRevalidation.ts +162 -0
  21. package/test/README.md +13 -13
  22. package/test/nextjs-test-projects/next-app-15-4-11/pnpm-lock.yaml +1 -1
  23. package/test/nextjs-test-projects/next-app-16-0-11/pnpm-lock.yaml +1 -1
  24. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
  25. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/pnpm-lock.yaml +1 -1
  26. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  27. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  28. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
  29. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  30. package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  31. package/test/nextjs-test-projects/next-app-16-2-6/pnpm-lock.yaml +1 -1
  32. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
  33. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/pnpm-lock.yaml +1 -1
  34. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  35. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  36. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
  37. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  38. package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  39. package/test/nextjs-test-projects/next-app-16-3-0/README.md +36 -0
  40. package/test/nextjs-test-projects/next-app-16-3-0/eslint.config.mjs +18 -0
  41. package/test/nextjs-test-projects/next-app-16-3-0/next.config.ts +7 -0
  42. package/test/nextjs-test-projects/next-app-16-3-0/package.json +28 -0
  43. package/test/nextjs-test-projects/next-app-16-3-0/pnpm-lock.yaml +4284 -0
  44. package/test/nextjs-test-projects/next-app-16-3-0/postcss.config.mjs +7 -0
  45. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/cached-static-fetch/route.ts +18 -0
  46. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
  47. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidatePath/route.ts +15 -0
  48. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidateTag/route.ts +20 -0
  49. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidated-fetch/route.ts +17 -0
  50. package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/uncached-fetch/route.ts +15 -0
  51. package/test/nextjs-test-projects/next-app-16-3-0/src/app/favicon.ico +0 -0
  52. package/test/nextjs-test-projects/next-app-16-3-0/src/app/globals.css +26 -0
  53. package/test/nextjs-test-projects/next-app-16-3-0/src/app/layout.tsx +59 -0
  54. package/test/nextjs-test-projects/next-app-16-3-0/src/app/page.tsx +755 -0
  55. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
  56. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
  57. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  58. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/no-fetch/default-page/page.tsx +55 -0
  59. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
  60. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
  61. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  62. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
  63. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
  64. package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
  65. package/test/nextjs-test-projects/next-app-16-3-0/src/app/revalidation-interface.tsx +267 -0
  66. package/test/nextjs-test-projects/next-app-16-3-0/src/app/update-tag-test/page.tsx +25 -0
  67. package/test/nextjs-test-projects/next-app-16-3-0/tsconfig.json +34 -0
  68. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/README.md +36 -0
  69. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/cache-handler.js +3 -0
  70. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/eslint.config.mjs +18 -0
  71. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +15 -0
  72. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/package.json +28 -0
  73. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/pnpm-lock.yaml +4284 -0
  74. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/postcss.config.mjs +7 -0
  75. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/file.svg +1 -0
  76. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/globe.svg +1 -0
  77. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/next.svg +1 -0
  78. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/vercel.svg +1 -0
  79. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/window.svg +1 -0
  80. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-static-fetch/route.ts +19 -0
  81. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-cachelife/route.ts +24 -0
  82. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-tag/route.ts +21 -0
  83. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
  84. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
  85. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidate-tag/route.ts +19 -0
  86. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidated-fetch/route.ts +19 -0
  87. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/cachelife-short/page.tsx +110 -0
  88. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +112 -0
  89. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
  90. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/runtime-data-suspense/page.tsx +127 -0
  91. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/stale-while-revalidate/page.tsx +130 -0
  92. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/tag-invalidation/page.tsx +127 -0
  93. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-nondeterministic/page.tsx +110 -0
  94. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
  95. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/favicon.ico +0 -0
  96. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/globals.css +26 -0
  97. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/layout.tsx +57 -0
  98. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/page.tsx +755 -0
  99. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/revalidation-interface.tsx +267 -0
  100. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/update-tag-test/page.tsx +22 -0
  101. package/test/nextjs-test-projects/next-app-16-3-0-cache-components/tsconfig.json +34 -0
  102. package/test/nextjs-test-projects/next-pages-16-2-6/pnpm-lock.yaml +4 -4
  103. package/test/nextjs-test-projects/next-pages-16-3-0/README.md +16 -0
  104. package/test/nextjs-test-projects/next-pages-16-3-0/eslint.config.mjs +18 -0
  105. package/test/nextjs-test-projects/next-pages-16-3-0/next.config.ts +7 -0
  106. package/test/nextjs-test-projects/next-pages-16-3-0/package.json +26 -0
  107. package/test/nextjs-test-projects/next-pages-16-3-0/pnpm-lock.yaml +3939 -0
  108. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/api/revalidate.ts +24 -0
  109. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/index.tsx +11 -0
  110. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/isr/[slug].tsx +49 -0
  111. package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/static-forever.tsx +20 -0
  112. package/test/nextjs-test-projects/next-pages-16-3-0/tsconfig.json +34 -0
  113. package/test/playwright/cache-lab.spec.ts +75 -0
  114. package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
  115. package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +67 -34
  116. package/test/vitest/integration/pages-router.integration.test.ts +16 -6
  117. package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
  118. package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
  119. package/test/vitest/unit/cache-ttl.test.ts +67 -0
  120. package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
  121. package/test/vitest/unit/index.test.ts +17 -0
  122. package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
  123. package/test/vitest/unit/redis-connection.test.ts +39 -0
  124. package/test/vitest/unit/tag-revalidation.test.ts +178 -0
  125. package/CHANGELOG.md +0 -404
@@ -7,7 +7,7 @@ import { CacheEntry } from '../../../src/RedisStringsHandler';
7
7
  import { revalidate as revalidatedFetchRouteRevalidate } from '../../nextjs-test-projects/next-app-15-4-11/src/app/api/revalidated-fetch/route';
8
8
 
9
9
  // Select which Next.js test app to use. Can be overridden via NEXT_TEST_APP env var
10
- // Examples: next-app-15-4-11, next-app-16-0-11, next-app-16-2-6
10
+ // Examples: next-app-15-4-11, next-app-16-0-11, next-app-16-2-6, next-app-16-3-0
11
11
  const NEXT_TEST_APP = process.env.NEXT_TEST_APP || 'next-app-15-4-11';
12
12
  const NEXT_APP_DIR = join(
13
13
  __dirname,
@@ -17,6 +17,8 @@ const NEXT_APP_DIR = join(
17
17
  NEXT_TEST_APP,
18
18
  );
19
19
  console.log('NEXT_APP_DIR', NEXT_APP_DIR);
20
+
21
+ const SHARED_TAGS_KEY = '__sharedTags__';
20
22
  const NEXT_START_PORT = 3055;
21
23
  const NEXT_START_URL = `http://localhost:${NEXT_START_PORT}`;
22
24
 
@@ -29,6 +31,20 @@ async function delay(ms: number) {
29
31
  return new Promise((resolve) => setTimeout(resolve, ms));
30
32
  }
31
33
 
34
+ async function findFetchCacheEntryKey(tag: string): Promise<string> {
35
+ const sharedTags = await redisClient.hGetAll(
36
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
37
+ );
38
+ const matches = Object.entries(sharedTags).filter(
39
+ ([key, serializedTags]) =>
40
+ !key.startsWith('/') &&
41
+ (JSON.parse(serializedTags) as string[]).includes(tag),
42
+ );
43
+
44
+ expect(matches).toHaveLength(1);
45
+ return matches[0]![0];
46
+ }
47
+
32
48
  async function runCommand(cmd: string, args: string[], cwd: string) {
33
49
  return new Promise((resolve, reject) => {
34
50
  let stderr = '';
@@ -64,7 +80,7 @@ async function runCommand(cmd: string, args: string[], cwd: string) {
64
80
  });
65
81
  }
66
82
 
67
- async function waitForServer(url, timeout = 20000) {
83
+ async function waitForServer(url: string, timeout = 20000) {
68
84
  const start = Date.now();
69
85
  while (Date.now() - start < timeout) {
70
86
  try {
@@ -226,7 +242,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
226
242
  expect(keys.length).toBe(0);
227
243
 
228
244
  const hashmap = await redisClient.hGet(
229
- process.env.VERCEL_URL + '__sharedTags__',
245
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
230
246
  '/api/cached-static-fetch',
231
247
  );
232
248
  expect(hashmap).toBeNull();
@@ -247,7 +263,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
247
263
  expect(keys.length).toBe(1);
248
264
 
249
265
  const hashmap = (await redisClient.hGet(
250
- process.env.VERCEL_URL + '__sharedTags__',
266
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
251
267
  '/api/cached-static-fetch',
252
268
  )) as string;
253
269
  expect(JSON.parse(hashmap)).toEqual([
@@ -297,6 +313,15 @@ describe('Next.js Turbo Redis Cache Integration', () => {
297
313
  const ttl = await redisClient.ttl(
298
314
  process.env.VERCEL_URL + '/api/revalidated-fetch',
299
315
  );
316
+
317
+ // Next.js 16.3+ passes cacheControl.expire for ISR fetch routes (~1 year).
318
+ // TTL is keyed on expire (SWR-safe), not 2 × revalidate — skip the
319
+ // long-running expiry wait in that case.
320
+ if (NEXT_TEST_APP.includes('16-3')) {
321
+ expect(ttl).toBeGreaterThan(2 * revalidatedFetchRouteRevalidate);
322
+ return;
323
+ }
324
+
300
325
  expect(ttl).toBeLessThan(2 * revalidatedFetchRouteRevalidate);
301
326
  expect(ttl).toBeGreaterThan(
302
327
  2 * revalidatedFetchRouteRevalidate -
@@ -317,7 +342,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
317
342
 
318
343
  // The key should also be removed from the hashmap
319
344
  const hashmap = await redisClient.hGet(
320
- process.env.VERCEL_URL + '__sharedTags__',
345
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
321
346
  '/api/revalidated-fetch',
322
347
  );
323
348
  expect(hashmap).toBeNull();
@@ -339,7 +364,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
339
364
  expect(keys.length).toBe(0);
340
365
 
341
366
  const hashmap = await redisClient.hGet(
342
- process.env.VERCEL_URL + '__sharedTags__',
367
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
343
368
  cachedStaticPath,
344
369
  );
345
370
  expect(hashmap).toBeNull();
@@ -495,7 +520,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
495
520
  expect(keys.length).toBe(0);
496
521
 
497
522
  const hashmap = await redisClient.hGet(
498
- process.env.VERCEL_URL + '__sharedTags__',
523
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
499
524
  '/api/nested-fetch-in-api-route/revalidated-fetch',
500
525
  );
501
526
  expect(hashmap).toBeNull();
@@ -514,10 +539,9 @@ describe('Next.js Turbo Redis Cache Integration', () => {
514
539
  it('After the new request was made the redis key and hashmap should be set again', async () => {
515
540
  await delay(REDIS_BACKGROUND_SYNC_DELAY);
516
541
 
517
- // This cache entry key is the key of the sub-fetch-request, it will be generated by nextjs based on the headers/payload etc.
518
- // So it should stay the same unless nextjs will change something in there implementation
519
- const cacheEntryKey =
520
- '094a786b7ad391852168d3a7bcf75736777697d24a856a0089837f4b7de921df';
542
+ const cacheEntryKey = await findFetchCacheEntryKey(
543
+ 'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
544
+ );
521
545
 
522
546
  // check Redis keys
523
547
  const keys = await redisClient.keys(
@@ -526,7 +550,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
526
550
  expect(keys.length).toBe(1);
527
551
 
528
552
  const hashmap = (await redisClient.hGet(
529
- process.env.VERCEL_URL + '__sharedTags__',
553
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
530
554
  cacheEntryKey,
531
555
  )) as string;
532
556
  expect(JSON.parse(hashmap)).toEqual([
@@ -551,7 +575,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
551
575
  expect(keys.length).toBe(0);
552
576
 
553
577
  const hashmap = await redisClient.hGet(
554
- process.env.VERCEL_URL + '__sharedTags__',
578
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
555
579
  '/api/nested-fetch-in-api-route/revalidated-fetch',
556
580
  );
557
581
  expect(hashmap).toBeNull();
@@ -569,10 +593,9 @@ describe('Next.js Turbo Redis Cache Integration', () => {
569
593
 
570
594
  it('After the new request was made the redis key and hashmap should be set again', async () => {
571
595
  await delay(REDIS_BACKGROUND_SYNC_DELAY);
572
- // This cache entry key is the key of the sub-fetch-request, it will be generated by nextjs based on the headers/payload etc.
573
- // So it should stay the same unless nextjs will change something in there implementation
574
- const cacheEntryKey =
575
- '094a786b7ad391852168d3a7bcf75736777697d24a856a0089837f4b7de921df';
596
+ const cacheEntryKey = await findFetchCacheEntryKey(
597
+ 'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
598
+ );
576
599
 
577
600
  // check Redis keys
578
601
  const keys = await redisClient.keys(
@@ -581,7 +604,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
581
604
  expect(keys.length).toBe(1);
582
605
 
583
606
  const hashmap = (await redisClient.hGet(
584
- process.env.VERCEL_URL + '__sharedTags__',
607
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
585
608
  cacheEntryKey,
586
609
  )) as string;
587
610
  expect(JSON.parse(hashmap)).toEqual([
@@ -708,7 +731,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
708
731
  expect(keys.length).toBe(0);
709
732
 
710
733
  const hashmap = await redisClient.hGet(
711
- process.env.VERCEL_URL + '__sharedTags__',
734
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
712
735
  '/pages/no-fetch/default-page',
713
736
  );
714
737
  expect(hashmap).toBeNull();
@@ -733,7 +756,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
733
756
  expect(keys.length).toBe(1);
734
757
 
735
758
  const hashmap = (await redisClient.hGet(
736
- process.env.VERCEL_URL + '__sharedTags__',
759
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
737
760
  '/pages/no-fetch/default-page',
738
761
  )) as string;
739
762
  expect(JSON.parse(hashmap)).toEqual([
@@ -771,6 +794,10 @@ describe('Next.js Turbo Redis Cache Integration', () => {
771
794
 
772
795
  await delay(REDIS_BACKGROUND_SYNC_DELAY);
773
796
 
797
+ const nestedFetchCacheEntryKey = await findFetchCacheEntryKey(
798
+ 'revalidated-fetch-revalidate15-default-page',
799
+ );
800
+
774
801
  // test cache entry for 3 keys are set
775
802
  const keys1 = await redisClient.keys(
776
803
  process.env.VERCEL_URL +
@@ -778,8 +805,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
778
805
  );
779
806
  expect(keys1.length).toBe(1);
780
807
  const keys2 = await redisClient.keys(
781
- process.env.VERCEL_URL +
782
- 'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
808
+ process.env.VERCEL_URL + nestedFetchCacheEntryKey,
783
809
  );
784
810
  expect(keys2.length).toBe(1);
785
811
  const keys3 = await redisClient.keys(
@@ -789,7 +815,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
789
815
 
790
816
  // test shared tag hashmap to be set for all keys
791
817
  const hashmap1 = (await redisClient.hGet(
792
- process.env.VERCEL_URL + '__sharedTags__',
818
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
793
819
  '/pages/revalidated-fetch/revalidate15--default-page',
794
820
  )) as string;
795
821
  expect(JSON.parse(hashmap1)).toEqual([
@@ -802,14 +828,14 @@ describe('Next.js Turbo Redis Cache Integration', () => {
802
828
  'revalidated-fetch-revalidate15-default-page',
803
829
  ]);
804
830
  const hashmap2 = (await redisClient.hGet(
805
- process.env.VERCEL_URL + '__sharedTags__',
806
- 'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
831
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
832
+ nestedFetchCacheEntryKey,
807
833
  )) as string;
808
834
  expect(JSON.parse(hashmap2)).toEqual([
809
835
  'revalidated-fetch-revalidate15-default-page',
810
836
  ]);
811
837
  const hashmap3 = (await redisClient.hGet(
812
- process.env.VERCEL_URL + '__sharedTags__',
838
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
813
839
  '/api/revalidated-fetch',
814
840
  )) as string;
815
841
  expect(JSON.parse(hashmap3)).toEqual([
@@ -849,20 +875,22 @@ describe('Next.js Turbo Redis Cache Integration', () => {
849
875
  expect(keys1.length).toBe(0);
850
876
 
851
877
  const hashmap1 = await redisClient.hGet(
852
- process.env.VERCEL_URL + '__sharedTags__',
878
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
853
879
  '/pages/revalidated-fetch/revalidate15--default-page',
854
880
  );
855
881
  expect(hashmap1).toBeNull();
856
882
 
857
883
  // sub-fetch-request is not removed directly but will be removed on next get request
884
+ const nestedFetchCacheEntryKey = await findFetchCacheEntryKey(
885
+ 'revalidated-fetch-revalidate15-default-page',
886
+ );
858
887
  const keys2 = await redisClient.keys(
859
- process.env.VERCEL_URL +
860
- 'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
888
+ process.env.VERCEL_URL + nestedFetchCacheEntryKey,
861
889
  );
862
890
  expect(keys2.length).toBe(1);
863
891
  const hashmap2 = await redisClient.hGet(
864
- process.env.VERCEL_URL + '__sharedTags__',
865
- 'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
892
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
893
+ nestedFetchCacheEntryKey,
866
894
  );
867
895
  expect(hashmap2).toBeDefined();
868
896
 
@@ -881,7 +909,7 @@ describe('Next.js Turbo Redis Cache Integration', () => {
881
909
  );
882
910
  expect(keys3.length).toBe(1);
883
911
  const hashmap3 = (await redisClient.hGet(
884
- process.env.VERCEL_URL + '__sharedTags__',
912
+ process.env.VERCEL_URL + SHARED_TAGS_KEY,
885
913
  '/api/revalidated-fetch',
886
914
  )) as string;
887
915
  expect(JSON.parse(hashmap3)).toEqual([
@@ -904,9 +932,14 @@ describe('Next.js Turbo Redis Cache Integration', () => {
904
932
  expect(timestamp).toBeDefined();
905
933
  expect(Number(timestamp)).toBeGreaterThan(Number(firstTimestamp));
906
934
 
907
- //but the new request should not have a higher counter than the first request (because the cache of the API route should not be invalidated)
935
+ // Next.js 16.3 revalidates nested fetch entries when the parent page path
936
+ // is revalidated; older versions keep the nested fetch cache intact.
908
937
  expect(secondCounter).toBeDefined();
909
- expect(secondCounter).toBe(firstCounter);
938
+ if (NEXT_TEST_APP === 'next-app-16-3-0') {
939
+ expect(Number(secondCounter)).toBeGreaterThan(Number(firstCounter));
940
+ } else {
941
+ expect(secondCounter).toBe(firstCounter);
942
+ }
910
943
  });
911
944
  });
912
945
 
@@ -48,6 +48,18 @@ async function delay(ms: number) {
48
48
  return new Promise((resolve) => setTimeout(resolve, ms));
49
49
  }
50
50
 
51
+ function expectIsrRedisTtl(ttl: number) {
52
+ // Next.js 16.3+ passes cacheControl.expire (~1 year) for ISR pages.
53
+ // Redis TTL is keyed on expire, not 2 × revalidate.
54
+ if (NEXT_PAGES_TEST_APP.includes('16-3')) {
55
+ expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS);
56
+ return;
57
+ }
58
+ // VERCEL_ENV=production -> expire age is 2 * revalidate
59
+ expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
60
+ expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
61
+ }
62
+
51
63
  async function runCommand(cmd: string, args: string[], cwd: string) {
52
64
  return new Promise((resolve, reject) => {
53
65
  let stderr = '';
@@ -233,9 +245,7 @@ describe('Pages Router Redis cache integration (two instances)', () => {
233
245
  const ttl = await redisClient.ttl(
234
246
  process.env.VERCEL_URL + '/isr/prebuilt',
235
247
  );
236
- // VERCEL_ENV=production -> expire age is 2 * revalidate
237
- expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
238
- expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
248
+ expectIsrRedisTtl(ttl);
239
249
  });
240
250
 
241
251
  it('instance B serves the identical cached HTML from the shared Redis', async () => {
@@ -342,12 +352,12 @@ describe('Pages Router Redis cache integration (two instances)', () => {
342
352
  tags: ['_N_T_/isr/not-found'],
343
353
  });
344
354
 
345
- // TTL is derived from the revalidate value returned with notFound
355
+ // TTL: Next.js 16.3+ keys on cacheControl.expire (SWR-safe); older
356
+ // versions use estimateExpireAge(revalidate).
346
357
  const ttl = await redisClient.ttl(
347
358
  process.env.VERCEL_URL + '/isr/not-found',
348
359
  );
349
- expect(ttl).toBeGreaterThan(2 * ISR_REVALIDATE_SECONDS - 30);
350
- expect(ttl).toBeLessThanOrEqual(2 * ISR_REVALIDATE_SECONDS);
360
+ expectIsrRedisTtl(ttl);
351
361
  });
352
362
 
353
363
  it('instance B serves the 404 from the shared cache', async () => {
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SyncedMap } from '../../../src/SyncedMap';
3
+
4
+ class HashMockClient {
5
+ hash = new Map<string, string>();
6
+ stringKeys: string[] = [];
7
+ hDelCalls: string[][] = [];
8
+ isOpen = true;
9
+ isReady = true;
10
+
11
+ on() {
12
+ return this;
13
+ }
14
+
15
+ async connect() {
16
+ this.isOpen = true;
17
+ this.isReady = true;
18
+ }
19
+
20
+ async configGet() {
21
+ return { 'notify-keyspace-events': 'Exe' };
22
+ }
23
+
24
+ async subscribe() {}
25
+
26
+ async quit() {}
27
+
28
+ async disconnect() {
29
+ this.isOpen = false;
30
+ this.isReady = false;
31
+ }
32
+
33
+ duplicate() {
34
+ return new HashMockClient();
35
+ }
36
+
37
+ async hScan() {
38
+ return {
39
+ cursor: 0,
40
+ tuples: [...this.hash.entries()].map(([field, value]) => ({
41
+ field,
42
+ value,
43
+ })),
44
+ };
45
+ }
46
+
47
+ async scan() {
48
+ return { cursor: 0, keys: this.stringKeys };
49
+ }
50
+
51
+ async hSet(_key: string, field: string, value: string) {
52
+ this.hash.set(field, value);
53
+ return 1;
54
+ }
55
+
56
+ async hDel(_key: string, fields: string | string[]) {
57
+ const list = Array.isArray(fields) ? fields : [fields];
58
+ this.hDelCalls.push(list);
59
+ for (const field of list) {
60
+ this.hash.delete(field);
61
+ }
62
+ return list.length;
63
+ }
64
+
65
+ async publish() {
66
+ return 1;
67
+ }
68
+ }
69
+
70
+ describe('SyncedMap orphan cleanup', () => {
71
+ it('deletes hash fields whose names are not Redis string keys', async () => {
72
+ const client = new HashMockClient();
73
+ const map = new SyncedMap<number>({
74
+ client: client as never,
75
+ keyPrefix: 'app:',
76
+ redisKey: '__revalidated_tags__',
77
+ database: 0,
78
+ querySize: 50,
79
+ filterKeys: () => true,
80
+ });
81
+ await map.waitUntilReady();
82
+ await map.set('posts', 1);
83
+
84
+ client.stringKeys = ['app:some-cache-entry'];
85
+ await (map as any).initialSync();
86
+
87
+ expect(client.hDelCalls.flat()).toContain('posts');
88
+ expect(map.get('posts')).toBeUndefined();
89
+ });
90
+
91
+ it('keeps tag-manifest fields when withoutOrphanCleanup is set', async () => {
92
+ const client = new HashMockClient();
93
+ const map = new SyncedMap<number>({
94
+ client: client as never,
95
+ keyPrefix: 'app:',
96
+ redisKey: '__cacheComponents_revalidated_tags__',
97
+ database: 0,
98
+ querySize: 50,
99
+ filterKeys: () => true,
100
+ customizedSync: { withoutOrphanCleanup: true },
101
+ });
102
+ await map.waitUntilReady();
103
+ await map.set('posts', 1);
104
+
105
+ client.stringKeys = ['app:some-cache-entry'];
106
+ await (map as any).initialSync();
107
+
108
+ expect(client.hDelCalls).toEqual([]);
109
+ expect(map.get('posts')).toBe(1);
110
+ });
111
+ });