// Detects time/randomness primitives that destabilize `'use cache'` cache keys, // which manifests as ISR write amplification when the cached output embeds a timestamp // that changes per request. // // Triggers when a file contains the `'use cache'` directive AND uses `new Date(`, // `Date.now(`, or `Math.random(` outside client-only hooks (useEffect / useCallback / // useMemo). Replacing module-scope `new Date().getFullYear()` with a build-time // `buildYear` constant, and removing dates passed as `'use cache'` function // arguments, prevents repeated writes when the rendered output is otherwise stable. import { lineOf } from '../util.mjs'; export const metadata = { id: 'use-cache-date-stamp', title: "new Date() / Date.now() / Math.random() inside a 'use cache' file", severity: 'high', billingDimension: 'isr', trafficIndependent: false, description: "`'use cache'` memoizes by argument identity AND prerender output. A timestamp baked into the cached output (`new Date().getFullYear()` in a footer, `Date.now()` in a payload field) forces a fresh ISR write on every regeneration even when the underlying data is unchanged. Random values have the same failure mode.", fix: "Replace module-scope `new Date()` with a build-time constant (`const buildYear = new Date().getFullYear()`) or move per-request timestamps into a client component inside `useEffect`. Do not pass dates as arguments to `'use cache'` functions — they invalidate the cache every call.", citations: [ 'https://nextjs.org/docs/app/api-reference/directives/use-cache', 'https://nextjs.org/docs/app/api-reference/functions/cacheLife', ], excludeGlobs: ['node_modules/**', '.next/**', 'dist/**', '__tests__/**', '**/*.test.*', '**/*.spec.*'], includeGlobs: [ '**/page.{ts,tsx,js,jsx}', '**/layout.{ts,tsx,js,jsx}', '**/route.{ts,tsx,js,jsx}', '**/lib/**/*.{ts,tsx,js,jsx}', '**/app/**/*.{ts,tsx,js,jsx}', '**/components/**/*.{ts,tsx,js,jsx}', ], }; const USE_CACHE_RE = /^[\t ]*['"]use cache['"]/m; const SUSPECT_RE = /\b(new Date\(|Date\.now\(|Math\.random\()/g; // Client-only hooks that don't affect server-side cache keys. const CLIENT_HOOK_RE = /\b(useEffect|useCallback|useMemo|useLayoutEffect)\s*\(/g; export function scan({ files }) { const out = []; for (const { path, content } of files) { if (!USE_CACHE_RE.test(content)) continue; const clientHookRanges = collectRanges(content, CLIENT_HOOK_RE); let match; SUSPECT_RE.lastIndex = 0; while ((match = SUSPECT_RE.exec(content)) !== null) { if (isInsideAnyRange(match.index, clientHookRanges)) continue; out.push({ pattern: metadata.id, file: path, line: lineOf(content, match.index), evidence: match[0], trafficIndependent: metadata.trafficIndependent, subtype: classifySubtype(content, match.index), }); } } return out; } function collectRanges(content, hookRe) { const ranges = []; hookRe.lastIndex = 0; let m; while ((m = hookRe.exec(content)) !== null) { const open = content.indexOf('(', m.index); if (open < 0) continue; const close = findMatchingParen(content, open); if (close < 0) continue; ranges.push([open, close]); } return ranges; } function findMatchingParen(content, openIdx) { let depth = 0; for (let i = openIdx; i < content.length; i++) { const c = content[i]; if (c === '(') depth++; else if (c === ')') { depth--; if (depth === 0) return i; } } return -1; } function isInsideAnyRange(idx, ranges) { for (const [a, b] of ranges) { if (idx >= a && idx <= b) return true; } return false; } // `module-scope` if the suspect appears before the first function/class declaration. // `in-cache-fn` otherwise (likely inside a render or helper function body). function classifySubtype(content, idx) { const head = content.slice(0, idx); if (!/\bfunction\b|\bclass\b|=>\s*\{/.test(head)) return 'module-scope'; return 'in-cache-fn'; }