@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
@@ -0,0 +1,24 @@
1
+ import type { NextApiRequest, NextApiResponse } from 'next';
2
+
3
+ // On-demand revalidation endpoint: res.revalidate(path) re-renders the page
4
+ // and writes the fresh entry through the cache handler, so all instances
5
+ // sharing the same Redis pick it up.
6
+ export default async function handler(
7
+ req: NextApiRequest,
8
+ res: NextApiResponse,
9
+ ) {
10
+ const path = req.query.path;
11
+ if (typeof path !== 'string') {
12
+ return res
13
+ .status(400)
14
+ .json({ revalidated: false, error: 'Missing ?path= query parameter' });
15
+ }
16
+ try {
17
+ await res.revalidate(path);
18
+ return res.json({ revalidated: true, path });
19
+ } catch (err) {
20
+ return res
21
+ .status(500)
22
+ .json({ revalidated: false, path, error: String(err) });
23
+ }
24
+ }
@@ -0,0 +1,11 @@
1
+ export default function Home() {
2
+ return (
3
+ <main>
4
+ <h1>Pages Router test app</h1>
5
+ <p>
6
+ Fixture for testing the Redis cache handler with the Next.js Pages
7
+ Router (PAGES, REDIRECT and notFound cache entries).
8
+ </p>
9
+ </main>
10
+ );
11
+ }
@@ -0,0 +1,49 @@
1
+ import type { GetStaticPaths, GetStaticProps } from 'next';
2
+
3
+ type Props = { slug: string; timestamp: number; counter: number };
4
+
5
+ // Per-process regeneration counter. Two server instances of the same build
6
+ // have independent counters, while the timestamp proves cross-instance
7
+ // freshness after on-demand revalidation.
8
+ let regenerationCounter = 0;
9
+
10
+ export default function IsrPage({ slug, timestamp, counter }: Props) {
11
+ return (
12
+ <main>
13
+ <h1>ISR page</h1>
14
+ <p>Slug: {slug}</p>
15
+ <p>Timestamp: {timestamp}</p>
16
+ <p>Counter: {counter}</p>
17
+ </main>
18
+ );
19
+ }
20
+
21
+ export const getStaticPaths: GetStaticPaths = async () => {
22
+ return {
23
+ paths: [{ params: { slug: 'prebuilt' } }],
24
+ fallback: 'blocking',
25
+ };
26
+ };
27
+
28
+ // revalidate is set high (300s) so that natural ISR regeneration does not
29
+ // interfere with the on-demand revalidation (res.revalidate) tests.
30
+ export const getStaticProps: GetStaticProps<Props> = async (context) => {
31
+ const slug = context.params?.slug as string;
32
+
33
+ if (slug === 'not-found') {
34
+ return { notFound: true, revalidate: 300 };
35
+ }
36
+
37
+ if (slug === 'redirect') {
38
+ return {
39
+ redirect: { destination: '/static-forever', permanent: false },
40
+ revalidate: 300,
41
+ };
42
+ }
43
+
44
+ regenerationCounter += 1;
45
+ return {
46
+ props: { slug, timestamp: Date.now(), counter: regenerationCounter },
47
+ revalidate: 300,
48
+ };
49
+ };
@@ -0,0 +1,20 @@
1
+ import type { GetStaticProps } from 'next';
2
+
3
+ type Props = { timestamp: number };
4
+
5
+ export default function StaticForever({ timestamp }: Props) {
6
+ return (
7
+ <main>
8
+ <h1>Static forever</h1>
9
+ <p>Timestamp: {timestamp}</p>
10
+ </main>
11
+ );
12
+ }
13
+
14
+ // revalidate is intentionally omitted (revalidate: false): the cache entry
15
+ // must fall through to the defaultStaleAge based TTL in the cache handler.
16
+ export const getStaticProps: GetStaticProps<Props> = async () => {
17
+ return {
18
+ props: { timestamp: Date.now() },
19
+ };
20
+ };
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "react-jsx",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./src/*"]
23
+ }
24
+ },
25
+ "include": [
26
+ "next-env.d.ts",
27
+ "**/*.ts",
28
+ "**/*.tsx",
29
+ ".next/types/**/*.ts",
30
+ ".next/dev/types/**/*.ts",
31
+ "**/*.mts"
32
+ ],
33
+ "exclude": ["node_modules"]
34
+ }
@@ -124,6 +124,81 @@ test.describe('Cache Lab (Cache Components)', () => {
124
124
  expect(payloadB).not.toBe(payloadA1);
125
125
  });
126
126
 
127
+ test('use cache: remote values are stable until updateTag', async ({
128
+ page,
129
+ }) => {
130
+ await page.goto('/cache-lab/use-cache-remote');
131
+
132
+ const computedAt = page.getByTestId('computedAt');
133
+ const value = page.getByTestId('value');
134
+
135
+ await expect(computedAt).toBeVisible();
136
+
137
+ const beforeComputedAt = await computedAt.textContent();
138
+ const beforeValue = await value.textContent();
139
+
140
+ await page.reload();
141
+ await expect(computedAt).toBeVisible();
142
+ expect(await computedAt.textContent()).toBe(beforeComputedAt);
143
+ expect(await value.textContent()).toBe(beforeValue);
144
+
145
+ await page
146
+ .getByRole('button', { name: "updateTag('cache-lab:remote')" })
147
+ .click();
148
+ await page.waitForLoadState('networkidle');
149
+
150
+ await expect(computedAt).toBeVisible();
151
+ expect(await computedAt.textContent()).not.toBe(beforeComputedAt);
152
+ expect(await value.textContent()).not.toBe(beforeValue);
153
+ });
154
+
155
+ test('revalidateTag with durations can serve stale values before refresh completes', async ({
156
+ page,
157
+ }) => {
158
+ await page.goto('/cache-lab/revalidate-durations');
159
+
160
+ const computedAt = page.getByTestId('computedAt');
161
+ const value = page.getByTestId('value');
162
+
163
+ await expect(computedAt).toBeVisible();
164
+
165
+ const beforeComputedAt = await computedAt.textContent();
166
+ const beforeValue = await value.textContent();
167
+
168
+ const actionResponse = page.waitForResponse(
169
+ (res) =>
170
+ res.request().method() === 'POST' &&
171
+ res.url().includes('/cache-lab/revalidate-durations'),
172
+ );
173
+ await page
174
+ .getByRole('button', {
175
+ name: "revalidateTag('cache-lab:durations', { expire: 2 })",
176
+ })
177
+ .click();
178
+ await actionResponse;
179
+
180
+ // Reload as soon as the Server Action has landed. Do not wait for
181
+ // networkidle — that can let the ~2.5s background refresh finish first.
182
+ await page.reload();
183
+ await expect(computedAt).toBeVisible();
184
+
185
+ const afterRevalidateComputedAt = await computedAt.textContent();
186
+ const afterRevalidateValue = await value.textContent();
187
+
188
+ // SWR: ideally the first reload still serves previous values. If background
189
+ // refresh already completed, skip the stale assertion and verify eventual refresh.
190
+ if (afterRevalidateComputedAt === beforeComputedAt) {
191
+ expect(afterRevalidateValue).toBe(beforeValue);
192
+ }
193
+
194
+ await page.waitForTimeout(5000);
195
+ await page.reload();
196
+ await expect(computedAt).toBeVisible();
197
+
198
+ expect(await computedAt.textContent()).not.toBe(beforeComputedAt);
199
+ expect(await value.textContent()).not.toBe(beforeValue);
200
+ });
201
+
127
202
  test('stale-while-revalidate demo eventually produces a new value after revalidateTag', async ({
128
203
  page,
129
204
  }) => {
@@ -15,6 +15,21 @@ describe('Next.js 16 Cache Components Integration', () => {
15
15
  return new Promise((resolve) => setTimeout(resolve, ms));
16
16
  }
17
17
 
18
+ async function waitForRedisKeys(
19
+ pattern: string,
20
+ minCount = 1,
21
+ timeoutMs = 10_000,
22
+ ) {
23
+ for (let elapsed = 0; elapsed < timeoutMs; elapsed += 100) {
24
+ const keys = await redisClient.keys(pattern);
25
+ if (keys.length >= minCount) {
26
+ return keys;
27
+ }
28
+ await delay(100);
29
+ }
30
+ return redisClient.keys(pattern);
31
+ }
32
+
18
33
  beforeAll(async () => {
19
34
  // Connect to Redis
20
35
  redisClient = createClient({
@@ -106,12 +121,13 @@ describe('Next.js 16 Cache Components Integration', () => {
106
121
  });
107
122
 
108
123
  it('should store cache entry in Redis', async () => {
109
- await fetch(`${BASE_URL}/api/cached-static-fetch`);
124
+ // expire-matrix is dynamic (`ƒ`); static prerendered `use cache` routes may
125
+ // not write through to Redis at runtime on some Next.js versions.
126
+ await fetch(`${BASE_URL}/api/expire-matrix?id=redis-smoke`);
110
127
 
111
- // Check Redis for cache keys
112
- const keys = await redisClient.keys(`${keyPrefix}*`);
128
+ const keys = await waitForRedisKeys(`${keyPrefix}*`);
113
129
  expect(keys.length).toBeGreaterThan(0);
114
- });
130
+ }, 15_000);
115
131
  });
116
132
 
117
133
  describe('cacheTag functionality', () => {
@@ -128,46 +144,36 @@ describe('Next.js 16 Cache Components Integration', () => {
128
144
  expect(data2.counter).toBe(data1.counter);
129
145
  });
130
146
 
131
- it('should invalidate cache when tag is revalidated (Stale while revalidate)', async () => {
132
- // Get initial cached data
133
- const res1 = await fetch(`${BASE_URL}/api/cached-with-tag`);
134
- const data1 = await res1.json();
147
+ it('should invalidate cache when tag is revalidated', async () => {
148
+ const id = `tag-invalidation-${Date.now()}`;
149
+ const matrixUrl = `${BASE_URL}/api/expire-matrix?id=${encodeURIComponent(id)}`;
135
150
 
136
- // Revalidate the tag
137
- await fetch(`${BASE_URL}/api/revalidate-tag`, {
138
- method: 'POST',
139
- headers: { 'Content-Type': 'application/json' },
140
- body: JSON.stringify({ tag: 'test-tag' }),
141
- });
142
-
143
- // The cache should be invalidated - verify by making multiple requests
144
- // until we get fresh data (with retries for async revalidation)
145
- let freshDataReceived = false;
146
- // Next.js tag revalidation can be async and may take longer under some runtimes.
147
- // Use a more tolerant window to avoid flaky failures.
148
- for (let i = 0; i < 60; i++) {
149
- await new Promise((resolve) => setTimeout(resolve, 500));
150
- const res = await fetch(`${BASE_URL}/api/cached-with-tag`);
151
- const data = await res.json();
152
-
153
- if (
154
- data.counter !== data1.counter ||
155
- data.timestamp !== data1.timestamp
156
- ) {
157
- freshDataReceived = true;
158
- break;
159
- }
160
- }
151
+ const data1 = await (await fetch(matrixUrl)).json();
152
+ const data2 = await (await fetch(matrixUrl)).json();
153
+ expect(data2.counter).toBe(data1.counter);
161
154
 
162
- expect(freshDataReceived).toBe(true);
163
- }, 20_000);
155
+ const revalidateRes = await fetch(
156
+ `${BASE_URL}/api/expire-matrix/revalidate`,
157
+ {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify({
161
+ tag: `expire-matrix-${id}`,
162
+ profile: { expire: 0 },
163
+ }),
164
+ },
165
+ );
166
+ expect(revalidateRes.status).toBe(200);
167
+
168
+ const after = await (await fetch(matrixUrl)).json();
169
+ expect(after.counter).toBeGreaterThan(data1.counter);
170
+ });
164
171
  });
165
172
 
166
173
  describe('cacheLife functionality', () => {
167
174
  it('should respect expire window and eventually return refreshed data', async () => {
168
175
  const res1 = await fetch(`${BASE_URL}/api/cached-with-cachelife`);
169
176
  const data1 = await res1.json();
170
- expect(data1.counter).toBe(1);
171
177
 
172
178
  const res2 = await fetch(`${BASE_URL}/api/cached-with-cachelife`);
173
179
  const data2 = await res2.json();
@@ -198,6 +204,158 @@ describe('Next.js 16 Cache Components Integration', () => {
198
204
  }, 20_000);
199
205
  });
200
206
 
207
+ describe('revalidateTag expire settings (next start)', () => {
208
+ // Expire seconds from next/dist/server/config-shared.js cacheLife presets
209
+ // (identical in Next.js 16.0.11, 16.2.6, 16.3.0). `default.expire` is
210
+ // INFINITE_CACHE = 0xfffffffe.
211
+ const CACHE_LIFE_EXPIRE_SECONDS = {
212
+ default: 0xfffffffe,
213
+ seconds: 60,
214
+ minutes: 60 * 60,
215
+ hours: 60 * 60 * 24,
216
+ days: 60 * 60 * 24 * 7,
217
+ weeks: 60 * 60 * 24 * 30,
218
+ max: 60 * 60 * 24 * 365,
219
+ } as const;
220
+
221
+ async function getMatrix(id: string) {
222
+ const res = await fetch(
223
+ `${BASE_URL}/api/expire-matrix?id=${encodeURIComponent(id)}`,
224
+ );
225
+ expect(res.status).toBe(200);
226
+ return res.json() as Promise<{
227
+ counter: number;
228
+ timestamp: number;
229
+ id: string;
230
+ }>;
231
+ }
232
+
233
+ async function revalidateMatrix(
234
+ id: string,
235
+ profile: string | { expire: number } | null,
236
+ ) {
237
+ const tag = `expire-matrix-${id}`;
238
+ const body = profile === null ? { tag, profile: null } : { tag, profile };
239
+ const res = await fetch(`${BASE_URL}/api/expire-matrix/revalidate`, {
240
+ method: 'POST',
241
+ headers: { 'Content-Type': 'application/json' },
242
+ body: JSON.stringify(body),
243
+ });
244
+ expect(res.status).toBe(200);
245
+ return res.json() as Promise<{ timestamp: number }>;
246
+ }
247
+
248
+ async function readTagManifest(tag: string) {
249
+ const hashKey = `${keyPrefix}__cacheComponents_revalidated_tags__`;
250
+ for (let elapsed = 0; elapsed < 5_000; elapsed += 50) {
251
+ const raw = await redisClient.hGet(hashKey, tag);
252
+ if (raw) {
253
+ const parsed: unknown = JSON.parse(raw);
254
+ if (typeof parsed === 'number') {
255
+ return { expired: parsed } as { stale?: number; expired?: number };
256
+ }
257
+ return parsed as { stale?: number; expired?: number };
258
+ }
259
+ await delay(50);
260
+ }
261
+ throw new Error(`tag manifest missing for ${tag} in ${hashKey}`);
262
+ }
263
+
264
+ function expectApproxMs(actual: number | undefined, expected: number) {
265
+ expect(actual, `expected ~${expected}, got ${actual}`).toBeDefined();
266
+ expect(Math.abs(actual! - expected)).toBeLessThan(5_000);
267
+ }
268
+
269
+ it.each(Object.entries(CACHE_LIFE_EXPIRE_SECONDS) as [string, number][])(
270
+ 'revalidateTag(tag, %s) marks stale now and expired=now+expire*1000, then SWR',
271
+ async (profile, expireSeconds) => {
272
+ const id = `profile-${profile}`;
273
+ await getMatrix(id);
274
+ const before = await getMatrix(id);
275
+
276
+ const startedAt = Date.now();
277
+ await revalidateMatrix(id, profile);
278
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
279
+
280
+ expectApproxMs(manifest.stale, startedAt);
281
+ expectApproxMs(manifest.expired, startedAt + expireSeconds * 1000);
282
+
283
+ const after = await getMatrix(id);
284
+ expect(after.counter).toBe(before.counter);
285
+ expect(after.timestamp).toBe(before.timestamp);
286
+ },
287
+ );
288
+
289
+ it('{ expire: 5 } uses the object expire, not a named profile', async () => {
290
+ const id = 'object-expire-5';
291
+ await getMatrix(id);
292
+ const before = await getMatrix(id);
293
+
294
+ const startedAt = Date.now();
295
+ await revalidateMatrix(id, { expire: 5 });
296
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
297
+
298
+ expectApproxMs(manifest.stale, startedAt);
299
+ expectApproxMs(manifest.expired, startedAt + 5_000);
300
+
301
+ const after = await getMatrix(id);
302
+ expect(after.counter).toBe(before.counter);
303
+ });
304
+
305
+ it('{ expire: 0 } writes expired=now and the next GET is a blocking miss', async () => {
306
+ const id = 'expire-zero';
307
+ await getMatrix(id);
308
+ const before = await getMatrix(id);
309
+
310
+ const startedAt = Date.now();
311
+ await revalidateMatrix(id, { expire: 0 });
312
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
313
+
314
+ expectApproxMs(manifest.expired, startedAt);
315
+ expect(manifest.stale).toBeUndefined();
316
+
317
+ const after = await getMatrix(id);
318
+ expect(after.counter).toBeGreaterThan(before.counter);
319
+ });
320
+
321
+ it('omitted profile writes expired=now without stale and the next GET misses', async () => {
322
+ const id = 'omit-profile';
323
+ await getMatrix(id);
324
+ const before = await getMatrix(id);
325
+
326
+ const startedAt = Date.now();
327
+ await revalidateMatrix(id, null);
328
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
329
+
330
+ expect(manifest.stale).toBeUndefined();
331
+ expectApproxMs(manifest.expired, startedAt);
332
+
333
+ const after = await getMatrix(id);
334
+ expect(after.counter).toBeGreaterThan(before.counter);
335
+ });
336
+
337
+ it('{ expire: 2 } serves stale, then hard-expires after the window', async () => {
338
+ const id = 'expire-two';
339
+ await getMatrix(id);
340
+ const before = await getMatrix(id);
341
+
342
+ const startedAt = Date.now();
343
+ await revalidateMatrix(id, { expire: 2 });
344
+ const manifest = await readTagManifest(`expire-matrix-${id}`);
345
+
346
+ expectApproxMs(manifest.stale, startedAt);
347
+ expectApproxMs(manifest.expired, startedAt + 2_000);
348
+
349
+ const stale = await getMatrix(id);
350
+ expect(stale.counter).toBe(before.counter);
351
+
352
+ await delay(3_000);
353
+
354
+ const afterWindow = await getMatrix(id);
355
+ expect(afterWindow.counter).toBeGreaterThan(before.counter);
356
+ }, 15_000);
357
+ });
358
+
201
359
  describe('Redis cache handler integration', () => {
202
360
  it('should call cache handler get and set methods', async () => {
203
361
  // Make request to trigger cache (don't clear first)