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:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user