Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: CI

on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 6 * * *' # daily: React Native nightly changes every day

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
mode: [development, production]
rn: ['0.87', '0.86']
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- run: bun install --frozen-lockfile
- run: npm run typecheck
- run: npm test
env:
UNIFIBER_MODE: ${{ matrix.mode }}
UNIFIBER_RN: ${{ matrix.rn }}

coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- run: bun install --frozen-lockfile
- run: npm run coverage

nightly:
# RN nightly + the React it targets (RN bundles a renderer built from React main). Never blocks a merge.
runs-on: ubuntu-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
mode: [development, production]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 22
- run: bun install --frozen-lockfile
- name: React Native nightly + the React it targets
run: |
REACT_RANGE="$(npm view react-native@nightly peerDependencies.react)"
REACT_VERSION="$(npm view "react@${REACT_RANGE}" version --json | node -e "const v = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(Array.isArray(v) ? v.at(-1) : v)")"
bun add -d react-native@nightly @react-native/babel-preset@nightly "react@${REACT_VERSION}"
node -e "console.log('react-native', require('react-native/package.json').version, '/ react', require('react/package.json').version)"
- run: npm test
env:
UNIFIBER_MODE: ${{ matrix.mode }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
bench/out/
coverage/
*.lcov
.DS_Store
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Jacek Pudysz

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<div align="center">

# unifiber

**One small, tested place for everything React Native styling libraries read from React's private fiber tree.**

Built for [Unistyles](https://github.com/jpudysz/react-native-unistyles) and Uniwind Pro.

`React 19` · `React Native 0.86 – 0.87` · `iOS + Android` · `Hermes-first` · `MIT`

</div>

---

## Why this exists

Unistyles and Uniwind both do something React Native doesn't officially support: they update the native shadow tree **without a React render**. Theme switches, breakpoints and insets reach native views in a single commit. To do that, both read React's private fiber fields: `__internalInstanceHandle`, `stateNode.node`, `memoizedState`, `return`, `alternate` and more.

Until now, each library kept its own copy of those reads. They drifted apart, and both had bugs that only show up on real apps.

unifiber is the one module that knows what a fiber looks like. It answers questions such as "which native view is this ref?", "is it hidden, and why?" and "what must refresh?". It's tested against **React Native's own components** and **Unistyles' real code**. A daily CI run against React Native nightly catches changes to React's internals before they ship.

## API

`Fiber` and `Reanimated` are plain objects. A **`Host`** is a native view's creation fiber behind an opaque type: free to keep, and stable, so the same view is always the same `Host` (`===` and `WeakMap` keys work).

### ref → Host
| | |
|---|---|
| `getHostFor(ref)` | Native-view refs, RN composites (ScrollView, FlatList / SectionList / VirtualizedList, ImageBackground, KeyboardAvoidingView, RefreshControl), and class components that render their view directly (Button on Android). Call once per link and keep the result. |
| `getShadowNodeFor(ref)` | Hot path when you don't keep a Host. |
| `getHostForInstanceHandle(handle)` | For fibers handed back from C++ (`ShadowNodeFamily::getInstanceHandle`). |
| `getContentContainerFor(ref)` | ScrollView / list content container, through RN's public `getInnerViewRef()`. |

### Host → native
| | |
|---|---|
| `getShadowNode(host)` | The node in React's `stateNode.node` wrapper. |
| `getShadowNodes(host, out?)` | Every node React may diff against or re-attach: one after a commit, two while a render is in flight. |
| `getNativeTag(host)` | `undefined` once the view has unmounted. |
| `getParentHost(host)` | The nearest native view above (ActivityIndicator's wrapper, on both platforms). |
| `patchRenderedProps(host, patch)` | Replaces React's diff baseline on both halves. **The only write**, used by Uniwind's baseline. |

### Hidden state
| | |
|---|---|
| `isHidden(host)` | Inside a Suspense fallback, a frozen screen or a hidden `<Activity>`: suspend instead of unlinking. |
| `isSuspended` · `isFrozen` · `isInHiddenActivity` | Why. Frozen means react-freeze (react-native-screens `freezeOnBlur`, react-navigation). |
| `collectVisibleAncestors(hosts, visit)` | Batch refresh. Each visible native view from `hosts` up to the root, visited once, in one shared pass. |
| `collectAncestors(hosts, visit)` | The same walk, with hidden views included. These are the semantics of Uniwind's `collectHostNodes` today. |

### Diagnostics
| | |
|---|---|
| `verify(host, expected)` | Style keys React would re-send, i.e. undo, on this view's next render. |
| `getOwners(host)` | *Development builds only:* the components that created the view, e.g. `['ProfileCard', 'ProfileScreen']`. |
| `getCreationStack(host)` | *Development builds only:* where the JSX was written, for Metro to symbolicate. |

### Adapters: when a 3rd-party component owns the view
Reanimated's `AnimatedComponent` keeps its own copy of a view's style for CSS transitions, so a native-only theme change gets overridden. The adapter keeps that copy in sync, so the theme change **animates** instead.

```ts
const owners = Fiber.createOwnerRegistry({
name: 'unistyles',
epoch: () => themeVersion, // bump on every runtime change
contexts: [ScopedThemeContext],
contextKey: (_ctx, scope) => scope?.name, // stable scopes are renamed in place
resolve: (props, read) => freshStyles(props, read(ScopedThemeContext)),
}, [Reanimated.cssAdapter], warnOnce)

owners.link(host) // in the link path: ~86 ns for a plain view
owners.refresh(hosts) // after a native-only update
```

An adapter that throws is switched off for the session and reported once. Private Reanimated fields are pinned by a contract test against the 4.5.1 and 4.7.0 sources.

## Compatibility

| | |
|---|---|
| React | 19.2, and 19.3.1 with RN nightly (fiber tags pinned for 19.x) |
| React Native | 0.87.1, 0.86.3 · iOS, Android · development and production. CI also runs React Native **nightly** (main) every day; 0.89.0-nightly-20261016 passes |
| Reanimated | 4.5.1, 4.7.0 |
| Unistyles | 3.5.3 and `main` (its real code runs in the suite) |
| Uniwind | its lookups copied verbatim into the suite |
| Engine | Hermes (legacy Hermes benchmarked) |

## License

MIT
34 changes: 34 additions & 0 deletions bench/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// esbuild bundle of a bench entry with the production renderer. Imports are hoisted: use metro.mjs for app numbers.
import path from 'node:path'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const { ESBUILD, RN, REACT, SCHEDULER } = require('../test/harness/paths.cjs')
const esbuild = require(ESBUILD)
const map = {
react: path.join(REACT, 'cjs', 'react.production.js'),
scheduler: path.join(SCHEDULER, 'cjs', 'scheduler-unstable_mock.production.js'),
'react-native-renderer': path.join(RN, 'Libraries', 'Renderer', 'implementations', 'ReactFabric-prod.js'),
}
const [entry, out] = process.argv.slice(2)
await esbuild.build({
entryPoints: [entry],
outfile: out,
bundle: true,
format: 'iife',
target: 'es2019', // Hermes: no class fields / ?? lowering issues
define: { 'process.env.NODE_ENV': '"production"', __DEV__: 'false' },
plugins: [{
name: 'rn',
setup(build) {
build.onResolve({ filter: /^(react|scheduler|react-native-renderer)$/ }, (a) => ({ path: map[a.path] }))
build.onResolve({ filter: /(ReactNativePrivateInterface|react-native\/react-private-interface)$/ }, () => ({ path: 'pi', namespace: 'stub' }))
build.onResolve({ filter: /(ReactNativePrivateInitializeCore|react-native\/setup-env)$/ }, () => ({ path: 'init', namespace: 'stub' }))
build.onLoad({ filter: /.*/, namespace: 'stub' }, (a) => ({
contents: a.path === 'pi' ? 'module.exports = globalThis.__unifiberPrivateInterface' : '',
loader: 'js',
}))
},
}],
logLevel: 'warning',
})
Loading
Loading