@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,71 @@
1
+ export type ResolveCacheEntryTtlContext = {
2
+ revalidate?: number | false;
3
+ cacheControl?: { revalidate?: number | false; expire?: number | undefined };
4
+ };
5
+
6
+ export type ResolveCacheEntryTtlData = {
7
+ kind?: string;
8
+ revalidate?: number | false;
9
+ } | null;
10
+
11
+ export type ResolveCacheEntryTtlOptions = {
12
+ estimateExpireAge: (staleAge: number) => number;
13
+ defaultStaleAge: number;
14
+ };
15
+
16
+ /**
17
+ * Resolves the Redis TTL (seconds) for an ISR / incremental-cache entry.
18
+ *
19
+ * Official Next.js semantics (cache-handler-redis example + self-hosting docs):
20
+ * - Key TTL on `cacheControl.expire`, never on `revalidate` alone.
21
+ * - Past `revalidate` an entry is only stale (SWR); evicting at that boundary
22
+ * would defeat background refresh.
23
+ * - When no finite `expire` is provided, fall back to `estimateExpireAge(revalidate)`
24
+ * for legacy Pages Router / pre-cacheLife callers that only pass `revalidate`.
25
+ * - When neither `expire` nor `revalidate` is available, return `undefined` (no TTL;
26
+ * rely on tag-based invalidation). This is intentional — not a missing
27
+ * `defaultStaleAge` fallback. `revalidate: false` is a separate branch above
28
+ * and is covered by unit + Pages Router integration tests (`/static-forever`).
29
+ */
30
+ export function resolveCacheEntryTtlSeconds(
31
+ ctx: ResolveCacheEntryTtlContext,
32
+ data: ResolveCacheEntryTtlData,
33
+ options: ResolveCacheEntryTtlOptions,
34
+ ): number | undefined {
35
+ const expire = ctx.cacheControl?.expire;
36
+
37
+ if (Number.isFinite(expire) && expire! > 0) {
38
+ return Math.max(1, Math.ceil(expire!));
39
+ }
40
+
41
+ const revalidate = resolveRevalidateValue(ctx, data);
42
+
43
+ if (revalidate === false) {
44
+ return options.estimateExpireAge(options.defaultStaleAge);
45
+ }
46
+
47
+ if (revalidate && Number.isSafeInteger(revalidate) && revalidate > 0) {
48
+ return options.estimateExpireAge(revalidate);
49
+ }
50
+
51
+ return undefined;
52
+ }
53
+
54
+ function resolveRevalidateValue(
55
+ ctx: ResolveCacheEntryTtlContext,
56
+ data: ResolveCacheEntryTtlData,
57
+ ): number | false | undefined {
58
+ if (data?.kind === 'FETCH' && data.revalidate !== undefined) {
59
+ return data.revalidate;
60
+ }
61
+ if (ctx.revalidate !== undefined) {
62
+ return ctx.revalidate;
63
+ }
64
+ if (ctx.cacheControl?.revalidate !== undefined) {
65
+ return ctx.cacheControl.revalidate;
66
+ }
67
+ if (data?.revalidate !== undefined) {
68
+ return data.revalidate;
69
+ }
70
+ return undefined;
71
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Atomically delete `key` only if its current value still equals `expected`.
3
+ * GET then UNLINK would race: another instance can SET a replacement in between.
4
+ */
5
+ const COMPARE_AND_UNLINK = `
6
+ if redis.call('GET', KEYS[1]) == ARGV[1] then
7
+ return redis.call('UNLINK', KEYS[1])
8
+ end
9
+ return 0
10
+ `;
11
+
12
+ type EvalClient = {
13
+ eval(
14
+ script: string,
15
+ options: { keys: string[]; arguments: string[] },
16
+ ): Promise<unknown>;
17
+ };
18
+
19
+ export async function compareAndUnlink(
20
+ client: EvalClient,
21
+ key: string,
22
+ expected: string,
23
+ ): Promise<boolean> {
24
+ const deleted = await client.eval(COMPARE_AND_UNLINK, {
25
+ keys: [key],
26
+ arguments: [expected],
27
+ });
28
+ return deleted === 1 || deleted === 1n;
29
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Matches `PHASE_PRODUCTION_BUILD` from `next/constants`.
3
+ *
4
+ * Hardcoded so ESM builds (tsup `shims: false`) never call `require()`.
5
+ * A dynamic `require('next/constants')` throws in ESM, and catching that used
6
+ * to cache `undefined` for the process lifetime — disabling the build-phase
7
+ * Redis skip entirely.
8
+ */
9
+ export const PHASE_PRODUCTION_BUILD = 'phase-production-build';
10
+
11
+ /**
12
+ * Returns true during `next build` so handlers can skip opening Redis
13
+ * connections. Matches the official Next.js cache-handler-redis example.
14
+ */
15
+ export function shouldDeferRedisConnection(): boolean {
16
+ return process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD;
17
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Tag manifest matching Next.js 16.0–16.3
3
+ * `next/dist/server/lib/cache-handlers/default.js` `updateTags`
4
+ * and `next/dist/server/lib/incremental-cache/tags-manifest.external.js`.
5
+ *
6
+ * `revalidateTag(tag, profile)` resolves `cacheLife[profile].expire` (seconds)
7
+ * and calls `updateTags(tags, { expire })`. `updateTag` / no profile calls
8
+ * `updateTags(tags)` with no durations (immediate hard expire).
9
+ */
10
+ export type TagManifestEntry = {
11
+ stale?: number;
12
+ expired?: number;
13
+ };
14
+
15
+ /**
16
+ * Read a tag-manifest field from Redis.
17
+ *
18
+ * Next.js 16.0–16.3 never persists plain numbers here — `updateTags` always
19
+ * writes `{ stale, expired }` via `applyTagUpdate()`. A plain number can only
20
+ * appear from older releases of this package that stored `Date.now()` directly
21
+ * (before the `{ stale, expired }` manifest). ISR uses a separate Redis hash
22
+ * (`__revalidated_tags__`) and is unaffected.
23
+ *
24
+ * A future-looking plain number is not produced by Next.js and is not the same
25
+ * as intentional SWR (`{ stale: now, expired: now + N }`). It can only happen
26
+ * when another instance wrote the timestamp with a faster clock (NTP skew).
27
+ * Clamp to `now` on read so the revalidation is not delayed on slower instances.
28
+ */
29
+ export function normalizeTagManifest(
30
+ stored: unknown,
31
+ now: number = Date.now(),
32
+ ): TagManifestEntry {
33
+ if (typeof stored === 'number' && Number.isFinite(stored)) {
34
+ return { expired: Math.min(stored, now) };
35
+ }
36
+
37
+ if (stored && typeof stored === 'object') {
38
+ const rec = stored as {
39
+ stale?: unknown;
40
+ expired?: unknown;
41
+ last?: unknown;
42
+ pending?: unknown;
43
+ };
44
+ const stale =
45
+ typeof rec.stale === 'number' && Number.isFinite(rec.stale)
46
+ ? rec.stale
47
+ : typeof rec.last === 'number' && Number.isFinite(rec.last)
48
+ ? rec.last
49
+ : undefined;
50
+ const expired =
51
+ typeof rec.expired === 'number' && Number.isFinite(rec.expired)
52
+ ? rec.expired
53
+ : typeof rec.pending === 'number' && Number.isFinite(rec.pending)
54
+ ? rec.pending
55
+ : undefined;
56
+ return {
57
+ ...(stale !== undefined ? { stale } : {}),
58
+ ...(expired !== undefined ? { expired } : {}),
59
+ };
60
+ }
61
+
62
+ return {};
63
+ }
64
+
65
+ /**
66
+ * Mirrors Next.js DefaultCacheHandler.updateTags().
67
+ */
68
+ export function applyTagUpdate(
69
+ existing: TagManifestEntry,
70
+ durations: { expire?: number } | undefined,
71
+ now: number,
72
+ ): TagManifestEntry {
73
+ if (durations) {
74
+ const updates = { ...existing };
75
+ updates.stale = now;
76
+ if (durations.expire !== undefined) {
77
+ updates.expired = now + durations.expire * 1000;
78
+ }
79
+ return updates;
80
+ }
81
+
82
+ return {
83
+ ...existing,
84
+ expired: now,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Immediate hard-expires (`expired <= now`) are stored as a plain number so
90
+ * older package versions that JSON.parse + numeric-compare the hash field still
91
+ * see the invalidation during a rolling deploy. SWR windows stay objects.
92
+ */
93
+ export function persistableTagManifest(
94
+ entry: TagManifestEntry,
95
+ now: number,
96
+ ): TagManifestEntry | number {
97
+ if (
98
+ typeof entry.expired === 'number' &&
99
+ entry.expired <= now &&
100
+ (entry.stale === undefined || entry.stale <= entry.expired)
101
+ ) {
102
+ return entry.expired;
103
+ }
104
+ return entry;
105
+ }
106
+
107
+ /**
108
+ * Mirrors Next.js `areTagsExpired`: hard miss once `expired` has elapsed
109
+ * and is newer than the entry timestamp.
110
+ */
111
+ export function areTagsExpired(
112
+ tags: string[],
113
+ entryTimestamp: number,
114
+ now: number,
115
+ getEntry: (tag: string) => TagManifestEntry | undefined,
116
+ ): boolean {
117
+ for (const tag of tags) {
118
+ const expiredAt = getEntry(tag)?.expired;
119
+ if (
120
+ typeof expiredAt === 'number' &&
121
+ expiredAt <= now &&
122
+ expiredAt > entryTimestamp
123
+ ) {
124
+ return true;
125
+ }
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Mirrors Next.js `areTagsStale`: SWR when `stale` is newer than the entry.
132
+ */
133
+ export function areTagsStale(
134
+ tags: string[],
135
+ entryTimestamp: number,
136
+ getEntry: (tag: string) => TagManifestEntry | undefined,
137
+ ): boolean {
138
+ for (const tag of tags) {
139
+ const staleAt = getEntry(tag)?.stale ?? 0;
140
+ if (typeof staleAt === 'number' && staleAt > entryTimestamp) {
141
+ return true;
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * Mirrors Next.js DefaultCacheHandler.getExpiration(): max `expired` or 0.
149
+ */
150
+ export function maxExpiredTimestamp(
151
+ tags: string[],
152
+ getEntry: (tag: string) => TagManifestEntry | undefined,
153
+ ): number {
154
+ let max = 0;
155
+ for (const tag of tags) {
156
+ const expired = getEntry(tag)?.expired || 0;
157
+ if (expired > max) {
158
+ max = expired;
159
+ }
160
+ }
161
+ return max;
162
+ }
package/test/README.md CHANGED
@@ -55,7 +55,7 @@ These tests spawn a real Next.js server as a child process, make `fetch()` reque
55
55
 
56
56
  ### Standard Integration (`nextjs-cache-handler.integration.test.ts`)
57
57
 
58
- Full cache lifecycle: static pages, fetch caching, revalidation, tag invalidation, TTL behavior. In CI this runs against a matrix of Next.js versions (15.4–16.2).
58
+ Full cache lifecycle: static pages, fetch caching, revalidation, tag invalidation, TTL behavior. In CI this runs against Next.js 15.4, 16.0, 16.2, and 16.3. Nested fetch entries are resolved from their explicit cache tag in Redis's shared-tags map rather than Next.js's internal fetch-key hash, which changed in Next.js 16.3.
59
59
 
60
60
  ```bash
61
61
  pnpm test:integration
@@ -63,7 +63,7 @@ pnpm test:integration
63
63
 
64
64
  ### Pages Router (`pages-router.integration.test.ts`)
65
65
 
66
- Pages Router cache lifecycle against the `next-pages-16-2-6` app: `PAGES` entry format, TTL derivation from `getStaticProps` `revalidate`, `fallback: 'blocking'` first hits, `notFound: true` (null cache entries), `redirect:` results, and `revalidate: false` TTL fallback.
66
+ Pages Router cache lifecycle against the `next-pages-16-2-6` and `next-pages-16-3-0` apps: `PAGES` entry format, TTL derivation from `getStaticProps` `revalidate`, `fallback: 'blocking'` first hits, `notFound: true` (null cache entries), `redirect:` results, and `revalidate: false` TTL fallback.
67
67
 
68
68
  Starts **two** `next start` instances of the same build sharing one Redis and proves that on-demand revalidation (`res.revalidate(path)`) triggered on instance A is served fresh by instance B (HTML and `/_next/data` pageData JSON) — the multi-instance ISR scenario behind a load balancer.
69
69
 
@@ -81,14 +81,14 @@ pnpm test:integration:build-id-prefix
81
81
 
82
82
  ### Cache Components (`cache-components/`)
83
83
 
84
- Integration tests specific to Next.js 16 Cache Components (`use cache`). Uses `next-app-16-2-6-cache-components` as the test app.
84
+ Integration tests specific to Next.js 16 Cache Components (`use cache`). CI runs them against the 16.0, 16.2, and 16.3 Cache Components test apps.
85
85
 
86
- | File | What it tests |
87
- | -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
88
- | `cache-components.integration.test.ts` | `use cache` lifecycle: store, retrieve, tag invalidation, `cacheLife` expiry |
89
- | `redis-kill-reconnect.test.ts` | Graceful recovery when Redis drops and reconnects (main client ping only) |
90
- | `redis-subscriber-outage.test.ts` | Issue #86: subscriber PubSub does not recover after Redis outage (expected to fail until bug is fixed) |
91
- | `redis-quit-vs-disconnect.test.ts` | `quit()` does not reliably close a subscriber during an outage; `disconnect()` does |
86
+ | File | What it tests |
87
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
88
+ | `cache-components.integration.test.ts` | `use cache` lifecycle: store, retrieve, tag invalidation, `cacheLife` expiry, and every `revalidateTag` expire setting (`max`/`default`/`seconds`/`minutes`/`hours`/`days`/`weeks`, `{ expire: N }`, `{ expire: 0 }`, omitted profile) against `next start` + Redis |
89
+ | `redis-kill-reconnect.test.ts` | Graceful recovery when Redis drops and reconnects (main client ping only) |
90
+ | `redis-subscriber-outage.test.ts` | Issue #86: subscriber PubSub does not recover after Redis outage (expected to fail until bug is fixed) |
91
+ | `redis-quit-vs-disconnect.test.ts` | `quit()` does not reliably close a subscriber during an outage; `disconnect()` does |
92
92
 
93
93
  `redis-kill-reconnect.test.ts` uses a container runtime and auto-detects `podman` first, then `docker`. You can force runtime selection with `CONTAINER_RUNTIME=podman` or `CONTAINER_RUNTIME=docker`.
94
94
 
@@ -121,10 +121,10 @@ Browser-based tests that validate Cache Components behavior from the user's pers
121
121
 
122
122
  The Vitest cache-components integration tests verify the server-side plumbing (Redis state, HTTP responses). Playwright closes the gap by testing the full user-facing flow.
123
123
 
124
- | File | What it tests |
125
- | -------------------- | -------------------------------------------------------------------------------------------------------------------- |
126
- | `cache-lab.spec.ts` | `use cache` stability, tag invalidation (`updateTag`/`revalidateTag`), runtime cookie-based cache keys, SWR behavior |
127
- | `update-tag.spec.ts` | `updateTag` via Server Actions (button click → action → UI update) |
124
+ | File | What it tests |
125
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | `cache-lab.spec.ts` | `use cache` / `use cache: remote` stability, tag invalidation (`updateTag`/`revalidateTag`), `revalidateTag` durations (SWR stale window), runtime cookie-based cache keys, SWR behavior |
127
+ | `update-tag.spec.ts` | `updateTag` via Server Actions (button click → action → UI update) |
128
128
 
129
129
  ```bash
130
130
  pnpm test:e2e
@@ -492,7 +492,7 @@ packages:
492
492
  '@trieb.work/nextjs-turbo-redis-cache@file:../../..':
493
493
  resolution: {directory: ../../.., type: directory}
494
494
  peerDependencies:
495
- next: '>=15.0.3 <16.3.0'
495
+ next: '>=15.0.3 <17.0.0'
496
496
  redis: 4.7.0
497
497
 
498
498
  '@tybys/wasm-util@0.9.0':
@@ -561,7 +561,7 @@ packages:
561
561
  '@trieb.work/nextjs-turbo-redis-cache@file:../../..':
562
562
  resolution: {directory: ../../.., type: directory}
563
563
  peerDependencies:
564
- next: '>=15.0.3 <16.3.0'
564
+ next: '>=15.0.3 <17.0.0'
565
565
  redis: 4.7.0
566
566
 
567
567
  '@tybys/wasm-util@0.10.1':
@@ -4,7 +4,9 @@ const nextConfig: NextConfig = {
4
4
  cacheComponents: true,
5
5
  cacheHandlers: {
6
6
  default: require.resolve('./cache-handler.js'),
7
+ remote: require.resolve('./cache-handler.js'),
7
8
  },
9
+ cacheMaxMemorySize: 0,
8
10
  turbopack: {
9
11
  root: __dirname,
10
12
  },
@@ -568,7 +568,7 @@ packages:
568
568
  '@trieb.work/nextjs-turbo-redis-cache@file:../../..':
569
569
  resolution: {directory: ../../.., type: directory}
570
570
  peerDependencies:
571
- next: '>=15.0.3 <16.3.0'
571
+ next: '>=15.0.3 <17.0.0'
572
572
  redis: 4.7.0
573
573
 
574
574
  '@tybys/wasm-util@0.10.2':
@@ -0,0 +1,34 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { revalidateTag } from 'next/cache';
3
+
4
+ type Profile =
5
+ | 'max'
6
+ | 'default'
7
+ | 'seconds'
8
+ | 'minutes'
9
+ | 'hours'
10
+ | 'days'
11
+ | 'weeks'
12
+ | { expire: number };
13
+
14
+ export async function POST(request: Request) {
15
+ const body = (await request.json()) as {
16
+ tag?: string;
17
+ profile?: Profile | null;
18
+ };
19
+ const tag = body.tag || 'expire-matrix-default';
20
+
21
+ if (body.profile === undefined || body.profile === null) {
22
+ // Deprecated single-arg form: Next.js calls updateTags(tags) with no durations.
23
+ (revalidateTag as (tag: string) => void)(tag);
24
+ } else {
25
+ revalidateTag(tag, body.profile);
26
+ }
27
+
28
+ return NextResponse.json({
29
+ revalidated: true,
30
+ tag,
31
+ profile: body.profile ?? null,
32
+ timestamp: Date.now(),
33
+ });
34
+ }
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { cacheLife, cacheTag } from 'next/cache';
3
+
4
+ let counter = 0;
5
+
6
+ export async function GET(request: Request) {
7
+ const id = new URL(request.url).searchParams.get('id') || 'default';
8
+ const data = await getExpireMatrixData(id);
9
+ return NextResponse.json({ ...data, id });
10
+ }
11
+
12
+ async function getExpireMatrixData(id: string) {
13
+ 'use cache';
14
+ // Long time-based lifetime so only tag invalidation can change the value.
15
+ cacheLife({ stale: 300, revalidate: 3600, expire: 86_400 });
16
+ cacheTag(`expire-matrix-${id}`);
17
+
18
+ counter += 1;
19
+ return {
20
+ counter,
21
+ timestamp: Date.now(),
22
+ };
23
+ }
@@ -70,6 +70,28 @@ export default function CacheLabIndexPage() {
70
70
  it into a cached component to show correct boundaries.
71
71
  </p>
72
72
  </Link>
73
+
74
+ <Link
75
+ className="rounded-lg border p-5 hover:bg-slate-50"
76
+ href="/cache-lab/use-cache-remote"
77
+ >
78
+ <h2 className="text-lg font-medium">use cache: remote</h2>
79
+ <p className="mt-1 text-sm text-slate-600">
80
+ Exercises <code>cacheHandlers.remote</code> with Redis-backed
81
+ streams and <code>updateTag</code>.
82
+ </p>
83
+ </Link>
84
+
85
+ <Link
86
+ className="rounded-lg border p-5 hover:bg-slate-50"
87
+ href="/cache-lab/revalidate-durations"
88
+ >
89
+ <h2 className="text-lg font-medium">revalidateTag durations</h2>
90
+ <p className="mt-1 text-sm text-slate-600">
91
+ Deferred tag invalidation via{' '}
92
+ <code>revalidateTag(tag, {'{ expire }'})</code> and SWR behavior.
93
+ </p>
94
+ </Link>
73
95
  </section>
74
96
 
75
97
  <section className="mt-12 rounded-lg border bg-slate-50 p-5">
@@ -0,0 +1,106 @@
1
+ import Link from 'next/link';
2
+ import { Suspense } from 'react';
3
+ import { cacheLife, cacheTag, revalidateTag } from 'next/cache';
4
+
5
+ function sleep(ms: number): Promise<void> {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+
9
+ async function getSlowTaggedValue() {
10
+ 'use cache';
11
+
12
+ cacheLife({ stale: 60, revalidate: 120, expire: 300 });
13
+ cacheTag('cache-lab:durations');
14
+
15
+ await sleep(2500);
16
+
17
+ return {
18
+ computedAt: Date.now(),
19
+ value: Math.random(),
20
+ };
21
+ }
22
+
23
+ async function triggerDeferredRevalidateTag() {
24
+ 'use server';
25
+ revalidateTag('cache-lab:durations', { expire: 2 });
26
+ }
27
+
28
+ async function CachedPanel() {
29
+ const data = await getSlowTaggedValue();
30
+
31
+ return (
32
+ <div className="rounded-lg border p-5">
33
+ <div className="text-sm text-slate-700">
34
+ <div>
35
+ <span className="font-medium">computedAt:</span>{' '}
36
+ <span data-testid="computedAt" className="font-mono">
37
+ {data.computedAt}
38
+ </span>
39
+ </div>
40
+ <div className="mt-1">
41
+ <span className="font-medium">value:</span>{' '}
42
+ <span data-testid="value" className="font-mono">
43
+ {data.value}
44
+ </span>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ export default function RevalidateDurationsPage() {
52
+ return (
53
+ <main className="mx-auto max-w-3xl p-10">
54
+ <div className="mb-6">
55
+ <Link
56
+ className="text-sm text-blue-600 hover:underline"
57
+ href="/cache-lab"
58
+ >
59
+ ← Back to Cache Lab
60
+ </Link>
61
+ </div>
62
+
63
+ <header className="mb-6">
64
+ <h1 className="text-2xl font-semibold">
65
+ revalidateTag durations (SWR stale window)
66
+ </h1>
67
+ <p className="mt-2 text-sm text-slate-600">
68
+ Calls <code>revalidateTag(tag, {'{ expire: 2 }'})</code>. The handler
69
+ should mark the tag stale immediately while keeping the Redis entry so
70
+ the next request can serve stale content (SWR) during refresh.
71
+ </p>
72
+ </header>
73
+
74
+ <Suspense
75
+ fallback={
76
+ <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-700">
77
+ Loading cached content… (first load can take ~2.5s)
78
+ </div>
79
+ }
80
+ >
81
+ <CachedPanel />
82
+ </Suspense>
83
+
84
+ <form action={triggerDeferredRevalidateTag} className="mt-6">
85
+ <button
86
+ className="rounded-md border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50"
87
+ type="submit"
88
+ >
89
+ revalidateTag(&apos;cache-lab:durations&apos;, {'{ expire: 2 }'})
90
+ </button>
91
+ </form>
92
+
93
+ <div className="mt-6 rounded-lg border bg-slate-50 p-5 text-sm text-slate-700">
94
+ <p className="font-medium">How to observe SWR invalidation</p>
95
+ <ol className="mt-2 list-decimal space-y-1 pl-5">
96
+ <li>Load once (expect ~2.5s).</li>
97
+ <li>Click the button, then reload immediately.</li>
98
+ <li>
99
+ The reload should still show the old values (fast SWR). After
100
+ background refresh (~2.5s), values should change.
101
+ </li>
102
+ </ol>
103
+ </div>
104
+ </main>
105
+ );
106
+ }
@@ -0,0 +1,85 @@
1
+ import Link from 'next/link';
2
+ import { Suspense } from 'react';
3
+ import { cacheTag, updateTag } from 'next/cache';
4
+
5
+ async function getRemoteCachedValue() {
6
+ 'use cache: remote';
7
+
8
+ cacheTag('cache-lab:remote');
9
+
10
+ return {
11
+ value: Math.random(),
12
+ computedAt: Date.now(),
13
+ };
14
+ }
15
+
16
+ async function triggerUpdateTag() {
17
+ 'use server';
18
+ updateTag('cache-lab:remote');
19
+ }
20
+
21
+ async function RemotePanel() {
22
+ const data = await getRemoteCachedValue();
23
+
24
+ return (
25
+ <div className="rounded-lg border p-5">
26
+ <div className="text-sm text-slate-700">
27
+ <div>
28
+ <span className="font-medium">computedAt:</span>{' '}
29
+ <span data-testid="computedAt" className="font-mono">
30
+ {data.computedAt}
31
+ </span>
32
+ </div>
33
+ <div className="mt-1">
34
+ <span className="font-medium">value:</span>{' '}
35
+ <span data-testid="value" className="font-mono">
36
+ {data.value}
37
+ </span>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ export default function UseCacheRemotePage() {
45
+ return (
46
+ <main className="mx-auto max-w-3xl p-10">
47
+ <div className="mb-6">
48
+ <Link
49
+ className="text-sm text-blue-600 hover:underline"
50
+ href="/cache-lab"
51
+ >
52
+ ← Back to Cache Lab
53
+ </Link>
54
+ </div>
55
+
56
+ <header className="mb-6">
57
+ <h1 className="text-2xl font-semibold">use cache: remote</h1>
58
+ <p className="mt-2 text-sm text-slate-600">
59
+ Exercises the <code>cacheHandlers.remote</code> handler backed by
60
+ Redis. Values should stay stable across reloads until{' '}
61
+ <code>updateTag</code> runs.
62
+ </p>
63
+ </header>
64
+
65
+ <Suspense
66
+ fallback={
67
+ <div className="rounded-lg border bg-slate-50 p-5 text-sm text-slate-700">
68
+ Loading remote cached content…
69
+ </div>
70
+ }
71
+ >
72
+ <RemotePanel />
73
+ </Suspense>
74
+
75
+ <form action={triggerUpdateTag} className="mt-6">
76
+ <button
77
+ className="rounded-md border border-blue-600 bg-white px-4 py-2 text-sm font-medium text-blue-700 hover:bg-blue-50"
78
+ type="submit"
79
+ >
80
+ updateTag(&apos;cache-lab:remote&apos;)
81
+ </button>
82
+ </form>
83
+ </main>
84
+ );
85
+ }
@@ -561,7 +561,7 @@ packages:
561
561
  '@trieb.work/nextjs-turbo-redis-cache@file:../../..':
562
562
  resolution: {directory: ../../.., type: directory}
563
563
  peerDependencies:
564
- next: '>=15.0.3 <16.3.0'
564
+ next: '>=15.0.3 <17.0.0'
565
565
  redis: 4.7.0
566
566
 
567
567
  '@tybys/wasm-util@0.10.1':