Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/no-null-render-ternary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@gaia-react/lint": minor
---

Flag rendering `null` from a JSX ternary via `no-restricted-syntax`.

`cond ? <JSX/> : null` and `cond ? null : <JSX/>` are now `error`-level. Both
are the boolean-guarded `&&` render guard written the long way; use
`cond && <JSX/>` instead (coerce a numeric/falsy guard with `!!cond` so the
`0` value can't leak into the output).

The selectors are flag-only, with no autofix: a blind `? : null` → `&&` rewrite
is unsafe because numeric-`0` guards need `!!`, `??`-fed values need
`: undefined`, and `||`-guards need per-operand coercion. A human applies the
fix the selector points to.

Both selectors only match a `JSXElement`/`JSXFragment` branch, so they are
inert outside `.tsx`/`.jsx`. All `no-restricted-syntax` selectors are
consolidated into one `gaia/no-restricted-syntax` config object, because ESLint
flat config merges that rule key by replacement (last match wins), not
concatenation.

A consumer with a `cond ? <JSX/> : null` ternary will see a NEW lint failure on
upgrade. That is the intent: convert it to `&&`.
56 changes: 42 additions & 14 deletions src/configs/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,32 +275,59 @@ const preferArrowFunctionsConfig: Linter.Config[] = [
},
},
{
// `prefer-arrow-functions` has a hardcoded exemption for named
// default-exported declarations (`guard.js:hasNameAndIsExportedAsDefaultExport`),
// so `export default function Foo() {}` slips through. This selector closes
// that gap. Convert to `const Foo = () => {}; export default Foo;` instead.
// `.d.ts` is ignored because ambient `export default function …(): T;`
// declarations have no body to convert.
files: ['**/*.d.ts'],
name: 'ts-definition-files/prefer-arrow-off',
rules: {
'prefer-arrow-functions/prefer-arrow-functions': 'off',
},
},
];

/**
* All `no-restricted-syntax` selectors live in one config object. ESLint flat
* config merges this rule key by replacement (last match wins), not
* concatenation, so a second block matching the same files would silently
* clobber these selectors. Each selector carries its own message.
*
* `.d.ts` is ignored: ambient `export default function …(): T;` declarations
* have no body to convert, and type-definition files contain no render code.
*/
const restrictedSyntaxConfig: Linter.Config[] = [
{
ignores: ['**/*.d.ts'],
name: 'prefer-arrow/no-default-exported-function',
name: 'gaia/no-restricted-syntax',
rules: {
'no-restricted-syntax': [
'error',
{
// `prefer-arrow-functions` has a hardcoded exemption for named
// default-exported declarations
// (`guard.js:hasNameAndIsExportedAsDefaultExport`), so
// `export default function Foo() {}` slips through. This selector
// closes that gap.
message:
'Use `const Name = () => {}; export default Name;` instead. The prefer-arrow-functions plugin exempts named default-exported declarations upstream.',
selector: 'ExportDefaultDeclaration > FunctionDeclaration',
},
{
// GAIA never renders `null`. `cond ? <JSX/> : null` is the
// numeric-0-safe `&&` guard written the long way. Both JSX-null
// ternary selectors only match a JSXElement/JSXFragment branch, so
// they are inert outside `.tsx`/`.jsx`.
message:
'Do not render `null` from a ternary. Use a boolean-guarded `&&`: `cond && <JSX/>` (coerce a numeric/falsy guard with `!!cond`).',
selector:
"ConditionalExpression[alternate.raw='null']:matches([consequent.type='JSXElement'], [consequent.type='JSXFragment'])",
},
{
message:
'Do not render `null` from a ternary. Invert the condition and use `&&`: `!cond && <JSX/>`.',
selector:
"ConditionalExpression[consequent.raw='null']:matches([alternate.type='JSXElement'], [alternate.type='JSXFragment'])",
},
],
},
},
{
files: ['**/*.d.ts'],
name: 'ts-definition-files/prefer-arrow-off',
rules: {
'prefer-arrow-functions/prefer-arrow-functions': 'off',
},
},
];

const lodashUnderscoreConfig: Linter.Config[] = [
Expand All @@ -324,5 +351,6 @@ export const buildBase = (sourceDir: string): Linter.Config[] => [
...buildImportXConfig(sourceDir),
...eslintCommentsConfig,
...preferArrowFunctionsConfig,
...restrictedSyntaxConfig,
...lodashUnderscoreConfig,
];