Files
brain/web/eslint.ssr.config.mjs
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

158 lines
7.7 KiB
JavaScript

// SSR/hydration-safety lint. Run automatically by the platform after each build:
// yarn eslint -c eslint.ssr.config.mjs .
// Catches the error class behind most Next.js hydration failures. Do NOT delete or
// weaken this file to silence errors — fix the flagged code (see components/client-only.tsx
// and components/safe-format.tsx for ready-made safe patterns).
import tsParser from '@typescript-eslint/parser'
const BROWSER_GLOBALS = new Set(['window', 'document', 'localStorage', 'sessionStorage', 'navigator'])
const IMPURE_CALLS = { 'Date.now': true, 'Math.random': true }
const LOCALE_METHODS = new Set(['toLocaleDateString', 'toLocaleTimeString', 'toLocaleString'])
const FUNCTION_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'])
function isTypeofGuard(node) {
// `typeof window !== 'undefined'` is a valid SSR guard
return node.parent?.type === 'UnaryExpression' && node.parent.operator === 'typeof'
}
function isReference(node) {
const p = node.parent
if (!p) return false
if (p.type === 'MemberExpression' && p.property === node && !p.computed) return false
if (p.type === 'Property' && p.key === node && !p.computed) return false
if (p.type.startsWith('TS')) return false
if (p.type.startsWith('Import') || p.type.startsWith('Export')) return false
if ((p.type === 'VariableDeclarator' && p.id === node) || p.type.includes('Pattern')) return false
return true
}
const ssrPlugin = {
rules: {
'no-browser-globals-in-module-scope': {
meta: {
type: 'problem',
docs: { description: 'Browser globals at module scope crash or mismatch during SSR' },
schema: [],
},
create(context) {
return {
Identifier(node) {
if (!BROWSER_GLOBALS.has(node.name) || !isReference(node) || isTypeofGuard(node)) return
const inFunction = context.sourceCode.getAncestors(node).some((a) => FUNCTION_TYPES.has(a.type) || a.type === 'ClassBody')
if (!inFunction) {
context.report({
node,
message: `\`${node.name}\` at module scope runs during SSR where it is undefined. Move it inside useEffect, an event handler, or wrap the UI in <ClientOnly> (components/client-only.tsx).`,
})
}
},
}
},
},
'no-impure-values-in-render': {
meta: {
type: 'problem',
docs: { description: 'Non-deterministic values in render/state-init cause hydration mismatches' },
schema: [],
},
create(context) {
function impureName(node) {
if (node.type === 'NewExpression' && node.callee.type === 'Identifier' && node.callee.name === 'Date' && node.arguments.length === 0) return 'new Date()'
if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression' && !node.callee.computed && node.callee.object.type === 'Identifier') {
const name = `${node.callee.object.name}.${node.callee.property.name}`
if (IMPURE_CALLS[name]) return `${name}()`
}
return null
}
function check(node) {
const name = impureName(node)
if (!name) return
let crossedFunction = false
// Walk ancestors innermost-first: hook initializer args always flag (lazy
// initializers still run during SSR render); direct JSX only flags when no
// function boundary (e.g. event handler) sits in between.
for (const anc of context.sourceCode.getAncestors(node).slice().reverse()) {
if (FUNCTION_TYPES.has(anc.type)) crossedFunction = true
if (anc.type === 'CallExpression' && anc.callee.type === 'Identifier' && ['useState', 'useReducer', 'useMemo'].includes(anc.callee.name)) {
context.report({ node, message: `\`${name}\` in ${anc.callee.name}() runs during SSR and again on the client, producing different values (hydration mismatch). Initialize deterministically and update the value inside useEffect, or wrap the UI in <ClientOnly>.` })
return
}
if (anc.type === 'JSXExpressionContainer' && !crossedFunction) {
context.report({ node, message: `\`${name}\` rendered directly in JSX differs between server and client (hydration mismatch). Compute it in useEffect/state, or use <SafeDate>/<ClientOnly> from components/.` })
return
}
}
}
return { CallExpression: check, NewExpression: check }
},
},
'no-bare-locale-format': {
meta: {
type: 'problem',
docs: { description: 'Locale formatting without explicit locale/timeZone differs between server and client' },
schema: [],
},
create(context) {
const DATE_METHODS = new Set(['toLocaleDateString', 'toLocaleTimeString'])
function hasTimeZone(node) {
return node.arguments.some((arg) =>
arg.type === 'ObjectExpression' &&
arg.properties.some((p) => p.type === 'Property' && !p.computed && ((p.key.type === 'Identifier' && p.key.name === 'timeZone') || (p.key.type === 'Literal' && p.key.value === 'timeZone'))))
}
return {
CallExpression(node) {
if (node.callee.type !== 'MemberExpression' || node.callee.computed || node.callee.property.type !== 'Identifier' || !LOCALE_METHODS.has(node.callee.property.name)) return
if (node.arguments.length === 0) {
context.report({
node,
message: `\`${node.callee.property.name}()\` without arguments uses the runtime's locale/timezone, which differs between server and client (hydration mismatch). Pass an explicit locale (and timeZone for dates), e.g. ('en-US', { timeZone: 'UTC' }), or use <SafeDate>/<SafeTime>/<SafeNumber> from components/safe-format.tsx.`,
})
return
}
// Date-only/time values stored in UTC render one day off (and can
// mismatch SSR) unless the timezone is pinned. toLocaleString is
// excluded: Numbers accept it too and cannot take timeZone.
if (DATE_METHODS.has(node.callee.property.name) && !hasTimeZone(node)) {
context.report({
node,
message: `\`${node.callee.property.name}()\` without a timeZone option renders in the runtime's timezone, which differs between server (UTC) and client (hydration mismatch, off-by-one dates). Add { timeZone: 'UTC' } or use <SafeDate>/<SafeTime> from components/safe-format.tsx.`,
})
}
},
}
},
},
},
}
export default [
{
ignores: ['node_modules/**', '.next/**', '.build/**', '.deploy/**', 'out/**', 'dist/**', 'build/**', 'prisma/**', 'scripts/**', '**/*.d.ts', 'next.config.js', 'postcss.config.js', 'tailwind.config.ts'],
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: { ecmaFeatures: { jsx: true }, sourceType: 'module' },
},
// This pass runs with --no-config-lookup, so plugins from eslint.config.mjs
// (react-hooks, @typescript-eslint, @next/next, ...) are not visible here and a
// disable comment naming any of their rules would otherwise hard-fail the run
// with "Definition for rule ... was not found". Ignoring inline config closes
// that whole class — and also means the ssr rules themselves cannot be
// suppressed with disable comments (fix the flagged code instead).
linterOptions: { noInlineConfig: true },
plugins: { ssr: ssrPlugin },
rules: {
'ssr/no-browser-globals-in-module-scope': 'error',
},
},
{
files: ['**/*.tsx'],
rules: {
'ssr/no-impure-values-in-render': 'error',
'ssr/no-bare-locale-format': 'error',
},
},
]