Skip to content

refactor: simplify HeaderRoot slot guards to direct conditionals#1076

Merged
georgewrmarshall merged 15 commits into
mainfrom
refactor/simplify-header-root-children-guard
Apr 14, 2026
Merged

refactor: simplify HeaderRoot slot guards to direct conditionals#1076
georgewrmarshall merged 15 commits into
mainfrom
refactor/simplify-header-root-children-guard

Conversation

@georgewrmarshall

@georgewrmarshall georgewrmarshall commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Description

HeaderRoot was using isReactNodeRenderable from @metamask/design-system-shared and several intermediate named boolean guards (hasRenderableChildren, hasTitleContent, shouldRenderTitleRow) to decide what to render in the left section. This logic was over-engineered — React already treats null, undefined, false, and '' as nothing when rendering, so !!prop is the correct and sufficient check.

The guards also introduced a real bug: isReactNodeRenderable('') returns true (it only excludes null, undefined, and booleans), which forced an additional && title !== '' workaround inline. Replacing everything with direct if (children) / if (title) conditionals removes the utility dependency, the workarounds, and the accidental complexity.

Changes:

  • Removed isReactNodeRenderable import and all intermediate guard variables from HeaderRoot
  • Replaced with direct if (children) / if (title) conditionals in renderLeftSection
  • Removed the inner Box wrapper around the left section; BoxRow now renders directly as a flex child with twClassName="flex-1"
  • End section container gains ml-auto to push it to the right without the wrapper
  • Removed stale tests that asserted titleAccessory renders without a title (behaviour that was never intentional and is now correctly absent)
  • Updated MIGRATION.md to document both breaking changes for 0.19.0

Breaking changes:

  1. titleAccessory no longer renders when title is falsy — it is decoration on the title row, not a standalone slot
  2. The intermediate Box wrapper around the left section is removed (structural change, no visual impact)

Related issue tracking the full isReactNodeRenderable cleanup: #1075

Related issues

Fixes: #1075 (partial — HeaderRoot only; TitleHub and shared package cleanup tracked in the issue)

Manual testing steps

  1. Run yarn storybook:ios or yarn storybook:android
  2. Navigate to Components/HeaderRoot
  3. Verify all existing stories render correctly (Default, Title, TitleAccessory, Children, EndAccessory, EndButtonIconProps, EndButtonIconPropsMultiple)
  4. Verify titleAccessory renders alongside title and does not render when title is omitted

Screenshots/Recordings

Before

No visual changes

before720.mov

After

after720.mov

Pre-merge author checklist

  • I've followed MetaMask Contributor Docs
  • I've completed the PR template to the best of my ability
  • I've included tests if applicable
  • I've documented my code using JSDoc format if applicable
  • I've applied the right labels on the PR (see labeling guidelines). Not required for external contributors.

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

Note

Medium Risk
Introduces breaking behavior changes in HeaderRoot (no standalone titleAccessory) and removes a previously exported shared utility, which can break downstream builds/usages if relied upon.

Overview
Simplifies HeaderRoot rendering logic and layout. Replaces isReactNodeRenderable-based guards with direct children/title conditionals, removes the left-section wrapper Box, and uses flex utilities (including ml-auto) to keep the end section aligned.

Behavior changes are now explicit and documented. titleAccessory only renders when title is truthy (tests updated accordingly), Storybook examples were adjusted for truncation, and MIGRATION.md adds guidance for the 0.19.0 change.

Shared package cleanup. Removes the isReactNodeRenderable utility (and its tests) from @metamask/design-system-shared exports and adds a shared migration guide noting the removal.

Reviewed by Cursor Bugbot for commit 08a0039. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Title row renders when title={false} with titleAccessory
    • Restored explicit guard title !== false and used isReactNodeRenderable for title/accessory so the row is suppressed when title is false; tests pass.

Create PR

Or push these changes by commenting:

@cursor push ed03fb450e
Preview (ed03fb450e)
diff --git a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
--- a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
+++ b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
@@ -7,6 +7,7 @@
 import { BoxRow } from '../BoxRow';
 import { ButtonIcon, ButtonIconSize } from '../ButtonIcon';
 import { TextVariant } from '../Text';
+import { isReactNodeRenderable } from '@metamask/design-system-shared';
 
 // Internal dependencies.
 import type { HeaderRootProps } from './HeaderRoot.types';
@@ -49,16 +50,21 @@
     if (children) {
       return children;
     }
-    if (title || titleAccessory) {
+    const hasTitleContent =
+      title !== false &&
+      (isReactNodeRenderable(title) || isReactNodeRenderable(titleAccessory));
+    if (hasTitleContent) {
       return (
         <BoxRow
-          endAccessory={titleAccessory}
+          endAccessory={
+            isReactNodeRenderable(titleAccessory) ? titleAccessory : undefined
+          }
           textProps={{
             variant: TextVariant.HeadingLg,
             ...titleProps,
           }}
         >
-          {title || null}
+          {isReactNodeRenderable(title) ? title : null}
         </BoxRow>
       );
     }

You can send follow-ups to the cloud agent here.

Comment thread packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx Outdated
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Flex-1 wrapper removed breaks children layout
    • Wrapped the children path in a flex-1 Box with alignItems start to restore fill and alignment behavior.

Create PR

Or push these changes by commenting:

@cursor push fc8c76a8bd
Preview (fc8c76a8bd)
diff --git a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
--- a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
+++ b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
@@ -47,7 +47,11 @@
 
   const renderLeftSection = () => {
     if (children) {
-      return children;
+      return (
+        <Box alignItems={BoxAlignItems.Start} twClassName="flex-1">
+          {children}
+        </Box>
+      );
     }
     if (title || titleAccessory) {
       return (

You can send follow-ups to the cloud agent here.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@georgewrmarshall georgewrmarshall self-assigned this Apr 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: titleAccessory alone no longer triggers title row rendering
    • Restored guard to render the title row when either title or titleAccessory is renderable while skipping when title === false.

Create PR

Or push these changes by commenting:

@cursor push 85a2998c85
Preview (85a2998c85)
diff --git a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
--- a/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
+++ b/packages/design-system-react-native/src/components/HeaderRoot/HeaderRoot.tsx
@@ -3,6 +3,7 @@
 import { useSafeAreaInsets } from 'react-native-safe-area-context';
 
 // External dependencies.
+import { isReactNodeRenderable } from '@metamask/design-system-shared';
 import { Box, BoxAlignItems, BoxFlexDirection } from '../Box';
 import { BoxRow } from '../BoxRow';
 import { ButtonIcon, ButtonIconSize } from '../ButtonIcon';
@@ -49,7 +50,10 @@
     if (children) {
       return children;
     }
-    if (title) {
+    const hasTitleContent =
+      title !== false &&
+      (isReactNodeRenderable(title) || isReactNodeRenderable(titleAccessory));
+    if (hasTitleContent) {
       return (
         <BoxRow
           endAccessory={titleAccessory}

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 96b0c4a. Configure here.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@@ -1,5 +1,4 @@
// Third party dependencies.
import { isReactNodeRenderable } from '@metamask/design-system-shared';
import React from 'react';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed isReactNodeRenderable from @metamask/design-system-shared — this was the only React Native component importing it. React already treats null, undefined, false, and '' as nothing when rendering, so the utility added a shared package dependency and a type import without solving a real problem. The !!prop implicit coercion that plain if (prop) performs is correct and sufficient for all realistic slot prop inputs.

@@ -27,14 +26,6 @@ export const HeaderRoot = ({
}: HeaderRootProps) => {
const insets = useSafeAreaInsets();

@georgewrmarshall georgewrmarshall Apr 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The three guard variables that lived here (hasRenderableChildren, hasTitleContent, shouldRenderTitleRow) were computing booleans that if (children) and if (title) now express directly. The most problematic was hasTitleContent, which combined isReactNodeRenderable(title) || isReactNodeRenderable(titleAccessory)

if (shouldRenderTitleRow) {
const titleNode =
isReactNodeRenderable(title) && title !== '' ? title : null;
if (title) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

titleAccessory is intentionally tied to title here — it is an accessory to the title row, not an independent slot. If you need a fully custom left section without a title string, use children instead. This makes the contract explicit: children owns its own layout, title + titleAccessory compose a managed row.

</Box>
{hasEndSection ? (
<Box flexDirection={BoxFlexDirection.Row} gap={2}>
{renderLeftSection()}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The outer Box wrapper (with style={{ flex: 1 }}) has been removed — children is now responsible for its own layout, and BoxRow gets twClassName="flex-1" directly. Using inline style on a wrapper that children couldn't see or control was the wrong layer to express flex growth; moving it onto BoxRow keeps the responsibility with the node that actually needs it.

<Box flexDirection={BoxFlexDirection.Row} gap={2}>
{renderLeftSection()}
{endSectionContent ? (
<Box flexDirection={BoxFlexDirection.Row} gap={2} twClassName="ml-auto">

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ml-auto replaces the old approach of letting the left section's flex: 1 push the end section to the right. Without the wrapper providing flex growth, the end section needs to position itself — ml-auto in a flex row achieves exactly that and is the idiomatic Tailwind pattern.

@@ -78,31 +78,6 @@ describe('HeaderRoot', () => {
expect(getByTestId(LEFT_CHILDREN_TEST_ID)).toBeOnTheScreen();
});

@georgewrmarshall georgewrmarshall Apr 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These two tests were asserting behaviour that was never intentionally designed. Removing the tests is the correct response because the behaviour they described was wrong, not because the feature was cut.


### From version 0.18.0 to 0.19.0

#### HeaderRoot: `titleAccessory` no longer renders without `title`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The titleAccessory standalone rendering change is a genuine breaking change worth calling out clearly here. The children escape hatch is the right migration path — it gives consumers full control over the left section layout without depending on the component's internal title row structure.

@georgewrmarshall georgewrmarshall marked this pull request as ready for review April 13, 2026 21:25
@georgewrmarshall georgewrmarshall requested a review from a team as a code owner April 13, 2026 21:25
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

*/
export function isReactNodeRenderable(node: ReactNode): boolean {
return node !== null && node !== undefined && typeof node !== 'boolean';
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The utility file and its test are fully deleted rather than deprecated — there is no transition period because !!prop is a zero-effort replacement. Keeping the utility would maintain a public API commitment for logic that plain JavaScript already provides, and the shared package is the wrong place for React rendering semantics anyway.

});

it('returns true for zero', () => {
expect(isReactNodeRenderable(0)).toBe(true);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The test suite is worth noting in the diff: it passed for isReactNodeRenderable('') returning true and isReactNodeRenderable(0) returning true — both treated as intended behaviour. In practice, consumers always pass pre-formatted strings (e.g. '$0.00', not 0) and empty string titles should never render a title row. The tests were accurately describing the utility's behaviour; the behaviour itself was the problem.


export { isReactNodeRenderable } from './utils/isReactNodeRenderable';

// AvatarBase types (ADR-0003 + ADR-0004)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removing the export here is the only source-breaking change for consumers of @metamask/design-system-shared. Any downstream code that imports isReactNodeRenderable will get a compile-time or runtime resolution error, which is the intended signal to migrate. The MIGRATION.md documents the exact replacement.


`isReactNodeRenderable` has been removed from the public API. The utility was introduced to guard conditional slot props (`children`, `title`, `titleAccessory`) against falsy values, but it solves a problem React already handles natively — `null`, `undefined`, `false`, and `''` all render as nothing in JSX without any guard.

The utility also introduced a subtle inconsistency: `isReactNodeRenderable('')` returned `true` (it only excluded `null`, `undefined`, and booleans), which required immediate workarounds like `&& title !== ''` at every callsite.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

First MIGRATION.md for @metamask/design-system-shared, following the same structure as the react-native and react packages (Table of Contents, Version Updates wrapper, What Changed / Migration / Impact sections). The empty string inconsistency is called out explicitly because it was the concrete evidence that the utility's design was wrong — not just unnecessary.

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

Comment on lines +85 to 93
twClassName="flex-1" // flex-1 ensures the text is truncated
>
<Text variant={TextVariant.BodyMd} twClassName="text-default">
Imported Account 1
<Text
ellipsizeMode="tail"
numberOfLines={1}
twClassName="flex-1" // flex-1 ensures the text is truncated
>
Imported Account 1 with a really long name
</Text>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updating custom component to show truncation

Image

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@georgewrmarshall georgewrmarshall force-pushed the refactor/simplify-header-root-children-guard branch from f93f441 to dd810b3 Compare April 14, 2026 19:19
@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@github-actions

Copy link
Copy Markdown
Contributor

📖 Storybook Preview

@georgewrmarshall georgewrmarshall merged commit a3d5295 into main Apr 14, 2026
44 checks passed
@georgewrmarshall georgewrmarshall deleted the refactor/simplify-header-root-children-guard branch April 14, 2026 20:50
@georgewrmarshall georgewrmarshall mentioned this pull request Apr 20, 2026
19 tasks
georgewrmarshall added a commit that referenced this pull request Apr 21, 2026
## Release 34.0.0

This release adds new shared `TitleHub` and `Checkbox` type contracts,
expands React 19 support for web and shared packages, and publishes a
React Native release that aligns its supported runtime with the React
Native 0.76 / Storybook 10 stack.

### 📦 Package Versions

- `@metamask/design-system-shared`: **0.12.0**
- `@metamask/design-system-react`: **0.17.1**
- `@metamask/design-system-react-native`: **0.19.0**
- `@metamask/design-system-twrnc-preset`: **0.4.2**

### 🔄 Shared Type Updates (0.12.0)

#### Shared contract additions and API cleanup
([#1052](#1052),
[#1040](#1040),
[#1076](#1076),
[#1089](#1089))

**What Changed:**

- Added `TitleHubPropsShared` and `CheckboxPropsShared` to
`@metamask/design-system-shared`
- Removed `isReactNodeRenderable` from the public shared API
- Expanded the shared package `react` peer dependency range to support
React 19

**Impact:**

- Enables consistent `TitleHub` and `Checkbox` implementations across
React and React Native
- Consumers importing `isReactNodeRenderable` must replace that import
with plain truthy checks
- Shared consumers on React 19 can now satisfy peer dependency
requirements without overrides

### 🌐 React Web Updates (0.17.1)

#### Changed

- Expanded the `react` and `react-dom` peer dependency ranges to support
React 19 consumers without changing the public component API
([#1089](#1089))

### 📱 React Native Updates (0.19.0)

#### Added

- Added `TitleHub` for stacked title, amount, and bottom-label layouts
with optional accessory slots
([#1052](#1052))

#### Changed

- **BREAKING:** Raised the minimum supported peer dependency versions to
React Native `>=0.76.0`, `react-native-gesture-handler >=2.25.0`,
`react-native-reanimated >=3.17.0`, and `react-native-safe-area-context
>=5.0.0`
([#844](#844))
- **BREAKING:** `HeaderRoot` now renders `titleAccessory` only when
`title` is present; use `children` for fully custom accessory-only title
rows
([#1076](#1076))
- **BREAKING:** `IconProps` now align with the underlying SVG component
props instead of `ViewProps`; move `View`-specific props to a wrapper
view if TypeScript flags them after upgrading
([#1090](#1090))

### 🎨 TWRNC Preset Updates (0.4.2)

#### Changed

- Expanded the `react` peer dependency range to `>=18.2.0`, allowing the
preset to install alongside newer React Native 0.76 and React 19 app
stacks
([#844](#844))

### ⚠️ Breaking Changes

#### `isReactNodeRenderable` removal (Shared)

**What Changed:**

- Removed `isReactNodeRenderable` from `@metamask/design-system-shared`
- Shared consumers should replace this helper with standard truthy
checks

**Migration:**

```tsx
// Before (0.11.0)
import { isReactNodeRenderable } from '@metamask/design-system-shared';

if (isReactNodeRenderable(title)) {
  return <Header title={title} />;
}

// After (0.12.0)
if (title) {
  return <Header title={title} />;
}
```

**Impact:**

- Any import of `isReactNodeRenderable` will fail after upgrading to
`0.12.0`
- See [Shared Migration
Guide](./packages/design-system-shared/MIGRATION.md#from-version-0110-to-0120)

#### React Native 0.76 peer minimums (React Native)

**What Changed:**

- Raised the minimum supported peer dependency versions to the React
Native 0.76 family used by the Storybook 10 migration

**Migration:**

```tsx
// Before (0.18.0)
// Compatible with older app stacks such as:
// react-native: 0.72.x
// react-native-gesture-handler: 2.12.x
// react-native-reanimated: 3.3.x
// react-native-safe-area-context: 4.x

// After (0.19.0)
// Consumers must be on at least:
// react-native: 0.76.x
// react-native-gesture-handler: 2.25.x
// react-native-reanimated: 3.17.x
// react-native-safe-area-context: 5.x
```

**Impact:**

- Apps on older React Native stacks will no longer satisfy peer
dependency requirements
- See [React Native Migration
Guide](./packages/design-system-react-native/MIGRATION.md#from-version-0180-to-0190)

#### `HeaderRoot` accessory rendering and `IconProps` typing (React
Native)

**What Changed:**

- `HeaderRoot` no longer renders `titleAccessory` unless `title` is
present
- `IconProps` now align with SVG props instead of `ViewProps`

**Migration:**

```tsx
// Before (0.18.0)
<HeaderRoot titleAccessory={<Icon name={IconName.Info} />} />
<Icon name={IconName.Lock} onLayout={handleLayout} />

// After (0.19.0)
<HeaderRoot title="Settings" titleAccessory={<Icon name={IconName.Info} />} />
<View onLayout={handleLayout}>
  <Icon name={IconName.Lock} />
</View>
```

**Impact:**

- Accessory-only `HeaderRoot` title rows must switch to `children` or
provide `title`
- `View`-specific props on `Icon` must move to a wrapper `View`
- See [React Native Migration
Guide](./packages/design-system-react-native/MIGRATION.md#from-version-0180-to-0190)

### ✅ Checklist

- [x] Changelogs updated with human-readable descriptions
- [x] Changelog validation passed (`yarn changelog:validate`)
- [x] Version bumps follow semantic versioning
- [x] design-system-shared: minor (`0.11.0` → `0.12.0`) - pre-1.0
breaking shared API cleanup plus new shared type exports
- [x] design-system-react: patch (`0.17.0` → `0.17.1`) - non-breaking
compatibility update that widens React peer support to include v19
- [x] design-system-react-native: minor (`0.18.0` → `0.19.0`) - pre-1.0
breaking peer minimum and API behavior/type changes
- [x] design-system-twrnc-preset: patch (`0.4.1` → `0.4.2`) -
non-breaking peer range expansion
- [x] Breaking changes documented with migration guidance
- [x] Migration guides updated with before/after examples (if breaking
changes)
- [x] PR references included in changelog entries

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs)
- [x] I've reviewed the [Release
Workflow](./.cursor/rules/release-workflow.md) cursor rule
- [x] All tests pass (`yarn build && yarn test && yarn lint`)
- [x] Changelog validation passes (`yarn changelog:validate`)

## **Pre-merge reviewer checklist**

- [ ] I've reviewed the [Reviewing Release
PRs](./docs/reviewing-release-prs.md) guide
- [ ] Package versions follow semantic versioning
- [ ] Changelog entries are consumer-facing (not commit message
regurgitation)
- [ ] Breaking changes are documented in MIGRATION.md with examples
- [ ] All unreleased changes are accounted for in changelogs
georgewrmarshall added a commit that referenced this pull request Apr 27, 2026
## **Description**

`HeaderRoot` was using `isReactNodeRenderable` from
`@metamask/design-system-shared` and several intermediate named boolean
guards (`hasRenderableChildren`, `hasTitleContent`,
`shouldRenderTitleRow`) to decide what to render in the left section.
This logic was over-engineered — React already treats `null`,
`undefined`, `false`, and `''` as nothing when rendering, so `!!prop` is
the correct and sufficient check.

The guards also introduced a real bug: `isReactNodeRenderable('')`
returns `true` (it only excludes `null`, `undefined`, and booleans),
which forced an additional `&& title !== ''` workaround inline.
Replacing everything with direct `if (children)` / `if (title)`
conditionals removes the utility dependency, the workarounds, and the
accidental complexity.

**Changes:**

- Removed `isReactNodeRenderable` import and all intermediate guard
variables from `HeaderRoot`
- Replaced with direct `if (children)` / `if (title)` conditionals in
`renderLeftSection`
- Removed the inner `Box` wrapper around the left section; `BoxRow` now
renders directly as a flex child with `twClassName="flex-1"`
- End section container gains `ml-auto` to push it to the right without
the wrapper
- Removed stale tests that asserted `titleAccessory` renders without a
`title` (behaviour that was never intentional and is now correctly
absent)
- Updated `MIGRATION.md` to document both breaking changes for 0.19.0

**Breaking changes:**

1. `titleAccessory` no longer renders when `title` is falsy — it is
decoration on the title row, not a standalone slot
2. The intermediate `Box` wrapper around the left section is removed
(structural change, no visual impact)

Related issue tracking the full `isReactNodeRenderable` cleanup: #1075

## **Related issues**

Fixes: #1075 (partial — `HeaderRoot` only; `TitleHub` and shared package
cleanup tracked in the issue)

## **Manual testing steps**

1. Run `yarn storybook:ios` or `yarn storybook:android`
2. Navigate to `Components/HeaderRoot`
3. Verify all existing stories render correctly (Default, Title,
TitleAccessory, Children, EndAccessory, EndButtonIconProps,
EndButtonIconPropsMultiple)
4. Verify `titleAccessory` renders alongside `title` and does not render
when `title` is omitted

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

No visual changes


https://github.com/user-attachments/assets/7e6e0db4-0a45-4a16-8b47-2a4016cd8f64

### **After**


https://github.com/user-attachments/assets/ea45dfb5-8a09-4b03-9504-c918347df7d5

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs)
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Introduces a small breaking behavior change in `HeaderRoot`
(`titleAccessory` no longer renders without a truthy `title`) and
removes `isReactNodeRenderable` from `@metamask/design-system-shared`,
which can break downstream imports.
> 
> **Overview**
> Simplifies `HeaderRoot` slot rendering by removing
`isReactNodeRenderable` and the derived guard booleans, switching to
direct `if (children)` / `if (title)` conditionals.
> 
> This also changes behavior so `titleAccessory` only renders when
`title` is truthy, and slightly restructures layout by removing the left
wrapper `Box` and using `twClassName="ml-auto"` to right-align the end
section.
> 
> In `@metamask/design-system-shared`, removes the
`isReactNodeRenderable` utility (and its tests) from the public exports
and adds migration docs for the removal; the React Native package
migration guide now documents the `HeaderRoot` breaking change and
structural tweak.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6463fb1. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
georgewrmarshall added a commit that referenced this pull request Apr 27, 2026
## Release 34.0.0

This release adds new shared `TitleHub` and `Checkbox` type contracts,
expands React 19 support for web and shared packages, and publishes a
React Native release that aligns its supported runtime with the React
Native 0.76 / Storybook 10 stack.

### 📦 Package Versions

- `@metamask/design-system-shared`: **0.12.0**
- `@metamask/design-system-react`: **0.17.1**
- `@metamask/design-system-react-native`: **0.19.0**
- `@metamask/design-system-twrnc-preset`: **0.4.2**

### 🔄 Shared Type Updates (0.12.0)

#### Shared contract additions and API cleanup
([#1052](#1052),
[#1040](#1040),
[#1076](#1076),
[#1089](#1089))

**What Changed:**

- Added `TitleHubPropsShared` and `CheckboxPropsShared` to
`@metamask/design-system-shared`
- Removed `isReactNodeRenderable` from the public shared API
- Expanded the shared package `react` peer dependency range to support
React 19

**Impact:**

- Enables consistent `TitleHub` and `Checkbox` implementations across
React and React Native
- Consumers importing `isReactNodeRenderable` must replace that import
with plain truthy checks
- Shared consumers on React 19 can now satisfy peer dependency
requirements without overrides

### 🌐 React Web Updates (0.17.1)

#### Changed

- Expanded the `react` and `react-dom` peer dependency ranges to support
React 19 consumers without changing the public component API
([#1089](#1089))

### 📱 React Native Updates (0.19.0)

#### Added

- Added `TitleHub` for stacked title, amount, and bottom-label layouts
with optional accessory slots
([#1052](#1052))

#### Changed

- **BREAKING:** Raised the minimum supported peer dependency versions to
React Native `>=0.76.0`, `react-native-gesture-handler >=2.25.0`,
`react-native-reanimated >=3.17.0`, and `react-native-safe-area-context
>=5.0.0`
([#844](#844))
- **BREAKING:** `HeaderRoot` now renders `titleAccessory` only when
`title` is present; use `children` for fully custom accessory-only title
rows
([#1076](#1076))
- **BREAKING:** `IconProps` now align with the underlying SVG component
props instead of `ViewProps`; move `View`-specific props to a wrapper
view if TypeScript flags them after upgrading
([#1090](#1090))

### 🎨 TWRNC Preset Updates (0.4.2)

#### Changed

- Expanded the `react` peer dependency range to `>=18.2.0`, allowing the
preset to install alongside newer React Native 0.76 and React 19 app
stacks
([#844](#844))

### ⚠️ Breaking Changes

#### `isReactNodeRenderable` removal (Shared)

**What Changed:**

- Removed `isReactNodeRenderable` from `@metamask/design-system-shared`
- Shared consumers should replace this helper with standard truthy
checks

**Migration:**

```tsx
// Before (0.11.0)
import { isReactNodeRenderable } from '@metamask/design-system-shared';

if (isReactNodeRenderable(title)) {
  return <Header title={title} />;
}

// After (0.12.0)
if (title) {
  return <Header title={title} />;
}
```

**Impact:**

- Any import of `isReactNodeRenderable` will fail after upgrading to
`0.12.0`
- See [Shared Migration
Guide](./packages/design-system-shared/MIGRATION.md#from-version-0110-to-0120)

#### React Native 0.76 peer minimums (React Native)

**What Changed:**

- Raised the minimum supported peer dependency versions to the React
Native 0.76 family used by the Storybook 10 migration

**Migration:**

```tsx
// Before (0.18.0)
// Compatible with older app stacks such as:
// react-native: 0.72.x
// react-native-gesture-handler: 2.12.x
// react-native-reanimated: 3.3.x
// react-native-safe-area-context: 4.x

// After (0.19.0)
// Consumers must be on at least:
// react-native: 0.76.x
// react-native-gesture-handler: 2.25.x
// react-native-reanimated: 3.17.x
// react-native-safe-area-context: 5.x
```

**Impact:**

- Apps on older React Native stacks will no longer satisfy peer
dependency requirements
- See [React Native Migration
Guide](./packages/design-system-react-native/MIGRATION.md#from-version-0180-to-0190)

#### `HeaderRoot` accessory rendering and `IconProps` typing (React
Native)

**What Changed:**

- `HeaderRoot` no longer renders `titleAccessory` unless `title` is
present
- `IconProps` now align with SVG props instead of `ViewProps`

**Migration:**

```tsx
// Before (0.18.0)
<HeaderRoot titleAccessory={<Icon name={IconName.Info} />} />
<Icon name={IconName.Lock} onLayout={handleLayout} />

// After (0.19.0)
<HeaderRoot title="Settings" titleAccessory={<Icon name={IconName.Info} />} />
<View onLayout={handleLayout}>
  <Icon name={IconName.Lock} />
</View>
```

**Impact:**

- Accessory-only `HeaderRoot` title rows must switch to `children` or
provide `title`
- `View`-specific props on `Icon` must move to a wrapper `View`
- See [React Native Migration
Guide](./packages/design-system-react-native/MIGRATION.md#from-version-0180-to-0190)

### ✅ Checklist

- [x] Changelogs updated with human-readable descriptions
- [x] Changelog validation passed (`yarn changelog:validate`)
- [x] Version bumps follow semantic versioning
- [x] design-system-shared: minor (`0.11.0` → `0.12.0`) - pre-1.0
breaking shared API cleanup plus new shared type exports
- [x] design-system-react: patch (`0.17.0` → `0.17.1`) - non-breaking
compatibility update that widens React peer support to include v19
- [x] design-system-react-native: minor (`0.18.0` → `0.19.0`) - pre-1.0
breaking peer minimum and API behavior/type changes
- [x] design-system-twrnc-preset: patch (`0.4.1` → `0.4.2`) -
non-breaking peer range expansion
- [x] Breaking changes documented with migration guidance
- [x] Migration guides updated with before/after examples (if breaking
changes)
- [x] PR references included in changelog entries

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs)
- [x] I've reviewed the [Release
Workflow](./.cursor/rules/release-workflow.md) cursor rule
- [x] All tests pass (`yarn build && yarn test && yarn lint`)
- [x] Changelog validation passes (`yarn changelog:validate`)

## **Pre-merge reviewer checklist**

- [ ] I've reviewed the [Reviewing Release
PRs](./docs/reviewing-release-prs.md) guide
- [ ] Package versions follow semantic versioning
- [ ] Changelog entries are consumer-facing (not commit message
regurgitation)
- [ ] Breaking changes are documented in MIGRATION.md with examples
- [ ] All unreleased changes are accounted for in changelogs
cursor Bot pushed a commit that referenced this pull request Apr 28, 2026
## **Description**

`HeaderRoot` was using `isReactNodeRenderable` from
`@metamask/design-system-shared` and several intermediate named boolean
guards (`hasRenderableChildren`, `hasTitleContent`,
`shouldRenderTitleRow`) to decide what to render in the left section.
This logic was over-engineered — React already treats `null`,
`undefined`, `false`, and `''` as nothing when rendering, so `!!prop` is
the correct and sufficient check.

The guards also introduced a real bug: `isReactNodeRenderable('')`
returns `true` (it only excludes `null`, `undefined`, and booleans),
which forced an additional `&& title !== ''` workaround inline.
Replacing everything with direct `if (children)` / `if (title)`
conditionals removes the utility dependency, the workarounds, and the
accidental complexity.

**Changes:**

- Removed `isReactNodeRenderable` import and all intermediate guard
variables from `HeaderRoot`
- Replaced with direct `if (children)` / `if (title)` conditionals in
`renderLeftSection`
- Removed the inner `Box` wrapper around the left section; `BoxRow` now
renders directly as a flex child with `twClassName="flex-1"`
- End section container gains `ml-auto` to push it to the right without
the wrapper
- Removed stale tests that asserted `titleAccessory` renders without a
`title` (behaviour that was never intentional and is now correctly
absent)
- Updated `MIGRATION.md` to document both breaking changes for 0.19.0

**Breaking changes:**

1. `titleAccessory` no longer renders when `title` is falsy — it is
decoration on the title row, not a standalone slot
2. The intermediate `Box` wrapper around the left section is removed
(structural change, no visual impact)

Related issue tracking the full `isReactNodeRenderable` cleanup: #1075

## **Related issues**

Fixes: #1075 (partial — `HeaderRoot` only; `TitleHub` and shared package
cleanup tracked in the issue)

## **Manual testing steps**

1. Run `yarn storybook:ios` or `yarn storybook:android`
2. Navigate to `Components/HeaderRoot`
3. Verify all existing stories render correctly (Default, Title,
TitleAccessory, Children, EndAccessory, EndButtonIconProps,
EndButtonIconPropsMultiple)
4. Verify `titleAccessory` renders alongside `title` and does not render
when `title` is omitted

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

No visual changes


https://github.com/user-attachments/assets/7e6e0db4-0a45-4a16-8b47-2a4016cd8f64

### **After**


https://github.com/user-attachments/assets/ea45dfb5-8a09-4b03-9504-c918347df7d5

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs)
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Introduces a small breaking behavior change in `HeaderRoot`
(`titleAccessory` no longer renders without a truthy `title`) and
removes `isReactNodeRenderable` from `@metamask/design-system-shared`,
which can break downstream imports.
> 
> **Overview**
> Simplifies `HeaderRoot` slot rendering by removing
`isReactNodeRenderable` and the derived guard booleans, switching to
direct `if (children)` / `if (title)` conditionals.
> 
> This also changes behavior so `titleAccessory` only renders when
`title` is truthy, and slightly restructures layout by removing the left
wrapper `Box` and using `twClassName="ml-auto"` to right-align the end
section.
> 
> In `@metamask/design-system-shared`, removes the
`isReactNodeRenderable` utility (and its tests) from the public exports
and adds migration docs for the removal; the React Native package
migration guide now documents the `HeaderRoot` breaking change and
structural tweak.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6463fb1. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
cursor Bot pushed a commit that referenced this pull request Apr 28, 2026
## Release 34.0.0

This release adds new shared `TitleHub` and `Checkbox` type contracts,
expands React 19 support for web and shared packages, and publishes a
React Native release that aligns its supported runtime with the React
Native 0.76 / Storybook 10 stack.

### 📦 Package Versions

- `@metamask/design-system-shared`: **0.12.0**
- `@metamask/design-system-react`: **0.17.1**
- `@metamask/design-system-react-native`: **0.19.0**
- `@metamask/design-system-twrnc-preset`: **0.4.2**

### 🔄 Shared Type Updates (0.12.0)

#### Shared contract additions and API cleanup
([#1052](#1052),
[#1040](#1040),
[#1076](#1076),
[#1089](#1089))

**What Changed:**

- Added `TitleHubPropsShared` and `CheckboxPropsShared` to
`@metamask/design-system-shared`
- Removed `isReactNodeRenderable` from the public shared API
- Expanded the shared package `react` peer dependency range to support
React 19

**Impact:**

- Enables consistent `TitleHub` and `Checkbox` implementations across
React and React Native
- Consumers importing `isReactNodeRenderable` must replace that import
with plain truthy checks
- Shared consumers on React 19 can now satisfy peer dependency
requirements without overrides

### 🌐 React Web Updates (0.17.1)

#### Changed

- Expanded the `react` and `react-dom` peer dependency ranges to support
React 19 consumers without changing the public component API
([#1089](#1089))

### 📱 React Native Updates (0.19.0)

#### Added

- Added `TitleHub` for stacked title, amount, and bottom-label layouts
with optional accessory slots
([#1052](#1052))

#### Changed

- **BREAKING:** Raised the minimum supported peer dependency versions to
React Native `>=0.76.0`, `react-native-gesture-handler >=2.25.0`,
`react-native-reanimated >=3.17.0`, and `react-native-safe-area-context
>=5.0.0`
([#844](#844))
- **BREAKING:** `HeaderRoot` now renders `titleAccessory` only when
`title` is present; use `children` for fully custom accessory-only title
rows
([#1076](#1076))
- **BREAKING:** `IconProps` now align with the underlying SVG component
props instead of `ViewProps`; move `View`-specific props to a wrapper
view if TypeScript flags them after upgrading
([#1090](#1090))

### 🎨 TWRNC Preset Updates (0.4.2)

#### Changed

- Expanded the `react` peer dependency range to `>=18.2.0`, allowing the
preset to install alongside newer React Native 0.76 and React 19 app
stacks
([#844](#844))

### ⚠️ Breaking Changes

#### `isReactNodeRenderable` removal (Shared)

**What Changed:**

- Removed `isReactNodeRenderable` from `@metamask/design-system-shared`
- Shared consumers should replace this helper with standard truthy
checks

**Migration:**

```tsx
// Before (0.11.0)
import { isReactNodeRenderable } from '@metamask/design-system-shared';

if (isReactNodeRenderable(title)) {
  return <Header title={title} />;
}

// After (0.12.0)
if (title) {
  return <Header title={title} />;
}
```

**Impact:**

- Any import of `isReactNodeRenderable` will fail after upgrading to
`0.12.0`
- See [Shared Migration
Guide](./packages/design-system-shared/MIGRATION.md#from-version-0110-to-0120)

#### React Native 0.76 peer minimums (React Native)

**What Changed:**

- Raised the minimum supported peer dependency versions to the React
Native 0.76 family used by the Storybook 10 migration

**Migration:**

```tsx
// Before (0.18.0)
// Compatible with older app stacks such as:
// react-native: 0.72.x
// react-native-gesture-handler: 2.12.x
// react-native-reanimated: 3.3.x
// react-native-safe-area-context: 4.x

// After (0.19.0)
// Consumers must be on at least:
// react-native: 0.76.x
// react-native-gesture-handler: 2.25.x
// react-native-reanimated: 3.17.x
// react-native-safe-area-context: 5.x
```

**Impact:**

- Apps on older React Native stacks will no longer satisfy peer
dependency requirements
- See [React Native Migration
Guide](./packages/design-system-react-native/MIGRATION.md#from-version-0180-to-0190)

#### `HeaderRoot` accessory rendering and `IconProps` typing (React
Native)

**What Changed:**

- `HeaderRoot` no longer renders `titleAccessory` unless `title` is
present
- `IconProps` now align with SVG props instead of `ViewProps`

**Migration:**

```tsx
// Before (0.18.0)
<HeaderRoot titleAccessory={<Icon name={IconName.Info} />} />
<Icon name={IconName.Lock} onLayout={handleLayout} />

// After (0.19.0)
<HeaderRoot title="Settings" titleAccessory={<Icon name={IconName.Info} />} />
<View onLayout={handleLayout}>
  <Icon name={IconName.Lock} />
</View>
```

**Impact:**

- Accessory-only `HeaderRoot` title rows must switch to `children` or
provide `title`
- `View`-specific props on `Icon` must move to a wrapper `View`
- See [React Native Migration
Guide](./packages/design-system-react-native/MIGRATION.md#from-version-0180-to-0190)

### ✅ Checklist

- [x] Changelogs updated with human-readable descriptions
- [x] Changelog validation passed (`yarn changelog:validate`)
- [x] Version bumps follow semantic versioning
- [x] design-system-shared: minor (`0.11.0` → `0.12.0`) - pre-1.0
breaking shared API cleanup plus new shared type exports
- [x] design-system-react: patch (`0.17.0` → `0.17.1`) - non-breaking
compatibility update that widens React peer support to include v19
- [x] design-system-react-native: minor (`0.18.0` → `0.19.0`) - pre-1.0
breaking peer minimum and API behavior/type changes
- [x] design-system-twrnc-preset: patch (`0.4.1` → `0.4.2`) -
non-breaking peer range expansion
- [x] Breaking changes documented with migration guidance
- [x] Migration guides updated with before/after examples (if breaking
changes)
- [x] PR references included in changelog entries

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs)
- [x] I've reviewed the [Release
Workflow](./.cursor/rules/release-workflow.md) cursor rule
- [x] All tests pass (`yarn build && yarn test && yarn lint`)
- [x] Changelog validation passes (`yarn changelog:validate`)

## **Pre-merge reviewer checklist**

- [ ] I've reviewed the [Reviewing Release
PRs](./docs/reviewing-release-prs.md) guide
- [ ] Package versions follow semantic versioning
- [ ] Changelog entries are consumer-facing (not commit message
regurgitation)
- [ ] Breaking changes are documented in MIGRATION.md with examples
- [ ] All unreleased changes are accounted for in changelogs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: remove isReactNodeRenderable utility — React handles empty values natively

2 participants