@symbiote-native/solid 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,75 +1,101 @@
1
1
  # @symbiote-native/solid
2
2
 
3
- SolidJS adapter for SymbioteNative. Compiled Solid JSX drives real native iOS/Android views through
4
- the same `@symbiote-native/engine` every other adapter uses — React's renderer is never in the path.
5
-
6
- **Status: L4 done (parity phase complete).** The renderer seam, `mount`/`unmount`, the
7
- primitives, the stateful touch components, the Touchable family, Button / ImageBackground /
8
- InputAccessoryView, StatusBar and the list family all ship at full parity with the React
9
- reference — off `@symbiote-native/components`, via `descriptorToSolid` where the component has a
10
- shared render fn. The engine-owned runtime modules are re-exported from the barrel. `Portal`
11
- (same-surface content relocation, this adapter's twin of React's `createPortal`) and
12
- `createTunnel` (cross-surface) both ship, built over the universal renderer rather than
13
- `solid-js/web`'s DOM-bound `Portal`/`Dynamic`. `Dynamic` itself stays absent by design — nothing
14
- here needs it, and no other adapter ships a twin for parity to require one. See
15
- `symbiote-new-adapter` §7 for what each layer means.
3
+ The **SolidJS adapter** for [SymbioteNative](../../README.md) — compiled Solid JSX drives real
4
+ native iOS/Android views through the same `@symbiote-native/engine` every other adapter uses,
5
+ with React's renderer never in the path. It targets `createRenderer` from `solid-js/universal`,
6
+ Solid's own custom-renderer API — no shimming of private internals, no compiler running beside
7
+ Metro, no custom Babel transformer path.
8
+
9
+ <div align="center">
10
+
11
+ ![Solid driving real native iOS views through SymbioteNative](../../assets/solid-demo.gif)
12
+
13
+ </div>
14
+
15
+ > New to SymbioteNative? The [root README](../../README.md) has the architecture.
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @symbiote-native/solid react-native solid-js
23
+ ```
24
+
25
+ `react-native` and `solid-js` stay your app's own top-level dependencies — this package only
26
+ replaces the JS renderer that drives them. `npx @symbiote-native/cli new --framework solid` wires
27
+ the Babel preset, Metro config, and `jsxImportSource` below for a new app; wiring them into an
28
+ existing one still follows [`examples/solid`](../../examples/solid) rather than a generator.
16
29
 
17
- Solid's own control-flow `Switch`/`Match` are NOT re-exported from this package: `Switch` collides
18
- with RN's Switch component, which every adapter must export under that name. Import the control-flow
19
- pair from `solid-js` directly.
30
+ Targets **Solid 1.9**, deliberately not 2.0 (RC as of 2026-08) — 2.0 moves the package to
31
+ `@solidjs/universal`, changes `RendererOptions`, and changes the compiled-output shape. Moving to
32
+ it is a rewrite of `src/renderer.ts`, not a range bump.
20
33
 
21
- The adapter is a regular publishable package (`@symbiote-native/solid` on npm, alongside React,
22
- Vue, Svelte and Angular) — it carries no `"private"` flag, so `changeset publish` and the
23
- pkg.pr.new canary cover it the same as every other adapter.
34
+ ---
24
35
 
25
- ## Why Solid needs no DOM shim and no Metro transformer
36
+ ## Use it
26
37
 
27
- Solid ships an official custom-renderer API, `createRenderer` from `solid-js/universal`. That is
28
- exactly the framework-agnostic seam this project looks for in every framework, so the adapter is
29
- `RendererOptions`' ten methods mapped onto the engine's mutation API — no shimming of private
30
- internals (the Svelte adapter's approach, forced by an unmerged upstream PR), no compiler running
31
- beside Metro (Angular's AOT), and no custom `babelTransformerPath` at all.
38
+ The app is ordinary Solid — the native primitives are plain lowercase intrinsic tags, no import
39
+ needed:
32
40
 
33
- Targets **Solid 1.9**, deliberately not 2.0 (RC as of 2026-08): 2.0 moved the package to
34
- `@solidjs/universal`, changed `RendererOptions` (added `createSentinel`/`cleanupNodes`, gave
35
- `createElement` a staticProps argument) and changed the compiled-output shape (`use()` →
36
- `applyRef`/`ref`, `effect(fn, init)` → `effect(fn, effectFn)`). Moving to it is a rewrite of
37
- `src/renderer.ts`, not a range bump.
41
+ ```jsx
42
+ import { createSignal } from 'solid-js';
38
43
 
39
- ## Wiring an app
44
+ export default function App() {
45
+ const [count, setCount] = createSignal(0);
46
+ return (
47
+ <view style={{ padding: 24 }}>
48
+ <text>Taps: {count()}</text>
49
+ <pressable onPress={() => setCount(c => c + 1)}>
50
+ <text>Tap me</text>
51
+ </pressable>
52
+ </view>
53
+ );
54
+ }
55
+ ```
40
56
 
41
- `babel.config.js` — the preset ships preconfigured, so the app can't get `moduleName` or `generate`
42
- wrong:
57
+ The zero-config entry wires the RN-backed host seams and registers the app in one call — this is
58
+ what [`examples/solid`](../../examples/solid) actually uses:
43
59
 
44
60
  ```js
45
- module.exports = {
46
- presets: [
47
- 'module:@react-native/babel-preset',
48
- // LAST on purpose: Babel applies presets in reverse order, so this runs FIRST and claims the
49
- // JSX before the React Native preset's own React-JSX transform can.
50
- '@symbiote-native/solid/babel-preset',
51
- ],
52
- };
61
+ // index.js
62
+ import { createApp } from '@symbiote-native/solid/bootstrap';
63
+ import App from './App';
64
+ import { name as appName } from './app.json';
65
+
66
+ createApp(App).mount(appName);
53
67
  ```
54
68
 
55
- `metro.config.js` needs `unstable_conditionNames: ['browser']` — `solid-js`'s export map has a `node`
56
- branch pointing at its SSR build, and resolving that into a native bundle is the same failure mode
57
- the Svelte example hit (`lifecycle_function_unavailable` on the first mount).
69
+ For anything the defaults don't cover, drive the lower-level seam directly — the same
70
+ `registerRunnable` seam every other adapter uses:
71
+
72
+ ```js
73
+ // index.js
74
+ import { AppRegistry } from 'react-native';
75
+ import { mount } from '@symbiote-native/solid';
76
+ import App from './App';
77
+ import { name as appName } from './app.json';
78
+
79
+ AppRegistry.registerRunnable(appName, ({ rootTag }) => {
80
+ mount(rootTag, App);
81
+ });
82
+ ```
58
83
 
59
- Solid compiles a component to `function App(props)` — a function with an uppercase name, which is
60
- exactly what react-refresh's `isLikelyComponentType` heuristic looks for. It then tries to patch a
61
- React Fiber tree that does not exist in this path, and the update is swallowed with no error. Force a
62
- full reload instead: `unstable_forceFullRefreshPatterns: [/\.tsx$/]`.
84
+ `babel.config.js` needs the adapter's preset LAST — Babel applies presets in reverse order, so
85
+ listing it last runs it first, claiming the JSX before RN's own React-JSX transform can:
86
+
87
+ ```js
88
+ module.exports = {
89
+ presets: ['module:@react-native/babel-preset', '@symbiote-native/solid/babel-preset'],
90
+ };
91
+ ```
63
92
 
64
93
  ## Primitives are plain intrinsic tags — `<view>`/`<text>`/`<pressable>`, no component involved
65
94
 
66
95
  An app writes the lowercase tags directly (`view`, `text`, `pressable`, …) — `babel-preset-solid`'s
67
96
  own `isComponent` check already treats a lowercase name as an element, so JSX compiles straight to
68
97
  `createElement`/`setProp` calls against `symbiote-view` etc., no import and nothing to opt into.
69
- This retires the compile-time lowering transform that used to rewrite a `<View>`/`<Text>`/
70
- `<Pressable>` COMPONENT call site into the same intrinsics (`babel-lower-host-primitives.cjs`,
71
- deleted 2026-09-08) — there is no component call site left to rewrite. See root `CLAUDE.md`,
72
- "Where we stand against stock React Native", for the measured device numbers.
98
+ Nothing rewrites the source on the way there, so this adapter carries no Babel plugin of its own.
73
99
 
74
100
  ## `./renderer` is a compiler target, not a convenience export
75
101
 
@@ -95,46 +121,94 @@ cite the runtime line that forces them.
95
121
  through those lookups, so hiding a node it inserted itself desyncs its bookkeeping from the tree.
96
122
  Anchors are invisible to Fabric, not to traversal.
97
123
 
98
- ## Typing — the app points `jsxImportSource` here, and that is the whole setup
124
+ `tsconfig.json` points `jsxImportSource` at this package — the whole typing setup, no
125
+ `solid-env.d.ts`, no per-file pragma:
99
126
 
100
127
  ```jsonc
101
- // tsconfig.json
102
- "jsx": "preserve",
103
- "jsxImportSource": "@symbiote-native/solid"
128
+ { "jsx": "preserve", "jsxImportSource": "@symbiote-native/solid" }
104
129
  ```
105
130
 
106
- That is all an app needs. No `/// <reference types>` line, no `solid-env.d.ts`, no per-file pragma:
107
- TypeScript resolves the entire JSX namespace from this package's `./jsx-runtime` entry, which is
108
- type-only (the emitted JS is `export {};` and nothing ever imports it — JSX becomes calls on
109
- `./renderer` through babel-preset-solid's `moduleName`, a separate mechanism).
131
+ Solid's own control-flow `Switch`/`Match` are **not** re-exported from this package — `Switch`
132
+ collides with RN's `Switch` component, which every adapter must export under that name. Import
133
+ the control-flow pair from `solid-js` directly.
134
+
135
+ ---
136
+
137
+ ## Parity — and the one gap
138
+
139
+ Solid reaches the same primitives, runtime modules, `Animated` on both drivers, gestures,
140
+ accessibility, and the `VirtualizedList` family as React, Vue, Svelte, and Angular, verified
141
+ on-device on iOS and Android — the root README's ["How Fast, Against Stock React
142
+ Native"](../../README.md#how-fast-against-stock-react-native) section has the measured numbers.
143
+ That parity is **structural, not hand-copied**: the component logic (state machines +
144
+ render functions) is written **once** in `@symbiote-native/components`, and Solid supplies only
145
+ its lifecycle via `descriptorToSolid`. `Portal` (same-surface content relocation) and
146
+ `createTunnel` (cross-surface) both ship, built over the universal renderer rather than
147
+ `solid-js/web`'s DOM-bound equivalents; `Dynamic` stays absent by design — nothing here needs it.
148
+
149
+ The one deliberate gap, same as every other non-React adapter — **third-party React component
150
+ packages** (`@react-native-community/slider` used directly) run only under the React adapter:
151
+ their body calls React hooks off the React dispatcher, which is null under Solid.
152
+ `@symbiote-native/slider` (this repo's own wrapper) _does_ ship a real Solid build through the
153
+ same `createNode`-by-ViewConfig path Solid uses for its own primitives.
154
+
155
+ ---
156
+
157
+ ## A Solid-specific gotcha — Fast Refresh, not Metro's export conditions
158
+
159
+ A compiled Solid component is a function with an uppercase name (`function App(props)`) —
160
+ exactly what react-refresh's `isLikelyComponentType` heuristic accepts as a React component.
161
+ Metro's HMR then tries to patch a React Fiber tree that doesn't exist here (no `react-reconciler`
162
+ in this path), and the update is silently swallowed: no error, no visible change.
163
+ `metro.config.js` needs `unstable_forceFullRefreshPatterns: [/\.tsx$/]` to force every `.tsx`
164
+ edit to a full reload instead.
110
165
 
111
- Two things follow that the earlier `declare module 'solid-js'` shim could not give:
166
+ `solid-js`'s export map does have a `node` branch pointing at its SSR build — the same shape that
167
+ broke the Svelte canary — but Metro's own default condition set (`['react-native']`, contributed
168
+ by `@react-native/metro-config`) never includes `node`, so the SSR branch is unreachable and the
169
+ client build resolves correctly with no extra `unstable_conditionNames` config. Adding one would
170
+ not fix anything that's broken and would actively drop `'react-native'` from the condition set
171
+ (`mergeConfig` replaces the array rather than extending it) — leave it alone.
112
172
 
113
- - **A web tag is a compile error.** `<div>` reports `TS2339: Property 'div' does not exist on type
114
- 'JSX.IntrinsicElements'` instead of building fine and failing on device. Augmenting solid-js's
115
- namespace merges into its HTML/SVG list, so `<div>` stayed legal there. The Svelte adapter needed
116
- a whole preprocessor for the same guarantee.
117
- - **JSX children are type-checked at all.** solid-js's `JSX.Element` names the DOM's `Node`, which
118
- does not resolve without a DOM lib — and `skipLibCheck` swallows that error rather than reporting
119
- it, leaving `Element` as `any` and every JSX child position unchecked. The visible symptom was
120
- small (a render-prop parameter needed an explicit annotation); the cause was not.
173
+ ---
121
174
 
122
- The risk recorded when this was deferred — that our `Element` must line up with what solid-js's own
123
- `<Show>`/`<For>` return — resolves for the same reason: solid's is `any` in the DOM-less programs we
124
- target, so it is assignable to anything of ours. `src/jsx-runtime.test.tsx` pins the runtime half;
125
- the type half rides on `examples/solid`'s typecheck. Full rationale, the measurements behind each
126
- claim, and the one config that would break it: `.claude/rules/solid-jsx-namespace.md`.
175
+ ## Run it
176
+
177
+ [`examples/solid`](../../examples/solid) is a stock React Native 0.86 app — the steps are
178
+ identical to the [React adapter](../react/README.md#run-it), just swap the directory:
179
+
180
+ ```bash
181
+ cd examples/solid
182
+ npm install
183
+ bundle install # first time only — installs CocoaPods itself
184
+ bundle exec pod install # fetch native pods
185
+
186
+ # terminal 1 — Metro (DEBUG=1 turns on diagnostic logs)
187
+ DEBUG=1 npm start --reset-cache
188
+
189
+ # terminal 2 — build + launch
190
+ npm run ios # iOS simulator
191
+ npm run android # Android emulator
192
+ ```
193
+
194
+ ---
195
+
196
+ ## Test it
197
+
198
+ ```bash
199
+ pnpm test # vitest, from the workspace root — headless, fake Fabric slot
200
+ ```
127
201
 
128
- ## Single root per process
202
+ The canary's own real-device verification is the perf benchmark run (see [Run
203
+ it](#run-it) plus the root README's device numbers), not an automated on-device suite —
204
+ `examples/solid` is the one canary without a Detox `e2e/` setup yet, unlike React/Vue/Svelte/
205
+ Angular. Why headless testing comes for free otherwise: see
206
+ [Testing](../../README.md#testing).
129
207
 
130
- Compiled JSX calls module-level functions with no surface argument, so there is nowhere to thread a
131
- per-surface renderer through — the active surface is module state, set by `mount`. The Svelte adapter
132
- reached the same single-root conclusion for its own reasons.
208
+ ---
133
209
 
134
210
  ## Reference
135
211
 
136
- - The canary app: `examples/solid` (its README covers running it and the checks it carries).
137
- - The engine API this targets: the `symbiote-engine-core` skill, `core/engine/src/node.ts`.
138
- - Building/porting an adapter: the `symbiote-new-adapter` skill.
212
+ - The canary app: [`examples/solid`](../../examples/solid).
139
213
  - The nodeOps table this mirrors: `adapters/vue/src/renderer/index.ts`.
140
214
  - The same seam on an ANSI target: `wolf-tui/packages/solid/src/renderer/node-ops.ts`.
@@ -113,6 +113,6 @@ export function FlatList(props) {
113
113
  // rebuilds (RN documents changing it live as unsupported — "change the key prop" — which is the
114
114
  // same fresh start) while every other prop change re-props the live list.
115
115
  return (<Show when={isMultiColumn()} fallback={<VirtualizedList {...passthrough} data={props.data} getItem={(_source, index) => props.data[index]} getItemCount={() => props.data.length} renderItem={props.renderItem} keyExtractor={props.keyExtractor} ItemSeparatorComponent={props.ItemSeparatorComponent} onViewableItemsChanged={props.onViewableItemsChanged} viewabilityConfigCallbackPairs={props.viewabilityConfigCallbackPairs}/>}>
116
- <VirtualizedList {...passthrough} data={rows()} getItem={(_source, index) => rows()[index]} getItemCount={() => rows().length} renderItem={renderRow} keyExtractor={rowKeyExtractor} ItemSeparatorComponent={rowSeparatorComponent()} onViewableItemsChanged={rowViewability()} viewabilityConfigCallbackPairs={rowViewabilityPairs()}/>
116
+ <VirtualizedList {...passthrough} data={rows()} getItem={(_source, index) => rows()[index]} getItemCount={() => rows().length} renderItem={renderRow} keyExtractor={(row) => rowKeyExtractor(row, props.keyExtractor)} ItemSeparatorComponent={rowSeparatorComponent()} onViewableItemsChanged={rowViewability()} viewabilityConfigCallbackPairs={rowViewabilityPairs()}/>
117
117
  </Show>);
118
118
  }
@@ -23,7 +23,7 @@
23
23
  // keyboard event without re-subscribing (React re-runs its useEffect for that; here the closure
24
24
  // already sees the live value).
25
25
  import { createMemo, createSignal, onCleanup, splitProps } from 'solid-js';
26
- import { computeInset, keyboardAvoidingEventNamesFor, readKeyboardFrame, readLayoutFrame, readPrefersCrossFadeTransitions, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
26
+ import { computeInset, configureKeyboardAvoidingAnimation, keyboardAvoidingEventNamesFor, readKeyboardAnimationTiming, readKeyboardFrame, readLayoutFrame, readPrefersCrossFadeTransitions, resolveKeyboardAvoidingLayout, DEFAULT_VERTICAL_OFFSET, } from '@symbiote-native/components';
27
27
  import { Keyboard, Platform, dlog, } from '@symbiote-native/engine';
28
28
  import { withStableKeys } from '../utils/stable-keys.js';
29
29
  // Read by KeyboardAvoidingView itself; everything else (the aria/role aliases, accessibility*,
@@ -62,6 +62,7 @@ export function KeyboardAvoidingView(props) {
62
62
  const onShow = (payload) => {
63
63
  const keyboard = readKeyboardFrame(payload);
64
64
  const offset = local.keyboardVerticalOffset ?? DEFAULT_VERTICAL_OFFSET;
65
+ const previousInset = inset();
65
66
  const next = computeInset(frame, keyboard, offset, {
66
67
  behavior: local.behavior,
67
68
  // The inset CURRENTLY applied (RN's this.state.bottom). Read from the signal at event time so
@@ -69,9 +70,12 @@ export function KeyboardAvoidingView(props) {
69
70
  // frame shorter by exactly that much and core adds it back to cancel the shrink. Without it
70
71
  // each further keyboard event computes a smaller overlap and the view walks back down under
71
72
  // the keyboard. Core gates the correction on 'height'; the other modes ignore the value.
72
- previousInset: inset(),
73
+ previousInset,
73
74
  prefersCrossFadeTransitions,
74
75
  });
76
+ // RN's `_updateBottomIfNecessary` skips the animation when the inset did not change.
77
+ if (next !== previousInset)
78
+ configureKeyboardAvoidingAnimation(readKeyboardAnimationTiming(payload), local.enabled !== false);
75
79
  dlog(`KeyboardAvoidingView show -> inset ${next}`);
76
80
  setInset(next);
77
81
  };
@@ -15,7 +15,9 @@ export interface IPressableProps extends IAccessibilityProps, IAriaProps {
15
15
  disabled?: boolean;
16
16
  focusable?: boolean;
17
17
  cancelable?: boolean;
18
+ blockNativeResponder?: boolean;
18
19
  hitSlop?: IRectOffset;
20
+ testOnly_pressed?: boolean;
19
21
  pressRetentionOffset?: IRectOffset;
20
22
  unstable_pressDelay?: number;
21
23
  minPressDuration?: number;
@@ -5,4 +5,5 @@ export interface ITouchableNativeFeedbackProps extends Omit<IPressableProps, 'st
5
5
  background?: INativeFeedbackBackground;
6
6
  useForeground?: boolean;
7
7
  children?: JSX.Element;
8
+ touchSoundDisabled?: boolean;
8
9
  }
@@ -13,6 +13,8 @@ export interface ITouchableOpacityProps extends ITouchableBaseProps {
13
13
  export interface ITouchableHighlightProps extends ITouchableBaseProps {
14
14
  activeOpacity?: number;
15
15
  underlayColor?: string;
16
+ touchSoundDisabled?: boolean;
17
+ testOnly_pressed?: boolean;
16
18
  onShowUnderlay?: () => void;
17
19
  onHideUnderlay?: () => void;
18
20
  }
@@ -3,4 +3,5 @@ import type { JSX } from '../../jsx-runtime';
3
3
  import type { IPressableProps } from '../pressable-props';
4
4
  export interface ITouchableWithoutFeedbackProps extends Omit<IPressableProps, 'style' | 'children'>, IPressTimingProps {
5
5
  children?: JSX.Element;
6
+ touchSoundDisabled?: boolean;
6
7
  }
@@ -54,7 +54,7 @@
54
54
  // the flip is an explicit rebuild boundary (trap 5), with the build untracked so every other prop
55
55
  // re-props the SAME nodes through `spread`.
56
56
  import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, splitProps, untrack, } from 'solid-js';
57
- import { DEFAULT_END_REACHED_THRESHOLD, DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_START_REACHED_THRESHOLD, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, NO_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildScrollViewHandle, buildViewabilityPairs, nextStickyHeaderY, isSeparatorGapInRange, listEffectSignature, attachStickyScroll, forwardScrollEvent, resolveAccessibilityProps, resolveScrollForwarding, selectScrollIntrinsics, createInitialListState, readLayoutLength, readLayoutOffset, readLayoutNumber, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
57
+ import { DEFAULT_INITIAL_NUM_TO_RENDER, DEFAULT_MAX_TO_RENDER_PER_BATCH, DEFAULT_UPDATE_CELLS_BATCHING_PERIOD, DEFAULT_WINDOW_SIZE, EMPTY_OFFSET, FIRST_INDEX, NO_INDEX, INVERTED_X_STYLE, INVERTED_Y_STYLE, buildListPlan, buildScrollViewHandle, buildViewabilityPairs, nextStickyHeaderY, isSeparatorGapInRange, listEffectSignature, attachStickyScroll, forwardScrollEvent, resolveAccessibilityProps, resolveScrollForwarding, selectScrollIntrinsics, createInitialListState, readLayoutLength, readLayoutOffset, readLayoutNumber, readScrollOffset, reduceList, resolveItemKey, } from '@symbiote-native/components';
58
58
  import { AnimatedValue, dlog, event as animatedEvent, getNativeTag, isNativeAnimatedAvailable, isSymbioteNode, whenCommitted, resolveClassName, } from '@symbiote-native/engine';
59
59
  import { createElement, insert, insertNode, spread } from '../../renderer.js';
60
60
  import { withStableKeys } from '../../utils/stable-keys.js';
@@ -190,8 +190,8 @@ export function createVirtualizedList() {
190
190
  maxToRenderPerBatch: props.maxToRenderPerBatch ?? DEFAULT_MAX_TO_RENDER_PER_BATCH,
191
191
  updateCellsBatchingPeriod: props.updateCellsBatchingPeriod ??
192
192
  DEFAULT_UPDATE_CELLS_BATCHING_PERIOD,
193
- onEndReachedThreshold: props.onEndReachedThreshold ?? DEFAULT_END_REACHED_THRESHOLD,
194
- onStartReachedThreshold: props.onStartReachedThreshold ?? DEFAULT_START_REACHED_THRESHOLD,
193
+ onEndReachedThreshold: props.onEndReachedThreshold,
194
+ onStartReachedThreshold: props.onStartReachedThreshold,
195
195
  maintainVisibleContentPosition: props.maintainVisibleContentPosition,
196
196
  initialScrollIndex: props.initialScrollIndex,
197
197
  onEndReachedActive: props.onEndReached !== undefined,
@@ -242,8 +242,12 @@ export function createVirtualizedList() {
242
242
  // lastViewable is folded back only when the fire actually LANDS, so a debounce superseded
243
243
  // mid-flight still diffs against the last committed set.
244
244
  const fire = () => {
245
- for (const pair of pairs)
246
- pair.onViewableItemsChanged(info);
245
+ for (const pair of pairs) {
246
+ pair.onViewableItemsChanged({
247
+ ...info,
248
+ viewabilityConfig: pair.viewabilityConfig,
249
+ });
250
+ }
247
251
  dispatch({ kind: 'viewable-fired', map });
248
252
  };
249
253
  if (viewableTimer !== null) {
@@ -35,7 +35,7 @@
35
35
  // structurally the same silent no-paint React documents for its own resetAfterCommit. Cross-
36
36
  // surface content sharing is a different mechanism entirely: `createTunnel` (../create-tunnel).
37
37
  import { createMemo, createRenderEffect, onCleanup } from 'solid-js';
38
- import { createAnchor, dlog, isSymbioteNode, SymbioteSurface, } from '@symbiote-native/engine';
38
+ import { componentOf, createAnchor, dlog, isSymbioteNode, SymbioteSurface, } from '@symbiote-native/engine';
39
39
  import { insert, insertNode, removeNode } from '../renderer.js';
40
40
  // The Solid flavour of React's isSymbioteNode guard, and it needs its own wording: React tells the
41
41
  // caller to check for a forgotten `.current`, which does not exist here. A Solid `ref` is a plain
@@ -68,7 +68,7 @@ export function Portal(props) {
68
68
  // deferred createEffect because a DOM append IS the paint.
69
69
  createRenderEffect(() => {
70
70
  const to = target();
71
- dlog(`solid portal -> ${to instanceof SymbioteSurface ? 'surface' : to.component}`);
71
+ dlog(`solid portal -> ${to instanceof SymbioteSurface ? 'surface' : componentOf(to)}`);
72
72
  insertNode(to, host);
73
73
  // Runs both on a `mount` change (before the effect re-attaches elsewhere) and on the Portal's
74
74
  // own disposal — the content leaves the target either way.
@@ -9,7 +9,7 @@
9
9
  // The node -> tag resolution itself belongs to the engine (getNativeTag, keyed on the raw node in the
10
10
  // commit mirror); this only unwraps the Solid-shaped inputs onto it. An uncommitted or unknown input
11
11
  // has no tag yet and surfaces as null.
12
- import { getNativeTag, isSymbioteNode, dlog } from '@symbiote-native/engine';
12
+ import { componentOf, getNativeTag, isSymbioteNode, dlog, } from '@symbiote-native/engine';
13
13
  // Solid's counterpart to Vue's isRef branch. Solid has no ref wrapper — `ref={el}` writes the node
14
14
  // straight into a variable — but holding that node in a signal is idiomatic, and a signal reads as a
15
15
  // zero-argument accessor. Calling it is the only way to unwrap one (there is no isSignal predicate:
@@ -28,7 +28,7 @@ export function findNodeHandle(componentOrHandle) {
28
28
  return candidate;
29
29
  if (isSymbioteNode(candidate)) {
30
30
  const tag = getNativeTag(candidate) ?? null;
31
- dlog(`findNodeHandle: component=${candidate.component} tag=${tag}`);
31
+ dlog(`findNodeHandle: component=${componentOf(candidate)} tag=${tag}`);
32
32
  return tag;
33
33
  }
34
34
  dlog('findNodeHandle: input did not resolve to a symbiote host node');
@@ -8,25 +8,7 @@ export declare const Animated: {
8
8
  Value: typeof AnimatedValue;
9
9
  ValueXY: typeof AnimatedValueXY;
10
10
  Color: typeof AnimatedColor;
11
- Easing: {
12
- step0(n: number): number;
13
- step1(n: number): number;
14
- linear(t: number): number;
15
- ease(t: number): number;
16
- quad(t: number): number;
17
- cubic(t: number): number;
18
- poly(n: number): import("@symbiote-native/engine").IEasingFunction;
19
- sin(t: number): number;
20
- circle(t: number): number;
21
- exp(t: number): number;
22
- elastic(bounciness?: number): import("@symbiote-native/engine").IEasingFunction;
23
- back(s?: number): import("@symbiote-native/engine").IEasingFunction;
24
- bounce(t: number): number;
25
- bezier(x1: number, y1: number, x2: number, y2: number): import("@symbiote-native/engine").IEasingFunction;
26
- in(easing: import("@symbiote-native/engine").IEasingFunction): import("@symbiote-native/engine").IEasingFunction;
27
- out(easing: import("@symbiote-native/engine").IEasingFunction): import("@symbiote-native/engine").IEasingFunction;
28
- inOut(easing: import("@symbiote-native/engine").IEasingFunction): import("@symbiote-native/engine").IEasingFunction;
29
- };
11
+ Easing: import("@symbiote-native/engine").IEasing;
30
12
  timing: typeof timing;
31
13
  spring: typeof spring;
32
14
  decay: typeof decay;
@@ -47,4 +29,29 @@ export declare const Animated: {
47
29
  FlatList: IAnimatedComponent;
48
30
  SectionList: IAnimatedComponent;
49
31
  createAnimatedComponent: typeof createAnimatedComponent;
32
+ } | {
33
+ Value: typeof AnimatedValue;
34
+ ValueXY: typeof AnimatedValueXY;
35
+ Color: typeof AnimatedColor;
36
+ Easing: import("@symbiote-native/engine").IEasing;
37
+ timing: (value: AnimatedValue, config: import("@symbiote-native/engine").ITimingConfig) => import("@symbiote-native/engine").ICompositeAnimation;
38
+ spring: (value: AnimatedValue, config: import("@symbiote-native/engine").ISpringConfig) => import("@symbiote-native/engine").ICompositeAnimation;
39
+ decay: (_value: AnimatedValue, _config: import("@symbiote-native/engine").IDecayConfig) => import("@symbiote-native/engine").ICompositeAnimation;
40
+ parallel: (animations: import("@symbiote-native/engine").ICompositeAnimation[], _config?: import("@symbiote-native/engine").IParallelConfig) => import("@symbiote-native/engine").ICompositeAnimation;
41
+ sequence: (animations: import("@symbiote-native/engine").ICompositeAnimation[]) => import("@symbiote-native/engine").ICompositeAnimation;
42
+ stagger: (_time: number, animations: import("@symbiote-native/engine").ICompositeAnimation[]) => import("@symbiote-native/engine").ICompositeAnimation;
43
+ loop: (_animation: import("@symbiote-native/engine").ICompositeAnimation) => import("@symbiote-native/engine").ICompositeAnimation;
44
+ delay: (_time: number) => import("@symbiote-native/engine").ICompositeAnimation;
45
+ add: typeof add;
46
+ subtract: typeof subtract;
47
+ multiply: typeof multiply;
48
+ divide: typeof divide;
49
+ modulo: typeof modulo;
50
+ diffClamp: typeof diffClamp;
51
+ event: typeof event;
52
+ forkEvent: typeof forkEvent;
53
+ unforkEvent: typeof unforkEvent;
54
+ FlatList: IAnimatedComponent;
55
+ SectionList: IAnimatedComponent;
56
+ createAnimatedComponent: typeof createAnimatedComponent;
50
57
  };
package/build/renderer.js CHANGED
@@ -17,13 +17,13 @@
17
17
  // babel.transformSync over representative JSX (elements, dynamic text, <Show>, <For>, spread,
18
18
  // ref) rather than read off the docs.
19
19
  import { createRenderer } from 'solid-js/universal';
20
- import { appendChild, createAnchor, createElement as createEngineElement, createRawText, dlog, insertBefore, removeChild as removeEngineChild, routeProp, setNodePressed, setProp as setEngineProp, setText as setEngineText, toPublicInstance, RAW_TEXT_COMPONENT, SymbioteSurface, } from '@symbiote-native/engine';
20
+ import { appendChild, createAnchor, createElement as createEngineElement, createRawText, dlog, firstChildOf, insertBefore, isRawTextNode, isTextContainer, nextSiblingOf, parentOf, removeChild as removeEngineChild, routeProp, setNodePressed, setProp as setEngineProp, setText as setEngineText, textOf, toPublicInstance, SymbioteSurface, propOf, } from '@symbiote-native/engine';
21
21
  import { descriptorFor, TEXT_INPUT_MULTILINE_TAG, } from '@symbiote-native/components';
22
22
  function isSurface(node) {
23
23
  return node instanceof SymbioteSurface;
24
24
  }
25
25
  function isRawText(node) {
26
- return !isSurface(node) && node.component === RAW_TEXT_COMPONENT;
26
+ return !isSurface(node) && isRawTextNode(node);
27
27
  }
28
28
  // One active surface per process. This is FORCED here, not chosen: the compiled-JSX contract above
29
29
  // means every mutation arrives through module-level functions with no surface argument, so there is
@@ -50,11 +50,6 @@ function requestCommit() {
50
50
  }
51
51
  activeSurface.requestCommit();
52
52
  }
53
- function siblingsOf(node) {
54
- if (node.parent !== undefined)
55
- return node.parent.children;
56
- return activeSurface?.children ?? [];
57
- }
58
53
  // Hoisted out of nodeOps and exported because descriptor-to-solid.ts needs the same text-update
59
54
  // path for a Descriptor's string child, and createRenderer() only hands back the 11 names the JSX
60
55
  // compiler imports — replaceText is not among them. Going through here rather than calling the
@@ -84,10 +79,7 @@ function asText(value) {
84
79
  // has to DETACH its anchor host from the portal target on cleanup. Going through here rather than
85
80
  // calling the engine's removeChild directly is what keeps the mutation paired with requestCommit(),
86
81
  // and keeps the surface-vs-node branch in one place.
87
- // The REFUSED path's half of `:active`, and the reason a refusal costs the component instance
88
- // rather than the pressed styling. A `<Pressable>` the lowering left as a component still owns its
89
- // own press machine, and one call here puts the node into its pressed state so `.btn:active`
90
- // applies exactly as it would on a lowered tag.
82
+ // The `:active` half: one call here puts the node into its pressed state so `.btn:active` applies.
91
83
  //
92
84
  // Exported paired with requestCommit() for the same reason removeNode below is: the press arrives
93
85
  // from a NATIVE EVENT, outside any renderer mutation, so nothing else schedules a commit. React's
@@ -110,41 +102,23 @@ export function removeNode(parent, node) {
110
102
  removeEngineChild(parent, node);
111
103
  requestCommit();
112
104
  }
113
- const TEXT_FOLDS = new Map([
114
- ['ellipsizeMode', value => value ?? 'tail'],
115
- ['allowFontScaling', value => value !== false],
116
- ]);
117
- function seedTextDefaults(node) {
118
- for (const [key, fold] of TEXT_FOLDS)
119
- setEngineProp(node, key, fold(undefined));
120
- }
121
- // Seeding at CREATE is not enough, and the gap is device-only. A framework that clears a prop it
122
- // set earlier hands us an explicit `undefined` at PATCH time, and the default has to come BACK
123
- // rather than stay cleared — RN treats a missing prop and an explicit undefined alike, and only a
124
- // literal `false` opts out of allowFontScaling. Two Map lookups on text nodes only, and none at
125
- // all on a View, so it stays off the hot path.
126
- function foldTextValue(node, key, value) {
127
- if (!node.isText)
128
- return value;
129
- const fold = TEXT_FOLDS.get(key);
130
- return fold === undefined ? value : fold(value);
131
- }
132
- // The alias fold, at the RENDERER — the only place it can live now that an app writes the tag
133
- // itself. A bare `<view id="x">` has no wrapper to inherit the fold from, and `id` is a key Fabric
134
- // does not know, so it committed nothing while the component committed `nativeID`. Measured by
135
- // mounting both forms and diffing committed key NAMES; totals were identical and said nothing.
105
+ // RN's two Text defaults left this renderer on 2026-09-18, and the per-key FOLD went with them. It
106
+ // was the last and subtlest of the three shapes this file tried: a create-time SEED (gone a month
107
+ // earlier, 6 000 wasted crossings per 1 000-row create), then a substitute-on-`undefined`, then a
108
+ // fold per key — because `?? 'tail'` has to catch a null too, and substituting only on `undefined`
109
+ // meant `<text ellipsizeMode={null}>` committed null, device-only and silent.
136
110
  //
137
- // One string comparison rather than a Map lookup, because this sits on the per-prop write path —
138
- // 32 001 prop writes on a benchmark create, where a Map.get is the kind of cost the engine spent
139
- // this month removing. That is only safe while every primitive shares ONE alias pair, which is a
140
- // property of the shared spec and not of this file, so `renderer-alias-fold.test.ts` re-derives
141
- // both constants from `HOST_PRIMITIVES` and fails the moment a second pair appears.
142
- const ALIAS_FROM = 'id';
143
- const ALIAS_TO = 'nativeID';
111
+ // All three were answering a question the layer below now answers for everyone: the rule reads the
112
+ // AUTHORED bag at payload time (`foldTextDefaults`, `SymbioteFabricProps.cpp`), where a null, an
113
+ // explicit `undefined` and an absent prop are all simply "not a value the author chose". The
114
+ // null case that cost this file two revisions is `ellipsize->isNull()` there, in one place, for
115
+ // every adapter.
116
+ // The `id` -> `nativeID` fold and its `aliasedNodes` WeakSet left this file on 2026-09-18. The
117
+ // memory was the right shape and the wrong LAYER: `routeProp` carries it now, so all five adapters
118
+ // resolve the precedence identically instead of three of them doing it three ways.
144
119
  // `multiline` selects between TWO Fabric views, so the TAG decides and no prop write moves a node
145
- // between them. Two of the three paths that can build the node resolve it earlier — the wrapper
146
- // CONSUMES the prop to pick its intrinsic, a lowering transform reads a literal at compile time —
147
- // and an author writing the tag by hand has neither, which leaves two silent divergences.
120
+ // between them. An author writing `<text-input multiline>` instead of `<text-input-multiline>` gets
121
+ // the single-line view with a prop no ViewConfig on it declares — two silent divergences.
148
122
  //
149
123
  // The shared behavior (`core/components/src/behaviors/text-input.ts`) already makes the PAYLOAD
150
124
  // follow the tag. The complaint has to live here instead of there: `foldPayload` runs inside the
@@ -152,32 +126,12 @@ const ALIAS_TO = 'nativeID';
152
126
  // call site — measured, a test awaiting the mount sees `nothing committed` rather than the error.
153
127
  const MULTILINE_PROP = 'multiline';
154
128
  function assertMultilineMatchesTag(node, value) {
155
- if ((value === true) === (node.props[MULTILINE_PROP] === true))
129
+ if ((value === true) === (propOf(node, MULTILINE_PROP) === true))
156
130
  return;
157
131
  throw new Error(`multiline={${String(value)}} contradicts the tag: <text-input> and ` +
158
132
  `<text-input-multiline> are different Fabric views and no prop write moves a node ` +
159
133
  `between them. Pick the tag (a runtime choice needs a <Show> around both).`);
160
134
  }
161
- // RN gives `id` UNCONDITIONAL priority when both are set (View.js:77-79,
162
- // `processedProps.nativeID = id`), and `foldHostBag` reproduces that by deleting the source key out
163
- // of a whole bag. This renderer never sees a bag — it folds one key at a time — so precedence would
164
- // otherwise come out as source order, and `<view id nativeID>` would keep the stale legacy value
165
- // while `<view nativeID id>` would not. The wrapper hid that; a bare tag does not.
166
- //
167
- // So the node remembers that its nativeID came from an `id`, and a later raw `nativeID` write loses
168
- // to it. Off the hot path in every ordinary case: nothing is touched unless the prop being written
169
- // is one of these two names.
170
- const aliasedNodes = new WeakSet();
171
- function foldAliasKey(node, name, value) {
172
- if (name === ALIAS_FROM) {
173
- if (value === undefined)
174
- aliasedNodes.delete(node);
175
- else
176
- aliasedNodes.add(node);
177
- return ALIAS_TO;
178
- }
179
- return name;
180
- }
181
135
  const nodeOps = {
182
136
  createElement(tag) {
183
137
  const descriptor = descriptorFor(tag);
@@ -187,8 +141,6 @@ const nodeOps = {
187
141
  // nothing, and the press machine silently never attaches. Registering under the Fabric name
188
142
  // instead would be worse: every plain `View` would get a press machine.
189
143
  const node = createEngineElement(descriptor.component, descriptor.isText, tag);
190
- if (descriptor.isText)
191
- seedTextDefaults(node);
192
144
  // Only the multiline tag is seeded: writing `multiline: false` on the single-line one would add
193
145
  // a key the wrapper's payload does not carry, i.e. a divergence in the other direction.
194
146
  if (tag === TEXT_INPUT_MULTILINE_TAG)
@@ -230,13 +182,11 @@ const nodeOps = {
230
182
  return;
231
183
  if (name === MULTILINE_PROP)
232
184
  assertMultilineMatchesTag(node, value);
233
- if (name === ALIAS_TO && aliasedNodes.has(node))
234
- return;
235
185
  // routeProp makes the prop-vs-event decision from the node's ViewConfig (onPress on a View
236
186
  // becomes a listener; onTintColor on a Switch stays a prop), and centralizes the class+style
237
- // merge. Shared with React and Vue — never re-implement an `onX` check here
238
- // (symbiote-engine-core §2).
239
- routeProp(node, foldAliasKey(node, name, value), foldTextValue(node, name, value));
187
+ // merge and the `id` -> `nativeID` rename. Shared with React and Vue — never re-implement an
188
+ // `onX` check here (symbiote-engine-core §2).
189
+ routeProp(node, name, value);
240
190
  requestCommit();
241
191
  },
242
192
  insertNode(parent, node, anchor) {
@@ -246,12 +196,12 @@ const nodeOps = {
246
196
  if (isSurface(node))
247
197
  return;
248
198
  const before = anchor !== undefined && !isSurface(anchor) ? anchor : undefined;
249
- if (isRawText(node) && (isSurface(parent) || !parent.isText)) {
199
+ if (isRawText(node) && (isSurface(parent) || !isTextContainer(parent))) {
250
200
  // Fabric has no bare-text host: RCTRawText is only valid as a <Text> child. Reached by a
251
201
  // dynamic expression that resolves to a string outside a <Text>, e.g.
252
202
  // `<symbiote-view>{label()}</symbiote-view>`. Failing loudly at mount beats building an
253
203
  // invalid tree that crashes deeper in native with a far less legible error.
254
- throw new Error(`Text string "${String(node.props.text)}" must be rendered inside a <Text>`);
204
+ throw new Error(`Text string "${textOf(node) ?? ''}" must be rendered inside a <Text>`);
255
205
  }
256
206
  if (isSurface(parent)) {
257
207
  if (before !== undefined)
@@ -281,10 +231,10 @@ const nodeOps = {
281
231
  getParentNode(node) {
282
232
  if (isSurface(node))
283
233
  return undefined;
284
- const parent = node.parent ?? activeSurface;
234
+ const parent = parentOf(node) ?? activeSurface;
285
235
  if (parent === undefined || isSurface(parent))
286
236
  return parent;
287
- const grandparent = parent.parent;
237
+ const grandparent = parentOf(parent);
288
238
  if (grandparent !== undefined && grandparent.childHost === parent) {
289
239
  return grandparent;
290
240
  }
@@ -307,14 +257,14 @@ const nodeOps = {
307
257
  getFirstChild(node) {
308
258
  if (isSurface(node))
309
259
  return node.children[0];
310
- return (node.childHost ?? node).children[0];
260
+ return firstChildOf(node.childHost ?? node);
311
261
  },
312
262
  getNextSibling(node) {
313
263
  if (isSurface(node))
314
264
  return undefined;
315
- const siblings = siblingsOf(node);
316
- const index = siblings.indexOf(node);
317
- return index >= 0 ? siblings[index + 1] : undefined;
265
+ // The surface is handed over because a TOP-LEVEL node has no parent to read a sibling list
266
+ // from — the surface owns that list. The engine returns undefined rather than guessing.
267
+ return nextSiblingOf(node, activeSurface);
318
268
  },
319
269
  };
320
270
  // The compiled-JSX contract. See the module header: these names are imported by generated code,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/solid",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "SymbioteNative's Solid adapter — a solid-js/universal createRenderer driving real native iOS/Android views through the same engine as every other adapter.",
5
5
  "keywords": [
6
6
  "react-native",
@@ -62,19 +62,19 @@
62
62
  "dependencies": {
63
63
  "babel-preset-solid": "^1.9.12",
64
64
  "typescript": "~6.0.0",
65
- "@symbiote-native/components": "2.0.0",
65
+ "@symbiote-native/components": "3.0.0",
66
66
  "@symbiote-native/css-parser": "0.5.0"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "solid-js": ">=1.9.0",
70
70
  "react-native": ">=0.86",
71
- "@symbiote-native/engine": "^0.5.0"
71
+ "@symbiote-native/engine": "^1.0.0"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@babel/core": "^7.25.2",
75
75
  "solid-js": "^1.9.14",
76
- "@symbiote-native/engine": "0.5.0",
77
- "@symbiote-native/test-utils": "0.3.0"
76
+ "@symbiote-native/engine": "1.0.0",
77
+ "@symbiote-native/test-utils": "0.3.1"
78
78
  },
79
79
  "scripts": {
80
80
  "typecheck": "tsc --build",