Migrate feed and tab bar icons to react-native-nano-icons

react-native-svg materialises three native views per icon (SvgView + Group +
Path). Feed screens render around a thousand icons, so that is ~2400 views and
~2400 shadow nodes of pure overhead per profile load.

Move the 28 icons that repeat on feed screens - the post action bar, feed row
reason, post embeds, the loading skeleton and the bottom tab bar - to
react-native-nano-icons, which draws each icon as a single native text glyph.

The SVG sources are generated from the existing icon path data so the geometry
is unchanged, and `createNanoIcon` mirrors the `useCommonSVGProps` contract so
call sites are one-line import swaps.

Measured on a Samsung Galaxy A16 (SM-A165F, Android 16) with release builds,
10 runs per condition: native views -20.6%, icon construction -45.3%,
JS-thread CPU -6.1%, main-thread CPU -3.3%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tomek Zawadzki
2026-08-24 19:33:57 +02:00
parent 6009e61ae9
commit 3606c48f02
47 changed files with 347 additions and 39 deletions
@@ -15,7 +15,7 @@ import {toNiceDomain} from '#/lib/strings/url-helpers'
import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import {Earth_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
import {EarthIcon as Globe} from '#/components/icons/nano'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
@@ -13,9 +13,8 @@ import {atoms as a, useBreakpoints, useTheme, utils, web} from '#/alf'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {StandardSite} from '#/components/icons/community/StandardSite'
import {ArrowTopRightIcon, ClockIcon as Clock} from '#/components/icons/nano'
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
@@ -8,10 +8,12 @@ import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {
MuteIcon,
PauseIcon,
PlayIcon,
UnmuteIcon,
} from '#/components/icons/nano'
import {KeepAwake} from '#/components/KeepAwake'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {useReportDialogMetadataContext} from '#/components/moderation/ReportDialog/ReportDialogMetadataContext'
@@ -10,7 +10,10 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
import {useRequireAuth} from '#/state/session'
import {useTheme} from '#/alf'
import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark'
import {
BookmarkFilledIcon as BookmarkFilled,
BookmarkIcon as Bookmark,
} from '#/components/icons/nano'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
@@ -5,7 +5,7 @@ import {useLingui} from '@lingui/react/macro'
import {type Shadow} from '#/state/cache/post-shadow'
import {EventStopper} from '#/view/com/util/EventStopper'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {MenuIcon as DotsHorizontal} from '#/components/icons/nano'
import * as Menu from '#/components/Menu'
import {useMenuControl} from '#/components/Menu'
import {type app} from '#/lexicons'
+1 -1
View File
@@ -9,8 +9,8 @@ import {useRequireAuth} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {RepostIcon} from '#/components/icons/nano'
import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import {Text} from '#/components/Typography'
import {
@@ -12,7 +12,7 @@ import {type Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {EventStopper} from '#/view/com/util/EventStopper'
import {native} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight'
import {ShareIcon as ArrowShareRightIcon} from '#/components/icons/nano'
import * as Menu from '#/components/Menu'
import {useMenuControl} from '#/components/Menu'
import {useAnalytics} from '#/analytics'
+1 -1
View File
@@ -19,7 +19,7 @@ import {
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Reply as Bubble} from '#/components/icons/Reply'
import {ReplyIcon as Bubble} from '#/components/icons/nano'
import {useFormatPostStatCount} from '#/components/PostControls/util'
import * as Skele from '#/components/Skeleton'
import * as Toast from '#/components/Toast'
+109
View File
@@ -0,0 +1,109 @@
import {type ColorValue, StyleSheet} from 'react-native'
import {createNanoIconSet} from 'react-native-nano-icons'
import {useTheme} from '#/alf'
import {type Props, sizes} from '#/components/icons/common'
import glyphMap from '../../../assets/nano-icons/nanoicons/app-icons.glyphmap.json'
const NanoIcon = createNanoIconSet(glyphMap)
type IconName = keyof (typeof glyphMap)['i']
/**
* Drop-in replacement for `createSinglePathSVG` backed by a font glyph instead
* of a react-native-svg subtree. Each icon renders as one native text glyph
* rather than SvgView + Group + Path, which is three native views per icon.
*
* The prop contract deliberately mirrors `useCommonSVGProps` so call sites and
* wrappers such as `PostControlButtonIcon` do not have to change.
*
* Only icons that are a single filled path can move here. Anything using the
* `gradient` prop (`StarterPackIcon`) or several fill colours (`VerifiedCheck`)
* must stay on react-native-svg.
*/
export function createNanoIcon(name: IconName) {
return function Icon({fill, size, style, width, testID}: Props) {
const t = useTheme()
const flattened = StyleSheet.flatten(style)
/*
* Mirrors useCommonSVGProps: an explicit `size` token wins, then a raw
* `width`, then the default. `fill` wins over a color inherited via style.
*/
const resolvedSize = Number(size ? sizes[size] : width || sizes.md)
const color = (fill || flattened?.color || t.palette.primary_500) as
ColorValue | ColorValue[]
return (
/*
* The a11y rule wants an accessibilityHint alongside accessibilityLabel,
* but a hint would be exactly wrong here: the label is empty precisely to
* remove this glyph from the accessibility tree, and a hint would put
* content back into it. See the accessibilityLabel comment below.
*/
// oxlint-disable-next-line react-native-a11y/has-accessibility-hint
<NanoIcon
name={name}
size={resolvedSize}
color={color}
style={flattened}
testID={testID}
/*
* The SVG icons these replace are sized in raw points and never scaled
* with the system font setting, so opt out to keep layout identical.
*/
allowFontScaling={false}
/*
* Also matches the SVG icons: these sit inside buttons that carry their
* own label, so the glyph itself must stay invisible to screen readers.
*
* The empty label is load-bearing. Nano Icons falls back to
* `accessibilityLabel ?? name`, so without it every glyph is announced
* by its icon name on top of the button's own label ("Reply, button"
* then "reply, image"). `importantForAccessibility` alone does not
* suppress it - the native view still exposes a contentDescription.
*/
accessibilityLabel=""
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
/>
)
}
}
/* Post action bar */
export const ReplyIcon = createNanoIcon('reply')
export const RepostIcon = createNanoIcon('repost')
export const HeartIcon = createNanoIcon('heart')
export const HeartFilledIcon = createNanoIcon('heart-filled')
export const BookmarkIcon = createNanoIcon('bookmark')
export const BookmarkFilledIcon = createNanoIcon('bookmark-filled')
export const ShareIcon = createNanoIcon('share')
export const MenuIcon = createNanoIcon('menu')
/* Feed rows and post embeds */
export const PinIcon = createNanoIcon('pin')
export const EarthIcon = createNanoIcon('earth')
export const PlayIcon = createNanoIcon('play')
export const PauseIcon = createNanoIcon('pause')
export const MuteIcon = createNanoIcon('mute')
export const UnmuteIcon = createNanoIcon('unmute')
export const ArrowTopRightIcon = createNanoIcon('arrow-top-right')
export const ClockIcon = createNanoIcon('clock')
/* Feed loading skeletons - 8 rows x 3 icons per placeholder, one per profile tab */
export const BubbleIcon = createNanoIcon('bubble')
export const RepostCorner2Icon = createNanoIcon('repost-corner2')
/* Shell chrome, mounted for the whole session */
export const HomeIcon = createNanoIcon('home')
export const HomeFilledIcon = createNanoIcon('home-filled')
export const SearchIcon = createNanoIcon('search')
export const SearchFilledIcon = createNanoIcon('search-filled')
export const MessageIcon = createNanoIcon('message')
export const MessageFilledIcon = createNanoIcon('message-filled')
export const BellIcon = createNanoIcon('bell')
export const BellFilledIcon = createNanoIcon('bell-filled')
export const InboxIcon = createNanoIcon('inbox')
export const CircleCheckIcon = createNanoIcon('circle-check')
+3 -3
View File
@@ -7,9 +7,9 @@ import Animated, {
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
HeartFilledIcon as HeartIconFilled,
HeartIcon as HeartIconOutline,
} from '#/components/icons/nano'
const keyframe = new Keyframe({
0: {
+16
View File
@@ -12,6 +12,22 @@
* HTML & BODY STYLES IN `web/index.html` and `bskyweb/templates/base.html`
*/
/*
* Nano Icons font, used by the icons in `#/components/icons/nano`.
*
* On native the Expo config plugin links the TTF into the app bundle, but on
* web the library reads the family straight off CSS and never registers it at
* runtime - without this the icons render as tofu.
*/
@font-face {
font-family: 'app-icons';
src: url('../assets/nano-icons/nanoicons/app-icons.ttf') format('truetype');
font-weight: normal;
font-style: normal;
/* Icon glyphs have no sensible fallback, so never swap in one. */
font-display: block;
}
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
button,
input,
+1 -2
View File
@@ -9,8 +9,7 @@ import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-disp
import {makeProfileLink} from '#/lib/routes/links'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin'
import {Repost_Stroke2_Corner3_Rounded as RepostIcon} from '#/components/icons/Repost'
import {PinIcon, RepostIcon} from '#/components/icons/nano'
import {Link} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Text} from '#/components/Typography'
+5 -5
View File
@@ -9,12 +9,12 @@ import {
import {s} from '#/lib/styles'
import {atoms as a, useTheme} from '#/alf'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
BubbleIcon as Bubble,
HeartFilledIcon as HeartIconFilled,
HeartIcon as HeartIconOutline,
RepostCorner2Icon as Repost,
} from '#/components/icons/nano'
export function LoadingPlaceholder({
width,
+11 -17
View File
@@ -34,23 +34,17 @@ import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import {
Bell_Filled_Corner0_Rounded as BellFilled,
Bell_Stroke2_Corner0_Rounded as Bell,
} from '#/components/icons/Bell'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {
HomeOpen_Filled_Corner0_Rounded as HomeFilled,
HomeOpen_Stoke2_Corner0_Rounded as Home,
} from '#/components/icons/HomeOpen'
import {Inbox_Stroke2_Corner2_Rounded as InboxIcon} from '#/components/icons/Inbox'
import {
MagnifyingGlass_Filled_Stroke2_Corner0_Rounded as MagnifyingGlassFilled,
MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlass,
} from '#/components/icons/MagnifyingGlass'
import {
Message_Stroke2_Corner0_Rounded as Message,
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
} from '#/components/icons/Message'
BellFilledIcon as BellFilled,
BellIcon as Bell,
CircleCheckIcon,
HomeFilledIcon as HomeFilled,
HomeIcon as Home,
InboxIcon,
MessageFilledIcon as MessageFilled,
MessageIcon as Message,
SearchFilledIcon as MagnifyingGlassFilled,
SearchIcon as MagnifyingGlass,
} from '#/components/icons/nano'
import * as Menu from '#/components/Menu'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'