Add ESLint guard for invalid use server exports and fix plaza staff messages.

Catch Next.js invalid-use-server-value at lint time and move shared form-error copy out of plazaStaffFormActions so the use server module only exports async actions.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 20:07:06 -03:00
parent 3adc8eacc3
commit a864adc1b5
6 changed files with 237 additions and 37 deletions

View file

@ -115,10 +115,177 @@ const componentArrowConventions = {
},
}
const isFunctionNode = (node) =>
node?.type === 'FunctionDeclaration' ||
node?.type === 'FunctionExpression' ||
node?.type === 'ArrowFunctionExpression'
const isAsyncFunctionNode = (node) => isFunctionNode(node) && node.async === true
const isUseServerDirectiveStatement = (statement) =>
statement?.type === 'ExpressionStatement' &&
statement.expression?.type === 'Literal' &&
statement.expression.value === 'use server'
const hasTopLevelUseServer = (program) =>
Array.isArray(program.body) && program.body.some(isUseServerDirectiveStatement)
const isTypeOnlyNamedExport = (node) => {
if (node.exportKind === 'type') return true
const declaration = node.declaration
if (!declaration) return false
return (
declaration.type === 'TSTypeAliasDeclaration' ||
declaration.type === 'TSInterfaceDeclaration' ||
declaration.type === 'TSModuleDeclaration'
)
}
const resolveLocalAsyncBinding = (program, name) => {
for (const statement of program.body) {
if (statement.type === 'FunctionDeclaration' && statement.id?.name === name) {
return isAsyncFunctionNode(statement)
}
if (statement.type === 'VariableDeclaration') {
for (const declarator of statement.declarations) {
if (declarator.id?.type === 'Identifier' && declarator.id.name === name) {
return isAsyncFunctionNode(declarator.init)
}
}
}
}
return false
}
/**
* Next.js rejects any non-async-function export from a top-level `'use server'`
* module (https://nextjs.org/docs/messages/invalid-use-server-value). Inspired by
* eslint-plugin-next-recommended's export-server-actions-only, kept local because
* that package is unmaintained and ESLint-8-era.
*/
const useServerAsyncExports = {
meta: {
type: 'problem',
docs: {
description:
'Require top-level `use server` modules to export only async functions (plus type-only exports)',
},
schema: [],
messages: {
nonAsyncExport:
'A `"use server"` file can only export async functions (found non-async export). Move constants, types-as-values, and helpers to a separate module. See https://nextjs.org/docs/messages/invalid-use-server-value',
reexport:
'A `"use server"` file cannot re-export values (`export … from` / `export *`). Export async functions defined in this file only.',
},
},
create(context) {
let programNode = null
let useServer = false
const report = (node, messageId) => {
context.report({ node, messageId })
}
return {
Program(node) {
programNode = node
useServer = hasTopLevelUseServer(node)
},
ExportAllDeclaration(node) {
if (!useServer) return
if (node.exportKind === 'type') return
report(node, 'reexport')
},
ExportDefaultDeclaration(node) {
if (!useServer) return
const declaration = node.declaration
if (isAsyncFunctionNode(declaration)) return
if (declaration?.type === 'Identifier' && programNode) {
if (resolveLocalAsyncBinding(programNode, declaration.name)) return
}
report(node, 'nonAsyncExport')
},
ExportNamedDeclaration(node) {
if (!useServer) return
if (isTypeOnlyNamedExport(node)) return
if (node.source) {
const hasValueSpecifier =
!node.specifiers?.length ||
node.specifiers.some((specifier) => specifier.exportKind !== 'type')
if (hasValueSpecifier) {
report(node, 'reexport')
}
return
}
const declaration = node.declaration
if (declaration?.type === 'FunctionDeclaration') {
if (!isAsyncFunctionNode(declaration)) {
report(node, 'nonAsyncExport')
}
return
}
if (declaration?.type === 'VariableDeclaration') {
for (const declarator of declaration.declarations) {
if (!isAsyncFunctionNode(declarator.init)) {
report(node, 'nonAsyncExport')
return
}
}
return
}
if (declaration?.type === 'TSEnumDeclaration' || declaration?.type === 'ClassDeclaration') {
report(node, 'nonAsyncExport')
return
}
if (declaration) {
// Unrecognized value declaration (e.g. const enum already covered).
if (
declaration.type !== 'TSTypeAliasDeclaration' &&
declaration.type !== 'TSInterfaceDeclaration'
) {
report(node, 'nonAsyncExport')
}
return
}
for (const specifier of node.specifiers ?? []) {
if (specifier.exportKind === 'type') continue
if (specifier.type !== 'ExportSpecifier') {
report(node, 'nonAsyncExport')
continue
}
const localName = specifier.local?.name
if (!localName || !programNode || !resolveLocalAsyncBinding(programNode, localName)) {
report(specifier, 'nonAsyncExport')
}
}
},
}
},
}
export const localRules = {
rules: {
'component-arrow-conventions': componentArrowConventions,
'framework-default-export': frameworkDefaultExport,
'no-component-default-export': noComponentDefaultExport,
'use-server-async-exports': useServerAsyncExports,
},
}

View file

@ -1,9 +1,9 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
import { globalIgnores } from 'eslint/config'
import prettier from 'eslint-config-prettier/flat'
import checkFile from 'eslint-plugin-check-file'
import { globalIgnores } from 'eslint/config'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { localRules } from './eslint-local-rules.mjs'
const __filename = fileURLToPath(import.meta.url)
@ -131,6 +131,16 @@ const eslintConfig = [
],
},
},
{
// Catch Next.js invalid-use-server-value before `next build` (Vercel).
files: ['src/**/*.{ts,tsx}'],
plugins: {
local: localRules,
},
rules: {
'local/use-server-async-exports': 'error',
},
},
prettier,
// Override default ignores of eslint-config-next.
globalIgnores([

View file

@ -14,7 +14,7 @@ import {
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation'
import { plazaStaffEditSafeMessages } from '../../plazaStaffFormActions'
import { plazaStaffEditSafeMessages } from '../../plazaStaffEditMessages'
export const updatePlazaStrategyFormAction = async (
_state: CampaignFormActionState,

View file

@ -0,0 +1,4 @@
/** Shared form-error copy for plaza staff edits — kept out of `'use server'` files. */
export const plazaStaffEditSafeMessages = [
'Somente a coordenação e a assessoria podem editar a Praça.',
] as const

View file

@ -20,9 +20,7 @@ import {
} from '@/utilities/campaignFormActionError'
import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation'
export const plazaStaffEditSafeMessages = [
'Somente a coordenação e a assessoria podem editar a Praça.',
] as const
import { plazaStaffEditSafeMessages } from './plazaStaffEditMessages'
export const setPlazaExpectedVotesFormAction = async (
_state: CampaignFormActionState,

View file

@ -40,9 +40,7 @@ const expectInvalid = async (filePath: string, code: string, ruleId: string) =>
}
describe('ESLint conventions', () => {
it(
'enforces PascalCase component and camelCase utility/test filenames',
async () => {
it('enforces PascalCase component and camelCase utility/test filenames', async () => {
await expectValid('src/components/FutureCard.tsx', 'export const FutureCard = () => <div />')
await expectInvalid(
'src/components/future-card.tsx',
@ -56,9 +54,7 @@ describe('ESLint conventions', () => {
'export const futureValue = 1',
'check-file/filename-naming-convention',
)
},
15_000,
)
}, 15_000)
it('requires uppercase component arrows to use const', async () => {
await expectInvalid(
@ -73,9 +69,9 @@ describe('ESLint conventions', () => {
const result = await lint('src/components/FutureCard.tsx', code)
const output = await lintWithFix('src/components/FutureCard.tsx', code)
expect(result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions')).toBe(
true,
)
expect(
result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions'),
).toBe(true)
expect(output).toContain('export const FutureCard = () => (<div />)')
await expectValid(
@ -89,9 +85,9 @@ describe('ESLint conventions', () => {
const result = await lint('src/components/FutureCard.tsx', code)
const output = (await lintWithFix('src/components/FutureCard.tsx', code)) ?? code
expect(result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions')).toBe(
true,
)
expect(
result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions'),
).toBe(true)
expect(output).toContain('keep this rationale')
})
@ -137,11 +133,7 @@ describe('ESLint conventions', () => {
]
for (const code of invalidExamples) {
await expectInvalid(
'src/app/future/page.tsx',
code,
'local/framework-default-export',
)
await expectInvalid('src/app/future/page.tsx', code, 'local/framework-default-export')
}
})
@ -175,4 +167,33 @@ describe('ESLint conventions', () => {
'const futureConfig = { enabled: true }; export default futureConfig',
)
})
it('rejects non-async exports from top-level use server modules', async () => {
await expectInvalid(
'src/app/(campaign)/campanha/actions/futureAction.ts',
`'use server'\n\nexport const safeMessages = ['x'] as const\n`,
'local/use-server-async-exports',
)
await expectInvalid(
'src/app/(campaign)/campanha/actions/futureAction.ts',
`'use server'\n\nexport function syncAction() { return 1 }\n`,
'local/use-server-async-exports',
)
await expectInvalid(
'src/app/(campaign)/campanha/actions/futureAction.ts',
`'use server'\n\nexport { helper } from './futureHelper'\n`,
'local/use-server-async-exports',
)
})
it('allows async function and type-only exports from use server modules', async () => {
await expectValid(
'src/app/(campaign)/campanha/actions/futureAction.ts',
`'use server'\n\nexport type Result = { ok: true }\nexport const save = async () => ({ ok: true as const })\nexport async function load() { return 1 }\n`,
)
await expectValid(
'src/app/(campaign)/campanha/(app)/pracas/plazaStaffEditMessages.ts',
`export const plazaStaffEditSafeMessages = ['x'] as const\n`,
)
})
})