@symbiote-native/components 0.5.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 (44) hide show
  1. package/README.md +20 -6
  2. package/build/accessibility-props.d.ts +11 -0
  3. package/build/accessibility-props.js +30 -118
  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/component-names/index.android.js +22 -0
  15. package/build/component-names/index.ios.js +8 -0
  16. package/build/component-names/shared.d.ts +1 -1
  17. package/build/component-names/shared.js +36 -0
  18. package/build/fold-host-bag.d.ts +15 -0
  19. package/build/fold-host-bag.js +99 -0
  20. package/build/index.d.ts +6 -1
  21. package/build/index.js +6 -1
  22. package/build/resolve-intrinsic.d.ts +7 -0
  23. package/build/resolve-intrinsic.js +49 -0
  24. package/build/state/pressable.d.ts +9 -0
  25. package/build/state/pressable.js +120 -34
  26. package/build/state/text-input.d.ts +6 -1
  27. package/build/state/text-input.js +11 -0
  28. package/build/state-style.d.ts +15 -0
  29. package/build/state-style.js +47 -0
  30. package/build/text-props.js +2 -1
  31. package/build/view/render-activity-indicator.js +26 -0
  32. package/build/view/render-image/index.d.ts +2 -0
  33. package/build/view/render-image/index.js +29 -2
  34. package/build/view/render-input-accessory-view.d.ts +2 -0
  35. package/build/view/render-input-accessory-view.js +37 -5
  36. package/build/view/render-switch.js +7 -1
  37. package/build/view/render-text-input.js +6 -2
  38. package/host-primitives.cjs +380 -0
  39. package/host-primitives.d.cts +35 -0
  40. package/lowering-fixtures.cjs +259 -0
  41. package/lowering-fixtures.d.cts +17 -0
  42. package/package.json +42 -5
  43. package/specialize-state-style.cjs +219 -0
  44. package/specialize-state-style.d.cts +15 -0
@@ -1,5 +1,6 @@
1
1
  import { type ISymbioteEvent } from '@symbiote-native/engine';
2
2
  export declare const DEFAULT_DELAY_LONG_PRESS_MS = 500;
3
+ export declare const DEFAULT_MIN_PRESS_DURATION_MS = 130;
3
4
  export declare const DEFAULT_PRESS_RECT_OFFSETS: {
4
5
  top: number;
5
6
  left: number;
@@ -56,19 +57,26 @@ export interface IPressRuntime {
56
57
  longPressCancel: (() => void) | undefined;
57
58
  longPressFired: boolean;
58
59
  pressDelayCancel: (() => void) | undefined;
60
+ pressOutCancel: (() => void) | undefined;
59
61
  pressOrigin: {
60
62
  x: number;
61
63
  y: number;
62
64
  } | undefined;
63
65
  driftedOut: boolean;
64
66
  region: IResponderRegion | undefined;
67
+ active: boolean;
68
+ activatedAt: number | undefined;
69
+ delayElapsed: boolean;
70
+ disposed: boolean;
65
71
  }
66
72
  export declare function createPressRuntime(): IPressRuntime;
73
+ export declare function disposePressRuntime(runtime: IPressRuntime): void;
67
74
  export type IFrameCallback = (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void;
68
75
  export interface IPressHost {
69
76
  setPressed: (pressed: boolean) => void;
70
77
  getMeasureFn: () => ((callback: IFrameCallback) => void) | undefined;
71
78
  schedule: (callback: () => void, ms: number) => () => void;
79
+ now: () => number;
72
80
  }
73
81
  export interface IPressMachineConfig {
74
82
  onPress?: IPressHandler;
@@ -78,6 +86,7 @@ export interface IPressMachineConfig {
78
86
  onLongPress?: IPressHandler;
79
87
  delayLongPress: number;
80
88
  unstable_pressDelay: number;
89
+ minPressDuration?: number;
81
90
  hitSlop?: IRectOffset;
82
91
  pressRetentionOffset?: IRectOffset;
83
92
  }
@@ -11,6 +11,8 @@
11
11
  // the decision of when each callback fires) is here, shared by every adapter.
12
12
  import { dlog, Platform } from '@symbiote-native/engine';
13
13
  export const DEFAULT_DELAY_LONG_PRESS_MS = 500;
14
+ // Pressability's default active-visual floor for a plain Pressable. Touchable* overrides this to 0.
15
+ export const DEFAULT_MIN_PRESS_DURATION_MS = 130;
14
16
  // RN's default extra slop kept around a press once it is active, before a drift fires pressOut.
15
17
  // The PressRect extension (Pressability.js DEFAULT_PRESS_RECT_OFFSETS). Per-edge, deeper bottom.
16
18
  export const DEFAULT_PRESS_RECT_OFFSETS = {
@@ -91,11 +93,40 @@ export function createPressRuntime() {
91
93
  longPressCancel: undefined,
92
94
  longPressFired: false,
93
95
  pressDelayCancel: undefined,
96
+ pressOutCancel: undefined,
94
97
  pressOrigin: undefined,
95
98
  driftedOut: false,
96
99
  region: undefined,
100
+ active: false,
101
+ activatedAt: undefined,
102
+ delayElapsed: false,
103
+ disposed: false,
97
104
  };
98
105
  }
106
+ function cancelRuntimeTimer(runtime, key) {
107
+ const cancel = runtime[key];
108
+ if (cancel === undefined)
109
+ return;
110
+ cancel();
111
+ runtime[key] = undefined;
112
+ }
113
+ // RN Pressability.reset(): cancel every timer and make any already-queued callback inert. The
114
+ // adapter invokes this exactly once from its own unmount/destroy hook.
115
+ export function disposePressRuntime(runtime) {
116
+ if (runtime.disposed)
117
+ return;
118
+ runtime.disposed = true;
119
+ cancelRuntimeTimer(runtime, 'longPressCancel');
120
+ cancelRuntimeTimer(runtime, 'pressDelayCancel');
121
+ cancelRuntimeTimer(runtime, 'pressOutCancel');
122
+ runtime.longPressFired = false;
123
+ runtime.pressOrigin = undefined;
124
+ runtime.driftedOut = false;
125
+ runtime.region = undefined;
126
+ runtime.active = false;
127
+ runtime.activatedAt = undefined;
128
+ runtime.delayElapsed = false;
129
+ }
99
130
  // Measure the responder's on-screen frame and cache it as the retention region for the life of
100
131
  // the press (RN measures on responder grant, _measureResponderRegion). When measure is
101
132
  // unavailable (no node yet, an uncommitted node, or a host slot without a measure method,
@@ -123,7 +154,7 @@ function measureRegion(runtime, measureFn) {
123
154
  // (the React adapter's useMemo body) lives here, shared by every adapter. The adapter rebuilds
124
155
  // these per render (the closures capture the live config) while the runtime persists across them.
125
156
  export function createPressHandlers(config, runtime, host) {
126
- const { onPress, onPressIn, onPressOut, onPressMove, onLongPress, delayLongPress, unstable_pressDelay, } = config;
157
+ const { onPress, onPressIn, onPressOut, onPressMove, onLongPress, delayLongPress, unstable_pressDelay, minPressDuration = DEFAULT_MIN_PRESS_DURATION_MS, } = config;
127
158
  // Per-edge offsets for the measured-rect retention test (RN's hitSlop + pressRectOffset).
128
159
  // pressRetentionOffset defaults to RN's DEFAULT_PRESS_RECT_OFFSETS when unset; hitSlop to zero.
129
160
  const hitSlopRect = normalizeRect(config.hitSlop);
@@ -145,20 +176,28 @@ export function createPressHandlers(config, runtime, host) {
145
176
  return (Math.hypot(point.x - origin.x, point.y - origin.y) <= fallbackThreshold);
146
177
  }
147
178
  function clearLongPress() {
148
- if (runtime.longPressCancel !== undefined) {
149
- runtime.longPressCancel();
150
- runtime.longPressCancel = undefined;
151
- }
179
+ cancelRuntimeTimer(runtime, 'longPressCancel');
180
+ }
181
+ function clearPressDelay() {
182
+ cancelRuntimeTimer(runtime, 'pressDelayCancel');
183
+ }
184
+ function clearPressOut() {
185
+ cancelRuntimeTimer(runtime, 'pressOutCancel');
152
186
  }
153
- // The real activation: flip pressed-state on, arm the long-press timer, fire onPressIn. Split
154
- // out so unstable_pressDelay can defer it behind a timer while an early release flushes it.
155
187
  function activate(event) {
188
+ if (runtime.disposed || runtime.active || runtime.driftedOut)
189
+ return;
190
+ // A new grant or drift-back-in supersedes a delayed out from the previous active interval.
191
+ clearPressOut();
192
+ runtime.active = true;
193
+ runtime.activatedAt = host.now();
156
194
  dlog('Pressable pressIn');
157
195
  host.setPressed(true);
158
- runtime.longPressFired = false;
159
- if (onLongPress) {
196
+ if (!runtime.longPressFired && onLongPress) {
160
197
  runtime.longPressCancel = host.schedule(() => {
161
198
  runtime.longPressCancel = undefined;
199
+ if (runtime.disposed || !runtime.active || runtime.driftedOut)
200
+ return;
162
201
  runtime.longPressFired = true;
163
202
  dlog('Pressable longPress timer fired');
164
203
  onLongPress(event);
@@ -166,51 +205,99 @@ export function createPressHandlers(config, runtime, host) {
166
205
  }
167
206
  onPressIn?.(event);
168
207
  }
169
- // Flush a still-pending pressDelay timer immediately, so a pressOut/press that arrives before
170
- // the delay elapsed still sees an activated press.
171
- function flushPressDelay(event) {
172
- if (runtime.pressDelayCancel !== undefined) {
173
- runtime.pressDelayCancel();
174
- runtime.pressDelayCancel = undefined;
175
- activate(event);
208
+ function deactivate(event) {
209
+ if (runtime.disposed || !runtime.active)
210
+ return;
211
+ runtime.active = false;
212
+ clearLongPress();
213
+ const activatedAt = runtime.activatedAt ?? host.now();
214
+ runtime.activatedAt = undefined;
215
+ const heldFor = Math.max(0, host.now() - activatedAt);
216
+ const wait = Math.max(minPressDuration - heldFor, 0);
217
+ const finish = () => {
218
+ runtime.pressOutCancel = undefined;
219
+ if (runtime.disposed)
220
+ return;
221
+ host.setPressed(false);
222
+ onPressOut?.(event);
223
+ };
224
+ if (wait > 0) {
225
+ dlog(`Pressable pressOut deferred ${wait}ms`);
226
+ runtime.pressOutCancel = host.schedule(finish, wait);
227
+ }
228
+ else {
229
+ finish();
176
230
  }
177
231
  }
232
+ // An honest release arrives as press then pressOut. If it beat delayPressIn, activate now so the
233
+ // tap still flashes and calls onPressIn; a cancelled/out-of-bounds gesture never calls this path.
234
+ function completePressDelay(event) {
235
+ if (runtime.pressDelayCancel !== undefined)
236
+ clearPressDelay();
237
+ if (!runtime.delayElapsed)
238
+ runtime.delayElapsed = true;
239
+ if (!runtime.driftedOut)
240
+ activate(event);
241
+ }
242
+ function resetGesture() {
243
+ runtime.pressOrigin = undefined;
244
+ runtime.region = undefined;
245
+ runtime.driftedOut = false;
246
+ runtime.delayElapsed = false;
247
+ runtime.longPressFired = false;
248
+ }
178
249
  return {
179
250
  handlePressIn(event) {
251
+ if (runtime.disposed)
252
+ return;
253
+ // A new grant cancels a delayed out from the prior gesture, matching Pressability's
254
+ // onResponderGrant -> _cancelPressOutDelayTimeout.
255
+ clearPressDelay();
256
+ clearLongPress();
257
+ clearPressOut();
258
+ runtime.active = false;
259
+ runtime.activatedAt = undefined;
260
+ runtime.longPressFired = false;
261
+ runtime.delayElapsed = false;
180
262
  runtime.pressOrigin = readPoint(event);
181
263
  runtime.driftedOut = false;
182
- // Measure now so the move stream tests against the real frame, not a press-start radius.
183
264
  measureRegion(runtime, host.getMeasureFn());
184
265
  if (unstable_pressDelay > 0) {
185
266
  dlog(`Pressable pressIn deferred ${unstable_pressDelay}ms`);
186
267
  runtime.pressDelayCancel = host.schedule(() => {
187
268
  runtime.pressDelayCancel = undefined;
188
- activate(event);
269
+ if (runtime.disposed)
270
+ return;
271
+ runtime.delayElapsed = true;
272
+ if (!runtime.driftedOut)
273
+ activate(event);
189
274
  }, unstable_pressDelay);
190
275
  return;
191
276
  }
277
+ runtime.delayElapsed = true;
192
278
  activate(event);
193
279
  },
194
280
  handlePressOut(event) {
281
+ if (runtime.disposed)
282
+ return;
195
283
  dlog('Pressable pressOut');
196
- flushPressDelay(event);
284
+ // A cancellation before delayPressIn must stay inert. An honest release already flushed the
285
+ // delay in handlePress, because the engine emits press immediately before pressOut.
286
+ clearPressDelay();
197
287
  clearLongPress();
198
- runtime.pressOrigin = undefined;
199
- runtime.region = undefined;
200
- host.setPressed(false);
201
- onPressOut?.(event);
288
+ deactivate(event);
289
+ resetGesture();
202
290
  },
203
291
  handlePress(event) {
292
+ if (runtime.disposed)
293
+ return;
204
294
  dlog('Pressable press');
205
- flushPressDelay(event);
295
+ completePressDelay(event);
206
296
  clearLongPress();
207
- // A drift past the retention region cancels the tap (RN); reset and suppress.
208
297
  if (runtime.driftedOut) {
209
- runtime.driftedOut = false;
210
298
  dlog('Pressable press suppressed by drift past retention region');
211
299
  return;
212
300
  }
213
- // A fired long press cancels the tap: reset the flag and suppress onPress.
214
301
  if (runtime.longPressFired) {
215
302
  runtime.longPressFired = false;
216
303
  dlog('Pressable press suppressed by prior longPress');
@@ -218,11 +305,9 @@ export function createPressHandlers(config, runtime, host) {
218
305
  }
219
306
  onPress?.(event);
220
307
  },
221
- // Responder-move stream of the Pressable's own View. onPressMove fires on every move while
222
- // the press is live (RN). Then the retention test: a move outside the measured region
223
- // (expanded by hitSlop+pressRectOffset) fires an early pressOut and marks the tap suppressed;
224
- // a move back inside re-activates.
225
308
  handleResponderMove(event) {
309
+ if (runtime.disposed)
310
+ return;
226
311
  onPressMove?.(event);
227
312
  const here = readPoint(event);
228
313
  if (!here)
@@ -232,14 +317,15 @@ export function createPressHandlers(config, runtime, host) {
232
317
  dlog('Pressable drifted past retention region — deactivating');
233
318
  runtime.driftedOut = true;
234
319
  clearLongPress();
235
- host.setPressed(false);
236
- onPressOut?.(event);
320
+ // Before delayPressIn there was no activation, so there is no onPressOut to emit.
321
+ deactivate(event);
237
322
  }
238
323
  }
239
324
  else if (runtime.driftedOut) {
240
325
  dlog('Pressable returned inside retention region — reactivating');
241
326
  runtime.driftedOut = false;
242
- activate(event);
327
+ if (runtime.delayElapsed)
328
+ activate(event);
243
329
  }
244
330
  },
245
331
  };
@@ -1,4 +1,4 @@
1
- import type { IPlatformOSType, ISymbioteEvent, ITextStyle } from '@symbiote-native/engine';
1
+ import type { IMeasureOnSuccess, IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, ISymbioteNode, IPlatformOSType, ISymbioteEvent, ITextStyle } from '@symbiote-native/engine';
2
2
  import type { IAccessibilityProps, IAriaProps } from '../accessibility-props';
3
3
  export type IInputMode = 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
4
4
  export type IEnterKeyHint = 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
@@ -95,10 +95,15 @@ export type ITextInputProps = IAccessibilityProps & IAriaProps & {
95
95
  onSelectionChange?: ITextInputEventHandler;
96
96
  onContentSizeChange?: ITextInputEventHandler;
97
97
  };
98
+ export declare const TEXT_INPUT_CALLBACK_NAMES: readonly string[];
98
99
  export type ITextInputHandle = {
99
100
  focus(): void;
100
101
  blur(): void;
101
102
  clear(): void;
102
103
  isFocused(): boolean;
103
104
  setSelection(start: number, end: number): void;
105
+ measure(callback: IMeasureOnSuccess): void;
106
+ measureInWindow(callback: IMeasureInWindowOnSuccess): void;
107
+ measureLayout(relativeToNativeNode: ISymbioteNode | number, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
108
+ setNativeProps(nativeProps: Record<string, unknown>): void;
104
109
  };
@@ -230,3 +230,14 @@ export function resolveTextInputProps(input) {
230
230
  : input.showSoftInputOnFocus,
231
231
  };
232
232
  }
233
+ const TEXT_INPUT_CALLBACKS = {
234
+ onValueChange: true,
235
+ onFocus: true,
236
+ onBlur: true,
237
+ onEndEditing: true,
238
+ onSubmitEditing: true,
239
+ onKeyPress: true,
240
+ onSelectionChange: true,
241
+ onContentSizeChange: true,
242
+ };
243
+ export const TEXT_INPUT_CALLBACK_NAMES = Object.keys(TEXT_INPUT_CALLBACKS);
@@ -0,0 +1,15 @@
1
+ export interface IPressStateArgument {
2
+ pressed: boolean;
3
+ }
4
+ export interface IResolvedStateStyle {
5
+ style: unknown;
6
+ activeStyle: unknown;
7
+ }
8
+ /**
9
+ * Splits an authored `style` into its resting and pressed halves, reading the value once.
10
+ *
11
+ * A non-callback passes through untouched with no active variant, which the engine reads as "leave
12
+ * slot 1 alone" — that is what makes one emission safe for an expression whose value cannot be
13
+ * known at compile time.
14
+ */
15
+ export declare function resolveStateStyle(value: unknown): IResolvedStateStyle;
@@ -0,0 +1,47 @@
1
+ // The runtime half of state-style lowering: splits an authored `style` into its resting and pressed
2
+ // halves. Referenced by the code every lowering transform EMITS, never by app code.
3
+ //
4
+ // SHARED for the same reason `HOST_PRIMITIVES` and `specializeStateStyle` are — two adapters had
5
+ // written byte-identical copies within an hour of each other, which is the duplication
6
+ // `<adapters_stay_thin>` exists to stop. Each adapter re-exports it from its own `./state-style`
7
+ // subpath so the emitted import specifier stays inside the package the app already depends on.
8
+ //
9
+ // WHY A HELPER EXISTS BESIDE THE INLINE GUARD, since a transform may emit either. Building the pair
10
+ // needs the authored `style` expression, and the two emissions are NOT equivalent:
11
+ //
12
+ // inline typeof e === 'function' ? e({pressed:false}) : e prints `e` on both props, so a
13
+ // `getStyle()` runs twice and a `flag ? a : b` can take DIFFERENT branches
14
+ // helper resolveStateStyle(e) prints `e` once and calls its
15
+ // RESULT twice
16
+ //
17
+ // The rule the two must satisfy is `REFUSAL_CATEGORIES.emitStyleExpressionOnce`, and it is about
18
+ // the output rather than the input: **an expression capable of DOING WORK is printed once.** A bare
19
+ // name or a non-computed dotted path is a read, not work, so the inline form is legitimate there —
20
+ // and it matters, because a helper returns one object, which a framework must spread, and a spread
21
+ // costs the element its patch flag (Vue measured `12 /* STYLE, PROPS */` becoming
22
+ // `16 /* FULL_PROPS */` plus a `mergeProps` per render on the hottest element in the tree).
23
+ //
24
+ // So the VERDICT is identical across adapters — every shape lowers — and only the cost differs,
25
+ // exactly as compile-time substitution differs between a JSX path and an SFC one. The verdicts are
26
+ // what `@symbiote-native/components/lowering-fixtures` pins.
27
+ //
28
+ // THE CONTRACT THIS IMPOSES ON APP CODE: a style callback must be PURE in `pressed`. Its result is
29
+ // invoked twice under every emission, so a side-effecting body is observable now.
30
+ function isStyleCallback(value) {
31
+ return typeof value === 'function';
32
+ }
33
+ /**
34
+ * Splits an authored `style` into its resting and pressed halves, reading the value once.
35
+ *
36
+ * A non-callback passes through untouched with no active variant, which the engine reads as "leave
37
+ * slot 1 alone" — that is what makes one emission safe for an expression whose value cannot be
38
+ * known at compile time.
39
+ */
40
+ export function resolveStateStyle(value) {
41
+ if (!isStyleCallback(value))
42
+ return { style: value, activeStyle: undefined };
43
+ return {
44
+ style: value({ pressed: false }),
45
+ activeStyle: value({ pressed: true }),
46
+ };
47
+ }
@@ -1,6 +1,7 @@
1
1
  // The defaults RN's Text.js applies before handing props to native, folded once for every adapter.
2
2
  //
3
- // RN does this unconditionally on the outer (non-virtual) path, Text.js:288-291:
3
+ // RN does this unconditionally on the outer (non-virtual) path, Text.js:289 and :291 (the two
4
+ // lines between them, 288 and 290, are `accessible` and `disabled` — not part of this fold):
4
5
  //
5
6
  // processedProps.allowFontScaling = allowFontScaling !== false;
6
7
  // processedProps.ellipsizeMode = ellipsizeMode ?? 'tail';
@@ -1,3 +1,29 @@
1
+ // ACTIVITYINDICATOR IS NOT A LOWERABLE PRIMITIVE, AND WILL NOT BECOME ONE. Decided 2026-09-01.
2
+ // It stays a component in every adapter; do not add it to `HOST_PRIMITIVES`.
3
+ //
4
+ // The reason is the `el('symbiote-view', wrapperProps, [el('symbiote-activity-indicator', …)])` at
5
+ // the bottom of this file: the render SYNTHESIZES a node that is not the primitive itself. A lowered
6
+ // tag is ONE engine node, and a host behavior's `foldPayload` maps props to props on that node — it
7
+ // cannot create a child. Lowering would therefore drop the centering container and change layout,
8
+ // which is an optimisation moving the observable surface, the one thing this design may not do.
9
+ //
10
+ // The wrapper is not over-building: RN's own `ActivityIndicator.js:112` renders a `<View>` around
11
+ // the native spinner too, so the two nodes are inherent. And the container cannot be folded INTO the
12
+ // spinner — it carries `alignItems`/`justifyContent`, which centre the spinner inside the space it
13
+ // was given; moved onto the spinner they would centre its children, of which it has none.
14
+ //
15
+ // The two alternatives were priced and both cost more than the primitive is worth. A behavior that
16
+ // CREATES a node needs a commit hook, i.e. a machine, i.e. a `-managed` tag split — a new category,
17
+ // not a fold. Synthesising the container inside the engine's commit walk is NOT the `RCTVirtualText`
18
+ // precedent it resembles: `viewNameFor` changes what one node IS and never ADDS one, so adding one
19
+ // would make the retained and committed trees disagree on node count — which every counter, census
20
+ // probe and benchmark in this repo assumes. Spinners are rare on a screen, so the per-instance cost
21
+ // lowering removes is not measurably paid here; the objection to leaving it is uniformity, not speed.
22
+ //
23
+ // Under half A this costs an app NOTHING: `View` and `ActivityIndicator` are both ordinary imports
24
+ // from the adapter barrel, so which one is internally a tag and which a component is invisible at
25
+ // the call site. The general rule is in `.claude/rules/host-primitive-tier.md`.
26
+ //
1
27
  // ActivityIndicator: the render half (framework-agnostic). RN wraps the native spinner
2
28
  // in a centering View and translates `size` in JS: 'small'/'large' map to a native size
3
29
  // enum AND a fixed box style; a numeric size never reaches native (it sizes the spinner
@@ -53,4 +53,6 @@ export type IImageViewProps = {
53
53
  referrerPolicy?: string;
54
54
  passthrough: Record<string, unknown>;
55
55
  };
56
+ export declare const IMAGE_VIEW_PROP_NAMES: readonly ["source", "defaultSource", "loadingIndicatorSource", "style", "resizeMode", "tintColor", "src", "srcSet", "alt", "width", "height", "crossOrigin", "referrerPolicy"];
57
+ export declare function mapImageProps(view: IImageViewProps): Record<string, unknown>;
56
58
  export declare function renderImage(view: IImageViewProps): IDescriptor;
@@ -112,7 +112,31 @@ function readSourceUri(source) {
112
112
  }
113
113
  return undefined;
114
114
  }
115
- export function renderImage(view) {
115
+ // The names `renderImage` CONSUMES rather than forwards — the fold's input list, and the reason it
116
+ // is exported: an adapter that splits props before calling renderImage was copying this list by
117
+ // hand, and a name added here reached the copy only if someone remembered. The Image behavior's
118
+ // `foldPayload` reads it too, so the flat path and the view path cannot drift apart.
119
+ export const IMAGE_VIEW_PROP_NAMES = [
120
+ 'source',
121
+ 'defaultSource',
122
+ 'loadingIndicatorSource',
123
+ 'style',
124
+ 'resizeMode',
125
+ 'tintColor',
126
+ 'src',
127
+ 'srcSet',
128
+ 'alt',
129
+ 'width',
130
+ 'height',
131
+ 'crossOrigin',
132
+ 'referrerPolicy',
133
+ ];
134
+ // The whole of Image's prop mapping, with the `el()` wrap taken off. Split out because a LOWERED
135
+ // `<symbiote-image>` has no component body to run it in: its behavior's `foldPayload` needs the same
136
+ // bag at commit time, and a second implementation of this is the exact drift this file already paid
137
+ // for once (`adapters/svelte/src/components/image/image-logic.ts` reproduces it by hand, with a
138
+ // comment saying it had to because nothing here was exported).
139
+ export function mapImageProps(view) {
116
140
  // `width` / `height` aliases fold into style (ImageProps.js:195,202); explicit
117
141
  // style keys win, matching RN's `{width, height}, ...style` ordering.
118
142
  const foldedStyle = view.width === undefined && view.height === undefined
@@ -138,6 +162,9 @@ export function renderImage(view) {
138
162
  if (view.loadingIndicatorSource !== undefined) {
139
163
  mapped.loadingIndicatorSrc = readSourceUri(view.loadingIndicatorSource);
140
164
  }
165
+ return mapped;
166
+ }
167
+ export function renderImage(view) {
141
168
  dlog('Image -> RCTImageView');
142
- return el('symbiote-image', mapped);
169
+ return el('symbiote-image', mapImageProps(view));
143
170
  }
@@ -6,4 +6,6 @@ export type IInputAccessoryViewViewProps = {
6
6
  style?: IStyleProp<IViewStyle>;
7
7
  passthrough: Record<string, unknown>;
8
8
  };
9
+ export declare const INPUT_ACCESSORY_VIEW_PROP_NAMES: readonly ["nativeID", "backgroundColor", "style"];
10
+ export declare function mapInputAccessoryViewProps(view: IInputAccessoryViewViewProps): Record<string, unknown>;
9
11
  export declare function renderInputAccessoryView(view: IInputAccessoryViewViewProps): IDescriptor;
@@ -6,16 +6,48 @@
6
6
  // verbatim across adapters: React and Vue both bridge this Descriptor.
7
7
  import { dlog, } from '@symbiote-native/engine';
8
8
  import { el } from '../descriptor.js';
9
- export function renderInputAccessoryView(view) {
10
- const props = {
9
+ // The names this fold CONSUMES, exported so an adapter splitting props before calling the render fn
10
+ // reads the list instead of copying it — the drift `render-image.ts` records paying for once.
11
+ export const INPUT_ACCESSORY_VIEW_PROP_NAMES = [
12
+ 'nativeID',
13
+ 'backgroundColor',
14
+ 'style',
15
+ ];
16
+ // The whole mapping, so the wrapper path and the lowered path share ONE implementation. There is no
17
+ // aliasing here at all: every consumed name leaves under the same name, which is what makes the
18
+ // fold idempotent by construction (asserted in `behaviors/input-accessory-view.test.ts`, not
19
+ // assumed).
20
+ //
21
+ // The `undefined` guards on `nativeID` / `backgroundColor` are LOAD-BEARING, and the asymmetry with
22
+ // the unguarded `style` above is not the tell it looks like.
23
+ //
24
+ // They were removed on 2026-09-01 on the reasoning `.claude/rules/fabric-boolean-event-gates.md`
25
+ // states — `setProp` collapses an undefined value to an absent key, so a conditional write in a
26
+ // shared render fn is cosmetic — and that reasoning was checked against a payload carrying no `id`,
27
+ // which is exactly the case where it holds. The equivalence oracle caught it on its first run:
28
+ //
29
+ // authored <InputAccessoryView id="p" testID="p" />
30
+ // guarded RCTInputAccessoryView{testID, nativeID:"p"}
31
+ // unguarded RCTInputAccessoryView{testID} <- the alias result, deleted
32
+ //
33
+ // `nativeID` has an ALIAS SOURCE. The wrapper leaves `id` in `passthrough`, the renderer's
34
+ // PROP_ALIASES renames it to `nativeID`, and a `nativeID: undefined` written afterwards deletes what
35
+ // the rename just produced — last write wins, and `undefined` collapsing to "absent" is precisely
36
+ // what makes it destructive rather than inert. So the guard is cosmetic for a key nothing else can
37
+ // produce, and required for a key an alias also targets.
38
+ export function mapInputAccessoryViewProps(view) {
39
+ const out = {
11
40
  ...view.passthrough,
12
41
  style: view.style,
13
42
  };
14
43
  if (view.nativeID !== undefined)
15
- props.nativeID = view.nativeID;
44
+ out.nativeID = view.nativeID;
16
45
  if (view.backgroundColor !== undefined)
17
- props.backgroundColor = view.backgroundColor;
46
+ out.backgroundColor = view.backgroundColor;
47
+ return out;
48
+ }
49
+ export function renderInputAccessoryView(view) {
18
50
  dlog('InputAccessoryView -> RCTInputAccessoryView');
19
51
  // Empty structural children: the adapter appends the user children directly under the host.
20
- return el('symbiote-input-accessory-view', props, []);
52
+ return el('symbiote-input-accessory-view', mapInputAccessoryViewProps(view), []);
21
53
  }
@@ -8,6 +8,12 @@ import { dlog } from '@symbiote-native/engine';
8
8
  import { el } from '../descriptor.js';
9
9
  // RN rounds the iOS background pill to this radius when ios_backgroundColor is set.
10
10
  const IOS_BACKGROUND_BORDER_RADIUS = 16;
11
+ // The `-managed` spelling, NOT the plain one the engine's Switch behavior registers under
12
+ // (`core/components/src/behaviors/switch.ts`) — the wrapper still runs its own lifecycle
13
+ // (lastNativeReport, the snap-back effect), so it may not share a tag the behavior registry would
14
+ // also attach to; see that behavior's module header and `.claude/rules/host-primitive-tier.md`,
15
+ // "A lowered element and its wrapper must not share an intrinsic tag" (the TextInput precedent).
16
+ const SWITCH_MANAGED_INTRINSIC = 'symbiote-switch-managed';
11
17
  // Fold ios_backgroundColor into the style, matching RN's iOS branch: it paints the
12
18
  // background that shows through the shrunken track. Untouched when unset, so a caller's own
13
19
  // backgroundColor wins by simply not passing ios_backgroundColor.
@@ -32,5 +38,5 @@ export function renderSwitch(view, platform) {
32
38
  thumbTintColor: view.thumbColor,
33
39
  style: foldIosBackground(view.style, view.ios_backgroundColor),
34
40
  };
35
- return el('symbiote-switch', props);
41
+ return el(SWITCH_MANAGED_INTRINSIC, props);
36
42
  }
@@ -8,8 +8,12 @@ import { dlog } from '@symbiote-native/engine';
8
8
  import { el } from '../descriptor.js';
9
9
  // One host element per native input class. Text carries the only non-trivial nesting elsewhere;
10
10
  // here the choice is binary and runtime (the `multiline` prop), so the module stays flat.
11
- const SINGLELINE_INTRINSIC = 'symbiote-text-input';
12
- const MULTILINE_INTRINSIC = 'symbiote-text-input-multiline';
11
+ // The `-managed` spelling, NOT the plain one the lowering transforms emit. Both resolve to the
12
+ // same native view; the tag is what decides whether the engine's TextInput machine attaches, and
13
+ // on this path the adapter's own lifecycle already owns that state. Sharing the tag would run both
14
+ // copies at once. Full reasoning in `component-names/shared.ts`.
15
+ const SINGLELINE_INTRINSIC = 'symbiote-text-input-managed';
16
+ const MULTILINE_INTRINSIC = 'symbiote-text-input-multiline-managed';
13
17
  export function renderTextInput(view) {
14
18
  const intrinsic = view.multiline ? MULTILINE_INTRINSIC : SINGLELINE_INTRINSIC;
15
19
  dlog(`TextInput render multiline=${String(view.multiline)} ` +