@symbiote-native/components 0.4.0 → 1.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.
Files changed (67) hide show
  1. package/README.md +30 -8
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -117
  4. package/build/behaviors/image.d.ts +3 -0
  5. package/build/behaviors/image.js +123 -0
  6. package/build/behaviors/input-accessory-view.d.ts +3 -0
  7. package/build/behaviors/input-accessory-view.js +70 -0
  8. package/build/behaviors/pressable.d.ts +2 -0
  9. package/build/behaviors/pressable.js +310 -0
  10. package/build/behaviors/switch.d.ts +2 -0
  11. package/build/behaviors/switch.js +182 -0
  12. package/build/behaviors/text-input.d.ts +14 -0
  13. package/build/behaviors/text-input.js +291 -0
  14. package/build/bootstrap/index.js +1 -1
  15. package/build/component-names/index.android.js +23 -1
  16. package/build/component-names/index.ios.js +9 -1
  17. package/build/component-names/shared.d.ts +1 -1
  18. package/build/component-names/shared.js +40 -1
  19. package/build/descriptor.d.ts +8 -0
  20. package/build/descriptor.js +27 -0
  21. package/build/fold-host-bag.d.ts +15 -0
  22. package/build/fold-host-bag.js +99 -0
  23. package/build/index.d.ts +25 -12
  24. package/build/index.js +18 -6
  25. package/build/resolve-intrinsic.d.ts +7 -0
  26. package/build/resolve-intrinsic.js +49 -0
  27. package/build/state/pressable.d.ts +9 -0
  28. package/build/state/pressable.js +133 -37
  29. package/build/state/sticky-header-reducer.js +28 -5
  30. package/build/state/switch.js +1 -1
  31. package/build/state/text-input.d.ts +7 -1
  32. package/build/state/text-input.js +46 -8
  33. package/build/state/touchable.d.ts +30 -1
  34. package/build/state/touchable.js +94 -5
  35. package/build/state/virtualized-list-diagnostics.d.ts +31 -0
  36. package/build/state/virtualized-list-diagnostics.js +33 -0
  37. package/build/state/virtualized-list-reducer.d.ts +14 -0
  38. package/build/state/virtualized-list-reducer.js +179 -45
  39. package/build/state/virtualized-list.d.ts +5 -2
  40. package/build/state/virtualized-list.js +143 -34
  41. package/build/state-style.d.ts +15 -0
  42. package/build/state-style.js +47 -0
  43. package/build/text-props.d.ts +9 -0
  44. package/build/text-props.js +25 -0
  45. package/build/view/render-activity-indicator.js +37 -3
  46. package/build/view/render-image/index.d.ts +2 -0
  47. package/build/view/render-image/index.js +42 -5
  48. package/build/view/render-input-accessory-view.d.ts +2 -0
  49. package/build/view/render-input-accessory-view.js +41 -6
  50. package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
  51. package/build/view/render-keyboard-avoiding-view.js +52 -6
  52. package/build/view/render-modal.js +4 -2
  53. package/build/view/render-pressable/index.js +3 -1
  54. package/build/view/render-scroll-sticky.js +1 -1
  55. package/build/view/render-scroll-view.js +9 -3
  56. package/build/view/render-switch.js +11 -2
  57. package/build/view/render-text-input.js +6 -2
  58. package/build/view/render-touchable-highlight.d.ts +11 -1
  59. package/build/view/render-touchable-highlight.js +11 -10
  60. package/build/view/render-touchable-native-feedback.js +5 -1
  61. package/host-primitives.cjs +380 -0
  62. package/host-primitives.d.cts +35 -0
  63. package/lowering-fixtures.cjs +259 -0
  64. package/lowering-fixtures.d.cts +17 -0
  65. package/package.json +42 -5
  66. package/specialize-state-style.cjs +219 -0
  67. package/specialize-state-style.d.cts +15 -0
@@ -0,0 +1,291 @@
1
+ // TextInput's machine, on the engine node instead of inside a framework component — the tier-2
2
+ // half of `.claude/rules/host-primitive-tier.md` for the second primitive to get one.
3
+ //
4
+ // WHAT A `TextInput` COMPONENT ACTUALLY DOES, and why none of it needs a framework. It holds three
5
+ // mirrors of native state (the acknowledged event count, the last text native reported, whether the
6
+ // input is focused), it commands text back down when the app's `value` diverges from that mirror,
7
+ // it fires `focus` once at mount when `autoFocus` is set, and it exposes five imperative methods.
8
+ // The TEMPLATE reads none of it — which is the whole tier-2 test. Every framework was paying an
9
+ // instance for a machine that only ever needed a per-node home.
10
+ //
11
+ // WHY IT NEEDED A NEW ENGINE HOOK AND `Pressable` DID NOT. A press machine is driven entirely by
12
+ // events, which arrive long after commit. The controlled handshake is driven by a PROP: `value`
13
+ // changing is what must re-run the divergence check, and in a component the render is what does
14
+ // that. A lowered element has no render, so `IHostBehavior.afterCommit` is the equivalent beat —
15
+ // see that interface for why it is not a hook on `setProp`.
16
+ //
17
+ // THE ORDER OF THE TWO COMMIT HOOKS IS LOAD-BEARING HERE, which is why the engine pins it with a
18
+ // test: `attachAfterCommit` seeds `lastNativeText` from the mount-time props, and `afterCommit`
19
+ // compares against that seed. Reversed, the very first beat would see an empty mirror, decide the
20
+ // app's value had diverged, and command a redundant `setTextAndSelection` down to native on every
21
+ // input in the tree.
22
+ import { appListenerFor, blurTextInput, dispatchViewCommand, dlog, registerHostBehavior, requestCommitFor, setBehaviorListener, setInputBlurred, setInputFocused, setProp, } from '@symbiote-native/engine';
23
+ import { eventCountFromChange, foldText, INITIAL_EVENT_COUNT, SELECTION_NONE, resolveTextInputProps, shouldCommandText, textFromChange, } from '../state/text-input.js';
24
+ // Both spellings, because `multiline` picks between two Fabric views and a lowering transform
25
+ // resolves that statically. The behavior is registered for both so it does not care which one the
26
+ // transform emitted.
27
+ export const TEXT_INPUT_TAG = 'symbiote-text-input';
28
+ export const TEXT_INPUT_MULTILINE_TAG = 'symbiote-text-input-multiline';
29
+ const states = new WeakMap();
30
+ function stateOf(node) {
31
+ return states.get(node);
32
+ }
33
+ function stringProp(node, key) {
34
+ const value = node.props[key];
35
+ return typeof value === 'string' ? value : undefined;
36
+ }
37
+ // `{ start, end? }` narrowed at runtime. `end` defaults to `start` — a caret, RN's own reading when
38
+ // only one bound is given — and both fall back to SELECTION_NONE when the prop is absent.
39
+ function selectionOf(value) {
40
+ if (typeof value !== 'object' || value === null) {
41
+ return { start: SELECTION_NONE, end: SELECTION_NONE };
42
+ }
43
+ const bag = { ...value };
44
+ const start = typeof bag.start === 'number' ? bag.start : SELECTION_NONE;
45
+ const end = typeof bag.end === 'number' ? bag.end : start;
46
+ return { start, end };
47
+ }
48
+ // The app's own callback for an owned event name, read from the STASH rather than from
49
+ // `node.props`: every name below is in `ownedListeners`, so `routeProp` parks the app's handler
50
+ // beside the machine's instead of overwriting it.
51
+ function callAppListener(node, name, event) {
52
+ const listener = appListenerFor(node, name);
53
+ if (typeof listener === 'function')
54
+ listener(event);
55
+ }
56
+ // `onValueChange(text, event)` is NOT a Fabric event — it is a fold the component wrapper used to do
57
+ // over the raw `change` payload, so it lives in `node.props` as a plain function key and
58
+ // `fabricProps` drops it on the way to native. A lowered element has no wrapper to run that fold, so
59
+ // before this the callback was simply never called: the field echoed keystrokes natively (native
60
+ // owns its own text) while every value the app derived from it stayed frozen. Device-found
61
+ // 2026-08-31 in examples/solid's canary — the greeting never left "Hello, stranger".
62
+ //
63
+ // Same class as `value -> text` (`core/engine/src/fabric-props.ts`) and the same repair: below the
64
+ // fork, where all five adapters inherit it. Refusing to lower an element carrying the prop was the
65
+ // other candidate and is strictly worse — it makes the optimisation opt out of the idiom the
66
+ // ecosystem actually writes, to avoid a fold the runtime can do in three lines.
67
+ function callValueChange(node, text, event) {
68
+ const listener = node.props.onValueChange;
69
+ if (typeof listener === 'function')
70
+ listener(text, event);
71
+ }
72
+ // The W3C/legacy alias fold the WRAPPER runs in its component body — `inputMode` -> `keyboardType`,
73
+ // `enterKeyHint` -> `returnKeyType`, `readOnly` -> inverted `editable`, `blurOnSubmit` ->
74
+ // `submitBehavior`, plus the `underlineColorAndroid: 'transparent'` default that hides the Material
75
+ // bar. A lowered element has no body, so before this every one of them was dropped: the raw alias
76
+ // reached Fabric as a key no ViewConfig declares, which throws nothing and renders nothing, so
77
+ // `inputMode="numeric"` simply produced the default keyboard on a device while the whole headless
78
+ // suite stayed green.
79
+ //
80
+ // Found by the wrapper-vs-behavior import audit rather than by hand
81
+ // (`.claude/rules/adapter-parity-audit.md`) — the same audit that found Pressable's two.
82
+ const ALIAS_ONLY_KEYS = [
83
+ 'inputMode',
84
+ 'enterKeyHint',
85
+ 'readOnly',
86
+ 'blurOnSubmit',
87
+ ];
88
+ function stringOf(value) {
89
+ return typeof value === 'string' ? value : undefined;
90
+ }
91
+ function booleanOf(value) {
92
+ return typeof value === 'boolean' ? value : undefined;
93
+ }
94
+ function foldPayload(props) {
95
+ const folded = resolveTextInputProps({
96
+ inputMode: stringOf(props.inputMode),
97
+ keyboardType: stringOf(props.keyboardType),
98
+ enterKeyHint: stringOf(props.enterKeyHint),
99
+ returnKeyType: stringOf(props.returnKeyType),
100
+ readOnly: booleanOf(props.readOnly),
101
+ editable: booleanOf(props.editable),
102
+ submitBehavior: stringOf(props.submitBehavior),
103
+ blurOnSubmit: booleanOf(props.blurOnSubmit),
104
+ // The tag already decided this at compile time, but the behavior is registered for BOTH tags
105
+ // with one object, so the prop is the only thing it can read. Absent reads as single-line,
106
+ // which is the tag the transform emits when `multiline` is absent — the two agree.
107
+ multiline: props.multiline === true,
108
+ cursorColor: stringOf(props.cursorColor),
109
+ selectionColor: stringOf(props.selectionColor),
110
+ selectionHandleColor: stringOf(props.selectionHandleColor),
111
+ autoComplete: stringOf(props.autoComplete),
112
+ textContentType: stringOf(props.textContentType),
113
+ showSoftInputOnFocus: booleanOf(props.showSoftInputOnFocus),
114
+ underlineColorAndroid: stringOf(props.underlineColorAndroid),
115
+ });
116
+ const out = { ...props, ...folded };
117
+ // The aliases themselves must NOT ride along: they are inert at native, and leaving them in the
118
+ // payload is how a reader concludes the fold ran when it did not.
119
+ for (const key of ALIAS_ONLY_KEYS)
120
+ delete out[key];
121
+ return out;
122
+ }
123
+ function onChange(node, event) {
124
+ const state = stateOf(node);
125
+ if (state === undefined)
126
+ return;
127
+ const text = textFromChange(event);
128
+ if (text !== undefined) {
129
+ // Ordering matches the component path exactly: record the mirror, then hand the app its text.
130
+ state.lastNativeText = text;
131
+ callValueChange(node, text, event);
132
+ }
133
+ // Ordering: record the text first, then the count, so the acknowledged count never runs ahead of
134
+ // the text it stands for. A count without its text makes the next controlled write echo an
135
+ // acknowledgement native has not actually given.
136
+ const count = eventCountFromChange(event);
137
+ if (count !== undefined) {
138
+ state.mostRecentEventCount = count;
139
+ // Native READS this prop, so the mirror is not enough — it has to reach the payload. Same shape
140
+ // as the press machine's `setNodePressed` + `requestCommitFor`: a behavior writing a prop owes
141
+ // the commit, because nothing else is going to ask for one.
142
+ setProp(node, 'mostRecentEventCount', count);
143
+ requestCommitFor(node);
144
+ }
145
+ callAppListener(node, 'change', event);
146
+ }
147
+ function onFocus(node, event) {
148
+ const state = stateOf(node);
149
+ if (state !== undefined)
150
+ state.isFocused = true;
151
+ // App-wide, so `Keyboard.dismiss()` can blur this input without holding a ref to it.
152
+ setInputFocused(node);
153
+ callAppListener(node, 'focus', event);
154
+ }
155
+ function onBlur(node, event) {
156
+ const state = stateOf(node);
157
+ if (state !== undefined)
158
+ state.isFocused = false;
159
+ setInputBlurred(node);
160
+ callAppListener(node, 'blur', event);
161
+ }
162
+ function attach(node) {
163
+ states.set(node, {
164
+ mostRecentEventCount: INITIAL_EVENT_COUNT,
165
+ lastNativeText: undefined,
166
+ isFocused: false,
167
+ });
168
+ // The mirror's seed has to reach the PAYLOAD too, not just this state object. Every wrapper hands
169
+ // the count to `renderTextInput` on every render, so a component-path input commits the key at
170
+ // create; the behavior used to write it only inside the change handshake, so a lowered input
171
+ // carried no such key until the user typed. Found independently by three adapters' equivalence
172
+ // arms, 2026-09-01 — a divergence between the two paths of ONE adapter, not between adapters.
173
+ //
174
+ // No `requestCommitFor` here: at create the renderer commits anyway, and on a re-attach the key is
175
+ // already standing at this same value, so `setProp`'s identity guard makes the write a no-op.
176
+ setProp(node, 'mostRecentEventCount', INITIAL_EVENT_COUNT);
177
+ setBehaviorListener(node, 'change', event => onChange(node, event));
178
+ setBehaviorListener(node, 'focus', event => onFocus(node, event));
179
+ setBehaviorListener(node, 'blur', event => onBlur(node, event));
180
+ }
181
+ // The first commit is the earliest point where the node has BOTH its props and a Fabric tag. The
182
+ // mirror needs the first, `autoFocus` needs the second.
183
+ function attachAfterCommit(node) {
184
+ const state = stateOf(node);
185
+ if (state === undefined)
186
+ return;
187
+ state.lastNativeText = foldText(stringProp(node, 'value'), stringProp(node, 'defaultValue'));
188
+ if (node.props.autoFocus !== true)
189
+ return;
190
+ // Driven in JS rather than as a native prop, exactly as RN does it
191
+ // (TextInput.js:538 -> TextInputState.focusInput). The native command is idempotent if the input
192
+ // is already focused.
193
+ dlog('TextInput behavior: autoFocus -> focus command');
194
+ dispatchViewCommand(node, 'focus', []);
195
+ }
196
+ // The controlled handshake. A plain prop re-push would race the user's keystrokes — native may have
197
+ // text JS has not seen yet — so the command carrying the acknowledged count is the only stale-safe
198
+ // path, and `shouldCommandText` is what keeps it a no-op unless the value genuinely diverged.
199
+ function afterCommit(node) {
200
+ const state = stateOf(node);
201
+ if (state === undefined)
202
+ return;
203
+ const value = stringProp(node, 'value');
204
+ if (!shouldCommandText(state.lastNativeText, value))
205
+ return;
206
+ // `selection` is `{ start, end? }` when present. SELECTION_NONE (-1) is RN's "leave the cursor
207
+ // where native put it" sentinel, so an absent selection must not be read as position 0 — that
208
+ // would jump the caret to the front of the field on every controlled write.
209
+ const { start, end } = selectionOf(node.props.selection);
210
+ dlog(`TextInput behavior: setTextAndSelection count=${state.mostRecentEventCount} ` +
211
+ `text=${JSON.stringify(value)}`);
212
+ dispatchViewCommand(node, 'setTextAndSelection', [
213
+ state.mostRecentEventCount,
214
+ value,
215
+ start,
216
+ end,
217
+ ]);
218
+ state.lastNativeText = value;
219
+ }
220
+ function detach(node) {
221
+ states.delete(node);
222
+ }
223
+ /**
224
+ * The imperative API RN exposes on a TextInput ref, built over the engine node. Reached through
225
+ * each adapter's own `host-instance` accessor — the capability, not a shape
226
+ * (`.claude/rules/adapter-parity-audit.md`).
227
+ *
228
+ * `focus`/`blur` are native view commands; `clear` and `setSelection` reuse `setTextAndSelection`,
229
+ * the same stale-safe path a controlled write takes, so they cannot race a keystroke either.
230
+ */
231
+ export function buildTextInputHandle(node) {
232
+ return {
233
+ // Forwarded, not re-implemented: these are the engine node's own prototype methods, and a
234
+ // TextInput ref that lacks them is poorer than every other host ref for no reason. See
235
+ // `ITextInputHandle` for why the handle is a UNION rather than the five below.
236
+ measure: callback => node.measure(callback),
237
+ measureInWindow: callback => node.measureInWindow(callback),
238
+ measureLayout: (relativeTo, onSuccess, onFail) => node.measureLayout(relativeTo, onSuccess, onFail),
239
+ setNativeProps: nativeProps => node.setNativeProps(nativeProps),
240
+ focus: () => dispatchViewCommand(node, 'focus', []),
241
+ // Through TextInputState, NOT a raw command — the same route the component path takes
242
+ // (`react/.../text-input/index.ts`, "so the app-wide focus tracking clears too"). The native
243
+ // `blur` event also clears the tracking via this behavior's own listener, so a raw command
244
+ // looks equivalent and is not: the event is the NATIVE side's, and it does not arrive when the
245
+ // input was already blurred. `Keyboard.dismiss()` reads `currentlyFocusedInput()`, so a stale
246
+ // entry there aims a blur at a node that no longer holds focus.
247
+ blur: () => blurTextInput(node),
248
+ isFocused: () => stateOf(node)?.isFocused === true,
249
+ clear: () => {
250
+ const state = stateOf(node);
251
+ if (state === undefined)
252
+ return;
253
+ dispatchViewCommand(node, 'setTextAndSelection', [
254
+ state.mostRecentEventCount,
255
+ '',
256
+ 0,
257
+ 0,
258
+ ]);
259
+ state.lastNativeText = '';
260
+ },
261
+ setSelection: (start, end) => {
262
+ const state = stateOf(node);
263
+ if (state === undefined)
264
+ return;
265
+ // The CURRENT text, not the app's `value`: a selection move must not also rewrite the text,
266
+ // and native discards a command whose text disagrees with what it holds.
267
+ dispatchViewCommand(node, 'setTextAndSelection', [
268
+ state.mostRecentEventCount,
269
+ state.lastNativeText,
270
+ start,
271
+ end,
272
+ ]);
273
+ },
274
+ };
275
+ }
276
+ // Idempotent: an adapter entry may be imported more than once in a bundle, and re-registering the
277
+ // same tag with an equivalent behavior must not double-install anything.
278
+ export function registerTextInputBehavior() {
279
+ const behavior = {
280
+ attach,
281
+ attachAfterCommit,
282
+ afterCommit,
283
+ detach,
284
+ foldPayload,
285
+ // The three the machine needs as INPUTS. Without the stash the app's own `onChange` would
286
+ // evict the machine from the very event the controlled handshake runs on.
287
+ ownedListeners: ['change', 'focus', 'blur'],
288
+ };
289
+ registerHostBehavior(TEXT_INPUT_TAG, behavior);
290
+ registerHostBehavior(TEXT_INPUT_MULTILINE_TAG, behavior);
291
+ }
@@ -4,7 +4,7 @@
4
4
  // startup. Lives OUTSIDE the package's main barrel (see package.json's separate "./bootstrap"
5
5
  // export): react-native's own source is Flow syntax Vitest's transform can't parse, so anything
6
6
  // importing it directly must stay unreachable from the tested main index.ts.
7
- import { processColor, DeviceEventEmitter, Image } from 'react-native';
7
+ import { processColor, DeviceEventEmitter, Image, } from 'react-native';
8
8
  import { setColorProcessor, setDeviceEventSource, setImageSourceResolver, setNativeViewConfigSource, } from '@symbiote-native/engine';
9
9
  // @ts-expect-error react-native ships no types for this internal path (plain .js) - the
10
10
  // try/catch below is what actually proves the shape, not TS.
@@ -2,9 +2,10 @@
2
2
  // Each name is the ViewManager's REACT_CLASS in react-native/ReactAndroid/.../views/**.
3
3
  // device-verify-pending: source-confirmed from RN's Android ViewManagers, proven on a
4
4
  // real host by the absence of a "Can't find ViewManager '<name>'" red box.
5
- import { buildDescriptors, makeDescriptorFor } from './shared.js';
5
+ import { buildDescriptors, makeDescriptorFor, } from './shared.js';
6
6
  const ANDROID_NAMES = {
7
7
  'symbiote-view': 'RCTView',
8
+ 'symbiote-pressable': 'RCTView',
8
9
  'symbiote-text': 'RCTText',
9
10
  'symbiote-image': 'RCTImageView',
10
11
  'symbiote-scroll-view': 'RCTScrollView',
@@ -19,8 +20,29 @@ const ANDROID_NAMES = {
19
20
  // Android has one text-input ViewManager for both single- and multiline.
20
21
  'symbiote-text-input': 'AndroidTextInput',
21
22
  'symbiote-text-input-multiline': 'AndroidTextInput',
23
+ // The component path's pair — same native views, a tag the behavior registry does not
24
+ // carry. See `shared.ts` for why the wrapper may not share the lowered tag.
25
+ 'symbiote-text-input-managed': 'AndroidTextInput',
26
+ 'symbiote-text-input-multiline-managed': 'AndroidTextInput',
22
27
  'symbiote-switch': 'AndroidSwitch',
28
+ // The wrapper's tag — same native view, a tag the behavior registry does not carry. See
29
+ // `shared.ts` for why the wrapper may not share the lowered tag.
30
+ 'symbiote-switch-managed': 'AndroidSwitch',
23
31
  'symbiote-activity-indicator': 'AndroidProgressBar',
32
+ // KNOWN DIVERGENCE FROM REACT NATIVE, and it is in our favour — recorded 2026-09-01 because it
33
+ // was arrived at by accident, not decided. Upstream `SafeAreaView.js` is
34
+ // `Platform.select({ ios: RCTSafeAreaViewNativeComponent, default: View })`, so RN's own JS
35
+ // renders a plain View on Android and applies NO insets. `RCTSafeAreaView` does exist and is
36
+ // registered (`ReactSafeAreaViewManager.REACT_CLASS`, wired in `MainReactPackage.kt:149`) and
37
+ // does real window-inset math — so we inset where RN does not, and an app ported from RN gets
38
+ // different Android layout with nothing to explain why.
39
+ //
40
+ // This table's rule is "the name is the ViewManager's REACT_CLASS", and by that rule the entry is
41
+ // correct. What nobody checked is whether RN's JS ROUTES there. Left as-is deliberately: insetting
42
+ // is the better behaviour and RN has deprecated its own component in favour of
43
+ // react-native-safe-area-context. Invisible to every audit we have, because both of our paths
44
+ // agree with each other and only disagree with RN — the boundary
45
+ // `.claude/rules/adapter-parity-audit.md` states about itself.
24
46
  'symbiote-safe-area-view': 'RCTSafeAreaView',
25
47
  'symbiote-modal': 'RCTModalHostView',
26
48
  'symbiote-refresh-control': 'AndroidSwipeRefreshLayout',
@@ -2,9 +2,10 @@
2
2
  // (component-names.ts re-exports it) for headless tsx / tsc / web fallback.
3
3
  // Fabric names are the codegen spec's registered name (the new-arch name), not the legacy
4
4
  // paperComponentName (RCTSwitch, …).
5
- import { buildDescriptors, makeDescriptorFor } from './shared.js';
5
+ import { buildDescriptors, makeDescriptorFor, } from './shared.js';
6
6
  const IOS_NAMES = {
7
7
  'symbiote-view': 'RCTView',
8
+ 'symbiote-pressable': 'RCTView',
8
9
  'symbiote-text': 'RCTText',
9
10
  'symbiote-image': 'RCTImageView',
10
11
  'symbiote-scroll-view': 'RCTScrollView',
@@ -15,7 +16,14 @@ const IOS_NAMES = {
15
16
  'symbiote-horizontal-scroll-content': 'RCTScrollContentView',
16
17
  'symbiote-text-input': 'RCTSinglelineTextInputView',
17
18
  'symbiote-text-input-multiline': 'RCTMultilineTextInputView',
19
+ // The component path's pair — same native views, a tag the behavior registry does not
20
+ // carry. See `shared.ts` for why the wrapper may not share the lowered tag.
21
+ 'symbiote-text-input-managed': 'RCTSinglelineTextInputView',
22
+ 'symbiote-text-input-multiline-managed': 'RCTMultilineTextInputView',
18
23
  'symbiote-switch': 'Switch',
24
+ // The wrapper's tag — same native view, a tag the behavior registry does not carry. See
25
+ // `shared.ts` for why the wrapper may not share the lowered tag.
26
+ 'symbiote-switch-managed': 'Switch',
19
27
  'symbiote-activity-indicator': 'ActivityIndicatorView',
20
28
  'symbiote-safe-area-view': 'SafeAreaView',
21
29
  'symbiote-modal': 'ModalHostView',
@@ -1,4 +1,4 @@
1
- export type ISymbioteIntrinsic = 'symbiote-view' | 'symbiote-text' | 'symbiote-image' | 'symbiote-scroll-view' | 'symbiote-scroll-content' | 'symbiote-horizontal-scroll-view' | 'symbiote-horizontal-scroll-content' | 'symbiote-text-input' | 'symbiote-text-input-multiline' | 'symbiote-switch' | 'symbiote-activity-indicator' | 'symbiote-safe-area-view' | 'symbiote-modal' | 'symbiote-refresh-control' | 'symbiote-input-accessory-view';
1
+ export type ISymbioteIntrinsic = 'symbiote-view' | 'symbiote-pressable' | 'symbiote-text' | 'symbiote-image' | 'symbiote-scroll-view' | 'symbiote-scroll-content' | 'symbiote-horizontal-scroll-view' | 'symbiote-horizontal-scroll-content' | 'symbiote-text-input' | 'symbiote-text-input-multiline' | 'symbiote-text-input-managed' | 'symbiote-text-input-multiline-managed' | 'symbiote-switch' | 'symbiote-switch-managed' | 'symbiote-activity-indicator' | 'symbiote-safe-area-view' | 'symbiote-modal' | 'symbiote-refresh-control' | 'symbiote-input-accessory-view';
2
2
  export interface IComponentDescriptor {
3
3
  component: string;
4
4
  isText: boolean;
@@ -13,7 +13,10 @@ const TEXT_INTRINSICS = new Set(['symbiote-text']);
13
13
  export function buildDescriptors(names) {
14
14
  const descriptors = {};
15
15
  for (const [intrinsic, component] of Object.entries(names)) {
16
- descriptors[intrinsic] = { component, isText: TEXT_INTRINSICS.has(intrinsic) };
16
+ descriptors[intrinsic] = {
17
+ component,
18
+ isText: TEXT_INTRINSICS.has(intrinsic),
19
+ };
17
20
  }
18
21
  return descriptors;
19
22
  }
@@ -24,6 +27,7 @@ export function buildDescriptors(names) {
24
27
  // is a raw Fabric view name from a library's codegen component and flows through untouched
25
28
  // (the engine derives its events/processors from the view's ViewConfig, no per-library glue).
26
29
  export function makeDescriptorFor(descriptors) {
30
+ const unrewritten = publicNamesThatAreNotViewNames(descriptors);
27
31
  return type => {
28
32
  const descriptor = descriptors[type];
29
33
  if (descriptor !== undefined)
@@ -31,6 +35,41 @@ export function makeDescriptorFor(descriptors) {
31
35
  if (type.startsWith('symbiote-')) {
32
36
  throw new Error(`Unknown symbiote component type: ${type}`);
33
37
  }
38
+ if (unrewritten.has(type)) {
39
+ throw new Error(`"${type}" is a primitive's PUBLIC name, not a Fabric view name — a rewrite was missed. ` +
40
+ `Expected the intrinsic tag (e.g. "symbiote-view"). Falling through would have committed ` +
41
+ `a Fabric view literally named "${type}", which fails on device only.`);
42
+ }
34
43
  return { component: type, isText: false };
35
44
  };
36
45
  }
46
+ // A primitive's public name reaching here means an adapter's rewrite missed a call site. Without
47
+ // this the name falls through as a raw Fabric view name and commits a view literally called `View`
48
+ // — no error at any layer, wrong only on a device.
49
+ //
50
+ // Both halves are DERIVED, because a hand-written list of either would be wrong within a release.
51
+ // The names come from the intrinsic union (kebab -> Pascal); the exclusions come from the platform's
52
+ // own table, and deriving them is not tidiness — two public names ARE real Fabric view names, so a
53
+ // hand-written block list would break an adapter's thin wrapper over a third-party native view,
54
+ // which resolves by view name through this same function.
55
+ //
56
+ // AND THE EXCLUSION IS PER PLATFORM, which is why it must be computed from the table rather than
57
+ // stated. `Switch` and `SafeAreaView` are iOS view names; Android spells them `AndroidSwitch` and
58
+ // `RCTSafeAreaView`, so the same public name is EXCLUDED on iOS and BLOCKED on Android. That
59
+ // asymmetry is correct — nothing legitimate resolves by the bare name on Android — but a reader who
60
+ // takes "these two are real view names" as platform-invariant will conclude the guard is broken on
61
+ // one side or the other.
62
+ function publicNamesThatAreNotViewNames(descriptors) {
63
+ const viewNames = new Set(Object.values(descriptors).map(descriptor => descriptor.component));
64
+ const names = new Set();
65
+ for (const intrinsic of Object.keys(descriptors)) {
66
+ const publicName = intrinsic
67
+ .slice('symbiote-'.length)
68
+ .split('-')
69
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
70
+ .join('');
71
+ if (!viewNames.has(publicName))
72
+ names.add(publicName);
73
+ }
74
+ return names;
75
+ }
@@ -9,3 +9,11 @@ export type IDescriptor = {
9
9
  };
10
10
  export declare function el(type: IDescriptorType, props?: IDescriptorProps, children?: IDescriptorChild[], key?: string): IDescriptor;
11
11
  export declare function txt(props?: IDescriptorProps, children?: IDescriptorChild[]): IDescriptor;
12
+ export type IDescriptorShapeGuard = {
13
+ error(detail: string): Error;
14
+ assertType(expected: IDescriptorType, actual: IDescriptorType): void;
15
+ assertChildCount(expected: number, actual: number): void;
16
+ asText(child: IDescriptorChild): string;
17
+ asElement(child: IDescriptorChild): IDescriptor;
18
+ };
19
+ export declare function createDescriptorShapeGuard(bridge: string): IDescriptorShapeGuard;
@@ -13,3 +13,30 @@ export function el(type, props = {}, children = [], key) {
13
13
  export function txt(props = {}, children = []) {
14
14
  return { type: 'symbiote-text', props, children };
15
15
  }
16
+ // `bridge` names the caller (`descriptorToSolid`, `descriptorToSvelte`) so a device log says which
17
+ // one tripped without a stack to read.
18
+ export function createDescriptorShapeGuard(bridge) {
19
+ const error = (detail) => new Error(`${bridge}: Descriptor shape changed between renders (${detail}) — a render-*.ts fn must ` +
20
+ `produce a CONSTANT tree shape; only prop values may vary between calls.`);
21
+ return {
22
+ error,
23
+ assertType(expected, actual) {
24
+ if (expected !== actual)
25
+ throw error(`${expected} -> ${actual}`);
26
+ },
27
+ assertChildCount(expected, actual) {
28
+ if (expected !== actual)
29
+ throw error(`child count ${expected} -> ${actual}`);
30
+ },
31
+ asText(child) {
32
+ if (typeof child !== 'string')
33
+ throw error(`text -> ${child.type}`);
34
+ return child;
35
+ },
36
+ asElement(child) {
37
+ if (typeof child === 'string')
38
+ throw error('element -> text');
39
+ return child;
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,15 @@
1
+ import { type IFoldOp } from '../host-primitives.cjs';
2
+ export type IHostBag = Record<string, unknown>;
3
+ interface IFoldPlan {
4
+ readonly aliases: ReadonlyArray<readonly [string, string]>;
5
+ readonly defaults: ReadonlyArray<readonly [string, IFoldOp]>;
6
+ }
7
+ export declare const FOLD_PLAN_BY_TAG: ReadonlyMap<string, IFoldPlan>;
8
+ /**
9
+ * Apply a primitive's aliases and defaults to a bag, copy-on-write.
10
+ *
11
+ * Never mutates the input: the bag belongs to whoever built it, and a framework may hand the same
12
+ * object back on a re-render, so writing into it would leak a fold into the author's own state.
13
+ */
14
+ export declare function foldHostBag(tagName: string, bag: IHostBag): IHostBag;
15
+ export {};
@@ -0,0 +1,99 @@
1
+ // The RUNTIME half of `HOST_PRIMITIVES` — RN's prop folds applied to a props bag on its way into
2
+ // the engine, keyed by intrinsic tag.
3
+ //
4
+ // Why it exists at all. A primitive's host node is produced by THREE paths, and the compile-time
5
+ // fold in a lowering transform covers exactly one of them:
6
+ //
7
+ // lowered `<View>` the transform folded it covered by the transform
8
+ // `<View>` it refused the wrapper folds it covered by the wrapper
9
+ // a hand-authored tag nobody folds it covered by NOTHING
10
+ //
11
+ // The third path is not hypothetical: Svelte's `components/button.svelte` writes `<symbiote-text>`
12
+ // directly rather than composing `Text.svelte`, so its title reached Fabric with no
13
+ // `ellipsizeMode` and clipped mid-word where every other adapter ellipsised (2026-08-31). Angular
14
+ // shipped the identical defect from the identical cause, and both fixed it the same way — a seed
15
+ // in the layer every path crosses.
16
+ //
17
+ // Why it is SHARED rather than one copy per adapter: an adapter that lowers has a transform doing
18
+ // this at compile time, an adapter that does not has only this, and a primitive with no wrapper at
19
+ // all (a bare intrinsic tag) has only this everywhere. Three producers of one answer is the shape
20
+ // `<adapters_stay_thin>` exists to stop. Both halves read the same spec, so they cannot drift into
21
+ // two different answers — only into one answer applied twice, which every operation here is
22
+ // idempotent under (`(x ?? 'tail') ?? 'tail'`, `(x !== false) !== false`).
23
+ import { HOST_PRIMITIVES, } from '../host-primitives.cjs';
24
+ const planFor = (primitive) => ({
25
+ aliases: Object.entries(primitive.aliases),
26
+ defaults: Object.entries(primitive.defaults),
27
+ });
28
+ // The wrapper's spelling of a tag whose behavior must attach to the LOWERED path only
29
+ // (`component-names/shared.ts`). A suffix rather than a spec field because it cannot go stale: the
30
+ // next primitive to grow a `-managed` twin is covered the day it is named, with no edit here.
31
+ // `managed-tags-fold.test.ts` pins the convention against the platform tables, so a twin named some
32
+ // other way fails rather than silently losing its folds.
33
+ const managedSpellingOf = (tag) => `${tag}-managed`;
34
+ // EVERY spelling a primitive commits under, and the two axes are independent.
35
+ //
36
+ // `intrinsicWhen` lets one primitive commit two different tags (`TextInput` ->
37
+ // `symbiote-text-input` / `…-multiline`). `-managed` is the wrapper's twin of each of those. A map
38
+ // keyed on the lowered spellings alone folds the lowered path and silently skips the component one
39
+ // — which is exactly what shipped: TextInput and Switch committed a raw `id`, a key no ViewConfig
40
+ // declares, so Fabric dropped it and the nativeID was lost on device with nothing red. Found by
41
+ // Svelte's equivalence arm, 2026-09-01, and it is the two paths of ONE adapter disagreeing.
42
+ //
43
+ // Folding a bag the wrapper already folded is a no-op — an alias deletes its source key, so the
44
+ // second pass finds nothing to rename — which is what makes covering both paths from one plan safe.
45
+ function tagsOf(primitive) {
46
+ const alternate = primitive.intrinsicWhen?.intrinsic;
47
+ const lowered = alternate === undefined
48
+ ? [primitive.intrinsic]
49
+ : [primitive.intrinsic, alternate];
50
+ return [...lowered, ...lowered.map(managedSpellingOf)];
51
+ }
52
+ export const FOLD_PLAN_BY_TAG = new Map(Object.values(HOST_PRIMITIVES).flatMap(primitive => {
53
+ const plan = planFor(primitive);
54
+ return tagsOf(primitive).map((tag) => [tag, plan]);
55
+ }));
56
+ // The runtime twin of a transform's `foldExpression`, which emits these same two operations as
57
+ // SOURCE. `notFalse` is RN's own encoding (`allowFontScaling !== false`): an explicit `undefined`
58
+ // reads as "not set" and only a literal `false` opts out, which is why it is not a `??`.
59
+ function fold(op, authored) {
60
+ return op.op === 'notFalse' ? authored !== false : (authored ?? op.value);
61
+ }
62
+ /**
63
+ * Apply a primitive's aliases and defaults to a bag, copy-on-write.
64
+ *
65
+ * Never mutates the input: the bag belongs to whoever built it, and a framework may hand the same
66
+ * object back on a re-render, so writing into it would leak a fold into the author's own state.
67
+ */
68
+ export function foldHostBag(tagName, bag) {
69
+ const plan = FOLD_PLAN_BY_TAG.get(tagName);
70
+ if (plan === undefined)
71
+ return bag;
72
+ let next = bag;
73
+ for (const [from, to] of plan.aliases) {
74
+ if (!(from in next))
75
+ continue;
76
+ if (next === bag)
77
+ next = { ...bag };
78
+ // RN gives the alias unconditional priority when both are set (View.js:77-79,
79
+ // `processedProps.nativeID = id`), and the raw key must not survive — no ViewConfig declares
80
+ // `id`, so Fabric would drop it silently.
81
+ next[to] = next[from];
82
+ delete next[from];
83
+ }
84
+ // Seeded whether or not the key was authored: a default that only applies to a key already
85
+ // present is not a default.
86
+ //
87
+ // A bag a transform already folded takes the `continue` on every key and never reaches the copy —
88
+ // which is what keeps the compile-time fold worth having once this one exists. It is no longer
89
+ // the mechanism, but it does keep the hot path allocation-free.
90
+ for (const [key, op] of plan.defaults) {
91
+ const folded = fold(op, next[key]);
92
+ if (key in next && Object.is(next[key], folded))
93
+ continue;
94
+ if (next === bag)
95
+ next = { ...bag };
96
+ next[key] = folded;
97
+ }
98
+ return next;
99
+ }