Skip to content

lint(no-nullish-jsx-guard): catch isRenderable written out by hand, and enforce with a baseline - #9147

Merged
cixzhang merged 3 commits into
mainfrom
lint/nullish-guard-reactnode
Oct 31, 2026
Merged

cixzhang merged 3 commits into
mainfrom
lint/nullish-guard-reactnode

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

A contributor who decides whether a slot renders by writing out the checks (label !== '' && label != null && typeof label !== 'boolean', as #8236 did in Badge) gets no lint signal. @astryx/no-nullish-jsx-guard only looks at bare != null JSX guards, and it's a warning even in strict mode, so neither form ever fails a pull request. The copies drift: one includes '', another forgets true, and slots disagree about what renders nothing.

Intended invariant

A slot's presence is decided by isRenderable from @astryxdesign/core/utils, the one shared definition (null, undefined, true, false and '' render nothing; 0 renders). architecture:react-component-runtime INV16, in #9139, states the general rule: a shared concern is extended in its owner and called once.

Change

  • What the rule now flags: a chain that tests the same value for nullishness or '' and for a boolean (typeof x !== 'boolean', x !== false, x !== true), anywhere: an assignment, a condition or a return. It also catches the negated x == null || typeof x === 'boolean'. The boolean test marks the value as a rendered node, so the chain is isRenderable by hand. A presence test without a boolean test (value != null && value !== '') stays valid, and so does isRenderable.ts itself.
  • Options: {baseline, root}, the same shape as @astryx/no-internal-barrel-import, for both the new check and the existing JSX-guard check.
  • Enforcement: eslint.config.js enforces the rule in core and richtext at the strict severity, with no-nullish-jsx-guard.baseline.json.
    • The baseline lists the 36 checks in 23 files that predate enforcement (JSX guards plus the hand-written checks in Badge, DropdownMenuGroup, RichTextEditor, Markdown and getNodeText). They stay allowed until the isRenderable migration clears them.
    • A new check fails strict lint, while main stays green.
    • no-nullish-jsx-guard.baseline.mjs writes the baseline, and --check verifies it.
  • README for the rule updated. The presets still ship it as a warning.

Evidence

  • Failure before the change: Badge's check passes lint in every mode, and a new != null JSX guard is only a warning in CI.
  • Success after the change: a probe x != null && typeof x !== 'boolean' added to Avatar.tsx fails strict lint ("writes out isRenderable(x) by hand"). Strict lint over packages/core/src and packages/richtext/src reports no errors with the baseline, and --check passes on main.
  • Product/runtime behavior verified unchanged: no package source changes.

Scope

  • No intended public API, product behavior, visual direction or policy change.
  • Product changes discovered during the work were removed or split. Migrating the baselined call sites to isRenderable is the follow-up fix PR.
  • Public text and artifacts contain no internal Meta context.

Testing

  • internal/eslint-plugin-astryx/no-nullish-jsx-guard.test.mjs:
    • flagged: Badge's, DropdownMenuGroup's and RichTextEditor's forms, the negated form, and a baseline entry for another value;
    • valid: isRenderable, presence without a boolean test, different values in the two tests, the utility's own file, and baselined values for both checks.
  • Plugin suite: 874 tests pass. Strict eslint over core, richtext, the plugin and eslint.config.js: 0 errors. The baseline --check passes.

…nd enforce with a baseline

The rule now also flags a chain that tests one value for nullishness or
the empty string AND for a boolean (Badge's
label !== '' && label != null && typeof label !== 'boolean', and the
negated == null || typeof === 'boolean' form) anywhere, not only as a JSX
guard: the boolean test marks the value as a rendered node, so the chain
is isRenderable by hand.

The repository config now enforces the rule in core and richtext at the
strict severity with {baseline, root}. The 38 guarded values in 27 files
that predate enforcement (JSX guards and hand-written checks) are listed
in no-nullish-jsx-guard.baseline.json, written by
no-nullish-jsx-guard.baseline.mjs (--check verifies), so main stays green
and a new one fails strict lint. isRenderable.ts itself is exempt.

Tests: Badge's, DropdownMenuGroup's and RichTextEditor's forms, the
negated form, baseline scoping by file and value, and what stays valid.
@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Error Error Oct 31, 2026 5:43pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 5:56 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the new hand-written isRenderable check also reports data and union type switches, so it needs narrowing before it becomes a strict-lint error.

  • The scalar switches value == null || typeof value === 'boolean' || typeof value === 'string' in protocol.ts, semanticFence.ts and the Tooltip browser spec, and the mobileNav config-object test in AppShell.tsx, are not slot checks; skipping a chain that also tests the value's typeof against another type (or calls isValidElement on it) clears them, after which the baseline can be regenerated.
  • The baseline is keyed by value per file, so a new guard of an already-listed value (for example description in Item.tsx) still passes; adjust "a new one is an error" in the description, eslint.config.js and the README, or key the baseline by count.
  • no-nullish-jsx-guard.baseline.mjs repeats no-internal-barrel-import.baseline.mjs almost line for line, so one shared runner would keep the two baselines in step.

[Automated review]

Comment on lines +381 to +386
const typed =
parseBooleanTypeof(operand, equal) ??
parseLiteralComparison(operand, equal, new Set([true, false]));
if (typed) {
booleans.set(sourceCode.getText(typed), typed);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Operands that test the same value against another type (typeof value === 'string', isValidElement(mobileNav)) are ignored, so type switches match too: protocol.ts:672, semanticFence.ts:99, the Tooltip browser spec :932 and the config-object test at AppShell.tsx:547 are all reported as isRenderable by hand. Following the message would change what those functions return. Consider skipping a chain that also tests the value with typeof against anything but 'boolean', or with isValidElement, and adding those shapes as valid tests.

Comment thread eslint.config.js Outdated
Comment on lines +902 to +904
// block are listed in the baseline and stay allowed until migrated; a new
// one is reported (an error in strict mode, so a pull request that adds one
// fails lint, while main stays green).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The baseline is keyed by value text per file, so a new guard of a value already listed for its file is not reported: a new description != null && <span>{description}</span> in Item.tsx passes under CI=true. Suggest "a check of a value not already listed for its file is reported", or keying the baseline by count.

*/

import fs from 'node:fs';
import path from 'node:path';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This script matches no-internal-barrel-import.baseline.mjs line for line apart from the rule id, lint targets and message regex. A shared runner taking those as parameters would keep a future fix to the --check flow from landing in only one of them.

Comment on lines +50 to +52
* The boolean test is what marks the value as a rendered node, so these are
* flagged even outside JSX (architecture:react-component-runtime: a shared
* concern is extended in its owner and called once).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current architecture:react-component-runtime record has no claim that a shared concern is extended in its owner and called once; that is INV16 in the open #9139, and it covers effects, listeners and trackers. Pointing to utils/isRenderable.ts instead would keep the reference accurate.

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Preview: the links below open this branch's latest Vercel deployment, which can trail a new push until that push's deployment is ready.

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

No new or modified components detected.

Bundle Size Summary

No component packages changed.

Accessibility Audit

Status: The accessibility audit did not run on this pull request. It runs after merges to main and before each release; pull requests that touch components run it with the ci:heavy-lanes label.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

… share the baseline runner

- A chain that also tests the value against another type (typeof x ===
  'number', isValidElement, Array.isArray, instanceof) is a type switch,
  not isRenderable by hand: protocol, semanticFence and AppShell's
  mobileNav check are no longer reported.
- The baseline lists each value once per check, so one more check of a
  listed value in that file is reported; regenerated: 37 checks in 24
  files.
- rule-baseline.mjs runs the write and --check flow for both this rule's
  and no-internal-barrel-import's baseline script.
- The header points at isRenderable.ts instead of a claim not yet
  current.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the hand-written isRenderable check is close, but one type switch still gets through, and the PR carries unrelated formatting.

  • AppShell.tsx:556-559 (mobileNav != null && mobileNav !== false && (isValidElement(mobileNav) || typeof mobileNav === 'string')) is still reported because its type tests sit in a nested group. Following the advice would treat a MobileNavConfig object as a node, so please treat a grouped type test as a type switch, add that exact shape as a valid test, and regenerate the baseline.
  • The Prettier-only changes to nine unrelated rule test files (docblock-example-format.test.mjs, no-raw-color.test.mjs and others) belong in a separate PR.
  • Minor: label != null && typeof label !== 'boolean' && <span>{label}</span> is reported twice. Skipping the JSX-guard report once the hand-written check has fired on the same node would fix it.

[Automated review]

* (`typeof x === 'string'`, `isValidElement(x)`, `Array.isArray(x)`,
* `x instanceof Y`), negated or not; otherwise null.
*/
function otherTypeTestValue(node) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only reads direct operands, so a grouped type test gets past it. AppShell.tsx:556-559 (mobileNav != null && mobileNav !== false && (isValidElement(mobileNav) || typeof mobileNav === 'string')) is reported as isRenderable by hand, although the README says type switches are left alone. Following the advice would make a MobileNavConfig object count as a node. Treating a parenthesised group of type tests on the value as a type test, plus a valid test with AppShell's exact shape, would clear it and drop the baseline entry.

return {
// `{value != null && <El>{value}</El>}`
LogicalExpression(node) {
checkHandRolled(node);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the hand-written chain also guards JSX (label != null && typeof label !== 'boolean' && <span>{label}</span>), this node gets two errors, handRolledRenderable and then nullishGuard, and spends two baseline allowances. Skipping the JSX-guard path once checkHandRolled has reported this node would avoid it.

// Pattern 1: @example on its own line, ``` on next line
{
code: `
ruleTester.run('docblock-example-format', docblockExampleFormatRule, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file and eight other rule test files change only by a Prettier pass unrelated to this rule, and no CI step checks .mjs formatting. Please move that reformat to its own PR.

…eport per chain; drop an unrelated reformat

- A parenthesised group of type tests on the value
  ((isValidElement(x) || typeof x === 'string'), AppShell's mobileNav)
  marks a type switch too; baseline regenerated: 36 checks in 23 files.
- A hand-written check that also guards JSX is reported once and spends
  one baseline allowance.
- Restores nine rule test files a Prettier pass had reformatted.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval recommended: the grouped isValidElement(…) || typeof … === 'string' switch in AppShell is now left alone, a hand-written check that also guards JSX is reported once, and the unrelated reformat is gone. Two optional follow-ups: a false | config option check such as scrollLock == null || scrollLock === false is also reported as isRenderable by hand (worth listing under the rule's known limitations), and running both baseline --check scripts in the lint job would keep allowances from outliving migrated checks, as has already happened to the barrel-import baseline.

[Automated review]

Comment on lines +469 to +471
const boolean =
parseBooleanTypeof(operand, equal) ??
parseLiteralComparison(operand, equal, new Set([true, false]));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bare === false / !== false counts as the boolean test here, so a false | config option check such as scrollLock == null || scrollLock === false is reported as isRenderable by hand, and isRenderable will not accept the config object. DropdownMenuGroup, ChatLayout and ChatMessageList use the same literal for real slots, so listing this beside the bare-assignment case under "Known limitations" (rewrite as !scrollLock) may be the simplest fix. Non-blocking.

@cixzhang
cixzhang merged commit f67d5fa into main Oct 31, 2026
50 of 51 checks passed
@github-actions
github-actions Bot deleted the lint/nullish-guard-reactnode branch November 1, 2026 06:05

This branch had an error being deployed

1 failed deployment
Preview — 378b15c2 Deployed Oct 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant