All @rootnative/* packages (core, components, icons, cli, seo,
and the rootnative binary) release together with a synced version, so
this is a single changelog for the repo. These are prereleases and they
break things freely — assume every entry can require a change on upgrade,
and read the whole release rather than scanning for a label.
Prior history: these packages were published as @onlynative/* through
0.0.0-alpha.8. The @rootnative line below starts over at 0.0.0-alpha.0.
No breaking change. No export and no prop is removed, and no change
moves a screen. One fix, two new ListItem props and one new List part in
components, one fix in the llms.txt files, and one new docs section. The
@rootnative/inertia floor stays at 0.0.20. @rootnative/inertia
0.0.21 removes the same Reanimated warning from useSpring and
useInView, so upgrade it with this release.
The library has no image component, and the Card page did not say so. A
React Native Image in Card.Media has no loading state and no error
state, so a slow or broken URL left the region empty. The new "Images"
section says that Card.Media sizes the image and does not load it, and
names expo-image with placeholder, placeholderContentFit,
transition and onError. No code change.
List renders all its children and does not scroll. A FlatList inside a
List got no bounded height, so the end of a long list did not scroll into
view, and the headline was not available on its own to move into the
FlatList. The headline is now exported as List.Headline, and by name as
ListHeadline. It takes headingLevel and style, and List renders its
headline through it, so the two look the same. Put it in the
ListHeaderComponent of a FlatList with ListItem rows. The List page
has a "Long lists" section with the pattern.
The generator dropped the "Inherits" line of a props type whose
extends Omit<…> clause is longer than one line. That was the case for
Button, Checkbox, FAB, IconButton, Switch, TextField, the
NavigationDrawer props, NavigationDrawer.Item, NavigationDrawer.Section
and the SearchBar props. Their entries now say which React Native props
they take, for example "Inherits PressableProps (except children,
style)".
ListItem cut the headline and the overline at one line, with no prop to
change it, so a long headline in a narrow window lost its end. The new
headlineNumberOfLines and overlineNumberOfLines props set the limit. The
default is 1, as before, and 0 removes the limit, the same as
supportingTextNumberOfLines. The item grows to fit the lines. Unlike a
multi-line supportingText, a wrapped headline or overline does not change
the item to the three-line template, so a row whose text fits on one line
keeps its height.
On native, in development, Reanimated 4.7 logs "Dependencies should only be
used on the web and are always ignored on native" when a worklet hook gets a
dependency list. LinearProgress, CircularProgress, LoadingIndicator
and Slider passed a list to useAnimatedStyle or useAnimatedProps in 10
places, so an app that rendered one of them printed the warning and found no
offending code in its own source. The 10 calls now pass no list, and
Reanimated builds the list from the worklet closures on native and on the
web. No change in a production build.
The Expo SDK 58 release. Breaking: every package moves to the SDK 58
runtime band and stops claiming SDK 57, so an app that stays on SDK 57 stays
on 0.0.0-alpha.28. One set of ranges cannot serve both bands. Three ref
props of components take the React Native 0.88 instance types; that entry
ends with what to check. The @rootnative/inertia floor moves to 0.0.20.
Also in this release: one fix in core and components, two new exports in
core, one new prop on Tabs, one new function and one new command in
seo, and one corrected seo audit message.
core, components, icons and seo declare react >=19.3.0 <20.0.0 and
react-native >=0.88.0 <0.89.0. components also moves
react-native-reanimated to >=4.7.0 <4.8.0 and react-native-worklets to
>=0.13.0 <0.14.0.
The band cannot be wider. React 19.2.3 does not pair with React Native 0.88,
and Worklets 0.10 does not pair with Reanimated 4.7. A range that admits both
SDK 57 and SDK 58 also admits pairs that cannot install, and a range promises
every combination it permits. @rootnative/inertia 0.0.20 moved for the
same reason.
Migration: move the app to Expo SDK 58 — React 19.3.0, React Native
0.88.0, Reanimated 4.7.1, Worklets 0.13.0 — and @rootnative/inertia
to 0.0.20. npx expo install --fix picks the SDK versions. An app on SDK 57
stays on 0.0.0-alpha.28:
npm install @rootnative/components@0.0.0-alpha.28. The README and the
installation page carry the table of SDK against version. The example app
and both rootnative create templates pin the SDK 58 runtime.
The release gate installs the components and core candidates into an Expo
SDK 58 fixture app and bundles it for Android and the web, before the publish.
It installs core from the same release: the registry still has core
0.0.0-alpha.28, which declares the SDK 57 band.
The React Native 0.88 types declare View, TextInput and ScrollView as
function components, so TextInput no longer names the host instance. The
ref of TextField, SearchBar and SearchView is now
Ref<TextInputInstance>, and the anchor of SearchView is
RefObject<ViewInstance | null>. Both types come from react-native. The
value at runtime does not change.
Check your app. Type a ref that you pass to TextField, SearchBar or
SearchView as useRef<TextInputInstance>(null), and the anchor of
SearchView as useRef<ViewInstance>(null). On React Native 0.88,
useRef<TextInput>(null) does not compile anyway.
The core and components peer ranges are now >=0.0.20 <0.1.0, and the
dev, example and template pins are 0.0.20 exact. 0.0.20 is the first
inertia release on the SDK 58 band; 0.0.19 declares SDK 57. It also lets
useAnimation take a start value, and animate={{ translateY: 24 }}
compiles again on React Native 0.88.
Above a swipe pager, the indicator stayed on the old tab during the swipe
and sprang to the new one only when the pager selected the page. The new
position prop takes the page position as a Reanimated shared value: the
page index plus the fraction of the swipe toward the next page, written on
the UI thread. The indicator then moves with the finger between the rects of
the two tabs on each side of the position, in items order, in a worklet.
The rects are measured, so they are correct in either layout direction. A
position past the last tab clamps to it, and a neighbour that is not
measured yet falls back to the active tab for that frame. Reduced motion
does not gate it: that is the swipe, not an animation. Without position,
nothing changes. Keep value in sync from the pager's page-selected event:
it still drives the active tab colors, and it scrolls a scrollable row to
the selected tab when the swipe ends; the row does not scroll with the
finger. The Tabs page shows the react-native-pager-view wiring with the
Reanimated onPageScroll handler. Four tests.
An overlay under a nested ThemeProvider mixed two themes. Dialog, Menu,
Tooltip, BottomSheet, SearchView, the modal NavigationDrawer and the
snackbar read the theme where the app writes them, so the overlay surface took
the nested colors. Their Portal rendered the content at the PortalHost,
which is usually above the nested provider, so a Button in a Dialog took
the root colors, and a useThemeMode() call in the content set the mode of
the root. The alpha.28 theming page documented two app-side fixes.
Now Portal reads the theme, the mode, the icon resolver and the motion
config at its call site, and provides them again around its content at the
host. Every overlay in the library follows the provider it is written under,
with no change in the app. A ThemeProvider inside the content inherits from
the carried values the same way it inherits from a real parent. Two Portal
tests; the first fails on the old Portal.
The pair is public in @rootnative/core, for content an app renders at
another place itself (createPortal, a third-party modal):
useThemeScope() returns a ThemeScope, and <ThemeScopeProvider scope>
provides it. Four core tests. The theming page and the Portal page describe
it; the two app-side workarounds are gone from the theming page.
Check your app. If you wrapped overlay content in a second nested
provider, or gave Menu, Tooltip, BottomSheet or the drawer a named
PortalHost only to carry the nested theme, you can remove that.
From Expo SDK 58, web.output: 'server' writes no page file: the server
renders each page at request time. writeSeoFiles, auditExport,
browseExport and readExport read the pages of a server export from
<outDir>/server, so they found no page and stopped with "no .html file".
The new renderServerExport in @rootnative/seo/node starts expo serve
on the export, requests each route of server/_expo/routes.json, and writes
the HTML to <outDir>/rendered. The other functions then read the pages from
there. The server export does not run generateStaticParams, so pass the
values of each dynamic route in params. The not-found route is written as
+not-found.html. With origin, it requests a server that runs already,
such as a preview deploy. An export from SDK 57 or earlier keeps its pages
in <outDir>/server, and nothing changes for it. On a server export with no
page, the "no .html file" error now names renderServerExport.
The new rootnative-seo render <outDir> command runs it, with
--params <file> for a JSON file of params and --origin <url>. Run it
before the other commands. The README and the server output section of the
checklist give the SDK 58 steps. Twelve tests; one starts a stand-in
expo serve and checks that it stops.
On Expo SDK 58 a static export skips internal routes, so it never calls the
generateMetadata of +not-found, and that page has no head. For this page
auditExport told the user to export generateMetadata, which cannot work.
It now says to write the head into the file after the export, with
headTagsHtml(toHeadTags(site, meta)). A server export renders that head,
so its message does not change. The checklist says the same. One test.
No breaking change. No export and no prop is removed. One change can move
a screen: a nested ThemeProvider with a { light, dark } pair now follows
the mode of its parent and uses the icon resolver of its parent. The entry
ends with what to check. Three more changes. A new subpath,
@rootnative/core/static-export, hides the light first paint of a static web
export from a reader with a dark scheme. @rootnative/seo adds toMetadata
for the generateMetadata export that Expo SDK 58 reads, and the Expo Router
PageHead now writes its JSON-LD into the page body. Three components
entry points export the types they used to hide.
A ThemeProvider inside another one did not inherit from it. With only
theme set:
- It provided
iconResolver ?? null, so every string icon below it drew nothing and logged the "no icon resolver" warning. - Its mode was its own state, which started at
'system'. So the subtree followed the OS, and a mode that the reader chose on the root provider did not reach it.
The MotionConfig in the provider already merged with its parent, so the two
values followed a different rule than the named transitions.
Now a nested provider with no iconResolver uses the resolver of its parent.
A nested provider with a pair, below a parent with a pair, follows the mode of
the parent when it gets none of mode, defaultMode and storage.
useThemeMode() below it reports the mode, the scheme and isReady of the
parent, and setMode() sets the mode of the parent and calls the
onModeChange of both providers. A nested provider that gets any of the three
props keeps a mode of its own, as before. 14 new tests; each of five faults
put back into the provider fails at least one of them.
The theming page has a new "Nested providers" section. It also states a limit
that stays: an overlay renders its content at the PortalHost. The overlay
surface takes the nested colors, but the components in the content take the
theme of the provider above the host. The section gives two fixes.
Check your app. If you passed iconResolver, mode and onModeChange
to a nested provider only to carry the root values down, you can remove them.
If a nested provider must follow the OS while the root has a pinned mode, pass
defaultMode="system" to it.
The alpha.21 entry "mode="system" is safe in a static web export" said that
a reader with a dark system setting sees "one light frame" first. That is
wrong. react-native-web writes each theme color into the HTML as an inline
style, and CSS in the HTML shell cannot change an inline style. So the page
stays light until the script downloads and hydrates. Measured in Chromium with
a dark color scheme, on a page with 2.6 MB of script: 1.3 s on a fast computer,
and 5.5 s with a 4× CPU throttle and a 6.4 Mbit/s network. A mode that
storage restores loads after hydration too, so a reader who chose dark sees
the same light page. The theming page, the ThemeProvider JSDoc and the
llms files now give that time.
The new subpath hides the light page:
schemeGuardScript()returns an inline script for the<head>of the HTML shell. Before the body paints, it reads the stored mode fromlocalStorage, then the OS scheme, and writes the scheme to<html>asdata-rootnative-scheme.schemeGuardCss({ background })returns CSS that hides#rootand paintsbackgroundfor the dark scheme, until<html>hasdata-rootnative-theme-ready. After 8 s the page shows anyway, for a script that fails to load.ThemeProviderwritesdata-rootnative-theme-readyon<html>on the first render after hydration, with the stored mode loaded. It does nothing on native.
A reader with a light scheme sees no change, and without JavaScript nothing is hidden. A reader with a dark scheme sees content when hydration ends, so the Largest Contentful Paint is later for that reader. The example app uses the guard. In its export, with a 4× CPU throttle, the light page showed for 2.4 s without the guard, and for no frame with it.
Check your app. Nothing changes until you add the script and the CSS to
app/+html.tsx. The theming page has the full shell under "The first paint
of a static export".
On Expo SDK 58, PageHead and RouteHead wrote nothing into a static
export: every page had no title, no canonical link and no Open Graph tag.
@expo/router-server 58.0.12 (expo/expo#46526) renders the export with the
streaming renderer. That renderer writes the head from the generateMetadata
export of the route, and it does not read the expo-router/head tags. The
seo peer range (expo-router >=57.0.0) claimed support for that version.
Only rootnative-seo audit showed the defect.
toMetadata(site, meta)in@rootnative/seoreturns theMetadataobject forgenerateMetadata. It applies the same rules astoHeadTags, from one shared resolver. A new test runs the result through the Expo serializer and compares the tags withtoHeadTagsfor ten page shapes. The 57.0.9 and 58.0.15 serializers give the same tags.- The Expo Router
PageHeadandRouteHeadwrite the JSON-LD as a script in the page body, not in the head, on every SDK.Metadatahas no field for JSON-LD. On SDK 57 the script moves from the head to the body. - After hydration, the focused
PageHead, andRouteHeadwhen it writes, remove each head tag in a slot they write that react-helmet-async did not write. Without this, an SDK 58 page held two sets of tags after hydration, because Helmet removes only its own tags, and the canonical link of the first page stayed after a client navigation. A removed tag is not put back. - The
auditmessages for a missing title and a missing canonical link namegenerateMetadata.
Checked on a scratch Expo SDK 58 app (expo 58.0.16, expo-router 58.0.24,
@expo/router-server 58.0.15) with the packed package: the static pages hold
the title, the canonical link, the Open Graph and Twitter tags, and the JSON-LD
in the body. rootnative-seo audit passes the title and canonical rules on
those pages, and rootnative-seo browse loads every page with no error. The
example app (SDK 57) passes check:export, with the browser run.
Check your app. On SDK 58, export generateMetadata from each route and
return toMetadata(site, meta) with the meta of its PageHead. In a static
render the request is undefined, so build the meta from the route params.
Keep the PageHead. RouteHead writes no static tag on SDK 58, and Expo
Router reads generateMetadata from the route file only, so a route that
RouteHead covers needs its own export. A custom app/+html.tsx must render
headNodes and bodyNodes from useServerDocumentContext() in
expo-router/html. Without them the page has no head tags, no CSS and no
JavaScript. Step 3 and step 7 of the SEO checklist have the code.
Three components entry points export their hidden types
@rootnative/components/button exported ButtonProps and ButtonVariant,
but not ButtonSize and ButtonShape. An app that wraps Button had to
copy the two unions or index into ButtonProps. A scan of every entry point
found the same gap in two more:
@rootnative/components/button:ButtonSize,ButtonShape.@rootnative/components/icon-button:IconButtonWidth,IconButtonShape.@rootnative/components/chip:AssistChipProps,FilterChipProps,InputChipProps,SuggestionChipProps, the four members of theChipPropsunion.
The package root exports the same eight names. api-surface.json records
them. No runtime code changes.
No breaking change. No export and no prop is removed. The
@rootnative/inertia floor moves to 0.0.19, so upgrade it in the app with
this release. Four changes can move a screen or fail a test:
LoadingIndicator turns, the children of a sized Card.Media stack, a
Chip label stays on one line, and Picture on the web follows the React
Native layout rules. Each of these entries ends with what to check. The
other fixes: a control that becomes disabled during a press shows the
disabled color, Dialog finds its slots inside a fragment, and
rootnative-seo browse names a base-path mismatch.
The indeterminate indicator did not turn. Only the shape change ran, one
change every 650 ms, so on a short task the indicator looked still. The spin
used useAnimation(1, loop). useAnimation starts its value at the target,
and Reanimated runs each turn of a loop from the value at the start of the
loop, so each turn ran from 1 to 1.
The spin now starts at 0 and runs to 1 through useAnimator. Reduced motion
still stops it. A determinate indicator also ran an endless loop from 0 to
0, and now it runs no loop. Three tests record the value that each loop
starts from, and all three fail on the old code.
Check your app. If you put the indicator in a Motion.View that turns
it, remove that wrapper, or the indicator turns two times as fast.
Button, Card with onPress, Chip, FAB and IconButton removed
their animated state-layer style while disabled. A view keeps the last value
that an animated style wrote. So a button that disabled itself in its own
onPress, such as Next on the last page, kept the pressed tint after the
press, and Button also kept the pressed corner.
The animated styles now stay on the container while disabled. The state
layer shows the disabled container color, also on the first frame of a
control that mounts disabled. A press or a hover that the control did not
release goes back to rest. A loading Button keeps its container color, as
before. A test for each of the five components disables it during a press,
and each case fails on the old code.
What does not change. A disabled control shows the same colors as before.
The docs said that a plain Image in a Card.Media with a height or an
aspectRatio needs no style. A network Image was 0 high. The slot put its
children in a column, which stretches a child across the width only, and a
network image has no height of its own.
Now each child of a sized slot gets StyleSheet.absoluteFill before its own
style, so a plain Image fills the slot. A child's own width or height
wins over the fill. A slot with no size does not change.
Check your cards. The children of a sized slot now stack: a second child
is an overlay on the first. If you put two children in a column in one slot,
wrap them in one View. If you gave an image flex: 1 only to fill the
slot, you can remove it.
The label had no line limit. A long label wrapped, and the 32dp chip cut the
second line in half. The label now has numberOfLines={1} and
flexShrink: 1, so a label that does not fit ends with an ellipsis. MD3
gives a chip one line of label.
Check your chips. A label that wrapped now ends with an ellipsis. To show more of it, give the chip more width.
Dialog read its slots from its direct children only. A Dialog.Title,
Dialog.Content or Dialog.Actions inside a fragment, such as a conditional
<>…</>, went to the body with no warning: no headline, no action row, and
no accessible name. Dialog now collects the children of a fragment at each
depth.
On native, Picture puts width and height before style, as a React
Native box. On the web, it rendered a raw <img> with the style as inline
CSS, and a raw element does not lay out as a react-native-web view:
- A border width drew no border, because the element had no
border-style. - A
flex: 1picture in a shorter frame kept the height of its ratio and went out of the frame, because the element had nomin-height: 0. - A side that
styleset toundefinedtook its size from thewidthorheightattribute, so anaspectRatiodid nothing. The picture on the showcase page of the example measured 390 by 360 in a 390 px frame, in place of 390 by 234.
The web branch now puts the react-native-web view defaults before the box:
box-sizing: border-box, a solid border of width 0, flex-shrink: 0,
min-width and min-height of 0, and position: relative. A side that
style sets to undefined is auto. Each case was measured in headless
Chromium, before and after.
Check your pages. A size in style wins over the box, on both
platforms. To let flex or aspectRatio set a side, set that side to
undefined: { flex: 1, width: undefined } fills a frame, and
{ width: 112, height: undefined, aspectRatio: 2 / 3 } keeps the ratio. A
border now draws, and it is inside the box.
An export whose scripts load from / and whose canonical link is under a
path, or the reverse, failed on each script of each page.
rootnative-seo browse printed one line for each failed request, and no
line for the cause. browseExport now reads the script URLs of
index.html before it starts Chrome. When they are not under the base path,
it stops with one message that names both paths and the fix: set
experiments.baseUrl, or pass basePath.
On the web, a component with a state layer showed no focus indicator in one
case: a click that focused nothing, then a focus that a script moved.
Chromium matches :focus-visible there and draws its outline, but the
components remove that outline. inertia 0.0.18 read only its own input
tracker, which was in pointer modality, so the state layer stayed off.
inertia 0.0.19 asks the focused element whether it matches
:focus-visible. Its tracker also skips a ⌘, Ctrl or Alt chord now, so ⌘S
in a form does not show a focus layer to a mouse user on the next focus.
Every pin moves together: the core, utils and components peer ranges
are now >=0.0.19 <0.1.0, the dev, example and template pins are 0.0.19
exact, and the registry's derived floor follows. Upgrade
@rootnative/inertia to 0.0.19 in the app with this release. Two web
tests, the script focus after a click on nothing and the chord, fail on an
older inertia.
No breaking change. No export and no prop is removed. Two changes can
move a screen or fail a test. On the web, AppBar, NavigationBar and
NavigationRail render a landmark, and the tablist of NavigationBar
moves to the row inside it. The layout keys in style place Card with
onPress and Switch by their wrapper. Each of these entries ends with
what to check. Most of this release is @rootnative/seo: auditExport and
browseExport check every exported page, RouteHead writes the head of a
screen that has no PageHead, Picture renders an image that a crawler
sees, PageHead from /dom serves an app with no Expo Router,
alternates writes hreflang links, and every command reads a server
export. A release now pushes its tag before it publishes.
web.output: 'server' writes the pages to dist/server, next to
_expo/routes.json, and the files that the server sends as they are to
dist/client. rootnative-seo dist read server/index.html as the route
/server, found no home page, and stopped. With --site-url, it wrote
sitemap.xml into dist, and the server does not send that folder.
Now each function in @rootnative/seo/node finds the layout first, by the
rule that expo serve uses: a server/_expo/routes.json file and a
client/ folder. readExport, auditExport and browseExport read the
pages from server/, and writeSeoFiles writes sitemap.xml and
robots.txt into client/. serveExport sends a file from client/,
then a page from server/, and never the route manifest. You pass dist
for both outputs.
The checklist has a server section, with three facts measured on the SDK 57 export:
- The server sends
+not-found.htmlwith a 404 for an unknown path, on any host. - The server sends a dynamic route template with a 200 for every slug,
also for a slug that has no page. So the
noindexanswer for an unknown entry is the only signal that a crawler gets there. - The server matches each route from the root of its origin and ignores
experiments.baseUrl, which moves only the URLs of the files.
The example exports itself a second time with web.output: 'server', in
CI. The audit and the content checks read that export, and expo serve
must send the expected status for six routes.
Check your server app. Pass dist to rootnative-seo, audit and
browse, not dist/server. If the app sets experiments.baseUrl, remove
it, or remove the base path before the request reaches the server. Answer
an unknown entry of a dynamic route with noindex.
PageMeta takes alternates: a list of { lang, url } with every
language version of the page, this page included. The new PageAlternate
type is one entry. toHeadTags writes one
<link rel="alternate" hreflang> for each entry after the canonical link,
with the URL under the site. When the list has no x-default entry, it
writes one more for x-default, with the URL of the first entry. A page
with no url gets no hreflang link.
Both PageHead components and headTagsHtml write the links. The /dom
PageHead takes out every hreflang link of the head below as one set, so
no language of the page below stays, and a link with no hreflang, such as
a feed, stays. rootnative-seo dist writes the same links into the sitemap
as xhtml:link entries, and readExport returns them as alternates.
auditExport gains the hreflang rule. On each indexable page that has
the links, it fails:
- a code that is not a language code or
x-default, and the regionUK, which isGB; - a relative link, or two links for one code;
- a page that does not name itself;
- a link to a page with
noindexor with no file, and a linked page that lists a different set; - a sitemap whose
xhtml:linkentries are not the links of the page.
The example has one page in English and in German, /i18n and /de/i18n.
The export check asserts both sets by value, because the audit passes a
page that has no hreflang link at all.
Check your translated pages. Pass the same alternates list to the
PageHead of each version. Every version still has the lang of
app/+html.tsx; the limits page
says what that changes.
An app on React Navigation with react-native-web, a Vite build, or an
Electron renderer has no expo-router/head, so it had toHeadTags and no
component to write the tags. PageHead in the new @rootnative/seo/dom
entry takes the same PageMeta props as the Expo Router one and writes the
same records to document.head in an effect, and takes them out on
unmount. A test renders both adapters from one PageMeta and compares the
heads.
A tag of the same name, property or rel that was there before, from the
template or from a screen below, is taken out while the head is on and put
back when it goes, so a screen mounted over another wins and the one below
gets its head back. active={false} takes the head out for a screen that
stays mounted behind another; a React Navigation screen passes
useIsFocused(). It needs no react-helmet-async: Expo Router vendors its
own copy, and without a server renderer to collect its output a direct
write gives the same result with no dependency and no provider.
An effect never runs on a server, so such an app has a static head only at
build time. headTagsHtml(tags) in the core writes the tags as escaped
HTML for the template, one line each.
expo-image mounts its image in an effect, and the React Native Image
loads its source in one, so a static export held no <img> for either: a
crawler that runs no script saw no image and no alt. The limits page
said the React Native Image had no gap; it did.
Picture in the new @rootnative/seo/expo-image entry, with expo-image
as an optional peer, renders a plain <img> on the web with src, alt,
width, height, loading and object-fit from contentFit, and
expo-image on native with alt and accessibilityLabel. Its source is
absolute or a path under the site URL, like image in PageHead: the web
gets the path under the host, native the absolute URL. style is a React
Native image style, flattened to CSS on the web. alt="" marks a
decorative image.
auditExport gains the alt rule: every <img> has an alt attribute.
The example's showcase pages render a wireframe through Picture, from
public/, and set the og:image fallback with it.
Check your pages. An image a crawler must see goes through Picture.
An expo-image that stays, for its cache or a transition, keeps the two
fallbacks, og:image and the JSON-LD image.
A crawler and a screen reader read the page by its landmarks, and a
React Native web export had none: every container was a <div>.
react-native-web renders a landmark from role, so the layouts now set
their own. AppBar is banner, which renders <header>; inside <main>
it is a plain header, not a landmark, so a bar on a demo page changes
nothing. NavigationBar and NavigationRail are navigation, which
renders <nav>; the standard NavigationDrawer already was. The tablist
of NavigationBar moves from the root to the row inside it, with the
accessibilityLabel, and gets the <testID>-tablist test id. A role in
the props replaces the default on every one of them.
auditExport gains two rules. main: exactly one <main> per page.
lang: a lang on <html>. The page content gets <main> from one
<View role="main"> around the navigator in the root layout, and the
checklist shows it. The example and the landing site pass both rules.
Check your app. A test that read role="tablist" on the root of a
NavigationBar now finds navigation there; the tablist is the row inside.
Add <View role="main"> around the navigator, or rootnative-seo audit
fails every page.
The limits page said that Lighthouse and Search Console are the checks
that count, and gave no way to run them. Lighthouse is now a gate in the
deploy workflow of this site: after each deploy, one
script
runs the SEO audit on the deployed URLs and fails the workflow below 100.
The docs link the script and the workflow. They also say what Lighthouse
cannot see: it audits the page after the script ran, so a defect in the
HTML file still scores 100, and rootnative-seo audit is the gate for the
file. Search Console stays manual, and the docs give the sitemap URL to
submit.
Check your deploy. Run Lighthouse on the deployed URLs after each deploy, and fail below 100.
The audit reads the HTML. A hydration mismatch, a component that reads the
browser during the hydrating render, or a bundle that fails to load leave
the file correct and the page broken, and PageSpeed Insights reported
exactly that on the docs home. browseExport({ outDir }) in
@rootnative/seo/node serves the export and loads every .html file in
headless Chrome, and returns each error the browser reports: an uncaught
exception, a console.error, or a log entry such as a request that
failed. It drives Chrome over its debugging pipe, so the package adds no
dependency; Chrome comes from CHROME_PATH, the usual names on PATH, or
the usual install paths, and the GitHub ubuntu-latest runner has it.
rootnative-seo browse <outDir> runs it from a script, prints each
problem with its file and kind, and exits with 1. Full Chrome asks for
/favicon.ico by itself when a page names no icon, and the headless shell
does not. That request is not a problem, so the result is the same in both.
serveExport({ outDir }) is the server behind it, and it is public: it
serves the folder as a static host serves an Expo Router export, under the
base path of the canonical link, for Lighthouse or a browser test of your
own.
The example runs the browser pass after the audit. On a machine with no
Chrome, check-export.mjs --no-browser skips it and says so.
Check your CI. Add rootnative-seo browse dist after
rootnative-seo audit dist. A page that fails here renders twice for a
crawler that runs the script, and the HTML gate cannot tell.
A screen with no PageHead was a page with no title, and nothing said so
until the audit above. RouteHead in @rootnative/seo/expo-router closes
the gap. It takes defaults, a function from the pathname to a PageMeta,
and writes that head for the current route when no focused screen on it
renders a PageHead. undefined writes nothing. On native it renders
null.
A PageHead wins the whole page, not one tag at a time. The head renderer
merges tags one by one, last one wins, so a noindex screen with no url
would have kept the canonical link the defaults gave the route. Instead,
PageHead claims its route during render, and RouteHead writes nothing
for a claimed route. The claim is made in render and not in an effect,
because the static export runs no effect.
That gives RouteHead one placement rule: after the navigator in the root
layout. The screen then renders first, and RouteHead reads its claim in
the same pass. Before the navigator, the static export holds both heads; the
client corrects itself after the first render, and the component warns in
development.
The example's component screens no longer render a PageHead: RouteHead
builds the same head from the catalog, the home and not-found screens keep
their own, and the export gate checks that the not-found page gets no
canonical link from the defaults.
Check your root layout. Add <RouteHead defaults={fn} /> after the
navigator, with fn returning the title, the description and the url of
each route your app can show, and undefined for the rest.
The docs told each app to write its own export check, and the example's
script checked two pages of thirty-eight. The other thirty-six could lose
their title and no gate would fail. On its first run over the whole example,
the new audit found two pages that had: the +not-found page had an empty
<title>, no canonical link and no <h1>, and the AppBar demo page had
thirty-seven <h1> tags, one per demo bar, because the bar title is level 1
by default.
auditExport({ outDir, siteUrl?, ignore? }) in @rootnative/seo/node reads
every .html file under outDir and returns each rule a page fails:
title: one<title>, with text.canonical: an absolute canonical link, ornoindex.h1: exactly one<h1>.sitemap:sitemap.xmlexists, every<loc>is the canonical link of a page, and every pagewriteSeoFileswould list is in it.
It returns { pages, problems: { file, rule, message }[] } and throws only
when the folder is missing or holds no HTML file. rootnative-seo audit <outDir> runs it from a package.json script, prints each problem with its
file and rule, exits with 1 when there is one, and writes nothing. --ignore <rule> skips a rule.
The example now runs the audit over its whole export and keeps its
per-page content check. It also gains a dynamic route, showcase/[slug],
with one page per entry of a seeded list through generateStaticParams,
so the audit and the sitemap see the shape every data-driven app has. The
checklist shows the pattern, including the noindex answer for the route
template Expo writes next to the pages. Its +not-found screen has a title, noindex, and the
AppBar heading, and the demo bars on the AppBar page pass
titleLevel={3}.
Check your deploy. Add rootnative-seo audit dist after
rootnative-seo dist in the CI step that exports the web app. A demo or a
list page that renders more than one AppBar needs titleLevel on every
bar but the page bar.
The release workflows published each package and pushed the commit and the
tags last. On alpha.23, the seo publish failed after the other five
packages were on npm, so the push did not run. No v0.0.0-alpha.23 tag
existed, and rootnative init, which pins the component registry to the
tag of the @rootnative/core version, used main and showed a warning.
Both workflows now push first and then publish, and they share one
concurrency: release group, so two releases never run at the same time.
release-manual.yml also runs the format check, as release.yml and CI
do.
What changes. Nothing in the packages. If a publish fails now, the tag exists for a version that npm does not have, and the release is finished by hand.
alpha.24 sent the layout keys in style to the wrapper of Button,
IconButton, FAB and Chip. Card with onPress and Switch have the
same kind of wrapper: it holds the focus ring, and on Card also the shadow.
Their style still went to the node inside the wrapper, and three things
broke:
flex: 1in a row did not grow aCardwithonPress. The card stayed at the width of its content. ACardwithoutonPresshas no wrapper and did grow, so the two forms did not agree.- A margin moved the card or the switch track away from its own focus ring and shadow.
alignSelfandpositionplaced the inner node in the wrapper, not the component in its parent.
Now the same keys go to the wrapper: alignSelf, flex, flexGrow,
flexShrink, flexBasis, width, the margins, position, the insets and
zIndex. minWidth and maxWidth go with a width. Every other key styles
the surface or the track, as before.
Card. WhenflexorflexGrowis above 0, oralignSelfis'stretch', the surface fills the wrapper. ACardwithoutonPressdoes not change.Switch. The track keeps its MD3 size of 52 by 32 at the start edge of the wrapper, because the thumb travel is fixed. Before, awidthinstylemade the track wider, and the thumb stopped before the end. Now thewidthsizes the wrapper, and the track does not change.
A web test reads the wrapper and the inner node of each component from the DOM, and 8 of its 12 new cases fail on the old code.
Check your layout. A flex, an alignSelf or a position that you set
on these components now takes effect. A margin moves the component as before,
and now the focus ring and the shadow move with it. If you added a wrapper
View only to give a Card with onPress a flex, you can remove it.
The VectorIconSet JSDoc, the Icons page and the llms files said that an
@expo/vector-icons set works as the IconSet of
createVectorIconsResolver. It does not type-check: TypeScript reports
TS2322. An @expo/vector-icons set is a class, and TypeScript compares the
props of a class covariantly, so its glyph union does not match
name: never. A @react-native-vector-icons/* set is a function component,
and it matches.
Expo is retiring @expo/vector-icons, so the library does not add support
for it. The JSDoc, the Icons page and every llms file now say: do not use
it, use the @react-native-vector-icons/* package of the same icon family,
and run npx @react-native-vector-icons/codemod on the app code. The llms
files also said that @expo/vector-icons and @react-native-vector-icons/*
are optional peers of @rootnative/icons. They are not. The package declares
Lucide and Phosphor only.
What changes. Nothing at run time and nothing in the types. An app that
passes an @expo/vector-icons set with a cast still works. Move it to the
@react-native-vector-icons/* package.
No breaking change. No export and no prop is removed. Five changes can
move or change a screen. Button, IconButton, FAB and Chip follow the
parent alignItems. A Menu on native is as wide as its widest item. A
standard IconButton in an AppBar slot takes the slot color. A
Checkbox, Radio or Switch with no handler lets its row take the press,
and is not announced. On the web, a focused control shows one focus ring,
not two. Each of these entries ends with what to check. New in this
release: loading on Button, a headline on List, and the
useAppBarContentColor hook. rootnative add button now also installs
progress. Text with an expression renders in Dialog.Content and
Tooltip. The docs now cover the topAppBar tokens and how to theme a
navigator.
The wrapper around the pressable set alignSelf: 'flex-start', so that the
component did not stretch across a column. But an alignSelf also overrides
every other alignItems of the parent. In a View with
alignItems: 'center', a Button stayed at the start edge, and in a centered
row it sat at the top. A width in style stayed on the pressable and
resolved against the wrapper, which is only as wide as the button. So
width: '50%' did not give half of the parent: on native the result changed
with the label, and on the web it was half of the button's own width.
Now the pressable, the focus ring and the shadow are in a frame, and the frame is in an outer view that the parent lays out:
- The outer view sets no
alignSelf, so the parent'salignItemsplaces the component.centerandflex-endwork in a column and in a row. - The outer view sets
alignItems: 'flex-start'. A parent that stretches its children, as a plainViewdoes, makes the outer view wide, and the button keeps its own width at the start edge, as before. widthgoes to the outer view, so a percentage resolves against the parent.minWidthandmaxWidthgo with awidth, sowidth: '100%'withmaxWidth: 360works. Alone, they stay on the container, as before.- With
flexorflexGrowabove 0, or withalignSelf: 'stretch', the frame and the container fill the outer view.
The rule was checked on the Yoga that React Native 0.86.3 ships, compiled with
its default layout conformance, and in headless Chromium with the
react-native-web base styles. The test was 126 layouts: 9 parents, 7 style
shapes, a Button and an IconButton. With the old tree, the focus ring was
not on the button in 37 layouts on native and in 49 on the web. Now it is on
the button in every layout on the web, and in all but 6 on native. In those 6,
flex: 1 in a column with no height makes the outer view 0 high, as it does
for any View, and the old tree fails the same 6. Not checked on a device.
What changes.
- A button in a parent with
alignItems: 'center'or'flex-end'moves there. In a centered row, a button beside a taller item moves from the top to the middle. If you added aRow, aViewor analignSelfonly to place a button, you can remove it. flex: 1in a column makes the button full width, as for anyView. Before, the button was only as wide as its label.- On the web,
flex: 1in a row that stretches makes the button as tall as the row. Native already did this. - Each of these components renders one more
View, with layout styles only.
Not changed. On the web, a percentage minWidth or maxWidth with no
width still resolves against the frame. Native resolves it against the
parent.
A Menu with one short item opened 280dp wide on iOS and Android. The item
label and its content row had flexGrow: 1. The default layout conformance
of React Native keeps a legacy Yoga rule (StretchFlexBasis): in a row that
is as wide as its content, a flexGrow child grows to all the width it can
get. So the menu reached its 280dp maximum every time. The web did not have
the defect.
Now no node in an item grows. The item is a column, so its content row stretches to the menu width only after the menu has its width. The first trailing element (the trailing text, or else the trailing icon) takes the free space with an auto start margin, so it stays at the end edge. On the Yoga that React Native 0.86.3 ships, a menu with one short item is 112dp wide (the minimum). A menu with mixed items is as wide as its widest item, with the trailing text at the end edge. A long label still stops at 280dp. The web layout does not change. Not checked on a device.
What changes. On native, a menu with short items is narrower. A width
in the Menu style still sets the width. Menu.Item lays out its content
in a column now, so a flexDirection or an alignItems in an item style
acts on a different layout than before.
AppBar gave its content color to its built-in buttons only. Custom content
in leading or trailing got no color. So on a primary bar, a Menu
anchor in trailing was a dark icon on the dark bar, while the title was
onPrimary. The consumer had to read the theme and set iconColor by hand,
and a change of colorScheme broke it.
Each slot now gives its icon color through a context. A standard
IconButton with no iconColor and no contentColor takes it, and its
hover and press layers derive from it. The colors follow the Compose
AppBarTokens (VERSION 14_0_0): leading gets the title color
(LeadingIconColor), and trailing gets onSurfaceVariant on the six
surface schemes (TrailingIconColor). On primary and primaryContainer,
both slots get the title color. The bar's contentColor replaces both. A
selected toggle keeps primary, and a disabled button keeps the MD3 disabled
color. Other custom content reads the slot color with the new
useAppBarContentColor() hook, from @rootnative/components/appbar or the
package root. The hook returns undefined outside the two slots. Content
that a Portal renders, such as an open menu, does not get the color.
What changes.
- On the surface schemes, the built-in icon actions change from
onSurfacetoonSurfaceVariant, as the MD3 tokens give them. Text actions and the navigation button keeponSurface. - The built-in buttons read the same context. Their hover and press layers
now derive from the slot color, not from
onSurfaceVariant. So on theprimaryandprimaryContainerschemes, the layer matches the icon. - A standard
IconButtonthat you put inleadingwith no color changes fromonSurfaceVariantto the title color. Intrailingon a surface scheme it keepsonSurfaceVariant. OnprimaryandprimaryContainerit changes to the title color in both slots.
check:singletons now also guards the new context and the
NavigationDrawer context, which it did not list before.
Layout always paints theme.colors.background and drops a
backgroundColor from style. The Layout page said so, but the style
JSDoc, and with it the llms files, did not. The JSDoc now gives the rule
and the fix: nest a Box or a View to draw a different background. No
behavior changes.
JSX text with an expression, such as You guessed in {tries} tries,
compiles to an array of parts, not to one string. Dialog.Content and
Tooltip put their children in the supporting-text Text only when the
children were one string or one number, so the array went into a View. On
native, the body was empty and React Native logged Text strings must be rendered within a <Text> component. On the web, react-native-web logged
Unexpected text node and showed the text without the supporting-text
style. A template string was the only workaround.
Both now wrap the children when every child is a string or a number. The
same rule names the dialog: a Dialog.Title such as Delete {name}? is now
the accessible name of the dialog, and before, that dialog had no name. One
internal helper does the check in the three places. A unit test covers the
helper, and a test for each place uses text with an expression.
What does not change. Text beside an element, such as
Delete <Text>{name}</Text>?, is not wrapped. Put it in your own Text.
On the web, a control that draws its own MD3 focus ring also showed the
browser's focus outline inside that ring, in the system focus color: two
rings in two colors on each keyboard focus. Only TextField, SearchBar and
SearchView removed the browser outline.
These now remove it too, from one shared reset: Avatar, Button,
ButtonGroup items, Card, Checkbox, Chip, FAB, IconButton,
ListItem, Menu.Item, the NavigationBar, NavigationRail and
NavigationDrawer items, Radio, Slider, Switch and Tabs. The close
button of an input Chip has no ring of its own, so it keeps the browser
outline. A web test per component asserts the reset, and a second test
asserts that the close button keeps the outline.
What changes. Keyboard focus shows the MD3 ring only. Mouse focus shows nothing, as before. One case differs from Chromium: after a click that focuses nothing, Chromium shows its outline when a script then moves focus, but the ring follows the input modality and stays off. So that focus has no indicator now. Keyboard input always shows the ring. The next entry closes this case.
The modality tracker that Slider reads now starts in keyboard modality on
the web, as the tracker of the other rings does. A slider that a script
focuses before any user input shows its ring.
Check your own styles. Where style reaches the focusable node, a
style that sets outlineStyle still wins, because the component applies
the reset before your style. On Slider, Tabs, the navigation items and
the ButtonGroup items, style does not reach that node.
The rings followed the input modality only: keyboard after a key press,
pointer after a pointer press. After a click that focuses nothing, Chromium
marks the next focus that a script moves as :focus-visible and draws its
outline there. The ring stayed off, and with the outline removed, that focus
had no indicator.
Slider now asks the focused element whether it matches :focus-visible,
so its ring shows where the browser would draw the outline. The
isFocusVisible function of @rootnative/utils takes the focus event for
this. Pass the event, and the browser decides for a focused target. With no
event, for a focus event that a script dispatches, and in a browser without
:focus-visible, the function reads the modality, as before.
The other rings come from @rootnative/inertia. They follow the same rule
after the @rootnative/inertia pin moves to the release that carries it. A
web test pins that gap until then.
What changes. In jsdom, :focus-visible matches every focused element.
A test that calls element.focus() on a Slider now always sees the ring.
fireEvent.focus moves no focus, so it still reads the modality.
The topAppBar tokens had no docs, and their geometry is not obvious:
horizontalPadding counts twice for the title and once for the actions. To
align a bar with a 32dp page gutter, an app set horizontalPadding: 32 and
titleStartInset: 0. The actions moved to 32dp, and the title moved to
64dp.
Each TopAppBarTokens field now has a JSDoc line. The AppBar page has a new
section, "Align with a Page Gutter", with the geometry and the recipe: keep
the tokens and pad the bar with style. The theming page and the llms
files list the fields. No behavior changes.
alpha.24 removed the old size names, but the iconSize prop of Button
still gave its default with them: "20 for xs/s, 24 m, 32 l, 40 xl".
TypeScript rejects each of those names, and a JavaScript app that passes one
to Button throws. The text now reads "20 for extraSmall and small, 24
for medium, 32 for large, 40 for extraLarge", in the JSDoc, the llms
files and the prop table of the Button page. The values are unchanged. Five
internal comments that used the old names use the new ones too.
A common list row puts onPress on the ListItem and shows the state in a
control: <Checkbox value={on} /> with no onValueChange. A tap on the row
worked. A tap on the control did nothing, and nothing was logged. The control
always rendered a pressable, so it became the touch responder, and its press
handler had nothing to call. On the web, the control also stopped the click
and added a second tab stop in the row.
Now a control with value, no onValueChange and no disabled only shows
the state. Compose has the same rule for Checkbox(checked, onCheckedChange = null). The control:
- lets the press go through to its parent, on native and on the web,
- leaves the tab order on the web,
- is hidden from assistive technology.
So the row must carry the state. Give it role and aria-checked:
<ListItem
headlineText="Wi-Fi"
role="switch"
aria-checked={on}
onPress={() => setOn(!on)}
trailingContent={<Switch value={on} />}
/>The List page has a new section, "Selection controls in a row", and the
Checkbox, Radio and Switch pages link to it. The onValueChange JSDoc and the
llms files give the rule.
What changes.
- A controlled control with no
onValueChangeis no longer announced. If you show one outside a pressable row, as a read-only indicator, give it anonValueChange, or putdisabledon it. A disabled control is never hidden. - Such a control no longer shows a hover, focus or press state layer.
A button that starts an action had no way to show that the action runs. A
leadingIcon render function with a CircularProgress worked, but each app
had to find it and choose the size, the stroke and the colors.
Button now takes loading. The button shows an indeterminate
CircularProgress in place of leadingIcon, at the icon size and in the
label color, with no track. While it loads, the button:
- ignores
onPressandonLongPress, - shows no hover or press state layer, and no press morph,
- sets
aria-busy, and hides the spinner from assistive technology, - keeps its colors and its focus. It is not disabled, so a keyboard user
stays on it. Pass
disabledas well if it must also look disabled.
MD3 has no loading button, so this is a library addition. With no
leadingIcon, the spinner adds the icon and its gap, so the button gets wider
while it loads. A width in style keeps the size. The Button page has a new
section, "Loading".
What changes. The CLI button entry now depends on progress, so
rootnative add button also installs progress and react-native-svg. The
same applies to appbar and snackbar, which depend on button. The npm
package already requires react-native-svg, so an npm install does not
change.
A list with a section title had to put a Typography above it and choose the
variant, the color, the padding and the heading level again on each screen.
List now takes headline, in the same form as NavigationDrawer.Section:
titleSmall in onSurfaceVariant, with the start and end padding of
ListItem. On the web it renders as <h2>, and headingLevel changes the
level. headlineStyle styles the text. A list with no headline does not
change.
A navigator draws its screen container, and can draw a header, a drawer sheet and a tab bar, from its own navigation theme. With no navigation theme, a dark RootNative theme showed a light screen behind content that paints no background. No docs page said how to connect the two.
The theming page has a new section, "With Expo Router and React Navigation".
It gives two ways: contentStyle in the Stack screenOptions, or a
navigation theme mapped from useTheme() for an app that keeps a navigator
header, drawer or tab bar. It also gives the rules for the mapping and where
the dark flag comes from. The NavigationDrawer page has a new section, "In a
navigator drawer", for drawerContent. No behavior changes.
Breaking. Button, IconButton and Avatar no longer accept the old
size names, so rename each size first. Four changes can move or change a
screen: the layout keys in style now place Button, IconButton, FAB
and Chip by their wrapper, a Chip with no action is a label, an
emphasized display or headline is a header on native, and hrefAttrs is
declared on ViewProps. Each entry below ends with what to check. New in
this release: a subtitle on AppBar, the rootnative-seo command that
writes the sitemap and robots.txt from the export, a light and a dark
theme colour in shellTags, and a Vite recipe that runs vite dev.
alpha.22 gave Button, IconButton and Avatar the MD3 size names and
kept the old names as aliases for one release, with a warning. The aliases
are now removed:
| Component | Removed | Use |
|---|---|---|
Button, IconButton |
'xs', 's', 'm', 'l', 'xl' |
'extraSmall', 'small', 'medium', 'large', 'extraLarge' |
Avatar |
'xSmall', 'xLarge' |
'extraSmall', 'extraLarge' |
TypeScript rejects an old name. No runtime check reads the name, so a
JavaScript app gets no warning: Button and IconButton throw
Cannot read properties of undefined (reading 'iconSize'), and Avatar
renders at the medium size with unstyled initials. The development warning,
resolveSize, and the size-aliases tests are removed. A new test holds a
@ts-expect-error for each removed name, so the typecheck fails if a props
type accepts one again.
The IconButton page listed its sizes with the old names, and the
accessibility page named xs. Both now use the MD3 names.
Check your app. If the development console showed
uses an old size name, rename each size that the warning named.
The MD3 Expressive app bar shows one line of supporting text under the title.
AppBar had only title, so an app built a page header by hand from a View
and two Typography elements.
AppBar now takes subtitle and subtitleStyle. The subtitle color is
onSurfaceVariant, and on the primary and primaryContainer schemes it is
the scheme's content color. contentColor does not change it. The type role
follows the variant: labelMedium on small and center-aligned,
labelLarge on medium, and titleMedium on large. The values are from
the Compose Material 3 tokens.
A small or center-aligned bar keeps its 64dp height. The spec gives a
subtitle only to the flexible medium and large bars, which also use a larger
title. The medium and large bars here keep their baseline title, take the
flexible subtitle, and grow by the same amount as the flexible bars: medium
from 112 to 136dp, large from 152 to 184dp. With scrollOffset, the
subtitle collapses to labelMedium with the title, and the scroll distance
grows by the same amount. An empty string adds no line and no height.
The subtitle is not a heading. On the web the <h1> holds the title only.
Sixteen new tests cover it: 14 native and 2 web. Seven faults put into the
source on purpose each made at least one of them fail. Measured in Chromium
on a web export of the example app at 390px: every bar has the heights and
positions above, and a medium bar scrolled 36px is 100dp high with a 13px
subtitle, halfway through its 72px collapse. Not checked on iOS or Android.
The Vite config on the "Web without Expo" page worked for vite build only.
vite dev pre-bundles node_modules in a separate Rolldown pass, and that
pass reads neither the plugins list nor resolve.extensions. With the
config as written, the dev server stopped at start with
Flow is not supported in react-native/Libraries/..., because the
pre-bundle took the native files of Reanimated, the safe-area package and
react-native-svg. With the extensions alone, the page was blank and
Reanimated threw useAnimatedStyle was used without a dependency array or Babel plugin.
The config now gives optimizeDeps.rolldownOptions its own copy of the
worklets plugin and of the extensions. A new section, "The Vite dev server",
explains why, and the symptoms table has a row for each failure. The llms
files say the same. Checked with Vite 8.3.2: the config from the page, copied
as written, starts the dev server and renders with no error, and the
pre-bundle holds no raw 'worklet' directive. vite build output does not
change. The webpack config needs nothing new.
A landing page that linked out with Button and Linking.openURL exported
no <a> element at all, so a crawler found no link from the page. The SEO
checklist named router.push as the trap, but not Linking.openURL, and the
Button page did not say how to make a button a link.
The Button page has a new section, "A button as a link": put the button
inside Expo Router's <Link asChild>, and the web renders an <a href>. The
SEO checklist and the "Why" page name Linking.openURL next to
router.push, and the llms files carry the same rule.
On the web, Link opens an external URL in the same tab, and
<Link target="_blank"> does not help under asChild: Link gives target
to the child as a plain prop, and react-native-web drops it. The fix is
hrefAttrs={{ target: '_blank', rel: 'noopener' }} on the button, which
react-native-web writes onto the <a>. The React Native types do not declare
hrefAttrs, so @rootnative/components now declares it on ViewProps, as it
does for aria-invalid on TextInputProps. A web test pins the target and
the rel on the anchor.
Check your own augmentation. If your app declares hrefAttrs on
ViewProps, remove that declaration, or make its type exactly
{ target?: string; rel?: string; download?: boolean | string }. A different
type fails with TS2717, "Subsequent property declarations must have the same
type".
An assist, suggestion or input chip with no onPress rendered a focusable
<button> that did nothing. Three static chips in a page hero gave a keyboard
user three dead tab stops, and a screen reader announced three buttons.
Chip now follows the Card rule. With no onPress, no onLongPress and
no href, an assist, suggestion or input chip renders a plain view: no button
role, no tab stop, no hover or press layer, and the default cursor. It keeps
its colours, its outline, its elevation at rest, and an input chip keeps its
close button. A filter chip is always a button, because a press toggles it.
An href counts as an action, because on the web Expo Router's
<Link asChild> gives the chip an href and an onClick, not an onPress.
Nine new tests cover the rule, and five of them fail on the old code.
Check your tests. A test that finds an action-less chip with
getByRole('button') now finds nothing. Give the chip an onPress, or query
it by its text.
On iOS and Android, Typography gives a display or headline variant with no
level the header role. It read the variants from a list of the six plain
names, so the 6 display*Emphasized and headline*Emphasized variants got no
role, and a screen reader did not announce an emphasized page title as a
header. The docs said that every display and headline variant is a header.
Typography now reads the role from the variant name: a name that starts
with display or headline is a heading, with or without Emphasized.
title* and the other roles stay plain text. The web does not change: with
no level, no variant gets the role there. New native tests cover four
emphasized headings and two emphasized non-headings, and a web test pins
<h1> for an emphasized headline with level={1}.
These four components wrap their pressable in a View that holds the focus
ring and the shadow. The wrapper sets alignSelf: 'flex-start', so it hugs
the button and does not stretch across a column. The consumer style went to
the pressable inside the wrapper, and three things broke:
alignSelfdid nothing. AButtonin<Column align="center">stayed at the start edge next to centred text.- A margin moved the button away from its own focus ring and shadow.
flex: 1in a row did not grow the button, andposition: 'absolute'placed the button against a wrapper of no size.
Now the keys that place a component in its parent go to the wrapper:
alignSelf, flex, flexGrow, flexShrink, flexBasis, the margins,
position, the insets and zIndex. When flex or flexGrow moves, the
pressable fills the wrapper. Every other key styles the container, as before.
A Reanimated animated style stays on the pressable whole, because Reanimated
finds it by identity. A web test per component reads the wrapper and the
button from the DOM, and 16 of its 20 cases fail on the old code.
Check your layout. An alignSelf, a flex or a position that you set on
one of these components now takes effect. A margin moves the button as before,
and now the focus ring and the shadow move with it. If you added a Row or a
wrapper View only to centre a button, you can remove it.
Every app wrote the same deploy script: list the exported HTML files, drop
the +not-found page, turn each file name into a route, and pass the list to
writeSitemap and writeRobots. That script had two faults. It listed a
page by its file name, not by the address the page declares, so a page with
a query in its canonical link got the wrong URL. And it did not read the
page, so an overlay with noindex went into the sitemap.
writeSeoFiles({ outDir }) in @rootnative/seo/node now does the step. It
reads every .html file under outDir, nested folders too, and lists a page
with its canonical link when that link is under the site and the page has no
noindex. It leaves out a special route (a + or _ segment, or a root
404.html), a dynamic route template such as [id].html, a page with no
canonical link, a canonical link outside the site, and a second page with the
same link, and it returns each page it leaves out with the reason. The site
URL comes from the canonical link of index.html, or from siteUrl. It
writes robots.txt too, unless you pass robots: false. It throws when no
page can go in the sitemap, because an empty sitemap on a live host fails
with no other signal.
The new rootnative-seo command runs it from a package.json script:
rootnative-seo dist, with --site-url, --sitemap (once per sitemap URL)
and --no-robots. It prints each page it leaves out, with the reason.
readExport(outDir) returns the canonical link and the noindex rule of
each page, for a URL list of your own.
The example app uses it. Its sitemap lists the same 37 URLs as before.
Check your deploy script. If it builds the sitemap from the file names,
replace it with rootnative-seo dist and read the list of pages it leaves
out. A page that must be in the sitemap needs a canonical link: pass url to
its PageHead. Each sitemap entry holds <loc> only, so a script that set
changefreq or priority loses them; Google ignores both.
PageHead read the site before it checked the platform. On iOS and Android
it renders nothing, but it still threw with no SeoProvider above it. So
every native test that rendered a screen with a PageHead had to wrap the
screen in a provider, for a component that draws nothing there.
On native, PageHead now returns null before it reads the site. A native
tree and a native test need no provider. The web does not change: with no
provider and no site prop, PageHead throws, and the static export fails
on the first page that has the fault. A native test pins the new rule, and
the web test for the throw stays.
Check your test setup. If you added SeoProvider to a native test
wrapper only for PageHead, you can remove it. Keep the provider at the
root of the app.
themeColor took one string, and shellTags wrote one theme-color tag
with no media. A browser uses the first theme-color tag whose media
matches, so on a site with a light and a dark theme the browser toolbar
stayed light over the dark page. The site had to write the two tags by hand.
themeColor now also takes { light, dark }. The object form writes one tag
for each prefers-color-scheme, light first. A scheme with an empty colour
gets no tag. The string form does not change. The tags follow the system
scheme, not a mode that the user picks in the app. writeManifest still
takes one string, because the manifest has one theme colour. Four new tests;
a fault put into the media, the order, the empty-colour rule or the keys
makes one of them fail.
Check your +html.tsx. If it writes two theme-color tags next to
shellTags, remove them and pass themeColor: { light, dark }.
Expo Router writes the PageHead tags at the start of <head>, before the
children of +html.tsx. So the <meta charset> that shellTags writes is
after byte 1024 on a page with a full head, and the HTML spec requires it in
the first 1024 bytes. shellTags cannot change that order. The shellTags
JSDoc, the SEO checklist, a new "The charset position" section on the Limits
page, and the llms files now say that the host must send
Content-Type: text/html; charset=utf-8. GitHub Pages does.
The two templates pinned 0.0.17. In inertia 0.0.18, the config keys of a
mixed transition map, such as
{ type: 'spring', tension: 120, opacity: { type: 'timing' } }, are the
default for every key with no entry. Before, those keys got the default
spring, and a dev build logged Unknown transition name "spring". The dev
and example pins move to 0.0.18 too.
The peer range stays >=0.0.17 <0.1.0. No component uses an inertia API
that is newer than 0.0.17, and no component passes a mixed map, so the
components animate the same on both versions.
No breaking change. This release is the structural audit of the whole
catalog plus three device passes: iOS simulator, web, and Android emulator.
The new @rootnative/seo package ships here with Container, a level on
Typography, and a clean hydration of the static export. Every overlay has
one back handler and one scrim, Dialog scrolls and respects the safe area,
Snackbar speaks on iOS and pauses under a pointer, and Tooltip opens from the
keyboard. Toggle IconButton and filter Chip work uncontrolled, toggles
announce their state, filled and tonal buttons rise on hover, and
containerColor takes any colour React Native accepts. Inline logical
insets mirror on web now through resolveLogical, and the app bar,
Switch and Slider hold their geometry in RTL on every platform.
- Inline logical insets sat at the wrong edge in RTL on web. A style from
StyleSheet.createmirrors through CSS, but react-native-web resolves the logical keys of an inline style object in JavaScript, from a locale context that defaults to LTR and thatdir="rtl"never updates. The small app bar title drew over the trailing actions, the text field label sat over the trailing icon, the Switch halo left the thumb, andBoxone-sided insets (ps,pe,ms,me) went to the left. The newresolveLogicalhelper in@rootnative/utilswrites the physical key for the document direction on web and the logical key on native. - Slider arrow, Home, End and Page keys also scrolled the page. The
handled keys are claimed with
preventDefaultnow. - Slider reported a rounded
aria-valuenow. A 0 to 1 slider at 0.4 said0, which a screen reader reads as "0 percent". The value is unrounded on every platform. - Tooltip never opened from the keyboard. On web, focus on the anchor shows the tooltip, blur hides a plain one, and Escape hides either. The key stops at the anchor, so an enclosing dialog does not close too.
- Filled TextField indicator did not change on hover. The indicator colour cascade has the hover step the outlined border already had.
- Snackbar timer ran on under the pointer. The clock pauses while a pointer is over the surface and continues from where it stopped.
- NavigationDrawer section headlines rendered as
<h1>on web. Each section headline is<h2>now, with aheadingLevelprop to fit the page outline. - NavigationRail put its
accessibilityLabelon a plain view. It names the tablist now, the same as NavigationBar, so the rail has an accessible name on web.
- Medium and large app bars clipped the title. The title sat in a row under the 64dp top row, and a 32dp headline plus its 24dp bottom padding does not fit in the 48dp a 112dp medium bar leaves there, so the descenders were cut off. The title has its own layer over the whole container now and sits at the Compose geometry.
- A centre-aligned app bar truncated its title next to two actions. The
title was centred inside a symmetric inset equal to the wider of the two
slots, which left 144dp on a 400dp phone. It now keeps the whole width
between the slots, sits at the screen's centre while it fits there, and
shifts off centre instead of truncating when it does not, the same as
Compose
TopAppBarLayout. - The Switch thumb left the track in RTL. The thumb rests at the start
edge and moved by a physical
translateX, so in RTL it slid off the right end and the halo stayed behind. The travel follows the layout direction now. - The Slider filled from the wrong side in RTL. The slots position with
left, which React Native already mirrors in RTL, and the slider mirrored the value once more, so the fill came back to the left. Native now leaves the mirroring to the platform and the web keeps its own. Theincrementanddecrementaccessibility actions also stop reversing in RTL: a larger value is a larger value in every writing direction.
The native and web elevation tables covered the four components that move
their shadow on hover. The seven that take a static elevation had no witness:
Menu (level 2), Dialog (3), Snackbar (3), the rich Tooltip (2), BottomSheet
(1), the modal NavigationDrawer (1) and NavigationBar (2). All seven are in
both tables now and pass the same two invariants: one shadow node that does
not clip, and no boxShadow beside the native keys.
docs/static/llms.txt is the file an AI agent is told to trust over the
hosted docs, and it was wrong in four places. It named 29 components (there
are 35), it told the agent to install @expo/vector-icons and
@material/material-color-utilities (the library uses the first only behind
the /mdi subpath through a different package, and bundles the second), it
gave @rootnative/inertia >=0.0.11 as the floor (it is >=0.0.17), and it
listed Layout as having no animated value (Grid animates). The generated
core llms.txt also said cornerFull is 9999; the token is 999. The public
doc example on elevationShadowConfig drove useShadow from useSpring,
which the library bans because it is not gated on reduced motion; it uses
useAnimation now.
MD3 raises a filled or tonal button from level 0 to level 1 on hover, and a filled or outlined card the same way. The library applied the hover rise to the elevated variants only. Every interactive card and every filled or tonal button now carries the same shadow carrier the elevated ones had, resting at level 0 so nothing changes at rest. Outlined and text buttons stay flat.
Three pieces of code that every overlay or selection control copied by hand
now live once under internal/:
useBackHandlerDismissreplaces the six copies of the Android back-button effect in Dialog, Menu, Tooltip, BottomSheet, NavigationDrawer and SearchView. A new table test drives every one of them on Android and checks that a closed, locked, or plain surface does not swallow the back press.useHaloLayerreplaces the three identical state-layer halo blocks in Switch, Checkbox and Radio.useStateLayeranduseHaloLayerreturn the focus-ring opacity style, so the twelve components that derived it by hand no longer do.
No visible behaviour changes from the three extractions.
Both had selected and nothing else, so a consumer who forgot to flip
selected in onPress got a control that never toggled. Both now follow the
Checkbox idiom: selected with onSelectedChange is the controlled form,
and defaultSelected is the uncontrolled form, where the control flips its
own state and reports the new state through onSelectedChange. onPress
still fires either way.
useStateLayer fell back to the consumer's containerColor while disabled
when a component passed no disabledContainerColor. ListItem, Menu.Item,
Avatar and SearchBar kept the override colour in their disabled state. The
disabled layer now rests on the variant's own colour.
Button, Chip, FAB, IconButton, Card and ListItem each computed hover, focus
and press colours in styles.ts that nothing read, because useStateLayer
derives the layers itself. About 200 lines are gone, and the one place the
layers are derived is the only place now.
NavigationRail and NavigationDrawer join the reduced-motion table, the pressable Avatar joins the touch-target table, and the SearchBar trigger joins the web cursor table. The Avatar also reads its hit slop from the shared helper instead of its own copy.
Every Pressable-based component spreads its state-layer handlers after the
consumer's props, so a consumer onPressIn, onPressOut, onHoverIn,
onHoverOut, onFocus or onBlur type-checked and then never ran. Avatar,
Button, Card, Checkbox, Chip, FAB, IconButton, ListItem, NavigationDrawer.Item,
Radio and Switch now merge the two through one internal helper: the
consumer's handler runs first, then the component's.
hitSlop had the same problem on Button, Chip, Switch, Checkbox and Radio. A
consumer value wins now, as it already did on IconButton and FAB. Checkbox and
Radio also drop their default slop of 4dp: their container is already 48dp,
and slop on a control that clears the floor only overlaps its neighbours.
A toggle IconButton and a filter Chip set aria-selected on a button,
and a single-select ButtonGroup set it on a radio. ARIA defines neither,
so a web screen reader announced no state. The web now gets aria-pressed on
the two buttons and aria-checked on the radio. Native keeps
accessibilityState.selected for the buttons, which is what TalkBack and
VoiceOver read for a toggle, and moves the radio to
accessibilityState.checked.
Switch, Checkbox, Radio and Slider wrote 0.38 and 0.12 as literals. They
read theme.stateLayer.disabledOpacity and disabledContainerOpacity now,
like every other component, so a theme that changes the disabled treatment
reaches them.
blendColor and alphaColor in @rootnative/utils read 6 and 8 digit hex
only. Any other form, #f00, rgb(), hsl() or a named colour, fell back
to the overlay at its alpha, so a filled component with
containerColor="red" lost its container under the hover and press layers
and showed a 10% tint of its content colour instead. Both functions now parse
through React Native's processColor, which reads every colour form the
platform does, on native and on the web. A base that is not opaque still
returns the overlay at its alpha, because the colour behind it is unknown.
With onPress the whole bar was one Pressable with the button role, and
it wrapped the clear button and the trailing actions. On the web that is a
<button> inside a <button>, which the test run logged as invalid DOM and
which a screen reader reads unreliably. The trigger is now a sibling behind
the content, the same shape the Chip close target uses. The bar is still
one tab stop, the input is still read-only and hidden, and the clear button
and the actions are still buttons of their own. A consumer role now names
the bar, so <Link asChild> can make it a link.
TextFieldwroteaccessibilityLabel,accessibilityHintandaccessibilityStateafter the rest spread, so a consumer value never reached the input. A field with no visible label had no accessible name even when the consumer gave it one. The consumer value wins now, andaria-labelis read as a name too. The visiblelabelstays the default.LinearProgressreplaced a consumeronLayoutwith its own width measurement. Both run now.- A rich
Tooltipclosed on hover out, which is before a mouse can reach its actions. A rich tooltip is persistent and now stays up until the outside press, an action, or the Android back button. A plain tooltip still hides on hover out.
The surface carried role="alert" and accessibilityLiveRegion="polite",
and the docs said screen readers announce the message. TalkBack and web
readers did. VoiceOver did not: iOS has no live region, and nothing asked it
to speak. The snackbar now calls AccessibilityInfo.announceForAccessibility
on iOS when it mounts and when a replacement arrives. Android and web are
unchanged, so nothing is announced twice.
Three defects in Dialog, each one visible on a device.
- The scrim was twice as dark as the token. The press target inside the
scrim layer carried the same 32%
scrimcolour as the layer itself, and the two composited to about 54%. The press target is transparent now, which is the same fixBottomSheetcarries. A test pins the layer at 32% and the press target at no colour. - A basic dialog grew past the screen. The surface had no height cap and
the content did not scroll, so long supporting content pushed the actions
off screen. With
dismissable={false}that trapped the user. The surface now stops at the screen height andDialog.Contentscrolls under a fixed headline and above fixed actions. - The full-screen dialog sat under the status bar. The surface filled the
portal layer from the top edge of the screen. It now pads itself by the
safe-area insets on every edge, the same way
BottomSheetandSnackbardo.
A screen on a wide window had no way to cap its content width except a
hand-written maxWidth on each screen. The example app had 23 copies of
that rule with three different numbers, and nothing in the library said
which one was right.
Container is a centred Column with a maximum width. The width prop
names a breakpoint band, and the Container stops growing at the lower bound
of that band: medium (the default) is 600dp, expanded 840, large 1200,
and extraLarge 1600. Below that width it fills its parent, so a phone sees
no change. The limit includes the padding. Put it as the one child of a
ScrollView with the screen padding on it, and every screen shares one
column. It ships from the layout subpath and the package root.
The example app uses it on every screen: medium on the component screens
and large on the home page.
Lighthouse reported React error #418 on the docs home. Two things in the
example read the browser during the hydrating render: the ?embed=1 flag
that the docs iframe passes, and the copy button that checks for the
clipboard API. Four screens also drew MaterialDesignIcons directly, past
the hydration guard the mdi resolver added in the last release, so every
component route had a glyph that the server wrote and the client did not.
All of them now wait for hydration or go through the resolver.
The home page also kept its hero pinned instead of its toolbar: the
PageHead element counted as the first child of the ScrollView and moved
the sticky index. The head now sits outside the ScrollView.
The static export of the example app showed every display* and headline*
Typography as an <h1> on the web, and the AppBar title as one more. The
library gave them the header role with no level, and react-native-web writes
a level-less header as <h1>. A page with a hero, a title bar and a display
glyph had three <h1>, and a crawler read no outline.
Typography now takes level (1 to 6). It sets the header role and
aria-level, so the web renders <h1> to <h6>. A display or headline
variant with no level announces a header on native only. On the web it
renders a plain element, because a level-less header there is always <h1>.
An explicit accessibilityRole still wins. Pass level on every heading
your page has, and nothing on decorative display text.
AppBar takes titleLevel, default 1, and renders the title with it. Pass
2 when a heading above the bar is the page heading. On native nothing
changes: the title still announces a header.
Heading from @rootnative/seo/react keeps working with as={Typography};
it passes the same role and level.
A React Native app that exports to the web starts as one empty page, and
every app wrote the same fix by hand. @rootnative/seo holds the parts that
do not depend on the app's data. The app keeps the mapping from its data to
the meta.
Five entries. The core and /schema have no dependency and run in Node and
in a browser. /react and /expo-router need React Native. /node runs in
Node only.
defineSiteandtoHeadTagsturn aPageMetainto tag records any head renderer can write. The tab title uses the site template and the Open Graph title does not. A page with nourlgets no canonical link and no share card.og:image:widthandog:image:heightare written only with an image. Each tag is written once.- The JSON-LD builders under
/schemacoverWebSite,BreadcrumbList,Organization,Article,Product,Movie,Person,EventandFAQPage. An empty field is left out at every depth, a rating with no votes is left out, and a duration of0is left out. The script text escapes<, so a</script>in a title cannot end the tag. SeoProvider,HeadingandimageLabelunder/react.Headingrenders<h1>to<h6>on the web through the text component you pass inas.PageHeadandshellTagsunder/expo-router.PageHeadwrites the head throughexpo-router/headand renders nothing on native.shellTagswrites the head of+html.tsxand prefixes every relativehrefwith the base path of a subpath host.writeSitemap,writeRobotsandwriteManifestunder/node, for the deploy step. Each has a pure sibling that returns the text.
The example app is the first consumer: a static export, a PageHead on every
screen, shellTags in the shell, and sitemap.xml and
manifest.webmanifest written after the export. A new CI step exports the
app and reads two pages, which is the test that catches a head renderer
change in Expo Router. It found that the vendored react-helmet-async drops
a script that uses dangerouslySetInnerHTML; renderHeadTag writes the text
as a string child instead.
The export also showed that the AppBar title and every display* or
headline* Typography render as <h1> on the web, because the library
gave them the header role with no level. The entry above fixes that:
Typography takes level, and AppBar takes titleLevel.
A new docs section, "SEO for the web export", carries the blockers, the
checklist, the API reference and the limits. The API page is generated from
the same source as the package llms.txt.
A static web export with iconResolver={mdiResolver} logged React error #418
on every load. React then discarded the static HTML and rendered the whole
page again on the client. @react-native-vector-icons/common 13 draws the
glyph on the export server, where it loads no fonts. On the client, its first
render is an empty string until expo-font loads the font, so the text did
not match.
While the page hydrates, mdiResolver now renders the icon with no name.
The icon set draws an empty string on both sides, and it starts the font load
during hydration. The next render passes the name, and the glyph shows when
the font loads. In a static export the icons are empty in the HTML. Native
and a single-page web build do not change. A web test in
hydration.web.test.tsx models the font state of the icon set and fails on
the old resolver.
If you copied the four-line resolver from the CLI page into a static export,
render the icon without a name while the page hydrates, as mdiResolver
does. An icon that the app renders directly from the icon package has the
same mismatch. Render it through Icon from @rootnative/components/icon,
which uses the theme's resolver.
One breaking change: a string icon name now needs an iconResolver on
ThemeProvider. Pass mdiResolver from @rootnative/components/mdi to
keep the Material Design Icons, now from
@react-native-vector-icons/material-design-icons. Three new components: NavigationRail,
NavigationDrawer and Icon. The components that apply safe-area insets no
longer throw on web without a SafeAreaProvider. SearchBar takes a
density. Every component now uses one size vocabulary, and the old names
warn. The @rootnative/inertia floor moves to 0.0.17, so a dialog or a
sheet that holds a progress indicator or a skeleton plays its exit. A new
docs page covers web without Expo.
Breaking. The shared icon code imported
@expo/vector-icons/MaterialCommunityIcons with a static import, so every
bundle had to resolve it, even in an app with its own iconResolver that
never rendered an MDI glyph. A Vite app without Expo failed to resolve the
package, because it needs expo-font, and the only way out was an alias to
an empty module.
The resolver now lives behind its own subpath, and nothing else imports an
icon font. The font comes from @react-native-vector-icons/material-design-icons,
the package Expo recommends in place of @expo/vector-icons
(https://expo.dev/blog/moving-away-from-expo-vector-icons). The glyph names
are the same MDI set, so no icon name changes:
import { mdiResolver } from '@rootnative/components/mdi'
<ThemeProvider iconResolver={mdiResolver}>{children}</ThemeProvider>To upgrade an app that uses string icon names:
- Install
@react-native-vector-icons/material-design-icons, andexpo-fonton Expo:npx expo install @react-native-vector-icons/material-design-icons expo-font. - Remove
@expo/vector-iconsfrompackage.json, and runnpx @react-native-vector-icons/codemodif the app imports it directly. - Pass
iconResolver={mdiResolver}toThemeProvider, as above. - On a bundler without Metro, delete the
@expo/vector-iconsalias stub from the bundler config. - In a Jest suite that renders the library with its own wrapper, pass the
same resolver, or use
renderWithThemefrom@rootnative/utils/test, which installs it.
An app that already passes its own iconResolver changes nothing, and can
now uninstall @expo/vector-icons.
What changes on upgrade, in detail:
- Every app that passes string icon names and no
iconResolveradds the prop above. Without it a string name renders nothing and the library logs one warning per process, in development and in production, that names the subpath. The example app and bothrootnative createtemplates pass it. @react-native-vector-icons/material-design-iconsreplaces@expo/vector-iconsas the icon peer of@rootnative/components, and it is optional. On Expo it needsexpo-font, which loads the font at run time in Expo Go, in a development build and on web. An app with its own resolver installs neither, and the alias stub in the Web without Expo page is gone.- Remove
@expo/vector-iconsfrom the app. Both packages register the same font, and the glyphs then render as?or as empty squares.npx @react-native-vector-icons/codemodrewrites the app's own imports;npx expo doctorreports a leftover. The example app and both templates made that move. createVectorIconsResolverfrom@rootnative/iconsaccepts any@react-native-vector-icons/*set, including the MDI/staticexport for a development build that embeds the font. ItsVectorIconSettype now takes a set whosenameis a glyph union, which every generated set is. An@expo/vector-iconsset still fits, and@rootnative/iconsno longer lists that package as a peer.getMaterialCommunityIconsis removed from the CLI registry, with theiconutil file.render-iconno longer declares an icon dependency. A CLI project that wants the MDI default writes the resolver itself; it is the function above, four lines aroundMaterialDesignIcons.renderWithThemefrom@rootnative/utils/testinstalls the MDI resolver by default, so the suite renders string icons the way an Expo app does. PassiconResolver: nullto render without one.
Not a lazy load and not an auto-registration. A lazy require() does not
survive tsup's ESM splitting, and Metro cannot see it. A side-effect import
that registers a default would be dropped by a bundler that honours
sideEffects: false. An explicit prop is what every bundler keeps.
The library had NavigationBar for a compact window and nothing for a wider
one, so a desktop sidebar was built by hand from Pressable and
Typography. Both MD3 side forms now ship.
NavigationRail is the 80dp rail: the same items array as NavigationBar,
the same 56×32dp secondaryContainer pill, and a header slot for the menu
button and the FAB. align places the destinations at the top, the centre,
or the bottom. labelVisibility="never" grows the indicator to a 56dp
circle. Controlled and uncontrolled, like the bar.
NavigationDrawer is the 360dp drawer, composed from
NavigationDrawer.Section, NavigationDrawer.Item and
NavigationDrawer.Divider. The 'modal' variant renders through Portal
behind a 32% scrim, slides in from the start edge, traps focus on web,
closes on Android back, and closes after a destination press
(dismissOnSelect). The 'standard' variant renders in place for an
expanded window. A modal drawer takes visible and onDismiss; the type
rejects them on a standard one. Items are 56dp rows with an optional icon, a
labelLarge label, and a trailing badge text, on a full-corner
secondaryContainer indicator while active.
Icon renders an IconSource through the theme's iconResolver, so a
custom control draws the same icon set and color as the library. Default
24dp and onSurface. Without accessibilityLabel it is decorative and
hidden; with one it is an image with that name.
<NavigationRail items={items} value={route} onValueChange={go} header={<FAB icon="plus" />} />
<NavigationDrawer visible={open} onDismiss={close} value={route} onValueChange={go}>
<NavigationDrawer.Section headline="Mail">
<NavigationDrawer.Item value="inbox" label="Inbox" icon="inbox" badge={24} />
</NavigationDrawer.Section>
</NavigationDrawer>
<Icon source="star" size={32} color={theme.colors.primary} />rootnative add navigation-rail, rootnative add navigation-drawer and
rootnative add icon install them. The rail pulls in navigation-bar, whose
destination it reuses; the drawer pulls in divider and portal.
Button, IconButton and Avatar now take the MD3 size names that
ButtonGroup, FAB, Badge and AppBar already used: 'extraSmall' | 'small' | 'medium' | 'large' | 'extraLarge'. Before, a toolbar with a
Button size="xs" next to a ButtonGroup size="extraSmall" needed two
names for one size, and Avatar had a third pair, 'xSmall' and
'xLarge'.
The old names still work in this release. 'xs' | 's' | 'm' | 'l' | 'xl'
on Button and IconButton, and 'xSmall' | 'xLarge' on Avatar, map to
the new names and log one development warning per component and name that
states the new name. The next release removes them. The defaults are
unchanged: Button and IconButton are 'small', Avatar is 'medium'.
<Button size="extraSmall">Save</Button>
<IconButton icon="star" size="medium" accessibilityLabel="Star" />
<Avatar size="extraLarge" label="AB" />density takes a step on the Material density scale, 0 to -4. Each
step removes 4dp from the 56dp container, so -4 is a 40dp bar for a
desktop toolbar. The icon buttons keep their 40dp frame and the text keeps
bodyLarge, so -4 is the floor. style={{ height }} never did this: the
style prop goes to the root view and the pill kept its own height.
<SearchBar placeholder="Search" density={-4} />On web, react-native-safe-area-context resolves SafeAreaView.web, which
reads useSafeAreaInsets() and throws No safe area value available when
no provider is mounted. The documented root (ThemeProvider > PortalHost
SnackbarProvider) therefore rendered a blank page on web, because the snackbar layer applies insets.AppBar,Layout,NavigationBar,BottomSheetand the snackbar layer now render a plainViewwith zero insets on web when the context isnull. Native keeps the package view. The web Jest mock of the package now throws without a provider, as the real module does, so a test cannot pass by mounting an inset component outside a provider.
A closed Dialog disappeared at once, with no exit animation, when it held a
LinearProgress, a CircularProgress or a Skeleton. The same was true of
the other overlays that take content and animate out through <Presence>:
BottomSheet, Menu, NavigationDrawer, SearchView and Tooltip. The
three content components each render a Motion.* with no exit. Before
inertia 0.0.17, the first Motion.* in an exiting child that finished its
exit removed the whole child. inertia 0.0.17 removes the child only when
every Motion.* in it has finished its exit.
Every pin moves together: the core, utils and components peer ranges
are now >=0.0.17 <0.1.0, the dev, example and template pins are 0.0.17
exact, and the registry's derived floor follows. Upgrade
@rootnative/inertia to 0.0.17 in the app with this release. A Dialog
test now holds each of the three components and fails on an older inertia.
- Web without Expo. A new page with a Vite config and a webpack config
for a bundler without Metro: the
react-native-webalias, the.web.*extensions, the globals, the worklets Babel plugin on@rootnative,react-native-reanimatedandreact-native-workletsinsidenode_modules, and the icon set choice. Without the plugin on those three packages the build passes and nothing animates. The page also lists the Electron facts: relative asset paths,'unsafe-inline'instyle-src, and theglobaldefine in a sandboxed renderer. Thellms-full.txtcarries the same section. @expo/vector-iconsin thellmsfiles. Both files disagreed on whether the peer is optional. It is, and both files, the installation page and the icons page now say where the one import of it lives.- Theme mode on web.
storage={localStorage}works as is: the provider catches agetItemorsetItemthat throws. The theming page now says so and shows the guard for a static export.
No breaking changes. A new SearchView component. ThemeProvider follows
a dark system setting in a static web export. The divider of a scrollable
Tabs row spans the whole row.
SearchView from @rootnative/components/search-view is the MD3 search
view: the panel that opens from a SearchBar and shows suggestions or
results. It renders through Portal on the dialog layer.
<SearchView
visible={open}
onDismiss={() => setOpen(false)}
anchor={barRef}
placeholder="Search mail"
value={query}
onChangeText={setQuery}
onSearch={(text) => runSearch(text)}
>
<List>{suggestions}</List>
</SearchView>- The header is 72dp and holds a back button, the input, a clear button
while the input holds text, and
actions.childrenfills the rest. layout="auto"is full screen on a compact window and docked on a wider one. A docked view has the extra-large corner, is 240dp tall at least and two thirds of the window at most, and closes on a press outside it. Pass the bar'sViewasanchor, and the docked view opens over the bar.- The view asks to close through
onDismiss: the back button, the Android back button, Escape on web, and a press outside a docked view. - On web, focus moves to the input on open, Tab stays inside the view, and
focus returns to the trigger on close. On native, the input takes focus on
open and
accessibilityViewIsModalkeeps the screen reader inside. valueandonChangeTextcontrol the query,defaultValuestarts an uncontrolled one,refreaches theTextInput, andinputPropspasses the rest of its props.
SearchBar gains onPress for this. With it, the bar is a button that opens
the view: one tab stop with the button role, a read-only input that still
shows value, and the clear button and actions as buttons of their own. Open
the view from onPress, not from onFocus: the view returns focus to the
bar when it closes, and a bar that opens on focus reopens it at that moment.
ThemeProvider read the system scheme with the react-native
useColorScheme. The export server has no matchMedia, so react-native-web
reports 'light' there, and the HTML ships the light theme. The client
reads the real scheme on its hydration render, and React 19 keeps the
server's light styles with no error. So in a dark browser the page stayed
light until the reader changed the mode.
ThemeProvider now resolves 'system' to light on the server and while the
client hydrates, and then to the OS scheme on the next render. It reads the
same hydration flag as useWindowDimensions, so a scheme and a window size
switch on the same render. On native and on a single-page web build, the
provider reads the OS scheme from the first render. A reader with a dark
system setting sees one light frame first on a static export. The server
cannot know the scheme, so that frame is the first paint. If you wrote your
own hydration gate around mode, remove it.
A scrollable row put edgePadding on the scroll content, and drew its 1dp
divider inside the tabs. So the divider started after the padding and
stopped before it: at 390dp wide with edgePadding={16}, it ran from x 16,
not from the screen edge. A row whose tabs did not fill the width drew the
divider under the tabs only.
The padding is now on the row, and the row fills the scroll view. The
divider spans the tabs and the edge padding on both sides, and it still
scrolls with the tabs, as Compose's ScrollableTabRow does. The same change
fixes the scroll position: a selected tab scrolled into view now sits at the
center of the row. It was edgePadding off center. If you set
showDivider={false} and drew a full-width Divider under the row, remove
both.
No breaking changes. A new SearchBar component. An anchored Badge sits at
the correct corner on iOS and Android. rootnative create pins
@rootnative/inertia 0.0.14.
SearchBar from @rootnative/components/search-bar is the MD3 search bar: a
56dp pill with a leading icon, a text input, and a trailing slot. It takes
every TextInputProps except editable and style, so you control it with
value and onChangeText as you do a TextInput.
const [query, setQuery] = useState('')
<SearchBar
placeholder="Search mail"
value={query}
onChangeText={setQuery}
onSearch={(text) => runSearch(text)}
/>- The keyboard shows a Search key, and
onSearchreceives the query when the user presses it. - A clear button shows while the bar holds text. It calls
onChangeText(''), thenonClear, and moves focus back to the input.showClearButton={false}removes it. - The leading icon is a decorative
magnifyby default.onLeadingIconPressmakes it a button, such as a menu or a back arrow. Name it withleadingIconAccessibilityLabel. In development, the component warns when that label is missing. - The trailing slot takes
actions, a list of icon buttons such as a voice action, ortrailing, custom content such as anAvatar. The two fill the same slot, so the type rejects both together. refreaches the innerTextInput, forfocus(),blur()andclear().disableddisables the input and every button in the bar.containerColorandcontentColorchange the two colors.
The bar is 720dp wide at most. Set style={{ maxWidth }} to change it. MD3
also sets a minimum width of 360dp, which the component does not apply,
because a bar that wide overflows a narrow phone screen.
On the web, the bar is a search landmark and the input has the searchbox
role. The placeholder is the accessible name of the input unless you set
accessibilityLabel. The bar costs a keyboard user one tab stop, plus one
for the clear button while it shows.
MD3 Search has a second part, the search view that opens from the bar and shows suggestions. The library does not have it yet.
rootnative add search-bar installs it, with icon-button as a dependency.
In alpha.19, a Badge that wrapped an anchor was in the wrong position on
iOS and Android. In the 56dp NavigationBar pill, the badge was 32dp to the
right of its 24dp icon. A 999+ label on a 24dp anchor showed as 99…. The
web build was correct.
React Native runs Yoga with its classic errata. The badge used
start: '100%', and Yoga resolved that percentage against the width the
parent offers, not the final width of the wrapper. Yoga also limited the
badge to the width of its 24dp containing block, and that cut the label.
The wrapper now puts the badge in a zero-size row at the top-end corner of
the anchor, and the badge takes a negative start equal to the MD3 offset.
The position on the web does not change. If you moved the badge with a
style offset to correct it on native, remove the offset.
The two templates pinned 0.0.13. inertia 0.0.14 fixes a layoutId
transition that did not run when its source element stayed mounted and did
not move. That is the usual case for a list card that opens a detail screen.
The peer range stays >=0.0.11 <0.1.0, because no component uses an inertia
API that is newer than 0.0.11.
No breaking changes. A new Badge component, which a NavigationBar item
can show on its icon. A selected filter chip reads its pill from the theme. A
Card inside a Link renders as a link on the web. core exports a
useWindowDimensions that is safe in a static web export.
Badge from @rootnative/components/badge is the MD3 badge. With no
label, it is a 6dp dot. With a label, it is a 16dp pill that holds a
count or a short word. A number above max shows as {max}+, and max is
999 by default. Wrap the anchor, usually a 24dp icon, and the badge sits at
its top-end corner with the MD3 offsets. With no children, the badge renders
inline. visible={false} removes the badge and keeps the anchor, so the
layout does not move when the badge comes back. The fill is error and the
label is onError, and containerColor and contentColor change them.
<Badge label={3} accessibilityLabel="3 unread messages">
<MaterialCommunityIcons name="email-outline" size={24} />
</Badge>A small badge has no text, so a screen reader does not announce it unless you
give it an accessibilityLabel.
A NavigationBarItem takes a new badge field. true shows a dot, and a
number or a string shows a large badge. The badge in the bar is decorative,
so put the count in the item's accessibilityLabel.
rootnative add badge installs it, and rootnative add navigation-bar now
installs badge as a dependency.
useBreakpoint was safe in a static web export, but a layout that needs the
raw width had only the react-native hook. On the export server that hook
measures a 0 by 0 window, and React 19 keeps the server's style when the
client hydrates, with no error. So on a 1440 wide screen, a carousel slot
sized from the width drew -128 wide, and a hero set as a fraction of the
width drew 0 high, until the reader resized the window.
useWindowDimensions from @rootnative/core returns the same ScaledSize
as the react-native hook. While the client hydrates, it returns the
server's 0 by 0 window, and then the measured window on the next render. On
native and on a single-page web build, it returns the measured window from
the first render. useBreakpoint now reads this hook, so a breakpoint and a
raw width cannot disagree during hydration. Its behaviour does not change.
If you wrote your own guarded copy of the hook, import this one and remove
the copy.
An interactive Card set role="button" after it spread your props, so the
role you passed was lost with no warning. Expo Router's <Link href asChild>
passes role="link" and href to its child. The card then rendered
<button href> on the web, which a crawler does not follow and a middle click
does not open. The card now uses your role and
falls back to button, so react-native-web renders an <a>. With no role,
the card is a <button> as before. Chip, Button, IconButton, FAB,
ListItem and Avatar already let your role win, because they set the
older accessibilityRole and role takes precedence over it. If you put a
Pressable between a Link and a Card to get an anchor, remove it: the
Card gets its state layer and focus ring back.
The selected rest radius was a constant, half the chip height, while the
unselected radius read theme.shape.cornerMedium. Under applyRoundness(0)
every surface was square except a selected filter chip, which morphed into a
16dp pill and could not be themed out of it. The selected radius now reads
theme.shape.cornerLarge, which is 16dp at the default roundness and scales
with it, capped at half the chip height so a rounder theme still draws a true
pill. The default look is unchanged.
No breaking changes. TextField gains a ref prop. Every component that
applies a safe-area inset reads it from SafeAreaProvider when one is
mounted. The snackbar fits a phone screen on native. Each entry names the
workaround you can now remove.
TextField took every TextInputProps but no ref, so a screen could not
call focus(), blur() or setSelection() on it. TextFieldProps now
declares ref?: Ref<TextInput>, and the component merges it with the ref it
uses for its own press-to-focus.
const ref = useRef<TextInput>(null)
<TextField ref={ref} label="Search" />
ref.current?.focus()React 19 passes ref as a prop, so the component is not wrapped in
forwardRef. If you drew a plain TextInput to get a programmatic focus,
you can use TextField again.
AppBar insetTop, Layout, NavigationBar insetBottom, BottomSheet and
the snackbar layer all wrapped their content in the native SafeAreaView
from react-native-safe-area-context. That view measures its own inset on
the native side, and on a transparentModal route from
react-native-screens the first measurement is 0. The bar sat under the
status bar until something re-rendered it, such as a theme change.
The shared wrapper now reads SafeAreaInsetsContext. Inside a
SafeAreaProvider it renders a plain View and adds each requested edge's
inset to the padding the style carries, the same additive maths as the
package's own web SafeAreaView. That value is correct on the first render.
Without a provider the context is null, and the wrapper renders the native
SafeAreaView as before.
Expo Router mounts a SafeAreaProvider at its root, so every Expo Router
app takes the new path with no change. If you dropped insetTop and padded
an AppBar from useSafeAreaInsets yourself, put insetTop back and remove
the padding. Do not keep both, or the bar gets a double inset on a phone.
On native, the snackbar surface was 600dp wide on a 402dp phone, and the
layer centred it 99dp off the left edge. The reader saw the tail of the
message and no action label. The surface set width: '100%' and
maxWidth: 600, but its parent, the entrance Motion.View, had no width. In
Yoga a percentage width does not resolve against a grandparent, so the
surface took its maximum. A browser resolves it, which is why the web build
was correct.
The entrance view now carries width: '100%' and maxWidth: 600, and the
surface no longer sets maxWidth. The surface still fills the 16dp-margined
layer up to 600dp, so a tablet layout does not change. If you passed a
numeric width through the provider style as a workaround, remove it.
No breaking changes. AppBar gains one prop and one exported type,
AppBarNavigationIcon, and its layout moves to the MD3 geometry, so every bar
shifts by a few dp. On iOS, the shadow of a non-interactive elevated Card
is softer. The rest is a lint and internal-hygiene pass. One dev-only behaviour changes,
and rootnative add copies one more file.
An IconButton passed to leading or trailing sat 4pt above the title. The
IconButton root sets alignSelf: 'flex-start', so that the button does not
stretch in a column. In the row of the side slot, the same rule acts on the
vertical axis and overrides the slot's alignItems: 'center'. The built-in
back button and the actions escaped the defect only because each one sits in
a centred 48×48 frame.
Custom slot content is now wrapped in a row that the slot centres. The row
keeps a fragment of several buttons side by side, and it is at least 48dp
wide, so a custom IconButton takes the same space as the built-in one. If
you wrapped an IconButton in your own centred View to correct the offset,
you can remove the wrapper. It is not necessary, and it does no harm.
The reference is the Compose Material 3 TopAppBarLayout, measured on the
web export of the example app. Every bar moves by a few dp:
| Position, in dp from the bar edge | Before | Now |
|---|---|---|
| Navigation button | 4–44 | 8–48 |
| Last action, from the end edge | 12 | 8 |
| Title, after a navigation button | 52 | 56 |
Title, after a custom leading IconButton |
44 | 56 |
| Medium or large title, with a navigation button | 52 | 16 |
- The built-in buttons had the same
flex-startdefect on the horizontal axis: each sat 4dp to the start of its 48dp frame. - The title had no padding of its own. MD3 gives it 4dp on each side, the same as each side slot.
- MD3 puts the expanded title of a medium or large bar under the navigation button, not after it. On scroll, the title now moves from 16dp to the small-bar position while it collapses.
No theme token changes. horizontalPadding: 4 and titleStartInset: 12
already had the MD3 values; the formulas that read them were wrong.
navigationIcon="close" changes the icon that canGoBack renders from a back
arrow to a cross. The button keeps the built-in size, colour, and frame, so a
modal no longer needs a custom leading. The accessibility label follows the
icon: the cross announces "Close", and the arrow still announces "Go back".
onBackPress fires for both. The default is 'back', so an existing bar does
not change.
<AppBar title="About" canGoBack navigationIcon="close" onBackPress={dismiss} />This change applies only to an elevated Card with no onPress, on iOS. That
card paints its shadow through boxShadow, because iOS clips the shadow*
keys of a view that clips its children. Fabric halves the blur of a
boxShadow on iOS (React/Fabric/Utils/RCTBoxShadow.mm), so the card had a
blur radius of 1 instead of the level-1 value of 2. The card now doubles the
blur before Fabric halves it, and its shadow is the same as the level-1 shadow
on every other surface.
The shadow is softer than in alpha.16. Android and web do not change, and
an interactive Card does not change.
eslint-plugin-react-hooks moves from 4.6.2 to 7.1.1, which brings the
React Compiler rule set. The build does not run the React Compiler, so these
rules are advisory here: each one reports a pattern the compiler cannot
optimize, or one a future concurrent re-render could break.
Three places wrote a ref during render and now write it in an effect:
BottomSheet, useFocusTrap and ThemeProvider. Every one of those refs is
read from a callback — a PanResponder handler, a keydown listener, or an async
loader — and a callback cannot run before the commit that produced the value it
reads. The behaviour is the same. react-hooks/refs forbids the
render-time write because a render that never commits must not leave its value
behind.
react-hooks/immutability is off for the whole repo. A Reanimated
SharedValue is mutated through .value, and that write is the entire API of
the type. It never triggers a render, so the hazard the rule describes cannot
happen, and no refactor satisfies the rule. inertia, impulse and
rootnative turn off the same single rule and nothing else from the compiler
set.
The compiler rules are also off in tests. A test harness assigns a hook's
result to a module-level let on purpose, so the test body can reach it. None
of that ships.
Five misuses print a dev-only error: <Grid.Cell> outside a <Grid>,
<Portal> outside a <PortalHost>, a named <PortalHost> acting as the root,
an uncontrolled <Menu> with an invalid anchor, and a plain <Tooltip>
carrying subhead or actions.
Each component held its own useRef latch, so the error repeated once per
mounted instance. A list of 100 misused cells printed 100 identical errors. A
shared warnOnce helper now keys the guard by the misuse, so each message
prints once for the lifetime of the module. Keying it rather than using one
boolean keeps two different misuses from silencing each other.
The warnings stay dev-only, and production is unaffected.
rootnative add copies one more file. The layout, menu, portal and
tooltip entries gain internal/warnOnce.ts. A component you scaffolded
before this release carries its own latch and still works, so there is nothing
to redo.
rootnative create pins @rootnative/inertia 0.0.13. The two templates
pinned 0.0.11. The peer range stays >=0.0.11 <0.1.0, because no component
uses an inertia API that is newer than 0.0.11.
No breaking changes. One silent correctness fix that reaches every static
export, two helpers promoted out of internal/, and two layout and keyboard
traps closed.
A static export renders on a server with no DOM, where react-native-web pins
Dimensions to width: 0. Every breakpoint resolved to compact, and that is
what shipped in the HTML — while the client measured the real width on its very
first render. React does not repair that disagreement. Measured against
React 19.2.3: a style mismatch is adopted from the server DOM, left on
screen, and reported through zero recoverable errors. A tablet got the phone
layout, permanently, with no console signal.
useBreakpoint now reads a useSyncExternalStore whose only job is to report
when React is reading it. React calls getServerSnapshot during server
rendering and through the hydration pass, then getSnapshot for every render
after, so the hook returns compact for exactly as long as the client is
reproducing the server's markup and switches to the measured breakpoint on the
re-render React schedules once hydration finishes. That correction is an
ordinary update, which does patch the DOM.
The visible consequence is that a breakpoint-dependent layout starts compact and widens a frame later. The server cannot know the viewport, so make the compact variant an honest first paint rather than a placeholder.
Consumers can delete their own useHydrated gates. So can any code that
hands a breakpoint map to Grid (columns) or Grid.Cell (span): those
resolve the map internally, which put the trap somewhere a consumer's gate
could not reach.
It costs nothing where there is no hydration. On native, and on a single-page
web build, getServerSnapshot is never called, so the measured breakpoint is
returned from the first render with no extra pass.
Both are exported from @rootnative/components. They convert a
theme.elevation.level* token into the surface each platform actually renders:
web gets a CSS boxShadow string, native gets the classic
shadow* / elevation keys.
Reach for elevationShadowConfig whenever an elevation is animated.
useShadow accepts the classic keys, and on web they animate nothing —
react-native-web 0.21 deprecated those props and the per-frame values never
reach CSS, so a hover lift stays visibly flat with a single deprecation warning
as the only signal. Passing theme.elevation.level* straight to useShadow
does not work either: those tokens are built from the shadow* keys and carry
no boxShadow field.
Nothing about the helpers changed — Button, Card, Chip and FAB have
used them since alpha.5. They simply lived in packages/components/src/internal/,
so a consumer who hit the web tween problem had to rebuild the conversion by
hand and had no way to know one already shipped. This is an access change, not
a new shape.
Do not flatten the platform split and emit both keys. RN 0.76+ on the new
architecture renders boxShadow natively too, so a config carrying both
applies two shadow systems to one view and whichever resolves last wins. That
is also why ElevationLevel carries no boxShadow field: a token holding both
shapes at once cannot be handed to useShadow on any platform.
A <Stagger> wrapping N cards used to collapse into a single grid cell, so the
whole cascade landed in one column.
Grid wraps every child that is not a Grid.Cell in a cell of its own, and
React.Children.map does not descend into a fragment — Stagger renders a
fragment of context providers and no host view, so N cards arrived as one
child. Grid now looks through a Stagger element: it cell-wraps the
stagger's children and keeps the Stagger element itself, so each cell still
sits under its own delay provider.
The fix had to live in Grid. Stagger already assigns its delays per child
rather than per parent slot, so the cascade was never what broke — only the
layout — and nothing Stagger could do would help, because the collapse
happens before it renders.
The container Pressable widens the press target and catches hover. It is not
a control, and it carried accessible={false} and focusable={false} to say
so — but neither prop removes the tab stop on web. Measured in jsdom
against react-native-web 0.21.2: an enabled Pressable renders tabindex="0"
and keeps it with both props set. Every field therefore cost a keyboard user
two stops, and the first one only forwarded focus to the second.
It now carries tabIndex={-1}, the same way Tooltip blocks the same stop.
Two web tests pin the attribute — reading the rendered DOM, not the props,
because the props are exactly what mislead here.
The dev pin moves from 0.0.11 to 0.0.12. The peer range is unchanged at
>=0.0.11 <0.1.0 — no package here calls a 0.0.12 API, so nothing forces a
consumer to upgrade. 0.0.12 adds useInView, a static-export entrance guard,
a pointerHandlers bag on useGesture, and a widened useMotionValue; all
four serve a page rather than a component library.
Column gains a page section on alignment and on when a wrapper collapses to
its content, which is the shape behind most "my centred block is the wrong
width" reports.
The responsive page replaces its server-rendering note. It used to tell you to
"treat the first render as compact and let the client correct it" — advice
the first entry above makes unnecessary — and now states what the hook
guarantees, what it costs, and that Grid inherits the same safety for its
columns map.
The Expo SDK 57 release. Every package moves to the SDK 57 runtime band and
stops claiming SDK 54, so an app that stays on SDK 54 stays on
0.0.0-alpha.14. No one set of ranges serves both bands.
core, components and icons declare react >=19.2.3 <20.0.0 and
react-native >=0.83.0 <0.87.0. components also narrows
react-native-reanimated to >=4.5.0 <4.6.0 and react-native-worklets to
>=0.10.0 <0.11.0. The floors they replace — react >=18.0.0,
react-native >=0.72.0, react-native-reanimated >=4.0.0 and
react-native-worklets >=0.5.0 — were never installable: components depends
on @rootnative/inertia, whose own floor was already react >=19.0.0 with
react-native >=0.81.0, so npm rejected the advertised floor with ERESOLVE.
The band cannot be wider. Each peer range resolves on its own, so any set that
admits both SDK 54 (react-native 0.81 with Reanimated 4.1) and SDK 57
(react-native 0.86 with Reanimated 4.5) also admits react-native 0.81
with Reanimated 4.6, which cannot install. A range promises every combination
it permits, not only the combinations that were tested. @rootnative/inertia
0.0.11 narrowed its own ranges for that reason, and this release tracks it so
the two cannot disagree.
Migration: move the app to Expo SDK 57 — React 19.2.3, React Native
0.86.3, Reanimated 4.5.1, Worklets 0.10.1. A project on SDK 54 stays on
0.0.0-alpha.14. A project that runs Jest also needs
@react-native/jest-preset at the version that matches its react-native:
React Native 0.86 moved the preset into that package and left a shim that
declares it as an optional peer, which no package manager installs.
The claim is gated rather than reviewed. compat.yml packs the components
tarball and installs it into every Expo SDK fixture in rootnative/sdk-compat
with enforce-range: true, on every push to main and every pull request that
touches packages/**. The test suite in this repository says nothing about
what a consumer can install, and that gap is what let the old floors ship.
Every pin moves together: the core, utils and components peer ranges are
now >=0.0.11 <0.1.0, the dev, example and template pins are 0.0.11 exact,
and the registry's derived floor follows. 0.0.11 is the release whose own
peer ranges match the SDK 57 band. 0.0.10 declares react-native >=0.81.0
and react-native-reanimated >=4.0.0, which a fresh install can reject.
0.0.11 also fixes a crash and exports three style types. The crash was a
keyframe array — animate={{ translateY: [0, -8, 0] }} — taking the whole
render down under reduced motion. The types are ColorStyle, TranslateStyle
and ShadowStyle. This library writes no keyframe array and names none of the
three: every call of useColorTransition, useColorCascade and useShadow
spreads the style into a style array, where inference is enough. So the
raised floor is a requirement of the SDK band rather than of anything the
library consumes.
Both were marked optional in peerDependenciesMeta, and no package manager
installs an optional peer. The library imports both statically, so no
runtime fallback exists to make them skippable: Metro builds its module graph
by scanning for literal import calls and fails with Unable to resolve module
before any fallback could run. The comment in src/safe-area.tsx records why
the lazy require() shape does not survive the build. The flags are gone, so
npm and pnpm now install both. react-native-svg was already required, and the
installation page states the status of every peer in one column. Yarn classic
installs no peers at all, so a Yarn project still adds the list by hand.
Every component that marked a decorative overlay non-interactive — focus rings,
state layers, elevation carriers, the Tabs indicator, the Menu / Tooltip /
BottomSheet / Snackbar overlay layers — passed pointerEvents as a prop.
react-native-web deprecated that spelling: createDOMProps warns
props.pointerEvents is deprecated. Use style.pointerEvents. The warning is
warnOnce-guarded, so it appeared exactly once, unattributed, in the console of
every web app that rendered any of these — 69 call sites across 24 components.
Nothing renders differently. react-native-web's own implementation translated
the prop into the identical style object and appended it after the caller's
style, so the components now write it themselves, last in the style array,
which is where it already landed. React Native has read the key from style
since 0.71, so native is unchanged too. cursor.web.test.tsx, which reads the
real DOM, was green before and after.
No public API change: pointerEvents was never part of any component's props.
A consumer passing it to a component that forwards ViewProps still works, and
still warns — that call site should move it into style as well.
react/forbid-component-props now fails the build on the prop form. It is a
lint rule rather than a test because the warning is warnOnce-guarded: only the
first offender in a process is observable, so a per-component test passes for
every component rendered after it. Fault injection confirmed that directly —
putting the prop back on Button left the web suite green, and fails lint.
Thirteen components each declared their own
const AnimatedPressable = Animated.createAnimatedComponent(Pressable).
createAnimatedComponent builds a new wrapper class per call, so the package
shipped thirteen distinct classes for one base component. They now share
internal/AnimatedPressable.ts, and the built bundle carries one definition in
one chunk.
The npm package is what gains here; a CLI-installed project is unchanged.
The installer flattens ../internal/* into each consuming component's own
directory, so rootnative add button chip switch still writes one
AnimatedPressable.ts per component. That self-containment is the point of the
copy-paste path, not an oversight. What does change for a CLI consumer is the
file list: those thirteen components each ship one more file, and rootnative update rewrites the component that imports it.
Separately, six useAnimatedStyle blocks driven by a single progress value
move to useInterpolatedStyle — Button's elevation radius, three plain-opacity
styles in Switch, TextField's hover layer, and the Slider thumb. Nothing renders
differently. Twenty-seven hand-rolled worklets remain, each of which either
mixes two or more driving values or needs a key useInterpolatedStyle cannot
express.
The Slider thumb needed extrapolate: 'extend', and without it this would
have been a silent regression. useInterpolatedStyle defaults to 'clamp';
a hand-rolled interpolate defaults to 'extend'. pressed rides
spring-fast-spatial, which is underdamped by design and overshoots both ends,
and evalEdge extrapolates that same value unclamped for the track segments and
the stop indicators. A clamped thumb would have drifted out of step with its own
track for the length of every settle.
Slider.test.tsx pins the option rather than the arithmetic, and the reason is
worth knowing before writing any similar test: @rootnative/inertia/jest-setup
stubs interpolate as value >= 1 ? last : first. That is a binary step which
ignores the input range and the extrapolation config, so a measured width reads
identically under 'extend' and 'clamp' and an assertion on one would pass
against either. Validated by fault injection — dropping the option fails the
case, and only that case.
No public API change: internal/ is not exported and api:check is unchanged.
rootnative create now scaffolds on expo ~57.0.20 with React Native
0.86.3, Reanimated 4.5.1, Worklets 0.10.1 and inertia 0.0.11. SDK 57
removed the legacy top-level splash key from app.json, so both templates
declare the splash screen through the expo-splash-screen plugin, and
newArchEnabled / edgeToEdgeEnabled are gone because the SDK defaults
them. Android's predictive back gesture is switched off explicitly with
predictiveBackGestureEnabled: false. babel.config.js is gone as well — it
held nothing but babel-preset-expo. The blank template's entry point is
index.ts.
The icon, adaptive icon, favicon and splash mark in both templates now carry
the RootNative mark. scripts/build-brand.mjs renders them into the template
asset directories as well as the example app, from one source, so a scaffolded
project cannot drift from the example it is modelled on.
pnpm run check:template-pins compares every template pin against the
installed Expo SDK and the components peer ranges. The templates are not
workspace members, so no install, typecheck or test run ever reads their pins:
they sat a whole Expo SDK behind the library while every gate stayed green.
pnpm run registry:check regenerates the registry and fails on drift.
check:inertia-pins reads the registry but compares only the inertia floor in
each entry, so the files lists were unguarded — 21 of 29 were missing
internal/pointerEvents.ts after the import was added without re-running the
generator. The registry is what rootnative add copies into a consumer
project, so a stale list hands them a component with a missing import.
Both guards run in ci.yml and in release.yml.
columns now accepts a map of window size
classes as well as a number, so
<Grid columns={{ compact: 1, medium: 2, expanded: 4 }}> replaces a
useBreakpointValue call at the call site. A map cascades down to the nearest
smaller breakpoint, and the required compact key makes a missing width a
compile error rather than an undefined reaching flexBasis.
The numeric form is unchanged, but Grid now reads the window width for every
consumer. A constant columns resolves to a stable value, so the cell style
keeps its object identity and nothing re-renders that did not before.
A plain Grid child always fills one column, so an asymmetric layout meant
dropping to Row with a hand-written flexBasis. Grid.Cell spans a
span count of columns instead:
<Grid columns={12} gap="sm">
<Grid.Cell span={8}>{/* main */}</Grid.Cell>
<Grid.Cell span={4}>{/* sidebar */}</Grid.Cell>
</Grid>span takes a breakpoint map too, and clamps to the parent's column count — an
oversized span fills the row instead of overflowing it. Plain children keep
today's behaviour and still read as span={1}, so the two forms mix in one
grid.
Grid passes the column count and gutter to a cell through cloneElement, and
detects a cell by element type rather than by displayName, which a minifier
rewrites. That keeps the geometry a private contract between the two components:
it adds no React context, and the injected fields stay off GridCellProps. A
Grid.Cell rendered outside a Grid warns once in development and renders
full-width.
New exports: GridCell and GridCellProps.
Grid had no web test, so every assertion about it lived in the native
project — which asserts on the React prop and is structurally blind to what
react-native-web emits. Two things were unproven for the component's whole life:
that the percentage flexBasis survives the as unknown as number cast into a
real flex-basis: 50%, and that the row's negative margin stays the exact
negation of the cell padding, which is what keeps a grid from adding a
horizontal scrollbar.
grid.web.test.tsx pins both. One finding is worth recording, because it
contradicts what the elevation notes imply: react-native-web resolves inline
logical properties to physical ones at render time, so the DOM shows
padding-left, never padding-inline-start. Logical-property emission applies
to StyleSheet-class styles, and Grid's cell styles are dynamic useMemo
objects. The gutter is direction-correct on web because it is symmetric, so the
web suite pins that symmetry and the native suite keeps asserting the logical
source keys.
New component: Skeleton renders a pulsing placeholder block that matches the
shape of the content it stands in for. It ships as the 29th component, with its
own subpath export (@rootnative/components/skeleton), a registry entry for
rootnative add skeleton, and an example screen.
Every pin moves together: the core / utils / components peer ranges are
now >=0.0.8 <0.1.0, the dev, example, and template pins are 0.0.8 exact,
and the registry's derived floor is >=0.0.8. Nothing in the library consumes
the 0.0.8 additions yet — <Stagger> is a consumer-app feature, and the
buildReleaseAnimation settle callback is not reachable through useTouchDrag
(the one drag surface this library uses) until inertia exposes it there.
The CLI reference documents command usage in full, the theming page explains
which package owns the createMaterialTheme dependency, the Portal docs
clarify where PortalHost must sit, and the API reference and component pages
got a clarity pass. All of it is covered by docs:check, which verifies every
JSX prop in every example against the real props types.
On alpha.10 and alpha.11, mounting any animated component on iOS or
Android — a single <IconButton /> was enough — crashed the app with:
TypeError: updater is not a function (it is Object)
The source was never wrong, and every test was green. The build format broke
it: under format: 'cjs' + splitting: true, esbuild emits a cross-chunk hook
call as _reanimated.useAnimatedStyle.call(void 0, cb). The Reanimated Babel
plugin auto-workletizes a callback by callee name, which resolves to
call, never matches, and the callback ships un-workletized — the UI thread
receives a plain object instead of a worklet and the component dies on mount.
alpha.9 worked because CJS without splitting emits
(0, import_reanimated.useAnimatedStyle)(cb), which the plugin does match;
alpha.10 added splitting: true to fix the PortalContext duplication and
silently flipped all 30 hook calls into the broken shape.
The two constraints are coupled, and ESM is what satisfies both at once: bare
useAnimatedStyle(cb) calls for the Babel plugin, and one definition per
singleton for the overlays. @rootnative/components now builds
format: 'esm' with "type": "module"; outExtension pins the emitted files
back to .js, so every exports path, typesVersions entry, and consumer
import is unchanged. The fix was confirmed on an Android emulator, not just in
the build output.
Two new guards keep both constraints honest, because nothing else in CI can
see either failure (the tests import from src/, and the public API surface
is identical either way):
pnpm run check:workletsasserts the emitted call shape in the built bundles — the only place the regression is observable.pnpm run check:singletons(existing) still asserts one definition per React context.
alpha.10 and alpha.11 shipped through a release workflow that ran none of
the five build guards — the worklet check existed in CI, but the publishing
path never ran it, so a green CI on main said nothing about the artifact. Both
release workflows now run the same gate list as ci.yml (docs check, inertia
pin check, API surface, singleton check, worklet check) and regenerate the
component registry, which had shipped two releases stale — still claiming a
@rootnative/core floor of >=0.0.0-alpha.9 — because only llms.txt was
regenerated at release time.
IconButton, and anything else rendering a string icon name, failed at runtime
under Metro with:
Requiring unknown module "@expo/vector-icons/MaterialCommunityIcons"
followed by the library's own @expo/vector-icons is required for icon support
error. Layout and AppBar hit the same fault one step quieter: they warned
that react-native-safe-area-context was not installed and rendered a plain
View, so every screen lost its insets. Both packages were installed and
resolvable in every case. Reported against 0.0.0-alpha.10 from a consumer
app; the icon path has been broken for far longer.
The source was never wrong. Both peers were loaded the way an optional
dependency normally is — a literal require() inside a try/catch — and
the build broke it. splitting: true makes esbuild compile through an ESM
intermediate, where require does not exist, so each call was rewritten to
esbuild's own shim and emitted as __require.call(void 0, '…'). Metro builds
its module graph by scanning for literal require('…') and import
statements, so an indirect call through an aliased binding is invisible to it:
the module never entered the graph, the call threw, and the catch blamed the
consumer for a package that was sitting in node_modules.
Listing both in tsup's external does not help — the shim breaks the call
before externalization is reached. Nor can splitting be dropped to avoid it;
that is what keeps PortalContext and friends single instances, and
check:singletons exists to defend it.
So both are now static imports, in packages/utils/src/icon.ts and
packages/components/src/safe-area.tsx. That is a form esbuild leaves intact
and Metro can see, and it keeps both packages external rather than inlined.
Behavior change worth reading before you upgrade. These two peers are now
resolved when the module loads, not when an icon first renders. Both are already
declared peer dependencies and both ship in any Expo app, so this should reach
nobody — but a consumer who genuinely omitted one now fails at import time
instead of at first icon. The old try/catch never actually bought that
tolerance: the only reason it ever ran was this bug.
Nothing changed in the public API — api:check reports no drift, and all 957
tests pass.
Closes the last open item from the developer-experience audit, and the docs guard that audit asked for.
Peer range is now >=0.0.7 <0.1.0 across core, utils and components.
0.0.7 adds Motion.FlatList and fixes gesture={{ pressed }} on web for
non-Pressable primitives. This library uses neither, so the raised floor is
a judgment call rather than a technical requirement — upgrading is only forced if
you consume @rootnative/ui's peer range directly.
SnackbarProvider's bottomOffset was a raw number the consumer had to work
out and keep in sync by hand, and the documented value for a standard FAB was
wrong. 88 assumed the offset had to include the snackbar's own 16dp
margin; the layer already adds that margin and the safe-area inset, so 88
produced 104dp of padding and pushed the snackbar 32dp above the FAB it was
supposed to clear. Measured against the rendered layer, not inferred.
Three additions, all exported from the root and from their subpaths:
FAB_SIZES/FAB_ICON_SIZES(@rootnative/components/fab) — the MD3 container and icon heights per FAB size, plusextended(56dp, whatever thesizeprop says). They were previously literals insidecreateStyles, so a consumer computing clearance had nothing to read.snackbarOffsetFor(height)(@rootnative/components/snackbar) — the offset that clears an element ofheightat the bottom edge.snackbarOffsetFor(FAB_SIZES.medium)is72. It takes a raw height rather than aFABSizesosnackbardoes not gainfabas a component dependency —rootnative add snackbarshould not copy in the whole FAB for four numbers — and so it also covers a bottom bar.useSnackbarOffset(offset)— raises the offset while the calling component is mounted, so the constant lives next to the FAB that determines it instead of in the app root two files away. A mounted caller wins over the provider's prop. With several mounted (a navigation transition, where the outgoing screen has not unmounted yet) the largest applies. A pushed0is an override, not an absence.
bottomOffset is unchanged and still correct for an app whose screens all carry
the same bottom furniture. On upgrade, re-check any hard-coded value: it was
almost certainly copied from the old docs and is 16dp too large.
Not a missing prop — the whole block. ButtonGroupProps is a union over
interfaces named ButtonGroupBaseProps, and the llms.txt generator admits a
local interface only when the name ends in CommonProps, so all four were
skipped and the generic branch found nothing to document. The section shipped
with an example and no prop list in every release to date.
Renamed to ButtonGroupCommonProps and gave the generator a ButtonGroup branch
that describes the three selectionMode arms, the same shape the AppBar branch
uses. That recovers 13 common props plus selectionMode, value,
defaultValue, onValueChange and onItemPress. No API change — the renamed
interface was never exported.
A fifth check group, props-coverage. The existing checks are one-directional:
they prove a documented prop is real, never that a real prop is documented, so a
prop absent from the docs left nothing to scan. Box.justify was implemented,
typed and undocumented for the library's whole life with every gate green, and
ButtonGroup above is the same failure one level up.
It asserts that every prop the library declares appears in
packages/components/llms.txt. Scoped to props declared in the same directory as
their props type, so React Native's inherited ViewProps are not demanded and a
declare module 'react-native' augmentation is not mistaken for a library prop.
Runs in CI inside the existing Docs check step.
Its limit is documented in the source: it reads llms.txt as one flat set, so it
catches a prop documented nowhere, not one documented for a different component.
Note: 0.0.0-alpha.7, -alpha.8 and -alpha.9 published to npm without
entries here, and -alpha.6 above was never published. Their user-facing work —
Card region slots, Typography Emphasized variants, and rootnative create
scaffolding into the current directory — is recorded only in the commit history.
The gap is left as-is rather than reconstructed after the fact.
The web pass. 0.0.0-alpha.5 covered Android; this one is react-native-web,
and it is mostly test coverage over behaviour that was already correct — the
web test project went from 53 to 125 tests. One real fix, plus the tail of the
Android audit.
- Layout direction is now read from the browser on web. Every RTL branch in
the library asked
I18nManager, which react-native-web ships as a hardcoded stub (isRTLis alwaysfalse). So on an RTL page the layout mirrored correctly — react-native-web emits CSS logical properties and the browser resolves them — while the JavaScript still answered "LTR": theAppBarback arrow pointed the wrong way,Menu/Tooltipresolvedalign="start"to the wrong edge, andSliderran its drag maths backwards.selectRTLandtransformOriginnow read the document's resolveddirectionon web and keep usingI18nManageron native. Native behaviour is unchanged. - Decorative icons no longer leak their glyph into the accessible name. All
22
renderIconsites sit underaria-hiddenon aView, so screen readers stop announcing private-use-area characters alongside a control's label. - Touch targets meet the 48dp floor.
Button(xsis 32dp tall),Switch(a 32dp track),Avatar,ButtonGroupand the other small controls size theirhitSlopfrom their own height instead of a flat 4dp. Web is excluded because react-native-web does not implementhitSlop. rootnative addresolves utility file names and import aliases correctly. Two independent CLI bugs found by installing into a fresh project.
isRTLDirection()in the shared utilities, for the places that need the boolean rather than a choice between two values (Sliderthreads it through track geometry and keyboard handling). Also added to the component registry, so a CLI-installedSlidergets it.pnpm run check:inertia-pins— fails when any@rootnative/inertiapin disagrees with the components peer range. The template pins were previously silent: nothing failed locally when one lagged.
- A modifier chord (⌘R, Ctrl+C) still flips input modality to keyboard, so the
next mouse click paints a focus ring. The cause is upstream in
@rootnative/inertia, whose focus-visible tracker has no modifier guard; it is pinned as a known-failing test here and fixes itself once inertia ships the guard.
-
Progress:containerColoris now the track andcontentColorthe indicator;trackColoris gone. It was the other way round —containerColorpainted the active indicator — which contradicted the library-wide contract wherecontainerColoris the container. This is the one change in this release the compiler cannot catch:containerColorstill exists and only changed meaning, so old call sites render inverted.- <LinearProgress containerColor="#2E7D32" trackColor="#C8E6C9" /> + <LinearProgress containerColor="#C8E6C9" contentColor="#2E7D32" />
-
LoadingIndicator:indicatorColor→contentColor.containerColoris unchanged (the contained variant's circle fill), so the three progress-family components now share onecontainerColor/contentColorpair.TabsandNavigationBarkeep their ownindicatorColor— there the indicator is a genuine third element alongside container and content. -
Divider:inset→insetStart, so it pairs withinsetEnd.ListDividertakes the same props and changes with it. -
@rootnative/core:TextStyle→TypographyToken. The old name shadowed React Native's ownTextStyleat every import site, and the two are structurally different — core's has five required fields. -
ColorsandTypographyno longer have index signatures.theme.colors.primryis a compile error instead of a silently-typedstring.BaseThemekeeps its[key: string]: unknown, so custom tokens still ride on the theme root — see the note in Theming.Both are now
typealiases rather thaninterfaces, which is what makes a strictColorsassignable toBaseTheme'scolors: Record<string, string>(TypeScript gives type aliases an implicit index signature; interfaces get none). Consequence: they can no longer be extended by declaration merging — adeclare module '@rootnative/core' { interface Colors { brandRed: string } }augmentation fails withDuplicate identifier. Put brand tokens on the theme root, or define your own theme interface. -
IconResolverContextis no longer exported from@rootnative/core. UseThemeProvider'siconResolverprop to write it anduseIconResolver()to read it. -
Checkbox, Radio and Switch work uncontrolled. They were controlled-only, so
<Checkbox onValueChange={fn} />fired the callback and never moved — a silent no-op. They now self-manage whenvalueis omitted, starting from a newdefaultValueprop, matching Tabs / NavigationBar / ButtonGroup / Slider. Passingvaluestill gives you full control. ARadiolatches when uncontrolled, since it is select-only. -
rootnative initpinsregistryVersionto the release tag matching the latest published@rootnative/core, instead of trackingmain, soaddandupdatefetch reproducible component source. Falls back tomainwhen npm is unreachable or the tag has not been pushed yet.rootnative upgrademoves the pin forward, before it re-fetches any component files. Existingrootnative.jsonfiles are untouched until you runupgrade. -
@rootnative/inertiapeer range widened to>=0.0.6 <0.1.0forcore,utilsandcomponents. The floor moves past 0.0.2 for four releases of upstream work: 0.0.3 is all correctness fixes (Presence exit ordering, endless-repeat unmount, style resting), 0.0.4 is purely additive — animatableboxShadow, pluslayoutIdshared-element transitions measured in window coordinates with a style carry — 0.0.5 makes the 40 layout and text-metric style keys animatable and rejects undriven keys at compile time, and 0.0.6 fixes two animations that never ran (animate={{ boxShadow }}under the default spring, and any colour key resting at its'transparent'default).No RootNative API change, and the floor is a correctness floor rather than a feature one: nothing here animates a colour key or a
boxShadowthroughanimate, and every colour this library moves goes throughuseColorTransition/useGestureLayer, which interpolate rather than spring and were never affected. 0.0.5's reduced-motion fix for sequence-declared step types is the one upstream change this library surfaced, fromLinearProgress/CircularProgress.0.0.6 also exports
TRANSPARENT, the seed value for a colour shared value a custom animated component drives itself. Unused here —Switchis the only component that callsresolveTransitiondirectly and it drives a numeric press progress, not a colour.
-
Every component forwards React Native props to its root node. The 11 newest components declared closed prop interfaces, so
<Tabs onLayout={fn} />was a type error while<Card onLayout={fn} />was not. They all extendViewPropsnow.PortalandSnackbarProviderdeliberately don't — they render no node of their own. See API stability. -
IconSourceis now exported from@rootnative/core, next toIconResolver/IconRenderProps. It was previously only exported from@rootnative/utils, which is private and unpublished, so npm consumers had no way to import it.@rootnative/utilsre-exports it unchanged for copy-pasted CLI installs. -
api-surface.jsonat the repo root snapshots every exported name, generated from the built.d.tsfiles and enforced bypnpm run api:check, so an accidental widening of the public surface shows up as a diff. -
New docs page: API stability — what the
exportsmaps cover, the React Native prop passthrough rule, the safe-area default asymmetry, and what is deliberately not covered (copy-pasted source, the vendored internal hooks, registry pinning).
-
Accessibility state now reaches the DOM on web. Every stateful component passed
accessibilityState={{ ... }}, which react-native-web 0.21 no longer reads — so on web noaria-selectedreached Tabs or NavigationBar, noaria-checkedreached Checkbox / Radio / Switch / filter Chips, and noaria-valuenowreached Slider or the progress indicators. A screen reader on web could not tell which tab was active or whether a checkbox was checked. Components now emitaria-*, which RNW consumes directly and React Native normalizes back toaccessibilityStatefor native. Native behavior is unchanged; this only ever affected web.Affected: Avatar, Button, ButtonGroup, Card, Checkbox, Chip, FAB, IconButton, ListItem, LoadingIndicator, Menu.Item, NavigationBar, Radio, Slider, Switch, Tabs, TextField, BottomSheet's drag handle, and both progress indicators.
-
LinearProgress(indeterminate) no longer renders an empty track under reduced motion — the sliding segment used to snap to its final keyframe past the track's end and sit clipped. It now shows a static centered segment, matching the paused-arc look ofCircularProgress. -
CircularProgress(indeterminate) no longer mounts its spin loop under reduced motion; the arc renders statically. Both indicators are now covered by the reduced-motion invariant suite. -
BottomSheet.tsxcontained a literal NUL byte (a'\0'separator written as a raw0x00), which madegrep -rskip the file entirely and would have copied the byte into projects viarootnative add.
The 1.0 catalog release: eight new components and the shared infrastructure they forced.
- Dialog — basic + fullscreen variants, compound slots (
Dialog.Icon/Dialog.Title/Dialog.Content/Dialog.Actions), scrim,dismissable, Android back-button handling. - Snackbar — imperative-only API:
SnackbarProvider+useSnackbar(), FIFO queue with one visible at a time,replace, per-snackbar durations (an action makes it indefinite), close button, safe-area bottom inset +bottomOffsetfor FAB clearance. - Menu — self-managing via
anchoror controlled viavisible+onDismiss,Menu.Itemwith leading/trailing icons and trailing text, anchor-relative positioning with edge flipping,maxHeight, scroll past the available height. - Tabs — primary + secondary variants, fixed or
scrollable, bar-only (value/defaultValue/onValueChange; deliberately not a navigator). - NavigationBar — 80dp bar, per-item indicator pill,
labelVisibility(always | selected | never),selectedIconper item,insetBottom. - BottomSheet — modal (scrim) + standard variants,
snapPoints(dp or%) with velocity-based settle, drag handle, drag-to-dismiss, safe-area aware, Android back-button dismiss. - Tooltip — plain (transient) + rich (persistent, subhead + actions) variants; long-press on touch, hover on web.
- Divider — standalone export promoted from
ListDivider(which remains as an alias), plusorientation,inset/insetEnd,thickness,containerColor. - Portal v2 —
priority+PORTAL_LAYERSz-order contract (sheet 100 / dialog 200 / snackbar 300 / menu 400 / tooltip 500) and named hosts (hostName+<PortalHost name>) with fallback to the default host. ExistingPortal/PortalHostusage is unaffected.
- The CLI registry never shipped
safe-area.tsx, sorootnative add appbar/layoutinstalled a dangling../safe-areaimport and never declaredreact-native-safe-area-context. Shared root modules now ship flattened into each consuming component, and the peer is declared optional. - Opening any overlay no longer re-renders the app tree under the
PortalHost(portal registry moved to an external store).
The MD3 Expressive release. The library is expressive-by-default with no scheme knob.
- LoadingIndicator — expressive shape-morphing activity indicator.
- Press shape-morphs for Button, FAB, IconButton, and Chip (
usePressMorph— containers morph squarer on press). - IconButton square shape option; toggle buttons invert shape when selected.
- Expressive typography and motion token definitions on the theme.
- IconButton
sizevalues renamed:small/medium/large→'xs' | 's' | 'm' | 'l' | 'xl', with no aliases for the old names. The five sizes are the MD3 Expressive scale and drive container height, icon size, and corner radius. - Motion feel changed across all components: spring tokens were replaced
with the Expressive motion values pinned from androidx
(
ExpressiveMotionTokens.kt). Anything that read the old token values or tuned around the old feel will look different. @rootnative/inertiapinned to its first stable release (0.0.2).
The animation-stack release.
- AppBar collapse-on-scroll behavior.
- Focus-visible treatment across interactive components (keyboard focus
rings via
useFocusVisible). rootnative doctorchecks for@rootnative/inertia.- LLM documentation surfaces: per-package
llms.txt,/llms-full.txton the docs site,CLAUDE.mdpointers in scaffolded templates.
@rootnative/inertiabecame a required peer dependency ofcoreandcomponents. Every animation now routes through it — components no longer importreact-native-reanimateddirectly (it remains a transitive requirement via inertia).- All components migrated from direct Reanimated usage to inertia's
declarative/value layer; state layers unified on
useStateLayer.
The MD3 compliance release — a full audit against androidx
compose.material3 token files, fixing every accidental deviation.
- Theme token corrections (state-layer opacities, elevation levels 4–5, palette, motion durations/easings) change rendered output anywhere the old values leaked into snapshots or overrides.
- Checkbox
indeterminateand error states.
- MD3 alignment for the button family (including an IconButton disabled-override bug), selection controls, Card, Chip, List, progress indicators, Avatar interaction parity, and TextField counter/caret/error states.
package.jsonexportsmaps for better module resolution in all packages.
Initial publish under the @rootnative scope — the rename from
@onlynative (which had reached 0.0.0-alpha.8). Carried over: the theme
system (core), ~20 component exports, the icon adapter package (icons),
and the scaffolding CLI (cli / rootnative).