Skip to content

fix(core): ReactNode slots use isRenderable for 'renders nothing' - #9160

Merged
cixzhang merged 5 commits into
mainfrom
lane4/badge-isrenderable
Oct 31, 2026
Merged

cixzhang merged 5 commits into
mainfrom
lane4/badge-isrenderable

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

User impact

  • Before: a few components decided "does this slot render anything?" with their own checks, and some of those checks let React's "renders nothing" values through:
    • A DropdownMenuGroup with title={true} rendered an empty heading, and the group was named by it.
  • After: these slots use the shared isRenderable check, so null, undefined, true, false and '' count as empty, the way React renders them. 0 still counts as content, since React renders "0".
  • Unchanged: Badge behaves exactly as before. Its hand-rolled check (from fix(Badge): a badge grows with its text #8236) was already the same as isRenderable.

Spec

  • component:DropdownMenuGroup (current) FR1/FR2 and AV1: a titled group is named by its visible heading, and "the title may be any React node". A title that renders nothing leaves no visible heading, so the group is untitled (FR2).
  • No record text changes.

Changes

  • Badge.tsx: hasLabel = isRenderable(label). Same result as before.
  • DropdownMenuGroup.tsx: hasTitle = isRenderable(title). It was title != null && title !== '' && title !== false, so true got through.
  • isRenderable: unchanged. No new helpers or exports.

isRenderable versus the old checks

Value isRenderable Badge (old) DropdownMenuGroup (old)
null, undefined false false false
false false false false
true false false true (empty heading)
'' false false false
0 true true true
[], <></> true (shallow) true true

isRenderable is shallow on purpose: arrays and fragments count without looking inside, as before at every site.

Sites deliberately left

Grouped by why, so the remaining sites can be migrated with these rules. The search covered packages/core and packages/lab.

1. String or data checks: not ReactNode slots, leave as they are

These test a string prop or a data value, and isRenderable is for ReactNode slots only.

  • String props:
    • Avatar/Avatar.tsx:495 (accessibleName: string) and :650 (trimmed tooltip text);
    • Icon/Icon.tsx:284 (label: string);
    • Timestamp/TimestampHoverCard.tsx:227 (line.label: string);
    • Chat/ChatComposer.tsx:486 (status.message: string);
    • lab Comments/CommentParts.tsx:618 (trimmed avatar URL) and lab Comments/i18n.ts:35 (description: string).
  • ARIA ids and roles:
    • SideNav/SideNavItem.tsx:680 and DropdownMenu/DropdownMenu.tsx:559;
    • Layer/useTouchTrigger.ts:103 and hooks/scrollKeyboardDelegation.ts:114.
  • CSS values: Layer/layerInset.ts:40 and gestures/nativeScroll.ts:170.
  • Data:
    • utils/plainDate.ts:454, CommandPalette/CommandPalette.tsx:467, gestures/haptics.ts:120, Table/columnUtils.ts:335, motion/animatedLayout.ts:1609, Markdown/plugins/headingLinks.ts:235;
    • Markdown/remark.ts and Markdown/plugins/protocol.ts (mdast fields).

2. false is a documented opt-out: keep the explicit check

  • AppShell/AppShell.tsx:548/558: mobileNav={false} turns the mobile nav off, and the check also tells a config object from an element.
  • Table/BaseTable.tsx:590: emptyState={false} turns the empty state off, while null/undefined fall back to the default.
  • Tokenizer/TokenizerBottomSheet.tsx:668/683 (emptySearchText): the prop says "pass an empty string to render nothing", and the popover renders '', false and true as nothing, so an isRenderable default here would contradict the prop.

3. isRenderable would visibly change 0: needs a decision first

  • TextArea/TextArea.tsx:625/676: startIcon && … and Boolean(startIcon).
  • lab MobilePowerSearch/MobilePowerSearch.tsx:1915: endContent || ….
  • In both, 0 renders "0" in React, so isRenderable would show a lane that the truthiness check hides today.

4. Governed by its record

  • Chat/ChatMessageList.tsx:256 (hasChildren): ChatMessageList FR4 says the empty state shows only for nullish, false or an empty top-level array. So '' or true children keep today's behavior until that record changes.

5. A different shape: ?? defaults on ReactNode slots

  • false and '' pass through ?? as content, so the default never shows.
  • Examples: Typeahead/BaseTypeahead.tsx:490 (emptySearchText, used by the popover Tokenizer), Selector/Selector.tsx:1011/1013 (emptyText, emptySearchText), Chat/ChatComposer.tsx:573/584 (input, sendButton).
  • There are many, so they get their own sweep.

Lint baseline

  • This PR removes Badge/Badge.tsx and DropdownMenu/DropdownMenuGroup.tsx from no-nullish-jsx-guard.baseline.json, since both now use isRenderable.
  • Every other baselined site stays for now.

Evidence

  • Tests added, '', false, true and null each, plus 0:
    • Badge: the grow style for each value, plus [] and an empty fragment;
    • DropdownMenuGroup: heading and name;
  • Mutations: each new test fails with its old check restored.
  • Suites: Badge and DropdownMenu pass. A batching test flaked once while the machine was at load 60–78; it passes alone, and passes on main's version.
  • Strict lint and check:changesets pass.

Credit: @vjeux for Badge's check (#8236).

Badge's label check (from #8236) becomes isRenderable, with the same
result. The same hand-rolled check on other ReactNode slots now uses it
too, so true, false and '' count as empty the way React renders them:
DropdownMenuGroup's title, and the Tokenizer sheet's emptySearchText
(custom element and string title). Each has a test for '', false, true
and null, plus 0, and each test fails without its change.

Co-authored-by: vjeux <vjeux@users.noreply.github.com>
@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 6:21pm 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, 6:29 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
@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

Modified Components

Badge (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Entry File (ESM) 664B 664B 0B
Lines of Code 226 227 -
Complexity Medium Low (5) -

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/Badge 4.0KB 4.0KB +12B 4.0KB 4.0KB 0B
core/ChatComposer 63.3KB 63.3KB +11B 11.1KB 11.1KB 0B
core/ChatComposerDrawer 24.5KB 24.5KB +5B 4.7KB 4.7KB 0B
core/ChatComposerInput 58.1KB 58.1KB +10B 10.3KB 10.3KB 0B
core/ChatComposerTokenElement 58.1KB 58.1KB +9B 10.3KB 10.3KB 0B
core/ChatTokenizedText 7.1KB 7.1KB +14B 4.0KB 4.0KB 0B
core/ChatToolCalls 32.8KB 32.8KB +2B 9.2KB 9.2KB 0B
core/ContextMenuItem 55.3KB 55.3KB 0B 9.9KB 9.9KB 0B
core/DropdownMenu 75.3KB 75.3KB 0B 15.4KB 15.4KB 0B
core/DropdownMenuCheckboxItem 17.6KB 17.6KB 0B 5.9KB 5.9KB 0B
core/DropdownMenuDivider 5.2KB 5.2KB 0B 548B 548B 0B
core/DropdownMenuGroup 6.2KB 6.2KB +11B 3.4KB 3.4KB 0B
core/DropdownMenuItem 16.8KB 16.8KB 0B 5.5KB 5.5KB 0B
core/DropdownMenuRadioGroup 6.1KB 6.1KB 0B 3.3KB 3.3KB 0B
core/DropdownMenuRadioItem 17.6KB 17.6KB 0B 5.9KB 5.9KB 0B
core/DropdownMenuSubMenu 60.9KB 60.9KB 0B 9.9KB 9.9KB 0B
core/MultiSelector 75.4KB 75.4KB +1B 15.0KB 15.0KB 0B
lab/CommentComposer 97.1KB 97.1KB -13B 1.9KB 1.9KB 0B
lab/CommentThread 220.1KB 220.1KB -17B 2.4KB 2.4KB 0B
lab/Comments 224.7KB 224.7KB -10B 2.5KB 2.5KB 0B

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 36.0KB 36.0KB 0B

Accessibility Audit

Status: Accessibility did not run on this pull request; add ci:run-a11y to run it. It runs after merges to main and before each release.


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

@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 DropdownMenuGroup title={true} fix is pinned by a test that fails without it, and the Badge consolidation keeps its behaviour. The sheet Tokenizer change, though, replaces emptySearchText values the caller passed ('', false, true) with the default "No results found" message, while the prop documents '' as the way to render nothing and the popover renders nothing for all three; please drop that change or keep only null and undefined falling through to the default. The changeset also reads as library-wide (lines 5 and 11) although three slots change, so please scope it to what this PR changes.

[Automated review]

Comment on lines +5 to +11
[fix] Components treat a ReactNode slot that renders nothing (`null`, `undefined`, `true`, `false`, `''`) the same way React does, through one shared check:

- A `DropdownMenuGroup` with a `title` of `true` no longer renders an empty heading that names the group.
- A sheet-presented `Tokenizer` with an `emptySearchText` of `false`, `true` or `''` shows the default "No results found" state. Before, it showed an empty box or an empty title.
- `Badge` uses the same shared check for its label (thanks @vjeux, #8236). Its behavior is unchanged.

`0` still counts as content everywhere, since React renders it.

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 release note reads as library-wide, but the PR changes three slots. The public Tokenizer popover still renders an empty results panel for emptySearchText={false} (BaseTypeahead resolves it with ??). TextArea's startIcon && … still hides 0, as the PR description notes. The sheet Tokenizer isn't reachable through public TokenizerProps yet. Suggested wording:

[fix] `DropdownMenuGroup`'s `title`, `Badge`'s `label` and the not-yet-public sheet presentation of `Tokenizer` now use the shared `isRenderable` check, so `null`, `undefined`, `true`, `false` and `''` count as rendering nothing; `0` still counts as content.

@cixzhang
cixzhang marked this pull request as ready for review October 31, 2026 16:42
…anges

Review follow-up: the note read as library-wide. It now names Badge's
label, DropdownMenuGroup's title and the not-yet-public sheet Tokenizer,
and says other slots (the popover Tokenizer's emptySearchText included)
are unchanged.

@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 rescoped changeset now matches what changes, and the DropdownMenuGroup title={true} fix and the Badge consolidation are good as they stand. The sheet Tokenizer hunk still turns emptySearchText values the caller passed ('', false, true) into the default "No results found" message, shown and announced, while the prop documents '' as the way to render nothing and the public popover renders nothing for all three. Please conform the sheet so only null and undefined fall through to the default (the inline suggestion does this and keeps the rest of the sheet suite passing), or drop that hunk along with its test and the sheet clause in the changeset.

[Automated review]

Comment on lines +683 to +684
const hasCustomEmptyElement =
typeof emptySearchText !== 'string' && emptySearchText != null;
isRenderable(emptySearchText) && typeof emptySearchText !== 'string';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only null and undefined are "not given" for this prop ("Pass an empty string to render nothing", Tokenizer.tsx:256-257), and the popover renders '', false and true as nothing. Here those three fall through to the default "No results found" state, shown and announced. This predicate makes the sheet match the popover for every value:

const hasCustomEmptyElement =
  emptySearchText != null &&
  (typeof emptySearchText !== 'string' || emptySearchText === '');

The new test at line 647 and the changeset's sheet clause would change with it.

Review follow-up: the prop documents '' as the way to render nothing, and
the popover renders '', false and true as nothing, so turning them into the
default empty state contradicted the prop. The sheet keeps its current
check; the PR body lists it among the sites left, and the changeset names
only Badge's label and DropdownMenuGroup's title.
The previous commit restored it from a newer main; it now matches the base
this branch forked from, so the PR no longer touches it.
…he nullish-JSX-guard baseline

Badge's label and DropdownMenuGroup's title now use isRenderable, so their
entries leave no-nullish-jsx-guard.baseline.json and strict lint stays
clean.

@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.

This looks ready to approve. Moving DropdownMenuGroup to the shared isRenderable check stops title={true} from rendering an empty heading that names the group or overrides a caller's aria-labelledby, and the new test fails without the fix; Badge keeps identical behaviour. The earlier requests about the sheet Tokenizer change and the changeset's scope are resolved at this head.

[Automated review]

@cixzhang
cixzhang merged commit 7ef23f2 into main Oct 31, 2026
52 of 53 checks passed
@github-actions
github-actions Bot deleted the lane4/badge-isrenderable branch November 1, 2026 06:05

This branch had an error being deployed

1 failed deployment
Preview — efbe80b5 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