README.md

Solid → Fabric prototype

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.

text
1TSX → official Solid Oxc compiler → esbuild → embedded JS bundle
2 ↓
3Solid universal renderer → logical nodes → Fabric JSI create/clone/completeRoot
4 ↓
5 Yoga + native text → UIKit / Android views

Run

Create a separate app

sh
1bun 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.

Run Studio

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.

sh
1bun install --frozen-lockfile
2bun run check
3bun run ios:setup
4bun run ios:build
5
6# Boot an iPhone simulator in Xcode/Simulator, then:
7xcrun simctl install booted ios/build/Build/Products/Debug-iphonesimulator/SolidFabric.app
8xcrun 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.

Repository layout and tooling

  • The app opens Studio, a local notebook with native Notes, Saved, and Settings tabs. Read, compose, edit, bookmark, and share notes; notes and the haptics preference persist on the device. iOS uses grouped surfaces and blue accents; Android uses purple tonal surfaces and system tabs. Tools on Notes, or Settings → Developer tools, opens the original diagnostics; Done returns to Studio. No account or cloud sync is used.
  • 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.

sh
1bun run format # Oxfmt: format JS/TS, configuration, and documentation
2bun run format:check # check without writing
3bun run lint # Oxlint: correctness + targeted strict rules, no warnings allowed
4bun run lint:fix # apply safe lint fixes
5bun 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.

JavaScript development and debugging

sh
1bun run doctor # read-only native toolchain checks
2bun run dev --ios # managed Debug simulator session
3bun run dev --android # managed Debug device/emulator session
4bun run dev --ios --device '<simulator UDID>'
5bun 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:

sh
1bun 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:

sh
1bun run dev # watch TSX; external source maps; files only
2bun run build --sourcemap # production maps are opt-in
3bun run test:components # Vitest: direct native TSX, no DOM
4bun run test:watch # watch the Vitest component tests
5bun run test:ui # Vitest's local test UI
6bun 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.

Opt-in full runtime reload (Debug only)

sh
1bun run dev --serve
2# Copy the freshly printed bearer token into TOKEN in this terminal.
3TOKEN='<printed token>'
4
5# iOS Simulator: terminate an existing app before launching with the token.
6xcrun simctl launch booted dev.solid.fabric.prototype --solid-dev-token "$TOKEN"
7
8# Android emulator/device: force-stop an existing app before token startup.
9android/.toolchain/sdk/platform-tools/adb reverse tcp:8082 tcp:8082
10android/.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.

Android

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.

sh
1bun run android:setup
2bun run android:emulator # keep running in a separate terminal
3bun run android:test # builds, installs, tests; leaves the app installed
4
5# Open the installed demo after tests close its Activity:
6android/.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.

What this proves

  • <view>, <text> and nested <span> map to View, Paragraph, Text, and RawText Fabric descriptors. JSX uses Solid's real universal compiler.
  • Signal updates dirty the logical node and its ancestors. One queued microtask publishes a Fabric revision; clean branches retain their shadow references.
  • Keyed moves retain node tags; removing a prop sends null, since omitted Fabric props otherwise retain their previous value.
  • Logical sibling links avoid array searches/shifts during keyed reconciliation. Linking and sibling traversal are constant-time in sibling count; dirty marking still walks ancestors. 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.
  • Native target events invoke Solid handlers through durable instance handles. Controls expose native accessibility button roles and activation callbacks.
  • Surface stop disposes the Solid owner, commits an empty root, and prevents queued commits and detached-target events from reaching a disposed surface. Native teardown is attempted even when user cleanup throws. User cleanups must nevertheless be nonthrowing: Solid RC8 can abort remaining owner cleanup after an exception. Failures inside Universal's initial flush can also have their error replaced by a cleanup error; direct component setup errors and our initial Fabric commit errors are preserved.

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.

Important native findings

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.

Device packages

ts
1import * as haptics from '@solid-native/haptics';
2import * as clipboard from '@solid-native/clipboard';
3import * as sharing from '@solid-native/sharing';
4
5await haptics.impact('light');
6await haptics.notification('success');
7await haptics.selection();
8
9await clipboard.write('https://github.com/owner/repo');
10const text = await clipboard.read(); // string | null
11
12await sharing.share('Some text');
13await 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.

  • Haptics: impact styles are 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.
  • Clipboard: 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.
  • Sharing: strings are always text. { 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.

Storage, authentication, and web

ImportAPI
@solid-native/storageopen(namespace) → get, set, remove for JSON preferences
@solid-native/storage/secureget, set, remove for Keychain/Keystore strings
@solid-native/auth/devicecapabilities, verify for local device-owner checks
@solid-native/auth/passkeyscreate, get for provider-owned WebAuthn credentials
@solid-native/webopen, authenticate, createPkce for system browser sessions
@solid-native/linkingexternal 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.

Component contracts and current limits

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.

tsx
1const 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.

  • The wider Expo service catalog, asset pipeline, generic permission broker, remote push, portals/cross-surface moves, state-preserving HMR, and the full RN error-reporting environment are not integrated.
  • 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.
  • No performance or leak claim yet. The VM tests exercise bootstrap restart in one JS runtime; Android tests retain the Application's host across Activity replacement. iOS tests still only cover process relaunch, not native surface restart in one runtime. Native stop-task completion is not a JS-cleanup barrier: RN schedules RN$stopSurface separately. Neither test establishes garbage-collection behavior.

Keep interaction tests on real Fabric, not just an increasingly elaborate mock.

Renderer benchmarks and stress tests

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:

sh
1bun 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.

Pinned source contracts