@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.
- package/README.md +30 -8
- package/build/accessibility-props.d.ts +11 -0
- package/build/accessibility-props.js +30 -117
- package/build/behaviors/image.d.ts +3 -0
- package/build/behaviors/image.js +123 -0
- package/build/behaviors/input-accessory-view.d.ts +3 -0
- package/build/behaviors/input-accessory-view.js +70 -0
- package/build/behaviors/pressable.d.ts +2 -0
- package/build/behaviors/pressable.js +310 -0
- package/build/behaviors/switch.d.ts +2 -0
- package/build/behaviors/switch.js +182 -0
- package/build/behaviors/text-input.d.ts +14 -0
- package/build/behaviors/text-input.js +291 -0
- package/build/bootstrap/index.js +1 -1
- package/build/component-names/index.android.js +23 -1
- package/build/component-names/index.ios.js +9 -1
- package/build/component-names/shared.d.ts +1 -1
- package/build/component-names/shared.js +40 -1
- package/build/descriptor.d.ts +8 -0
- package/build/descriptor.js +27 -0
- package/build/fold-host-bag.d.ts +15 -0
- package/build/fold-host-bag.js +99 -0
- package/build/index.d.ts +25 -12
- package/build/index.js +18 -6
- package/build/resolve-intrinsic.d.ts +7 -0
- package/build/resolve-intrinsic.js +49 -0
- package/build/state/pressable.d.ts +9 -0
- package/build/state/pressable.js +133 -37
- package/build/state/sticky-header-reducer.js +28 -5
- package/build/state/switch.js +1 -1
- package/build/state/text-input.d.ts +7 -1
- package/build/state/text-input.js +46 -8
- package/build/state/touchable.d.ts +30 -1
- package/build/state/touchable.js +94 -5
- package/build/state/virtualized-list-diagnostics.d.ts +31 -0
- package/build/state/virtualized-list-diagnostics.js +33 -0
- package/build/state/virtualized-list-reducer.d.ts +14 -0
- package/build/state/virtualized-list-reducer.js +179 -45
- package/build/state/virtualized-list.d.ts +5 -2
- package/build/state/virtualized-list.js +143 -34
- package/build/state-style.d.ts +15 -0
- package/build/state-style.js +47 -0
- package/build/text-props.d.ts +9 -0
- package/build/text-props.js +25 -0
- package/build/view/render-activity-indicator.js +37 -3
- package/build/view/render-image/index.d.ts +2 -0
- package/build/view/render-image/index.js +42 -5
- package/build/view/render-input-accessory-view.d.ts +2 -0
- package/build/view/render-input-accessory-view.js +41 -6
- package/build/view/render-keyboard-avoiding-view.d.ts +14 -2
- package/build/view/render-keyboard-avoiding-view.js +52 -6
- package/build/view/render-modal.js +4 -2
- package/build/view/render-pressable/index.js +3 -1
- package/build/view/render-scroll-sticky.js +1 -1
- package/build/view/render-scroll-view.js +9 -3
- package/build/view/render-switch.js +11 -2
- package/build/view/render-text-input.js +6 -2
- package/build/view/render-touchable-highlight.d.ts +11 -1
- package/build/view/render-touchable-highlight.js +11 -10
- package/build/view/render-touchable-native-feedback.js +5 -1
- package/host-primitives.cjs +380 -0
- package/host-primitives.d.cts +35 -0
- package/lowering-fixtures.cjs +259 -0
- package/lowering-fixtures.d.cts +17 -0
- package/package.json +42 -5
- package/specialize-state-style.cjs +219 -0
- 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
|
+
}
|
package/build/bootstrap/index.js
CHANGED
|
@@ -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] = {
|
|
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
|
+
}
|
package/build/descriptor.d.ts
CHANGED
|
@@ -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;
|
package/build/descriptor.js
CHANGED
|
@@ -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
|
+
}
|