@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.
- package/.github/workflows/ci.yml +11 -2
- package/.github/workflows/release.yml +8 -157
- package/ARCHITECTURE.md +86 -47
- package/README.md +91 -25
- package/dist/index.d.mts +40 -1
- package/dist/index.d.ts +40 -1
- package/dist/index.js +264 -96
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +261 -95
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -7
- package/release.config.cjs +6 -25
- package/src/CacheComponentsHandler.ts +141 -111
- package/src/RedisStringsHandler.ts +37 -25
- package/src/SyncedMap.ts +16 -2
- package/src/index.ts +7 -0
- package/src/utils/cacheTtl.ts +71 -0
- package/src/utils/compareAndUnlink.ts +29 -0
- package/src/utils/redisConnection.ts +17 -0
- package/src/utils/tagRevalidation.ts +162 -0
- package/test/README.md +13 -13
- package/test/nextjs-test-projects/next-app-15-4-11/pnpm-lock.yaml +1 -1
- package/test/nextjs-test-projects/next-app-16-0-11/pnpm-lock.yaml +1 -1
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/pnpm-lock.yaml +1 -1
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-0-11-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-2-6/pnpm-lock.yaml +1 -1
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/next.config.ts +2 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/pnpm-lock.yaml +1 -1
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-2-6-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-3-0/README.md +36 -0
- package/test/nextjs-test-projects/next-app-16-3-0/eslint.config.mjs +18 -0
- package/test/nextjs-test-projects/next-app-16-3-0/next.config.ts +7 -0
- package/test/nextjs-test-projects/next-app-16-3-0/package.json +28 -0
- package/test/nextjs-test-projects/next-app-16-3-0/pnpm-lock.yaml +4284 -0
- package/test/nextjs-test-projects/next-app-16-3-0/postcss.config.mjs +7 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/cached-static-fetch/route.ts +18 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/nested-fetch-in-api-route/revalidated-fetch/route.ts +27 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidatePath/route.ts +15 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidateTag/route.ts +20 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/revalidated-fetch/route.ts +17 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/api/uncached-fetch/route.ts +15 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/favicon.ico +0 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/globals.css +26 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/layout.tsx +59 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/page.tsx +755 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--default-page/page.tsx +34 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/cached-static-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/no-fetch/default-page/page.tsx +55 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--default-page/page.tsx +35 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/revalidated-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/default--force-dynamic-page/page.tsx +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--default-page/page.tsx +32 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/pages/uncached-fetch/revalidate15--force-dynamic-page/page.tsx +25 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/revalidation-interface.tsx +267 -0
- package/test/nextjs-test-projects/next-app-16-3-0/src/app/update-tag-test/page.tsx +25 -0
- package/test/nextjs-test-projects/next-app-16-3-0/tsconfig.json +34 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/README.md +36 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/cache-handler.js +3 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/eslint.config.mjs +18 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/next.config.ts +15 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/package.json +28 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/pnpm-lock.yaml +4284 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/postcss.config.mjs +7 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/file.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/globe.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/next.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/vercel.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/public/window.svg +1 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-static-fetch/route.ts +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-cachelife/route.ts +24 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/cached-with-tag/route.ts +21 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/revalidate/route.ts +34 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/expire-matrix/route.ts +23 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidate-tag/route.ts +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/api/revalidated-fetch/route.ts +19 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/cachelife-short/page.tsx +110 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/page.tsx +112 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/revalidate-durations/page.tsx +106 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/runtime-data-suspense/page.tsx +127 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/stale-while-revalidate/page.tsx +130 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/tag-invalidation/page.tsx +127 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-nondeterministic/page.tsx +110 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/cache-lab/use-cache-remote/page.tsx +85 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/favicon.ico +0 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/globals.css +26 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/layout.tsx +57 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/page.tsx +755 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/revalidation-interface.tsx +267 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/src/app/update-tag-test/page.tsx +22 -0
- package/test/nextjs-test-projects/next-app-16-3-0-cache-components/tsconfig.json +34 -0
- package/test/nextjs-test-projects/next-pages-16-2-6/pnpm-lock.yaml +4 -4
- package/test/nextjs-test-projects/next-pages-16-3-0/README.md +16 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/eslint.config.mjs +18 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/next.config.ts +7 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/package.json +26 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/pnpm-lock.yaml +3939 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/api/revalidate.ts +24 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/index.tsx +11 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/isr/[slug].tsx +49 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/src/pages/static-forever.tsx +20 -0
- package/test/nextjs-test-projects/next-pages-16-3-0/tsconfig.json +34 -0
- package/test/playwright/cache-lab.spec.ts +75 -0
- package/test/vitest/integration/cache-components/cache-components.integration.test.ts +194 -36
- package/test/vitest/integration/nextjs-cache-handler.integration.test.ts +67 -34
- package/test/vitest/integration/pages-router.integration.test.ts +16 -6
- package/test/vitest/unit/SyncedMap-orphan-cleanup.test.ts +111 -0
- package/test/vitest/unit/cache-components-update-tags.test.ts +350 -0
- package/test/vitest/unit/cache-ttl.test.ts +67 -0
- package/test/vitest/unit/compare-and-unlink.test.ts +22 -0
- package/test/vitest/unit/index.test.ts +17 -0
- package/test/vitest/unit/pages-router-kinds.test.ts +11 -0
- package/test/vitest/unit/redis-connection.test.ts +39 -0
- package/test/vitest/unit/tag-revalidation.test.ts +178 -0
- 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,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
|
-
|
|
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
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
const
|
|
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
|
-
|
|
137
|
-
await fetch(
|
|
138
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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)
|