Solid 2.0.0-rc.8 driving React Native 0.87.1 Fabric directly. The iOS and Android hosts use RN's Hermes/runtime/surface infrastructure, but the bundle contains no React renderer or React Native JavaScript. React is installed only to satisfy RN's peer dependency. This is an experiment, not a mobile framework yet.
| 1 | TSX → official Solid Oxc compiler → esbuild → embedded JS bundle |
| 2 | ↓ |
| 3 | Solid universal renderer → logical nodes → Fabric JSI create/clone/completeRoot |
| 4 | ↓ |
| 5 | Yoga + native text → UIKit / Android views |
| 1 | bun run create:project /absolute/path/to/my-tally --name Tally --app-id dev.example.tally --scheme example-tally |
The destination must not exist and must be outside this workspace. This creates
a vendored framework workspace, not an installed or published SDK. It includes
the renderer, explicitly registered native packages, hosts and tooling, plus a tiny
persisted counter in app/; it excludes Studio, tests, dependencies, SDKs, Pods,
build output, credentials and receipts. Ordinary file copies have no dependency
back to this directory. framework-provenance.json records SHA-256 source and
generated content hashes. No package publishing or generic autolinking is involved.
Inside the new directory run bun install --frozen-lockfile, bun run typecheck,
and bun run build, then follow its README for native setup and managed dev.
Bun's pinned hoisted setup is required by the native node_modules paths. All
registered packages are deliberately included, even those the counter does not use.
The root package name stays unchanged to preserve the pinned workspace lockfile;
it does not control the installed identity. Component/bootstrap imports remain
local source imports; device-service imports use the public workspace packages.
Optional solid.config.json sets entry, displayName, appId, and urlScheme.
The default Studio values are demo/index.ts, SolidFabric,
dev.solid.fabric.prototype, and solid-native. The starter uses app/index.ts,
Tally, dev.solid.tally, and solid-tally. App IDs must be lowercase dotted
identifiers. Entry paths must stay within the project. Xcode target/module names
and Kotlin/JNI namespaces stay implementation names; installed identities change.
Use a separate identity and scheme to leave Studio and its data untouched.
Android can explicitly reuse an installed toolchain without copying framework code:
SOLID_ANDROID_TOOLCHAIN=/absolute/path/to/toolchain bun run dev --android.
The directory contains sdk/, user/avd/, and gradle-9.4.1/bin/gradle; the default
is the new project's android/.toolchain. Without an installed
toolchain, android:setup explicitly downloads tools and accepts licenses.
Requires Bun 1.4.0, macOS, Xcode with an iOS simulator runtime, CocoaPods, and a Node
version supported by RN 0.87 (tested with Node 26). CocoaPods' Ruby environment
must include xcodeproj; the setup script also handles Homebrew CocoaPods.
| 1 | bun install --frozen-lockfile |
| 2 | bun run check |
| 3 | bun run ios:setup |
| 4 | bun run ios:build |
| 5 | |
| 6 | # Boot an iPhone simulator in Xcode/Simulator, then: |
| 7 | xcrun simctl install booted ios/build/Build/Products/Debug-iphonesimulator/SolidFabric.app |
| 8 | xcrun simctl launch booted dev.solid.fabric.prototype |
bun run ios:test runs the native XCTest on an iPhone 17 Pro simulator. For
another device, run the same xcodebuild command with a different -destination.
First run node scripts/network-fixture.mjs in another terminal: the platform
networking tests require its loopback server on port 8083. Stop it with Ctrl-C
when testing finishes. This fixture is separate from the port-8082 reload server.
For keyboard UI tests, open Simulator and disable I/O → Keyboard → Connect
Hardware Keyboard so the software keyboard can appear.
The test taps controls, checks the counter, checks physical row positions after
a keyed move, hides/shows rows, captures both states, and checks a fresh launch.
Xcode's result bundle contains its screenshots and recording.
There is no Metro server or state-preserving hot reload. Debug builds can opt in
to full runtime reload below. Otherwise, after a TSX change, run ios:build and
reinstall, or run bun run build and rebuild in the generated Xcode workspace.
The Xcode project is generated; edit scripts/ios-project.rb, not the project.
Rerun ios:setup after changing the generator. Simulator signing is disabled.
src/: reusable Solid renderer, components, navigation, and native-service adapters.renderer/: Fabric tree, universal runtime, prop processing, JSX types, and inspector.components/: native primitives, press handling, text input, scrolling, lists, and modal.navigation/: router, link codecs, retained stacks, and tabs.motion/: native animation graphs and composition.platform/: host bootstrap, native modules/events, Back, window, keyboard, and safe area.services/: networking and local notifications.packages/: Bun workspace packages for device services, storage, auth, web, and linking, each owning
its TypeScript entry and iOS/Android sources. @solid-native/native is their shared
internal module/event bridge, also used by the renderer host.demo/: application bootstrap and example screens; demo/index.ts is the default bundle entry.test/: Node protocol/build tests and Vitest component tests.test/types/: compile-only public API contracts, checked by TypeScript.native/, ios/, android/: shared Hermes factory and platform hosts/tests.scripts/: bundle tooling, fixtures, and native build/test orchestration.Import implementation modules directly; src/components/index.tsx retains the existing
component entry point. The compiler-facing @solid-native/runtime and TypeScript's
@solid-native/jsx-runtime aliases resolve into src/renderer/.
These folders group responsibilities, not enforce independent layers: renderer and
motion retain their existing lifecycle coupling, and keyboard/safe-area wrappers use
renderer APIs. Platform bindings do not import the component entry point.
Bun manages packages and runs scripts. Commit bun.lock; use
bun install --frozen-lockfile for reproducible installs. The hoisted linker preserves
the node_modules/react-native paths used by the native hosts. Existing dependency
versions remain pinned. Node still runs the build/protocol scripts, and Vitest runs
component tests: use bun run test, not Bun's separate bun test runner.
| 1 | bun run format # Oxfmt: format JS/TS, configuration, and documentation |
| 2 | bun run format:check # check without writing |
| 3 | bun run lint # Oxlint: correctness + targeted strict rules, no warnings allowed |
| 4 | bun run lint:fix # apply safe lint fixes |
| 5 | bun run check # formatting, lint, tsc, both test suites, and bundle build |
Oxfmt uses single quotes, semicolons, and a 100-column width without reordering
imports. Oxlint adds targeted control-flow, promise, and TypeScript checks to its
correctness rules. Explicit any, CommonJS imports, @ts-ignore, and @ts-nocheck
are errors; @ts-expect-error needs a description. Unused suppression comments
are errors too. Runtime/demo, app, and starter-template imports of React, React Native JS, and Solid's DOM
renderer are forbidden, including subpaths and type-only imports.
Intentional router generic erasure has narrowly documented exceptions; type-error
fixtures allow unused declarations and expressions. Nullish == null checks and
native callback interop remain allowed. JavaScript scripts and tests use Node globals
and reject undefined names and switch fallthrough. TypeScript owns these checks for TS,
with strict, noImplicitOverride, noImplicitReturns, noFallthroughCasesInSwitch,
isolatedModules, verbatimModuleSyntax, and noUncheckedSideEffectImports enabled.
skipLibCheck is disabled in the framework and generated projects, so TypeScript
also validates declaration files, including the pinned dependencies' declarations.
exactOptionalPropertyTypes and noUncheckedIndexedAccess are deferred: the installed
TypeScript 5.9.3 reports 66 and 62 diagnostics respectively, requiring deliberate
API and indexing migrations rather than blanket assertions. Promise lint rules are
syntax-level, not type-aware floating-promise analysis; the type-aware companion is
not installed and needs a separate dependency/version review. TypeScript remains the
type checker.
Generated projects retain app-aware lint, lint:fix, and check commands; their
check runs formatting, lint, typechecking, and bundling, without the omitted framework tests.
Native sources, dependencies, generated
projects, and build artifacts are excluded from both tools. Editor integrations
can use the committed .oxfmtrc.json and .oxlintrc.json configurations.
check does not run simulator/emulator suites; those remain explicit native commands.
| 1 | bun run doctor # read-only native toolchain checks |
| 2 | bun run dev --ios # managed Debug simulator session |
| 3 | bun run dev --android # managed Debug device/emulator session |
| 4 | bun run dev --ios --device '<simulator UDID>' |
| 5 | bun run dev --android --device emulator-5554 |
Managed development selects one target, prepares/builds the Debug host when native
inputs change, verifies the installed app's bytes, installs only when needed, and
launches with fresh credentials. Android reverse forwarding is automatic and
session-owned. An ambiguous target requires --device; toolchain installation and
license acceptance remain explicit setup steps. iOS supports simulators, not physical
devices or LAN connections. Android can launch the existing project AVD when no
device is connected; it does not install an SDK or create an AVD implicitly.
Successful JS edits trigger full runtime reload, not state-preserving refresh. On compile failure, the last successful bundle remains published; this does not imply that the app is still running. The terminal distinguishes compiled, published, downloaded, loaded, and surface-started revisions. “Running” means the bundle reached its execution marker and the initial Solid/Fabric commit returned; it does not prove a compositor frame or continuous application health. Packaged startup is labelled separately from downloaded revisions.
Runtime exceptions do not roll back to the last good bundle. Managed iOS sessions can recover from a failed startup evaluation when a corrected revision arrives. On Android, an uncaught JavaScript exception can terminate the app under React Native's exception policy. The CLI does not automatically restart a crashed app.
In a managed session, enter r + Enter to deliberately restart the verified
installed app with a new launch identity. This does not reinstall or rebuild;
published revisions and installed app data remain available. JavaScript state is
reset, not preserved. Enter q + Enter to exit the session.
Debug hosts persist bounded JavaScript diagnostics so the terminal can recover evidence after the app exits. Diagnostics are matched to the session, launch and exact executed bundle; source mapping uses that bundle's retained map, never the current mutable build output. Missing/evicted maps and unrecognized frames fall back to raw diagnostics. Retention is bounded, not a complete crash history. An observed process exit is distinct from process state unavailable; unavailable inspection does not prove a crash. An exit without fatal JavaScript evidence is not labelled a JavaScript crash. Arbitrary native failures may have no JavaScript stack.
If a fatal packaged bootstrap repeats before corrected JavaScript can load,
fix the source, enter q, and rerun the same managed command with --rebuild:
| 1 | bun run dev --android --device emulator-5554 --rebuild |
| 2 | # Or: bun run dev --ios --device '<simulator UDID>' --rebuild |
This managed-only flag explicitly bypasses a matching native receipt, rebuilds
the packaged bootstrap from the current successful JavaScript build, and verifies
and installs the artifact without clearing app data. r remains restart-only;
it never silently rebuilds the app.
Native receipts and frozen build inputs live in ignored .solid/. Native sources,
package native code, dependency/codegen inputs, lockfiles, configuration, and toolchain
identity determine rebuilds; application TSX and generated bundles do not. Installed
apps are checked against the cached artifact, not their version or install timestamp.
Native edits require restarting dev: inputs are verified during startup and before
publishing JS, and incompatible updates are withheld. Do not use a development-host
receipt as evidence that a packaged native test contains the latest JS.
q + Enter or Ctrl-C closes the watcher, transport, log processes, and owned forwarding. Existing
simulators and app data are preserved. One managed session owns the project and port
8082 at a time. If a process is forcibly killed, inspect the PID in .solid/dev.lock
before removing a stale lock; never remove another live session's lock or forwarding.
Release continues to use packaged JS and ignores development credentials.
The lower-level workflows remain available:
| 1 | bun run dev # watch TSX; external source maps; files only |
| 2 | bun run build --sourcemap # production maps are opt-in |
| 3 | bun run test:components # Vitest: direct native TSX, no DOM |
| 4 | bun run test:watch # watch the Vitest component tests |
| 5 | bun run test:ui # Vitest's local test UI |
| 6 | bun run symbolicate dist/main.jsbundle.map 120 9 |
bun run test runs both the Node protocol/build suites and Vitest. Vitest uses the
same Oxc universal transform and Solid client conditions as the native bundle;
it does not substitute Solid's nonreactive server exports. Mock Fabric tests
cannot verify native layout, IME, accessibility services, or gesture takeover.
For short iterations, run the affected Node suite (for example,
node --test test/ui-building-blocks.test.mjs) and bun run typecheck first.
Group native cases into one runner launch; repeated simulator/test-runner startup
costs much more than the JavaScript suites. XCTest's test-without-building is
useful only when both the installed bundle and test binary already match the
source being tested. Rebuild after TSX changes; a stale bundle is not a valid
shortcut. Keep native animation and asynchronous deadlines enabled.
Source maps compose Oxc's TSX mappings through esbuild. Symbolication takes
1-based stack line/column coordinates and resolves sources relative to the map.
Optional --hermes /path/to/matching/hermesc emits .hbc and .hbc.map siblings;
use the HBC map for Hermes bytecode address at frames, not the JS map. The
current native hosts still load JavaScript. Watch mode preserves the last good
output on compile failure; native reload requires explicit --serve opt-in.
| 1 | bun run dev --serve |
| 2 | # Copy the freshly printed bearer token into TOKEN in this terminal. |
| 3 | TOKEN='<printed token>' |
| 4 | |
| 5 | # iOS Simulator: terminate an existing app before launching with the token. |
| 6 | xcrun simctl launch booted dev.solid.fabric.prototype --solid-dev-token "$TOKEN" |
| 7 | |
| 8 | # Android emulator/device: force-stop an existing app before token startup. |
| 9 | android/.toolchain/sdk/platform-tools/adb reverse tcp:8082 tcp:8082 |
| 10 | android/.toolchain/sdk/platform-tools/adb shell am start \ |
| 11 | -n dev.solid.fabric.prototype/.MainActivity --es solidDevToken "$TOKEN" |
The server binds 127.0.0.1:8082; clients use the fixed URL
http://localhost:8082. Tokens stay in launch configuration, not source files.
Restarting the server rotates its token and session: relaunch the app with the
new token. Release builds ignore this configuration and use packaged JavaScript.
Physical iOS devices and LAN endpoints are not supported.
Foreground native polling downloads immutable successful-build snapshots,
checks exact paths, byte limits and SHA-256, then publishes the cache and replaces
the runtime. Every reload resets JS state; this is not HMR. Compile errors,
failed downloads and invalid hashes retain the current application. A valid
build that throws at runtime can still break the new application: there is no
automatic runtime rollback. --hermes and --serve cannot be combined.
The previous bundle is retained for explicit recovery. On Android, send an
existing Activity --es solidDevAction revert (or retry) with the am start
command above, without the token extra. On iOS, the Debug AppDelegate exposes
devReload.revert() and devReload.retry() for native debugger use; no recovery
UI is provided. Revert stays pinned until retry. A reload timeout suspends
automatic reload to avoid overlapping runtimes; relaunch to recover. An
unexpected external iOS reload also suspends this client, so do not mix it with
Cmd-R or DevMenu reloads. Source maps are available on the server for
symbolication. Android downloads only the bundle; iOS currently verifies and
caches both artifacts, although only the bundle is needed to run JavaScript.
The setup script targets macOS ARM64, with Java 17+ already installed
(tested with Corretto 23). It downloads a checksum-verified Gradle 9.4.1 and
Android command-line tools, accepts local SDK licenses, and installs SDK 37,
build tools 37, NDK 27.1, CMake 3.22.1, and an API 36 ARM64 emulator under
android/.toolchain.
No Android Studio, Metro, RN Gradle plugin or separate Kotlin plugin is required;
AGP 9.2.1 provides Kotlin support.
| 1 | bun run android:setup |
| 2 | bun run android:emulator # keep running in a separate terminal |
| 3 | bun run android:test # builds, installs, tests; leaves the app installed |
| 4 | |
| 5 | # Open the installed demo after tests close its Activity: |
| 6 | android/.toolchain/sdk/platform-tools/adb -s emulator-5554 shell am start \ |
| 7 | -n dev.solid.fabric.prototype/.MainActivity |
android:build produces android/app/build/outputs/apk/debug/app-debug.apk.
android:test defaults to emulator-5554 (override ANDROID_SERIAL) and writes
screenshots/logs under artifacts/android. It exercises native taps, physical
keyed order, hide/show, and Activity stop/relaunch using the Application's
existing Hermes host. A second test holds RN's private startup queue, destroys
the Activity while start is pending, and checks that the retired surface stops
and clears. It fails with stop-before-start ordering and passes when teardown
awaits startup off-main. The host includes MainReactPackage for native view
managers and pins Hermes to RN's exact Maven version, 250829098.0.17.
A small appmodules library registers RN's core FBReactNativeSpec_ModuleProvider
so Java TurboModules are available without building RN native sources. Its ELF
load alignment is 16 KB. The current APK includes only arm64-v8a.
The local toolchain and build outputs require several GB, plus ~/.gradle caches.
Stop the emulator with android/.toolchain/sdk/platform-tools/adb -s emulator-5554 emu kill.
Run bash scripts/android-platform-test.sh for navigation, motion, notifications,
networking and the new UI gallery. It requires the same port-8083 fixture above
and manages its own adb reverse mapping without stopping a server it does not own.
The optional --fresh-permission flag resets notification permission flags on
the disposable prototype app to exercise real dismissal and grant prompts.
<view>, <text> and nested <span> map to View, Paragraph, Text, and
RawText Fabric descriptors. JSX uses Solid's real universal compiler.null, since omitted
Fabric props otherwise retain their previous value.Node.children is a lazy read-only ordered snapshot,
rebuilt once when read after structural edits. Use renderer insertion/removal
APIs rather than mutating it. Fabric commits still walk mounted membership and
clone/append affected native child lists; native reordering is not constant-time.The Node tests freeze published mock Fabric trees to detect accidental mutation,
and compile the actual demo using the same client package resolution as the app.
bun run build writes dist/metafile.json and rejects React/RN JS dependencies.
The Oxc + esbuild step is Babel/Metro-free; RN still brings its own native
codegen/build dependencies. Build timings printed by the script are local JS
build timings, not native-build or rendering benchmarks.
Hermes source block scoping must be enabled explicitly. RN's stock Hermes
factory leaves this compiler option off; captured for (let ...) callbacks can
all observe the final loop index. The app-owned factory in native/ preserves
RN 0.87.1 GC, microtasks, profiling and inspector behavior while enabling
ES6BlockScoping. Optional HBC builds pass the matching compiler flag. Native
runtime tests cover asynchronous loop captures. This does not enable separate
temporal-dead-zone checks or claim complete JavaScript specification conformance.
Inline text uses a legacy wire name. RN normalizes incoming Text to the
Paragraph descriptor and VirtualText to Text. The adapter therefore sends
VirtualText for <span> and nested <Text>. Sending Text creates a nested
paragraph attachment instead of an attributed run, breaking font inheritance.
Clone empty, then append. RN 0.87's bulk child-clone path can leave Yoga clean when identically styled siblings reorder. The logical order changes but their old native positions remain. This adapter follows RN's own renderer path: clone with empty children, then append in order. The native position assertion is a regression test for this; the JS mock alone cannot detect it.
Native identity has limits. Atomic same-parent keyed reorders preserve tags. Reparenting materialized nodes and reinserting nodes after a committed removal are rejected: Fabric families have immutable parents, and removal releases their event targets. Showing conditional content creates fresh native nodes. The retirement check walks the current tree per commit; this is correctness bookkeeping, not an incremental-performance claim.
Android colors and touch targets differ. Numeric color / *Color props
are converted to signed 32-bit ARGB. Android's Double.toInt() otherwise clamps
unsigned values to half-transparent white. Demo controls use box-only hit
testing because Android Paragraph does not honor pointerEvents="none" like
a View. Pressable captures the closest willing responder and cancels on native
scroll takeover, cancellation, multiple fingers, disable, and removal.
Minimal bootstrap is possible. environment.ts obtains the real
NativeMicrotasksCxx binding and registers the host's log/device-event callback
destinations. demo/index.ts implements the small RN$AppRegistry / RN$stopSurface
contract invoked by native Fabric surfaces. It does not load RN setup-env.
One pinned-version workaround. The RN 0.87.1 generated eager-module list
includes SampleTurboModule, unavailable in the prebuilt iOS framework. The
app dependency provider filters that entry. No node_modules patches are used.
| 1 | import * as haptics from '@solid-native/haptics'; |
| 2 | import * as clipboard from '@solid-native/clipboard'; |
| 3 | import * as sharing from '@solid-native/sharing'; |
| 4 | |
| 5 | await haptics.impact('light'); |
| 6 | await haptics.notification('success'); |
| 7 | await haptics.selection(); |
| 8 | |
| 9 | await clipboard.write('https://github.com/owner/repo'); |
| 10 | const text = await clipboard.read(); // string | null |
| 11 | |
| 12 | await sharing.share('Some text'); |
| 13 | await sharing.share({ kind: 'url', content: 'https://github.com/owner/repo' }); |
Named imports work too. These are private local workspace packages, not published
npm releases. Run bun install --frozen-lockfile, then rebuild the native app;
the iOS generator and Android source sets include their native modules. JavaScript
package resolution uses normal exports, not custom aliases. Native implementations
use UIKit/Android APIs directly; ExpoModulesCore and React/RN JavaScript are not added.
Installing one of these packages into an unrelated host does not auto-link it.
light, medium, heavy, soft, and rigid;
notification kinds are success, warning, and error. The promise acknowledges
the request, not completion of a physical vibration. Android approximates styles
using system feedback and requires a foreground Activity. Device settings can
suppress feedback. Simulator success does not prove physical output.write(string) replaces one text item, including empty text.
read() requests first-item text without coercing URLs/images to strings. null
means no text was exposed, not a reliable empty/denied distinction. Paste reads
may trigger OS permission UI; invoke them from user actions.
write(text, { android: { sensitive: true } }) sets a preview hint on Android;
it is not encryption or expiration and has no iOS effect.
getTypes() returns advertised text/html/image metadata, not content.
onChange(({ types }) => ...) returns an idempotent unsubscribe function and
auto-cleans up inside a Solid owner. It does not emit an initial snapshot or
guarantee background delivery; a subsequent read may see different content.{ kind: 'url', content } explicitly shares
an absolute HTTP(S) link. Empty content is rejected. Fulfillment means the native
session ended, including cancellation—not that a recipient received anything.
Concurrent calls reject E_BUSY; there is no queued sheet or timeout. Caller
component disposal does not cancel the sheet. Host/module loss rejects the session.
Optional ios.anchor uses { x, y, width, height } in window-relative logical
points, with a safe default for iPad. android.chooserTitle is presentation text,
not an email subject. Pass these inside the second options argument.Missing native modules fail explicitly. Clipboard HTML/image reads and writes,
file attachments, incoming shares, and Android-specific haptic escapes are not
exported yet. Rich content will extend write/read/share when its native
storage/permission lifecycle is implemented.
| Import | API |
|---|---|
@solid-native/storage | open(namespace) → get, set, remove for JSON preferences |
@solid-native/storage/secure | get, set, remove for Keychain/Keystore strings |
@solid-native/auth/device | capabilities, verify for local device-owner checks |
@solid-native/auth/passkeys | create, get for provider-owned WebAuthn credentials |
@solid-native/web | open, authenticate, createPkce for system browser sessions |
@solid-native/linking | external open and app-root onOpen for incoming links |
Browser sign-in belongs to web; device authentication does not substitute for
cryptographic secure-storage protection or server-side authentication. There is
no embedded WebView yet. Read the storage contract,
auth setup and limitations, and
web/linking integration before building sign-in.
Passkeys need your signed app, associated domain, and a verifying server; the
sample does not configure a production relying party. Simulator Keychain access
uses simulator-only ad-hoc entitlements generated by ios:setup, not production signing.
Run node --test test/capabilities.test.mjs for package contracts. Native cases are
SolidFabricNativeTests/NewPackageTests on iOS and
dev.solid.fabric.prototype.CapabilityModulesTest on Android. These cover native
clipboard behavior and chooser lifecycle separately from the mocked JS contracts.
The new service contracts live in test/{storage,auth,web-linking}.test.mjs;
both native hosts include StorageTests, AuthenticationTests, and WebLinkingTests.
Android's browser test uses Chrome on the disposable emulator and completes its
first-run screen without an account. These tests do not establish passkey-provider
success or physical biometric protection.
src/components/index.tsx exports typed View, Text, Span, Image, ScrollView,
Switch, ActivityIndicator, Pressable, TextInput, Modal, VirtualizedList,
SafeAreaProvider, SafeAreaView, and KeyboardAvoidingView. Nested Text becomes
an inline native text run. Styles support recursive arrays and conditional
entries; later styles override earlier ones and flat props. Colors support
hex3/4/6/8, integer rgb/rgba, transparent/black/white, and numeric ARGB—not
the complete CSS color grammar. Image sources use URI objects with explicit
layout dimensions; there is no asset registry or source-size inference.
Pressable render children and styles receive a stable reactive state object:
read state.pressed inside JSX or the style callback, rather than destructuring
it once during setup. Long press defaults to 500ms and suppresses ordinary press
when delivered. Missing native bounds suppress activation. Hover, ripple,
sound, delayed feedback, and multi-finger activation are not implemented.
TextInput uses raw native event payloads, not React synthetic events. Its ref
offers focus, blur, clear, and setSelection; selection offsets are UTF-16.
Accepted controlled edits do not issue a text-reset command. Rejected edits
restore with the latest native event count. Selection-before-change events can
run before Solid commits: reconciliation reads latest() to avoid momentarily
restoring stale text and cancelling composition. The hosted iOS native test
verifies marked Japanese composition and emoji selection through JS roundtrips.
Android direct InputConnection tests must isolate the installed keyboard;
they are not proof of full candidate-UI behavior with a real IME.
src/navigation/router.ts provides code-defined routes with typed $params, validated
search, asynchronous loaders, immutable history, and push/replace/back.
router.bindBack(addBackHandler) gives Android Back first refusal and preserves
native exit at the root. The diagnostics use /, /components, and /platform. Commands read
Solid's pending state with latest; rendering reads router.state() normally.
Loader cancellation uses a native-independent NavigationSignal, not a browser
AbortSignal. Superseded results cannot overwrite newer navigation. Configured
link prefixes restrict external URLs accepted by openLink; buildLink retains
typed route/search inputs. Versioned history export/restore validates serialized
input before replacing state. OS link delivery and persistent storage remain
application integrations, not automatic subscriptions.
src/navigation/navigation.tsx adds pathless layouts, retained StackView entries, focus
lifecycle, and lazy retained tabs with independent routers. Inactive screens
remain mounted but are hidden from layout, pointer input, and accessibility.
Use useFocusEffect for subscriptions that must stop on blur. Back pops the
active tab's stack before traversing tab history. These are Fabric view stacks,
not native navigation controllers: no interactive swipe-back, native headers,
file routing, shared-element transitions, or preload/cache policy.
NativeStack and NativeTabs provide native presentation over the same routers.
They use pinned react-native-screens 4.28.0 native components directly, without
its React wrappers or React Navigation JavaScript. iOS uses native navigation/tab
controllers; Android uses screens' native Fragment containers and system Back.
| 1 | const tabs = createTabs({ |
| 2 | initial: 'home', |
| 3 | tabs: { |
| 4 | home: { label: 'Home', create: createHomeRouter }, |
| 5 | activity: { label: 'Activity', create: createActivityRouter }, |
| 6 | }, |
| 7 | }); |
| 8 | |
| 9 | <NativeTabs |
| 10 | tabs={tabs} |
| 11 | views={{ |
| 12 | home: { |
| 13 | icon: { ios: 'house', android: 'ic_home' }, |
| 14 | render: (tab) => <NativeStack router={tab.router} views={homeViews} />, |
| 15 | }, |
| 16 | activity: { |
| 17 | icon: { ios: 'bell', android: 'ic_activity' }, |
| 18 | render: (tab) => <NativeStack router={tab.router} views={activityViews} />, |
| 19 | }, |
| 20 | }} |
| 21 | />; |
Route view options: screen => ({ title: screen.match().params.id }) computes
native headers reactively without remounting content. header: false hides the
header; backGesture: false disables iOS interactive Back. Icons name SF Symbols
on iOS and application-provided drawable resources on Android. The two platforms
keep their own appearance rather than sharing a custom-drawn tab bar.
Use one native presenter per router/tabs model. Presenters borrow those models; unmounting a presenter releases its screens and Back handlers, not the router. Native presenters register Back automatically: do not separately bind the same model. Focus follows logical selection, inherits parent tab focus, and does not change on a canceled back gesture. Native completed dismissal removes the exact screen identity, preserving newer JS pushes and their loaders. Like ordinary router Back, a completed native pop reloads the revealed route.
The initial native scope is 2–5 fixed tabs containing independent push stacks, lazy first content mount, retained state, native titles/back buttons, iOS interactive Back, and router push/replace/pop/restore. Native multi-pop menus and repeated-tab pop/scroll effects are disabled. Navigation modals, custom header JSX, large-title/search configuration, prevent-remove prompts, and arbitrary nested native navigators are not part of this API. No predictive-back animation parity is claimed. Native screen content handles its own header/tab insets; do not add window safe-area padding around the navigator. Explore the working fixture through Tools → Native navigation in the demo.
animateOpacity(node, { from, to, duration }) in src/motion/animation.ts drives a
mounted View through native timing frames, not a per-frame JavaScript timer.
Start from an event or a guarded onLayout, not the initial ref callback.
It requires collapsable={false} and keeps the view non-flattened for its
lifetime. The returned finished promise reports completion/cancellation;
cancel() restores logical opacity rather than capturing an in-flight frame.
The default reduced-motion policy follows the OS; always and never are
explicit alternatives. Missing native capability rejects rather than silently
falling back to a JS tween.
animateTransform adds timing and physical springs for translateX/Y, rotation
in radians, and scaleX/Y. Use transform2D({...}) for the fixed native transform
order; arbitrary transform arrays cannot be decomposed into this API. Opacity
remains timing-only. sequence and parallel accept lazy animation factories;
cancellation stops active children and prevents later sequence stages.
RN retains animated-property ownership after graph disconnection. This host therefore retains separate opacity/transform graphs per ever-animated live node, mirrors later logical writes through them, and drops them on node retirement. Completion writes a final style override; a subsequent style/property write reclaims logical control. A same-value Solid signal assignment is still a signal no-op, not an imperative reset. There are no layout/shared-element transitions, gesture-driven worklets, or Reanimated API.
ScrollView supports a fixed horizontal or vertical direction, native scrolling
commands through scrollRef, content-size events, paging and snapping props.
Remount the entire subtree to change direction. VirtualizedList windows known
row extents with stable keys; renderItem(item, index) receives accessors.
Offscreen row owners are disposed, so keep durable state in the data model.
Variable sizes require a deterministic itemSize function, not estimates.
Dynamic measurement, recycling, section lists and pull-to-refresh are not provided.
Modal presents a native host and requires onRequestClose. On iOS it retains
the host until native dismissal finishes; on Android hiding removes the Dialog
host. SafeAreaProvider waits for measured window insets unless given explicit
initial insets. SafeAreaView adds selected edges to numeric padding. Window
dimensions and keyboard hooks subscribe to core RN events; initial keyboard
visibility is unknown until an event arrives. KeyboardAvoidingView provides
padding-based avoidance, not keyboard-animation synchronization.
src/services/networking.ts exports fetch, Headers, Request, Response, and abort
classes over native RN Networking. Import them explicitly; installing globals
is opt-in. Requests and responses support buffered text/JSON, not streams,
binary data, multipart uploads, or browser cookie/redirect-control parity.
HTTP error statuses remain responses; transport failures reject. timeoutMs
sets a deadline and releases listeners. Android uses a JavaScript deadline plus
native abort to avoid ambiguous native timeout errors; suspended JavaScript can
delay that deadline. This is a deliberately limited fetch-shaped API, not a
complete browser Fetch implementation.
src/services/notifications.ts exposes local permission requests, scheduling/cancellation,
foreground receipt, response events, and consume-once initial responses. There
is no remote APNs/FCM registration or Expo push service. Android delays are
inexact and do not survive reboot; iOS permits at most 64 pending notifications.
Payload data must be a finite plain JSON object within 16KiB and 64 nesting
levels. Both platforms measure the same UTF-8 JSON serialized once by JavaScript,
not a platform-specific reserialization. Scheduling does not guarantee exact
delivery time.
inspectSurface(surface) from src/renderer/inspector.ts returns a detached JSON-safe
logical tree and last-submitted shadow-tree membership. It includes only
allowlisted scalar diagnostic props; text input values, text, URLs, selection,
labels, callbacks, and command arguments are omitted. Test IDs are opt-in via
{ includeTestIDs: true } because they can contain application identifiers.
This is explicit programmatic inspection—not actual native frames/visibility,
a mounting fence, source-linked inspection, or React DevTools integration.
flush() settles Solid, not native mounting/layout. Refs expose logical nodes,
not UIKit views. Async/loading behavior needs dedicated native tests before
claiming transition support. Multiple surfaces are not verified.RN$stopSurface separately. Neither test establishes
garbage-collection behavior.Keep interaction tests on real Fabric, not just an increasingly elaborate mock.
Run bun run benchmark for a local Node/V8 architecture baseline. The harness
compiles real Solid client TSX through the universal compiler and uses an immutable
mock Fabric host. It measures mount/dispose, sparse updates, 100 coalesced writes,
and keyed reversals at 100, 1,000, and 10,000 mounted rows. Each workload has 10
warmups and 50 measured samples, reporting median/p95 and Fabric operation counts.
Correctness assertions run outside timing intervals; compilation is excluded.
It also runs 1,000 mount/dispose stress cycles after 100 warmups, checking row-owner
cleanup, listener removal, stale events, double disposal, and queued commits after
disposal. Post-GC V8 heap samples are diagnostic, not a leak-free guarantee. JSON
results with machine/runtime metadata are saved under artifacts/benchmarks/.
Run several fresh processes on an otherwise idle machine before comparing changes.
These timings include mock allocation/freezing and exclude native JSI, Yoga, platform mounting, compositor frames, and Hermes. Rows are deliberately not virtualized to expose scaling. They are not FPS, mobile startup measurements, or evidence of a performance advantage over React Native. Timing thresholds are not CI gates; failed correctness assertions do fail the command.
For real Hermes/Fabric measurements, boot an iOS simulator and run:
| 1 | bun run benchmark:ios <simulator-UDID> |
This requires the existing iOS setup. It builds Release with a separate installed
identity (dev.solid.fabric.benchmark), verifies the packaged bundle, runs a
dedicated fixture, and saves JSON under artifacts/benchmarks/ios-*.json. It does
not change Studio's bundle source or stored data. The runner stops its benchmark
app afterward and leaves the simulator booted. Do not run concurrent iOS builds
against the same ios/build-release directory.
The fixture uses 100/1,000/3,000/10,000 non-flattened native sibling views. It measures single-row width changes and keyed reversals (5 warmups, 20 samples), then checks 100 render/dispose cycles on the existing native root. Solid flush, submission including flush, and native layout-event arrival are separate timings. Only two rows subscribe to layout events; receipt timestamps exclude polling delay. Layout events validate widths and positions but are not UIKit mounting or compositor fences. Large reversals can saturate native mounting and take several minutes. Simulator timings use the Mac CPU, not an iPhone CPU; no FPS, physical device, native leak, or native-host restart claim follows from these results.
The native report also includes virtualized measurements for 1K/10K/100K
datasets: nonanimated jump submission, dataset-clone/layout-rebuild submission,
logical mounted-node counts, and row-owner cleanup. These loops await JS
microtasks, not native scroll completion, and use the initial 500-point viewport.
They establish bounded renderer work, not physical view counts, real gesture
scrolling, or frame smoothness.
Run node scripts/benchmark-sparse.mjs to compare one, ten, and one hundred
changed leaves across wide and nested trees of roughly 10K nodes in Node/mock
Fabric. The report includes clone/append counts, median/p95 submission timings,
and checks the submitted leaf values. It does not isolate phase durations or
measure nested-tree performance on Hermes.