@symbiote-native/solid 0.1.0 → 2.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 (106) hide show
  1. package/README.md +24 -14
  2. package/build/components/activity-indicator-props.d.ts +5 -0
  3. package/build/components/activity-indicator-props.js +1 -0
  4. package/build/components/button-props.d.ts +5 -0
  5. package/build/components/button-props.js +1 -0
  6. package/build/components/flat-list/index.jsx +3 -3
  7. package/build/components/{image-background.d.ts → image-background-props.d.ts} +2 -3
  8. package/build/components/image-background-props.js +1 -0
  9. package/build/components/image-props.d.ts +5 -0
  10. package/build/components/image-props.js +6 -0
  11. package/build/components/{input-accessory-view.d.ts → input-accessory-view-props.d.ts} +1 -2
  12. package/build/components/input-accessory-view-props.js +5 -0
  13. package/build/components/keyboard-avoiding-view.jsx +9 -4
  14. package/build/components/modal.jsx +6 -6
  15. package/build/components/{pressable.d.ts → pressable-props.d.ts} +8 -6
  16. package/build/components/pressable-props.js +13 -0
  17. package/build/components/{refresh-control.d.ts → refresh-control-props.d.ts} +3 -3
  18. package/build/components/refresh-control-props.js +7 -0
  19. package/build/components/safe-area-view-props.d.ts +10 -0
  20. package/build/components/safe-area-view-props.js +5 -0
  21. package/build/components/{scroll-view/shared.d.ts → scroll-view-props.d.ts} +7 -13
  22. package/build/components/scroll-view-props.js +29 -0
  23. package/build/components/switch-props.d.ts +6 -0
  24. package/build/components/switch-props.js +5 -0
  25. package/build/components/text-input-props.d.ts +8 -0
  26. package/build/components/text-input-props.js +1 -0
  27. package/build/components/{text.d.ts → text-props.d.ts} +3 -3
  28. package/build/components/text-props.js +11 -0
  29. package/build/components/touchable-native-feedback/touchable-native-feedback-props.d.ts +8 -0
  30. package/build/components/touchable-native-feedback/touchable-native-feedback-props.js +1 -0
  31. package/build/components/touchable-props.d.ts +18 -0
  32. package/build/components/touchable-props.js +1 -0
  33. package/build/components/touchable-without-feedback/touchable-without-feedback-props.d.ts +6 -0
  34. package/build/components/touchable-without-feedback/touchable-without-feedback-props.js +1 -0
  35. package/build/components/{view.d.ts → view-props.d.ts} +2 -3
  36. package/build/components/view-props.js +11 -0
  37. package/build/components/virtualized-list/index.android.js +5 -4
  38. package/build/components/virtualized-list/index.ios.js +6 -6
  39. package/build/components/virtualized-list/shared.d.ts +1 -3
  40. package/build/components/virtualized-list/shared.jsx +87 -98
  41. package/build/components/virtualized-list/sticky-header.d.ts +6 -0
  42. package/build/components/{scroll-view → virtualized-list}/sticky-header.jsx +28 -36
  43. package/build/components.d.ts +20 -30
  44. package/build/components.js +6 -33
  45. package/build/create-portal/index.jsx +1 -1
  46. package/build/descriptor-to-solid.js +1 -1
  47. package/build/host-instance.js +3 -3
  48. package/build/index.d.ts +2 -0
  49. package/build/index.js +13 -3
  50. package/build/jsx-runtime.d.ts +39 -2
  51. package/build/modules/animated/create-animated-component.d.ts +1 -1
  52. package/build/modules/animated/create-animated-component.jsx +22 -0
  53. package/build/modules/animated/index.d.ts +0 -4
  54. package/build/modules/animated/index.js +16 -22
  55. package/build/modules/image.d.ts +3 -0
  56. package/build/modules/image.js +12 -0
  57. package/build/register.d.ts +1 -0
  58. package/build/register.js +15 -0
  59. package/build/renderer.d.ts +1 -0
  60. package/build/renderer.js +133 -6
  61. package/build/utils/stable-keys.d.ts +1 -1
  62. package/build/utils/stable-keys.js +6 -5
  63. package/metro-css-parser.cjs +2 -1
  64. package/package.json +18 -7
  65. package/build/components/activity-indicator/index.android.d.ts +0 -2
  66. package/build/components/activity-indicator/index.android.js +0 -11
  67. package/build/components/activity-indicator/index.d.ts +0 -1
  68. package/build/components/activity-indicator/index.ios.d.ts +0 -2
  69. package/build/components/activity-indicator/index.ios.js +0 -10
  70. package/build/components/activity-indicator/index.js +0 -4
  71. package/build/components/activity-indicator/shared.d.ts +0 -8
  72. package/build/components/activity-indicator/shared.js +0 -51
  73. package/build/components/button.d.ts +0 -7
  74. package/build/components/button.jsx +0 -45
  75. package/build/components/image-background.jsx +0 -74
  76. package/build/components/image.d.ts +0 -9
  77. package/build/components/image.js +0 -75
  78. package/build/components/input-accessory-view.jsx +0 -39
  79. package/build/components/pressable.jsx +0 -185
  80. package/build/components/refresh-control.jsx +0 -36
  81. package/build/components/safe-area-view.d.ts +0 -10
  82. package/build/components/safe-area-view.jsx +0 -24
  83. package/build/components/scroll-view/index.android.d.ts +0 -2
  84. package/build/components/scroll-view/index.android.js +0 -11
  85. package/build/components/scroll-view/index.d.ts +0 -2
  86. package/build/components/scroll-view/index.ios.d.ts +0 -2
  87. package/build/components/scroll-view/index.ios.js +0 -8
  88. package/build/components/scroll-view/index.js +0 -9
  89. package/build/components/scroll-view/shared.jsx +0 -334
  90. package/build/components/scroll-view/sticky-header.d.ts +0 -18
  91. package/build/components/switch/index.android.d.ts +0 -2
  92. package/build/components/switch/index.android.js +0 -12
  93. package/build/components/switch/index.d.ts +0 -1
  94. package/build/components/switch/index.ios.d.ts +0 -2
  95. package/build/components/switch/index.ios.js +0 -11
  96. package/build/components/switch/index.js +0 -4
  97. package/build/components/switch/shared.d.ts +0 -11
  98. package/build/components/switch/shared.js +0 -87
  99. package/build/components/text-input.d.ts +0 -10
  100. package/build/components/text-input.js +0 -268
  101. package/build/components/text.jsx +0 -27
  102. package/build/components/touchable/index.d.ts +0 -22
  103. package/build/components/touchable/index.jsx +0 -247
  104. package/build/components/touchable-native-feedback/index.d.ts +0 -16
  105. package/build/components/touchable-native-feedback/index.jsx +0 -68
  106. package/build/components/view.jsx +0 -49
@@ -1,268 +0,0 @@
1
- // TextInput — the Solid lifecycle half. The folds/maps (value→text, the W3C/alias resolution) and
2
- // the controlled-write predicate live in @symbiote-native/components/state; the render (intrinsic +
3
- // native-prop mapping) in @symbiote-native/components/view. Both are shared verbatim with React,
4
- // Vue and Svelte. Solid supplies only the reactivity: a signal for the acknowledged event count,
5
- // setup-scope variables for the host node / the last text native holds / the focus flag, one effect
6
- // for the controlled write, and the imperative handle handed to `ref`.
7
- //
8
- // THE CONTROLLED HANDSHAKE IS NOT A PROP RE-PUSH, and that is the whole subtlety. Native owns its
9
- // own text and has already changed it by the time the change event arrives. When JS REFUSES that
10
- // change the `value` prop never changes — so nothing re-commits, and the only thing that can
11
- // correct native is an imperative setTextAndSelection carrying the ACKNOWLEDGED event count (the
12
- // one native last reported), which native's eventLag check accepts. Same shape as Switch's
13
- // snap-back; the counter arithmetic is documented in @symbiote-native/components/state/text-input.
14
- //
15
- // A FLAT MODULE, NOT A FOLDER: TextInput has no platform variant to select by filename. The
16
- // iOS/Android differences (keyboardType, autoComplete vs textContentType, underlineColorAndroid)
17
- // are resolved platform-AGNOSTICALLY by resolveTextInputProps, which emits both platforms' native
18
- // props and lets the inert one ride — see its header. Only genuine platform/shared groups get a
19
- // folder (symbiote-file-layout), so Switch has one and this does not.
20
- //
21
- // NOTHING here destructures `props`. Solid props are getters and a component body runs ONCE, so a
22
- // destructure would freeze the input at its mount-time config; splitProps is the idiomatic split
23
- // that keeps the rest reactive, and every read below sits inside an accessor, an effect or an event
24
- // handler. The two deliberate exceptions are marked where they happen.
25
- import { createEffect, createMemo, createSignal, onCleanup, splitProps, untrack, } from 'solid-js';
26
- import { eventCountFromChange, foldText, renderTextInput, resolveAccessibilityProps, resolveTextInputProps, shouldCommandText, textFromChange, INITIAL_EVENT_COUNT, SELECTION_NONE, } from '@symbiote-native/components';
27
- import { blurTextInput, dispatchViewCommand, dlog, setInputBlurred, setInputFocused, whenCommitted, } from '@symbiote-native/engine';
28
- import { descriptorToSolid } from '../descriptor-to-solid.js';
29
- // Read by the component itself; everything else forwards onto the host node. onValueChange/onFocus/
30
- // onBlur must be split off — they are pure JS, and it is our WRAPPED handlers that go on the host
31
- // (a raw function prop that is not a ViewConfig event crashes Android's folly::dynamic serializer).
32
- // The alias props (inputMode, enterKeyHint, readOnly, submitBehavior, blurOnSubmit) are inert at
33
- // the native layer and must be folded, never forwarded raw. Same list as React's destructure.
34
- const HANDLED_PROPS = [
35
- 'ref',
36
- 'value',
37
- 'defaultValue',
38
- 'multiline',
39
- 'selection',
40
- 'onValueChange',
41
- 'onFocus',
42
- 'onBlur',
43
- 'inputMode',
44
- 'enterKeyHint',
45
- 'readOnly',
46
- 'submitBehavior',
47
- 'blurOnSubmit',
48
- 'cursorColor',
49
- 'selectionColor',
50
- 'selectionHandleColor',
51
- 'keyboardType',
52
- 'returnKeyType',
53
- 'editable',
54
- 'autoComplete',
55
- 'textContentType',
56
- 'autoFocus',
57
- 'showSoftInputOnFocus',
58
- 'underlineColorAndroid',
59
- ];
60
- export function TextInput(props) {
61
- const [local, rest] = splitProps(props, HANDLED_PROPS);
62
- // The count native last acknowledged. A signal because two readers need it live: the render (it
63
- // rides down as mostRecentEventCount) and the controlled-write effect, which echoes it so
64
- // native's eventLag lands on 0 and the write applies instead of being dropped as stale.
65
- const [mostRecentEventCount, setMostRecentEventCount] = createSignal(INITIAL_EVENT_COUNT);
66
- // The host node, held by IDENTITY in a plain variable — never a store or a deep proxy, which
67
- // would be a different key than the one the engine's commit mirror holds, and every command below
68
- // would silently no-op (symbiote-engine-core §3). Null only before the first build and after a
69
- // multiline swap disposes the old one.
70
- let host = null;
71
- // The last text native holds, as far as JS knows. Deliberately NOT reactive: the controlled-write
72
- // effect both reads and writes it, and a signal would make that a loop. Seeded by a one-time
73
- // setup read of the mount-time value — the `text` prop already carries that value down via
74
- // createNode, so the FIRST controlled value is not a divergence and must NOT re-command.
75
- let lastNativeText = foldText(local.value, local.defaultValue);
76
- // Mirrored from the focus/blur pair for isFocused(): native exposes no synchronous focus getter,
77
- // so RN's own TextInputState keeps the same flag.
78
- let focused = false;
79
- // autoFocus fires once, on the first host node that commits; guarded so a later multiline swap
80
- // does not steal focus back.
81
- let autoFocused = false;
82
- // The acknowledged count, read WITHOUT subscribing. The imperative handle can be called from
83
- // anywhere, including inside a caller's effect or memo — a tracked read there would silently
84
- // enroll their computation in our keystroke counter. React's handle closes over a render value
85
- // and has the same non-reactive contract.
86
- const acknowledgedCount = () => untrack(mostRecentEventCount);
87
- const handleChange = (event) => {
88
- // Event seam: the controlled handshake hinges on the change payload carrying `text`
89
- // (+ `eventCount`). iOS and Android Fabric can key these differently, so log the actual shape
90
- // here; a missing `text` means onValueChange never fires.
91
- dlog(`TextInput change keys=[${Object.keys(event.nativeEvent).join(',')}] ` +
92
- `text=${JSON.stringify(event.nativeEvent.text)} count=${JSON.stringify(event.nativeEvent.eventCount)}`);
93
- const text = textFromChange(event);
94
- if (text !== undefined) {
95
- // Ordering matters: record the text first, then bump the acknowledged count, so the count
96
- // never runs ahead of the text it stands for.
97
- lastNativeText = text;
98
- local.onValueChange?.(text, event);
99
- }
100
- const count = eventCountFromChange(event);
101
- if (count !== undefined)
102
- setMostRecentEventCount(count);
103
- };
104
- const handleFocus = (event) => {
105
- focused = true;
106
- // Track focus app-wide so Keyboard.dismiss can blur this input without a ref.
107
- if (host !== null)
108
- setInputFocused(host);
109
- local.onFocus?.(event);
110
- };
111
- const handleBlur = (event) => {
112
- focused = false;
113
- if (host !== null)
114
- setInputBlurred(host);
115
- local.onBlur?.(event);
116
- };
117
- // One host node for one intrinsic. `multiline` arrives as an ARGUMENT, not as a prop read, so the
118
- // descriptor's type is constant for this node's whole lifetime — descriptorToSolid builds the
119
- // element once and only diffs prop VALUES afterwards, and a type that changed under it would
120
- // throw rather than paint.
121
- const buildHost = (multiline) => {
122
- const node = descriptorToSolid(() => renderTextInput({
123
- multiline,
124
- text: foldText(local.value, local.defaultValue),
125
- mostRecentEventCount: mostRecentEventCount(),
126
- selection: local.selection,
127
- folded: resolveTextInputProps({
128
- inputMode: local.inputMode,
129
- keyboardType: local.keyboardType,
130
- enterKeyHint: local.enterKeyHint,
131
- returnKeyType: local.returnKeyType,
132
- readOnly: local.readOnly,
133
- editable: local.editable,
134
- submitBehavior: local.submitBehavior,
135
- blurOnSubmit: local.blurOnSubmit,
136
- multiline,
137
- cursorColor: local.cursorColor,
138
- selectionColor: local.selectionColor,
139
- selectionHandleColor: local.selectionHandleColor,
140
- autoComplete: local.autoComplete,
141
- textContentType: local.textContentType,
142
- showSoftInputOnFocus: local.showSoftInputOnFocus,
143
- underlineColorAndroid: local.underlineColorAndroid,
144
- }),
145
- // TextInput owns its host element rather than rendering through a symbiote View, so it
146
- // folds aria/role into the canonical accessibility* props here. The vanished-key widening
147
- // that fold needs is descriptorToSolid's job (utils/stable-keys), applied to the whole
148
- // Descriptor prop bag — nothing to repeat here.
149
- passthrough: {
150
- ...resolveAccessibilityProps(rest),
151
- onChange: handleChange,
152
- onFocus: handleFocus,
153
- onBlur: handleBlur,
154
- },
155
- }));
156
- host = node;
157
- // Dropped together with the computations this build owns, so a command can never aim at a node
158
- // that was swapped out and is no longer mounted.
159
- onCleanup(() => {
160
- if (host === node)
161
- host = null;
162
- });
163
- // autoFocus is driven in JS, not as a native prop (RN does the same via TextInputState.focusInput,
164
- // TextInput.js:538). The engine commits on a microtask here (renderer.ts's requestCommit), so at
165
- // this point the node has no Fabric tag and a bare dispatchViewCommand would silently no-op —
166
- // whenCommitted defers it to the commit that assigns the tag. Cancelled on cleanup so an
167
- // un-committed pending focus cannot outlive the node.
168
- if (local.autoFocus === true && !autoFocused) {
169
- autoFocused = true;
170
- dlog('TextInput autoFocus -> focus command');
171
- const cancel = whenCommitted(node, () => {
172
- dispatchViewCommand(node, 'focus', []);
173
- });
174
- onCleanup(cancel);
175
- }
176
- return node;
177
- };
178
- // Controlled write: when JS-side `value` diverges from what native reported, command the new text
179
- // down with the acknowledged count. A plain prop re-push would race the user's keystrokes.
180
- //
181
- // READING THE COUNT IS LOAD-BEARING, not bookkeeping — it is what makes a REFUSED change snap
182
- // back. On a refusal `value` never changes, so a value-only dependency (Vue's watch shape) would
183
- // never re-run and native would keep the text JS rejected. The count bumps on every native
184
- // change, which is React's `useLayoutEffect` with no deps expressed in Solid's terms.
185
- createEffect(() => {
186
- const value = local.value;
187
- const count = mostRecentEventCount();
188
- const selection = local.selection;
189
- if (host === null)
190
- return;
191
- if (!shouldCommandText(lastNativeText, value))
192
- return;
193
- const selStart = selection?.start ?? SELECTION_NONE;
194
- const selEnd = selection?.end ?? selection?.start ?? SELECTION_NONE;
195
- dlog(`TextInput setTextAndSelection count=${count} text=${JSON.stringify(value)}`);
196
- dispatchViewCommand(host, 'setTextAndSelection', [
197
- count,
198
- value,
199
- selStart,
200
- selEnd,
201
- ]);
202
- lastNativeText = value;
203
- });
204
- // The imperative API RN exposes on a TextInput ref. focus/blur drive native view commands; clear
205
- // and setSelection reuse setTextAndSelection (the same stale-safe path as a controlled write),
206
- // echoing the acknowledged count so native applies them. Every method reads `host` LIVE, so a
207
- // multiline swap cannot leave the handle pointing at a dead node.
208
- const handle = {
209
- focus: () => {
210
- if (host !== null)
211
- dispatchViewCommand(host, 'focus', []);
212
- },
213
- blur: () => {
214
- // Routes through TextInputState so the app-wide focus tracking clears too.
215
- blurTextInput(host);
216
- },
217
- clear: () => {
218
- if (host === null)
219
- return;
220
- dispatchViewCommand(host, 'setTextAndSelection', [
221
- acknowledgedCount(),
222
- '',
223
- 0,
224
- 0,
225
- ]);
226
- lastNativeText = '';
227
- },
228
- isFocused: () => focused,
229
- setSelection: (start, end) => {
230
- if (host === null)
231
- return;
232
- const current = lastNativeText ?? '';
233
- dispatchViewCommand(host, 'setTextAndSelection', [
234
- acknowledgedCount(),
235
- current,
236
- start,
237
- end,
238
- ]);
239
- },
240
- };
241
- // Single- and multiline are DIFFERENT native views (RCTSinglelineTextInputView vs
242
- // RCTMultilineTextInputView), so a runtime flip has to build a new host node rather than re-prop
243
- // the old one — React remounts, Vue h()s a new type, Svelte swaps an {#if} branch. The memo is
244
- // that swap: `insert` replaces the previous node with the new one and disposes the old build.
245
- //
246
- // The untrack is load-bearing. Building reads descriptorToSolid's own memo, so without it THIS
247
- // memo would subscribe to every prop the render fn touches and rebuild the entire input — new
248
- // native view, lost cursor and keyboard — on every keystroke. (Solid's own `createComponent`
249
- // untracks for exactly this reason; there is no component here to inherit it from.)
250
- const hostTree = createMemo(() => {
251
- const multiline = local.multiline === true;
252
- return untrack(() => buildHost(multiline));
253
- });
254
- // Solid's `ref` is a compile-time construct: on a component the compiler has already rewritten
255
- // `ref={handle}` into a callback prop by the time we read it, so calling what we were handed is
256
- // the whole job (utils/host-ref.ts explains the rewrite). Not applyHostRef, which is typed for a
257
- // host node — this ref receives the handle, matching React's forwardRef contract.
258
- //
259
- // Called AFTER `hostTree` has built at least once, not right after `handle` is constructed -
260
- // same reasoning as ScrollView's ref-ordering fix (scroll-view/shared.tsx). `handle`'s methods
261
- // read `host` LIVE on every call, so calling ref early never caused a visible bug here (unlike
262
- // ScrollView, whose handle a consumer - createAnimatedComponent's resolveHostNode - unwrapped
263
- // eagerly, once, at ref-call time). Reordered anyway: a future Animated.TextInput would hit the
264
- // same permanently-null capture, and matching the safe ordering now costs nothing.
265
- if (typeof local.ref === 'function')
266
- local.ref(handle);
267
- return hostTree;
268
- }
@@ -1,27 +0,0 @@
1
- // Text — the Solid host primitive for RN's <Text>. Same children/ref idiom as ./view.tsx; read
2
- // that file's header first, it carries the reasoning both share.
3
- //
4
- // THE NESTING (`RCTText` vs `RCTVirtualText`) IS NOT THIS FILE'S JOB, and that is worth stating
5
- // because it is the one place React's own renderer needs a context. React Native tracks a
6
- // TextAncestor context so a <Text> inside another <Text> renders as a virtual span instead of a
7
- // paragraph host. Here the retained tree already knows: the engine's commit walk carries a
8
- // `hasTextAncestor` flag down and picks the view name itself
9
- // (`viewNameFor` in core/engine/src/commit.ts — `node.isText && hasTextAncestor` -> RCTVirtualText),
10
- // and it re-creates the node from scratch when that kind flips. So there is no Solid context, no
11
- // provider, and nothing for an adapter to thread; every adapter emits the same flat
12
- // `symbiote-text` and the engine resolves the position-dependent name for all of them. The
13
- // Solid equivalent of TextAncestorContext is deliberately ABSENT, not missing.
14
- import { splitProps } from 'solid-js';
15
- import { resolveAccessibilityProps, resolveTextProps, } from '@symbiote-native/components';
16
- import { applyHostRef } from '../utils/host-ref.js';
17
- import { withStableKeys } from '../utils/stable-keys.js';
18
- export function Text(props) {
19
- const [local, rest] = splitProps(props, ['children', 'ref']);
20
- const bag = withStableKeys(() => resolveTextProps({ ...resolveAccessibilityProps(rest) }));
21
- const attachRef = (node) => {
22
- applyHostRef(local.ref, node);
23
- };
24
- return (<symbiote-text ref={attachRef} {...bag()}>
25
- {local.children}
26
- </symbiote-text>);
27
- }
@@ -1,22 +0,0 @@
1
- import type { JSX } from '../../jsx-runtime';
2
- import { type IPressTimingProps } from '@symbiote-native/components';
3
- import { type IStyleProp, type IViewStyle } from '@symbiote-native/engine';
4
- import { type IPressableProps } from '../pressable';
5
- type ITouchableBaseProps = Omit<IPressableProps, 'style' | 'children'> & IPressTimingProps & {
6
- style?: IStyleProp<IViewStyle>;
7
- children?: JSX.Element;
8
- };
9
- export interface ITouchableOpacityProps extends ITouchableBaseProps {
10
- activeOpacity?: number;
11
- }
12
- export declare function TouchableOpacity(props: ITouchableOpacityProps): JSX.Element;
13
- export interface ITouchableHighlightProps extends ITouchableBaseProps {
14
- activeOpacity?: number;
15
- underlayColor?: string;
16
- onShowUnderlay?: () => void;
17
- onHideUnderlay?: () => void;
18
- }
19
- export declare function TouchableHighlight(props: ITouchableHighlightProps): JSX.Element;
20
- export type ITouchableWithoutFeedbackProps = ITouchableBaseProps;
21
- export declare function TouchableWithoutFeedback(props: ITouchableWithoutFeedbackProps): JSX.Element;
22
- export {};
@@ -1,247 +0,0 @@
1
- // The Touchable* family for Solid, all built on Pressable. Ported against RN's own sources
2
- // (.vendors/react-native/.../Components/Touchable/*.js), NOT against this repo's React adapter —
3
- // an audit on 2026-08-19 found the React/Vue/Svelte/Angular ports diverge from RN in ten places.
4
- // This adapter is the first on the RN-accurate shared API; the other four migrate in phase two,
5
- // which is why @symbiote-native/components carries both forms for now.
6
- //
7
- // Shared (framework-agnostic) half: the press-scheduling machine, the underlay machine, the resting
8
- // opacity math, the press-handler gate, the extra-style split. Solid owns only the lifecycle —
9
- // signals, memos, the real timers, and the Animated wiring.
10
- //
11
- // NOTHING here destructures `props`. A Solid component body runs ONCE and props are getters, so a
12
- // destructure would freeze the component at its mount-time config; every read below sits inside an
13
- // accessor, a memo or an event handler.
14
- //
15
- // No conditional picks a branch in any of the three trees. Solid's `insert` REPLACES a subtree
16
- // rather than diffing it, and a rebuild landing mid-gesture costs the native responder grant
17
- // (.claude/rules/solid-descriptor-bridge.md §4). Every per-press value here is a prop-bag value,
18
- // and a prop bag reaches the host through `spread` — a per-key diff on the SAME element.
19
- import { createEffect, createMemo, createSignal, onCleanup, splitProps, untrack, } from 'solid-js';
20
- import { createHighlightUnderlayHandlers, createHighlightUnderlayRuntime, createTouchableFeedbackHandlers, createTouchableFeedbackRuntime, hasTouchablePressHandler, resolveHighlightExtraStyles, restingOpacityFromStyle, DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS, OPACITY_INACTIVE_DURATION_MS, TOUCHABLE_MIN_PRESS_DURATION_MS, } from '@symbiote-native/components';
21
- import { dlog, } from '@symbiote-native/engine';
22
- import { Animated } from '../../modules/animated/index.js';
23
- import { Pressable } from '../pressable.jsx';
24
- // The real timers both shared machines schedule on — core/components carries no timer globals, so
25
- // scheduling is the adapter's half. Every canceller is retained so unmount cancels what is still in
26
- // flight: a Solid body runs once, so there is no re-render that would otherwise drop a stale timer,
27
- // and a fired timer would call back into a disposed scope.
28
- function createTimeoutScheduler() {
29
- const pending = new Set();
30
- onCleanup(() => {
31
- for (const cancel of [...pending])
32
- cancel();
33
- pending.clear();
34
- });
35
- return (callback, ms) => {
36
- const id = setTimeout(() => {
37
- pending.delete(cancel);
38
- callback();
39
- }, ms);
40
- const cancel = () => {
41
- clearTimeout(id);
42
- pending.delete(cancel);
43
- };
44
- pending.add(cancel);
45
- return cancel;
46
- };
47
- }
48
- // `class` is split off with `style`: both describe the FEEDBACK node (the inner Animated.View),
49
- // not the outer Pressable that owns the responder. Left in `rest` they would land on the wrong one.
50
- const TOUCHABLE_OPACITY_HANDLED = [
51
- 'activeOpacity',
52
- 'style',
53
- 'class',
54
- 'children',
55
- 'onPressIn',
56
- 'onPressOut',
57
- 'delayPressIn',
58
- 'delayPressOut',
59
- 'minPressDuration',
60
- ];
61
- export function TouchableOpacity(props) {
62
- const [local, rest] = splitProps(props, TOUCHABLE_OPACITY_HANDLED);
63
- // RN's _getChildStyleOpacityWithDefault: the fade settles at the opacity the CALLER's style asks
64
- // for, not at a hard 1.
65
- const restingOpacity = createMemo(() => restingOpacityFromStyle(local.style));
66
- // One Animated.Value per mount, held by IDENTITY in a plain variable — a signal or store would
67
- // wrap this engine object in a proxy, and the graph's bookkeeping is keyed on the raw instance
68
- // (symbiote-engine-core §3). untrack because the body must not subscribe to the initial read.
69
- const opacity = new Animated.Value(untrack(restingOpacity));
70
- // The shared press-scheduling cell (delayPressIn canceller + activation clock). A setup-scope
71
- // object, never reactive: the machine mutates it on every event and nothing reads it reactively.
72
- const runtime = createTouchableFeedbackRuntime();
73
- const schedule = createTimeoutScheduler();
74
- function setOpacityTo(toValue, duration) {
75
- dlog(`TouchableOpacity opacity -> ${toValue} over ${duration}ms`);
76
- // useNativeDriver: true is RN's own (TouchableOpacity.js:242). opacity is natively drivable and
77
- // the fade then survives a busy JS thread, which is the whole point of the feedback.
78
- Animated.timing(opacity, {
79
- toValue,
80
- duration,
81
- easing: Animated.Easing.inOut(Animated.Easing.quad),
82
- useNativeDriver: true,
83
- }).start();
84
- }
85
- // Rebuilt only when the TIMING config changes — the callbacks read activeOpacity / onPressIn /
86
- // onPressOut when they fire, so a re-supplied callback is honored without churning the handler
87
- // identities the host node holds. The runtime persists across rebuilds, so an in-flight
88
- // delayPressIn timer survives one.
89
- const handlers = createMemo(() => createTouchableFeedbackHandlers({
90
- delayPressIn: local.delayPressIn ?? 0,
91
- delayPressOut: local.delayPressOut ?? 0,
92
- // RN's Touchables override Pressability's 130ms floor with 0; the active visual is held by
93
- // the fade's own duration instead.
94
- minPressDuration: local.minPressDuration ?? TOUCHABLE_MIN_PRESS_DURATION_MS,
95
- schedule,
96
- now: Date.now,
97
- }, runtime, {
98
- activate(event) {
99
- setOpacityTo(local.activeOpacity ?? DEFAULT_ACTIVE_OPACITY, OPACITY_ACTIVE_GRANT_DURATION_MS);
100
- local.onPressIn?.(event);
101
- },
102
- deactivate(event) {
103
- setOpacityTo(restingOpacity(), OPACITY_INACTIVE_DURATION_MS);
104
- local.onPressOut?.(event);
105
- },
106
- }));
107
- // RN's componentDidUpdate: a changed `disabled` or a changed style opacity re-settles the view,
108
- // so a Touchable disabled mid-press does not stay stuck at its active opacity. Skipped on the
109
- // first run — RN does this on UPDATE only, and firing at mount would animate over the initial
110
- // value the Animated.Value already holds.
111
- createEffect(previous => {
112
- const current = { disabled: rest.disabled, resting: restingOpacity() };
113
- if (previous !== undefined &&
114
- (previous.disabled !== current.disabled ||
115
- previous.resting !== current.resting)) {
116
- setOpacityTo(current.resting, OPACITY_INACTIVE_DURATION_MS);
117
- }
118
- return current;
119
- }, undefined);
120
- // RN's componentWillUnmount: stop the animation and drop the value back, so a teardown mid-fade
121
- // leaves no driver running against a node that is gone.
122
- onCleanup(() => {
123
- opacity.resetAnimation();
124
- });
125
- // Depends on `style` alone. `opacity` is a stable graph node, so a press changes what the node
126
- // HOLDS, never this array — the per-frame path is value.setValue -> AnimatedProps.update ->
127
- // setNativeProps, which never re-enters this component.
128
- const feedbackStyle = createMemo(() => [local.style, { opacity }]);
129
- // A spread followed by explicit props is safe here only because neither override can be
130
- // `undefined` — mergeProps takes the first NON-undefined value scanning back-to-front, so an
131
- // optional override after a spread would silently lose (.claude/rules/solid-descriptor-bridge.md
132
- // §6). handlers() always returns both functions.
133
- return (<Pressable {...rest} onPressIn={handlers().handlePressIn} onPressOut={handlers().handlePressOut}>
134
- <Animated.View style={feedbackStyle()} class={local.class}>
135
- {local.children}
136
- </Animated.View>
137
- </Pressable>);
138
- }
139
- // onPress/onPressIn/onPressOut are INTERCEPTED, not forwarded: RN drives the underlay from those
140
- // three callbacks and then calls the caller's. onLongPress stays in `rest` (Pressable fires it
141
- // directly) and is only READ here, for the has-press-handler gate.
142
- const TOUCHABLE_HIGHLIGHT_HANDLED = [
143
- 'activeOpacity',
144
- 'underlayColor',
145
- 'style',
146
- 'children',
147
- 'onPress',
148
- 'onPressIn',
149
- 'onPressOut',
150
- 'onShowUnderlay',
151
- 'onHideUnderlay',
152
- 'delayPressOut',
153
- ];
154
- export function TouchableHighlight(props) {
155
- const [local, rest] = splitProps(props, TOUCHABLE_HIGHLIGHT_HANDLED);
156
- // `shown` is NOT `pressed`: RN holds the underlay for delayPressOut past the tap so a fast press
157
- // still flashes, and the pressed flag is already false by then.
158
- const [shown, setShown] = createSignal(false);
159
- const runtime = createHighlightUnderlayRuntime();
160
- const schedule = createTimeoutScheduler();
161
- const hasPressHandler = createMemo(() => hasTouchablePressHandler({
162
- onPress: local.onPress,
163
- onPressIn: local.onPressIn,
164
- onPressOut: local.onPressOut,
165
- onLongPress: rest.onLongPress,
166
- }));
167
- const underlay = createMemo(() => createHighlightUnderlayHandlers({
168
- delayPressOut: local.delayPressOut ?? 0,
169
- hasPressHandler: hasPressHandler(),
170
- schedule,
171
- }, runtime, {
172
- setShown,
173
- onShowUnderlay: () => local.onShowUnderlay?.(),
174
- onHideUnderlay: () => local.onHideUnderlay?.(),
175
- }));
176
- const extraStyles = createMemo(() => resolveHighlightExtraStyles({
177
- shown: shown(),
178
- hasPressHandler: hasPressHandler(),
179
- underlayColor: local.underlayColor,
180
- activeOpacity: local.activeOpacity,
181
- }));
182
- // POINT 7 IS DELIBERATELY NOT FIXED HERE, and this is the whole of the divergence from RN.
183
- // RN clones its single child with `extra.child` so the lowered opacity lands on the CHILD while
184
- // the container keeps an opaque underlay. Solid has no cloneElement, so the only way to reach the
185
- // child is a permanent wrapper view — which inserts a node into the flex chain between this
186
- // container and the children, silently re-parenting any `flex` a child declares. The fake Fabric
187
- // runs no Yoga, so no headless test can measure that damage, only assert the extra node exists.
188
- // Shipping an unmeasurable layout change is worse than the visual approximation, so both styles
189
- // stay on the container (the pre-audit behavior) and the child style is folded in last.
190
- // resolveHighlightExtraStyles keeps them SEPARATE regardless, so React's cloneElement path and a
191
- // future Solid fix are both unblocked. A layout-safe fix needs a real device check, not a test.
192
- const containerStyle = createMemo(() => {
193
- const extra = extraStyles();
194
- if (extra === undefined)
195
- return local.style;
196
- return [local.style, extra.underlay, extra.child];
197
- });
198
- // Visual first, then the caller's callback — RN's order in _createPressabilityConfig.
199
- const handlePressIn = (event) => {
200
- underlay().handlePressIn(event);
201
- local.onPressIn?.(event);
202
- };
203
- const handlePress = (event) => {
204
- underlay().handlePress(event);
205
- local.onPress?.(event);
206
- };
207
- const handlePressOut = (event) => {
208
- underlay().handlePressOut(event);
209
- local.onPressOut?.(event);
210
- };
211
- return (<Pressable {...rest} style={containerStyle()} onPress={handlePress} onPressIn={handlePressIn} onPressOut={handlePressOut}>
212
- {local.children}
213
- </Pressable>);
214
- }
215
- const TOUCHABLE_WITHOUT_FEEDBACK_HANDLED = [
216
- 'children',
217
- 'onPressIn',
218
- 'onPressOut',
219
- 'delayPressIn',
220
- 'delayPressOut',
221
- 'minPressDuration',
222
- ];
223
- export function TouchableWithoutFeedback(props) {
224
- const [local, rest] = splitProps(props, TOUCHABLE_WITHOUT_FEEDBACK_HANDLED);
225
- // RN's TouchableWithoutFeedback builds a FULL Pressability config with delayPressIn /
226
- // delayPressOut / minPressDuration: 0 — "without feedback" means no VISUAL, not no timing. The
227
- // same shared machine TouchableOpacity uses runs here with the visual half left empty.
228
- const runtime = createTouchableFeedbackRuntime();
229
- const schedule = createTimeoutScheduler();
230
- const handlers = createMemo(() => createTouchableFeedbackHandlers({
231
- delayPressIn: local.delayPressIn ?? 0,
232
- delayPressOut: local.delayPressOut ?? 0,
233
- minPressDuration: local.minPressDuration ?? TOUCHABLE_MIN_PRESS_DURATION_MS,
234
- schedule,
235
- now: Date.now,
236
- }, runtime, {
237
- activate(event) {
238
- local.onPressIn?.(event);
239
- },
240
- deactivate(event) {
241
- local.onPressOut?.(event);
242
- },
243
- }));
244
- return (<Pressable {...rest} onPressIn={handlers().handlePressIn} onPressOut={handlers().handlePressOut}>
245
- {local.children}
246
- </Pressable>);
247
- }
@@ -1,16 +0,0 @@
1
- import type { JSX } from '../../jsx-runtime';
2
- import { canUseNativeForeground, rippleBackground, selectableBackground, selectableBackgroundBorderless, type INativeFeedbackBackground } from '@symbiote-native/components';
3
- import { type IPressableProps } from '../pressable';
4
- export type { INativeFeedbackBackground, IThemeAttrBackground, IRippleBackground, } from '@symbiote-native/components';
5
- export interface ITouchableNativeFeedbackProps extends Omit<IPressableProps, 'style' | 'children'> {
6
- background?: INativeFeedbackBackground;
7
- useForeground?: boolean;
8
- children?: JSX.Element;
9
- }
10
- declare function TouchableNativeFeedbackImpl(props: ITouchableNativeFeedbackProps): JSX.Element;
11
- export declare const TouchableNativeFeedback: typeof TouchableNativeFeedbackImpl & {
12
- SelectableBackground: typeof selectableBackground;
13
- SelectableBackgroundBorderless: typeof selectableBackgroundBorderless;
14
- Ripple: typeof rippleBackground;
15
- canUseNativeForeground: typeof canUseNativeForeground;
16
- };
@@ -1,68 +0,0 @@
1
- // TouchableNativeFeedback — Android's ripple / state-drawable touchable, built on Pressable like
2
- // the rest of the family. RN clones its child into an RCTView carrying the native ripple props;
3
- // we nest the child under a feedback view that carries them, inside a Pressable that owns the
4
- // press wiring. `nativeBackgroundAndroid` / `nativeForegroundAndroid` are read by Android's
5
- // ReactViewManager; on iOS they are inert props, so the component still renders its child with
6
- // working press wiring — exactly what React's and Vue's do.
7
- //
8
- // EVERYTHING NON-LIFECYCLE IS SHARED. The static factories (selectableBackground,
9
- // selectableBackgroundBorderless, rippleBackground), the platform gate (canUseNativeForeground)
10
- // and the background→native-prop mapping (backgroundProps) all live in
11
- // @symbiote-native/components; this file only attaches them to the component value and nests the
12
- // feedback view. Press timing is Pressable's, which is the shared state machine.
13
- //
14
- // The feedback carrier is a raw `symbiote-view`, not the `View` component — the same choice
15
- // Pressable already makes for its android_ripple wrapper. It carries native props and nothing
16
- // else: no accessibility surface, no class, no ref, so View's aria fold and nativeID resolution
17
- // would be pure overhead. The committed node is `RCTView` either way.
18
- //
19
- // NOTHING here destructures `props`. A Solid component body runs ONCE, so a destructure would
20
- // freeze the background at its mount-time value; every read below sits inside an accessor.
21
- import { splitProps } from 'solid-js';
22
- import { backgroundProps, canUseNativeForeground, rippleBackground, selectableBackground, selectableBackgroundBorderless, } from '@symbiote-native/components';
23
- import { dlog } from '@symbiote-native/engine';
24
- import { withStableKeys } from '../../utils/stable-keys.js';
25
- import { Pressable } from '../pressable.jsx';
26
- function TouchableNativeFeedbackImpl(props) {
27
- const [local, rest] = splitProps(props, [
28
- 'background',
29
- 'useForeground',
30
- 'children',
31
- ]);
32
- // ONE bag, and it goes through withStableKeys. backgroundProps returns a DIFFERENT single key
33
- // per branch (nativeForegroundAndroid vs nativeBackgroundAndroid), and Solid's `spread` walks
34
- // only the current key set with no removal pass — so flipping `useForeground` on an Android
35
- // host would leave the abandoned key painting its old drawable forever. Widening restores what
36
- // React and Vue get from their reconcilers: the vanished key arrives as `undefined`, which
37
- // routeProp treats as a delete, and `spread` still does the actual diffing
38
- // (.claude/rules/solid-descriptor-bridge.md §1).
39
- const feedbackProps = withStableKeys(() => {
40
- // RN never leaves a TouchableNativeFeedback without feedback: a missing background defaults
41
- // to SelectableBackground().
42
- const resolved = local.background ?? selectableBackground();
43
- const useForeground = local.useForeground === true;
44
- dlog(`TouchableNativeFeedback render ${resolved.type} useForeground ${useForeground}`);
45
- // The no-op seam: RN silently routes a requested foreground back to the background slot off
46
- // Android / below API 23, and a missing ripple on device looks like a broken component.
47
- if (useForeground && !canUseNativeForeground()) {
48
- dlog('TouchableNativeFeedback useForeground ignored: no native foreground here');
49
- }
50
- return backgroundProps(resolved, useForeground);
51
- });
52
- // Two single spreads, never spread-then-override: `mergeProps` (which the compiler uses for a
53
- // spread followed by an explicit prop) keeps the FIRST non-undefined value, so an explicit
54
- // `undefined` after a spread silently loses — the opposite of React's JSX spread
55
- // (.claude/rules/solid-descriptor-bridge.md §6). `rest` and `feedbackProps()` are each the sole
56
- // source for their element, so there is nothing to merge.
57
- return (<Pressable {...rest}>
58
- <symbiote-view {...feedbackProps()}>{local.children}</symbiote-view>
59
- </Pressable>);
60
- }
61
- // The statics ride on the component value so callers reach TouchableNativeFeedback.Ripple(…)
62
- // exactly like RN. They are the shared functions themselves, not wrappers.
63
- export const TouchableNativeFeedback = Object.assign(TouchableNativeFeedbackImpl, {
64
- SelectableBackground: selectableBackground,
65
- SelectableBackgroundBorderless: selectableBackgroundBorderless,
66
- Ripple: rippleBackground,
67
- canUseNativeForeground,
68
- });