@symbiote-native/engine 0.3.0 → 0.5.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/build/debug.js CHANGED
@@ -12,11 +12,17 @@
12
12
  // logging off. On a hot path (a getter Angular re-reads every change-detection
13
13
  // pass, an Animated reconcile, a scroll-driven apply) pass a THUNK instead:
14
14
  // `dlog(() => \`…\`)` - it is only called once the switch is on.
15
- function envEnabled() {
16
- return typeof process !== 'undefined' && process.env.DEBUG === '1';
17
- }
15
+ // Read ONCE, at module load. `process.env` is not a plain object in Node - each property read
16
+ // crosses into the host environment - and isDebug() is called on the per-node commit path, so the
17
+ // per-call read showed up as 17% of self time in a create-path CPU profile (headless; on a native
18
+ // host `process` is a shim and the read is cheap, so treat that figure as a Node one).
19
+ //
20
+ // Safe to freeze because nothing toggles the ENV switch mid-process: every runtime toggle in the
21
+ // repo goes through __SYMBIOTE_DEBUG__ below, which stays dynamic, and bootstrap mirrors the env
22
+ // onto it at start anyway.
23
+ const envDebug = typeof process !== 'undefined' && process.env.DEBUG === '1';
18
24
  export function isDebug() {
19
- return globalThis.__SYMBIOTE_DEBUG__ === true || envEnabled();
25
+ return envDebug || globalThis.__SYMBIOTE_DEBUG__ === true;
20
26
  }
21
27
  export function dlog(message) {
22
28
  if (!isDebug())
@@ -8,7 +8,7 @@
8
8
  import { dlog } from '../debug.js';
9
9
  import { runWrapped } from '../dispatch.js';
10
10
  import { getSlot } from '../fabric.js';
11
- import { isAnchor, isSymbioteNode, } from '../node.js';
11
+ import { committedOf, isAnchor, isSymbioteNode, } from '../node.js';
12
12
  import { registeredNativeEvent } from '../registry.js';
13
13
  import { attachTouchHistory, recordTouchTrack, resetTouchHistory, touchHistory, } from '../touch-history.js';
14
14
  import { isRecord } from '../type-guards.js';
@@ -92,32 +92,34 @@ const DEFAULT_LONG_PRESS_MS = 500;
92
92
  // points from where it started (Pressability.DEFAULT_LONG_PRESS_DEACTIVATION_DISTANCE).
93
93
  const LONG_PRESS_DEACTIVATION_DISTANCE = 10;
94
94
  let installed = false;
95
- // Target of the in-flight touch, remembered at topTouchStart and consumed (or
96
- // cleared) at topTouchEnd / topTouchCancel.
97
- let pressStart;
95
+ // Each unrelated target owns an independent press. Additional fingers under the same owner join
96
+ // that press, so one target still receives exactly one pressIn/press/pressOut lifecycle.
97
+ const activePresses = new Set();
98
98
  // The node that claimed the responder for the in-flight touch (PanResponder), or
99
99
  // undefined when nobody claimed it. Receives move and release/terminate.
100
100
  let currentResponder;
101
- // Long-press synthesis: armed at touch start when some node in the press path listens
102
- // for it, fired once after the hold delay, disarmed on end/cancel; the same arm/clear
103
- // lifecycle Pressable runs in JS. Pressability ALSO cancels the timer when the touch
104
- // drifts past LONG_PRESS_DEACTIVATION_DISTANCE, so we record the start point at touch
105
- // start and clear the timer on a move that exceeds it.
106
- let longPressTimer;
107
- let longPressFired = false;
108
- // Touch coordinate at touch start (pageX/pageY), or undefined when the native event
109
- // carried no coords; then the move-distance cancel is simply skipped.
110
- let longPressStart;
111
- function clearLongPress() {
112
- if (longPressTimer !== undefined) {
113
- clearTimeout(longPressTimer);
114
- longPressTimer = undefined;
101
+ function clearLongPress(press) {
102
+ if (press.longPressTimer !== undefined) {
103
+ clearTimeout(press.longPressTimer);
104
+ press.longPressTimer = undefined;
115
105
  }
116
106
  }
117
- // Read the gesture's page coordinate from a raw native touch event, defensively: RN
118
- // puts pageX/pageY on the event for a single touch, or on the first entry of a
119
- // `touches` array for multi-touch. Returns undefined when neither shape carries
120
- // numbers, so callers skip any coordinate-dependent logic rather than guess.
107
+ function takeAllPresses() {
108
+ const presses = [...activePresses];
109
+ activePresses.clear();
110
+ for (const press of presses)
111
+ clearLongPress(press);
112
+ return presses;
113
+ }
114
+ function findActivePress(target) {
115
+ for (const press of activePresses) {
116
+ if (endsWithin(target, press.owner))
117
+ return press;
118
+ }
119
+ return undefined;
120
+ }
121
+ // Read the changed finger's page coordinate from a raw native touch event, falling back to the
122
+ // active-touch list and then undefined. Callers skip coordinate-dependent logic rather than guess.
121
123
  function readTouchPoint(nativeEvent) {
122
124
  const fromPair = (source) => {
123
125
  if (!source)
@@ -130,21 +132,27 @@ function readTouchPoint(nativeEvent) {
130
132
  const direct = fromPair(nativeEvent);
131
133
  if (direct)
132
134
  return direct;
133
- const touches = nativeEvent.touches;
134
- if (Array.isArray(touches)) {
135
- const first = touches[0];
136
- if (isRecord(first))
137
- return fromPair(first);
135
+ // `changedTouches` identifies the finger for this frame. Read it before the full active-touch
136
+ // list, whose first entry may belong to another simultaneously pressed target.
137
+ for (const key of ['changedTouches', 'touches']) {
138
+ const touches = nativeEvent[key];
139
+ if (!Array.isArray(touches))
140
+ continue;
141
+ for (const touch of touches) {
142
+ if (!isRecord(touch))
143
+ continue;
144
+ const point = fromPair(touch);
145
+ if (point)
146
+ return point;
147
+ }
138
148
  }
139
149
  return undefined;
140
150
  }
141
- // Whether any touch still down started inside the responder (its target IS the
142
- // responder or a descendant). RN's noResponderTouches walks nativeEvent.touches and
143
- // returns false the moment one is found; a release fires only when none remain. The
144
- // headless smokes fire with an empty `{}` event (no `touches`) -> no remaining touch ->
145
- // release fires, preserving single-touch behavior. (ResponderEventPlugin.noResponder-
146
- // Touches + isAncestor.)
147
- function hasRemainingResponderTouch(responder, nativeEvent) {
151
+ // Whether any touch still down started inside an owner (its target IS the owner or a descendant).
152
+ // RN's noResponderTouches walks nativeEvent.touches and returns false the moment one is found; a
153
+ // responder release and our synthesized press completion both wait until none remain. The headless
154
+ // smokes fire with an empty `{}` event -> no remaining touch -> preserve single-touch behavior.
155
+ function hasRemainingTouchWithin(owner, nativeEvent) {
148
156
  const touches = nativeEvent.touches;
149
157
  if (!Array.isArray(touches))
150
158
  return false;
@@ -152,7 +160,7 @@ function hasRemainingResponderTouch(responder, nativeEvent) {
152
160
  if (!isRecord(touch))
153
161
  continue;
154
162
  const target = touch.target;
155
- if (isSymbioteNode(target) && endsWithin(target, responder))
163
+ if (isSymbioteNode(target) && endsWithin(target, owner))
156
164
  return true;
157
165
  }
158
166
  return false;
@@ -240,6 +248,34 @@ function findWantsResponder(path, captureName, bubbleName, nativeEvent, skip) {
240
248
  }
241
249
  return undefined;
242
250
  }
251
+ // Tell native which node owns the gesture, RN's `injectGlobalResponderHandler`
252
+ // (ReactFabric-dev.js:18862) — the OLD owner first, then the new one, both in one call so no
253
+ // site can do half of it.
254
+ //
255
+ // WITHOUT THIS A JS RESPONDER LOSES TO ANY SCROLL VIEW ABOVE IT, and it is invisible from JS:
256
+ // `onStartShouldSetResponder` returns true, the native UIScrollView never learns the gesture was
257
+ // claimed, and every subsequent move arrives as `topScroll` instead of `topTouchMove` — so the
258
+ // negotiation never even gets a move to grant on. Device-diagnosed 2026-09-08 on a PanResponder
259
+ // drag box inside the canary's ScrollView: `startShouldSet -> true` followed by
260
+ // `topScrollBeginDrag` and twenty `topScroll`, with no grant and no move.
261
+ //
262
+ // `blockNativeResponder` is the taker's own `onResponderGrant` return, exactly as RN reads it.
263
+ function handOverNativeResponder(from, to, blockNativeResponder) {
264
+ const slot = getSlot();
265
+ const fromHandle = from === undefined ? undefined : committedOf(from)?.handle;
266
+ const toHandle = to === undefined ? undefined : committedOf(to)?.handle;
267
+ dlog(`setIsJSResponder from=${from === undefined ? 'none' : fromHandle === undefined ? 'UNCOMMITTED' : 'yes'} ` +
268
+ `to=${to === undefined ? 'none' : toHandle === undefined ? 'UNCOMMITTED' : 'yes'} block=${blockNativeResponder}`);
269
+ if (fromHandle !== undefined)
270
+ slot.setIsJSResponder(fromHandle, false, blockNativeResponder);
271
+ if (toHandle !== undefined)
272
+ slot.setIsJSResponder(toHandle, true, blockNativeResponder);
273
+ }
274
+ // Whether the taker asked native to stand down. RN reads this off the grant dispatch's return;
275
+ // an absent listener means no claim, which is RN's `blockNativeResponder || false`.
276
+ function blocksNative(result) {
277
+ return result === true;
278
+ }
243
279
  // Negotiate (or re-negotiate) the responder for a touch start/move. If nobody holds
244
280
  // it, the winner is granted. If someone does, the incumbent is asked to relinquish
245
281
  // via onResponderTerminationRequest (absent listener = implicit yes); on yes it is
@@ -260,12 +296,21 @@ function negotiateResponder(target, phase, nativeEvent) {
260
296
  const wants = phase === 'start'
261
297
  ? findWantsResponder(path, START_SHOULD_SET_CAPTURE, START_SHOULD_SET, nativeEvent, skip)
262
298
  : findWantsResponder(path, MOVE_SHOULD_SET_CAPTURE, MOVE_SHOULD_SET, nativeEvent, skip);
263
- if (!wants || wants === currentResponder)
299
+ // Every exit is logged: a negotiation that declines is indistinguishable from one that never
300
+ // ran, and the two have opposite causes.
301
+ if (!wants) {
302
+ dlog(`responder ${phase}: nobody wants it (path=${path.length}${skip === undefined ? '' : ', one skipped'})`);
264
303
  return;
304
+ }
305
+ if (wants === currentResponder) {
306
+ dlog(`responder ${phase}: ${wants.component} already holds it`);
307
+ return;
308
+ }
265
309
  if (currentResponder === undefined) {
266
310
  currentResponder = wants;
267
311
  dlog(`responder granted to ${wants.component}`);
268
- callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
312
+ const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
313
+ handOverNativeResponder(undefined, wants, blocksNative(granted));
269
314
  return;
270
315
  }
271
316
  const incumbent = currentResponder;
@@ -284,9 +329,10 @@ function negotiateResponder(target, phase, nativeEvent) {
284
329
  // before terminate on the consent path (matching RN's grant<terminate ordering) and
285
330
  // omit it on reject; the consent OUTCOME is unchanged either way.
286
331
  dlog(`responder transferred ${incumbent.component} -> ${wants.component}`);
287
- callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
332
+ const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
288
333
  callOwnListener(incumbent, RESPONDER_TERMINATE, nativeEvent);
289
334
  currentResponder = wants;
335
+ handOverNativeResponder(incumbent, wants, blocksNative(granted));
290
336
  }
291
337
  else {
292
338
  dlog(`responder takeover of ${incumbent.component} rejected`);
@@ -301,28 +347,46 @@ export function installEventHandler() {
301
347
  if (!isSymbioteNode(instanceHandle))
302
348
  return;
303
349
  if (topLevelType === TOUCH_START) {
304
- dlog(`event ${TOUCH_START}`);
350
+ dlog(`event ${TOUCH_START} on ${isSymbioteNode(instanceHandle) ? instanceHandle.component : 'NON-NODE'}`);
305
351
  // Update the touch bank, then attach it so responder handlers (PanResponder)
306
352
  // read each touch's own previous->current delta; RN records before dispatch.
307
353
  recordTouchTrack('start', nativeEvent);
308
354
  attachTouchHistory(nativeEvent);
309
- pressStart = instanceHandle;
310
- // Arm long-press synthesis: only when a listener exists in the path, fired once
311
- // after the hold delay, then suppresses the tap (longPressFired) on release.
312
- longPressFired = false;
313
- clearLongPress();
314
- longPressStart = readTouchPoint(nativeEvent);
315
- if (hasListenerInPath(instanceHandle, LONG_PRESS)) {
316
- const longPressTarget = instanceHandle;
317
- longPressTimer = setTimeout(() => {
318
- longPressTimer = undefined;
319
- longPressFired = true;
320
- dlog('synthesized longPress -> dispatch');
321
- runWrapped(() => bubble(longPressTarget, LONG_PRESS, nativeEvent));
322
- }, DEFAULT_LONG_PRESS_MS);
355
+ const canJoinExistingPress = Array.isArray(nativeEvent.touches) && nativeEvent.touches.length > 1;
356
+ const joinedPress = canJoinExistingPress
357
+ ? findActivePress(instanceHandle)
358
+ : undefined;
359
+ // A one-touch or identifier-less frame starts a new physical gesture. Any surviving press
360
+ // is stale (its end/cancel was lost), so release it instead of letting it suppress this start.
361
+ const stalePresses = canJoinExistingPress ? [] : takeAllPresses();
362
+ let startedPress;
363
+ if (joinedPress === undefined) {
364
+ startedPress = {
365
+ owner: instanceHandle,
366
+ longPressTimer: undefined,
367
+ longPressFired: false,
368
+ longPressStart: readTouchPoint(nativeEvent),
369
+ };
370
+ activePresses.add(startedPress);
371
+ if (hasListenerInPath(instanceHandle, LONG_PRESS)) {
372
+ const press = startedPress;
373
+ press.longPressTimer = setTimeout(() => {
374
+ if (!activePresses.has(press))
375
+ return;
376
+ press.longPressTimer = undefined;
377
+ press.longPressFired = true;
378
+ dlog('synthesized longPress -> dispatch');
379
+ runWrapped(() => bubble(press.owner, LONG_PRESS, nativeEvent));
380
+ }, DEFAULT_LONG_PRESS_MS);
381
+ }
323
382
  }
324
383
  runWrapped(() => {
325
- bubble(instanceHandle, PRESS_IN, nativeEvent);
384
+ for (const stale of stalePresses)
385
+ bubble(stale.owner, PRESS_OUT, nativeEvent);
386
+ if (startedPress)
387
+ bubble(startedPress.owner, PRESS_IN, nativeEvent);
388
+ else
389
+ dlog('pressIn retained (another touch joined the active press)');
326
390
  // Responder negotiation runs alongside press synthesis: a View can be both
327
391
  // a Pressable (press) and a PanResponder target (responder).
328
392
  negotiateResponder(instanceHandle, 'start', nativeEvent);
@@ -335,16 +399,18 @@ export function installEventHandler() {
335
399
  if (topLevelType === TOUCH_MOVE) {
336
400
  recordTouchTrack('move', nativeEvent);
337
401
  attachTouchHistory(nativeEvent);
338
- // Cancel the pending long press if the touch drifted too far (Pressability's
339
- // deactivation-distance check). Skipped when either coord is unknown.
340
- if (longPressTimer !== undefined && longPressStart) {
402
+ // Only the press owning this move can lose its long-press timer; movement on an unrelated
403
+ // simultaneously held Pressable must not disturb another target's clock.
404
+ const movedPress = findActivePress(instanceHandle);
405
+ if (movedPress?.longPressTimer !== undefined &&
406
+ movedPress.longPressStart) {
341
407
  const here = readTouchPoint(nativeEvent);
342
408
  if (here) {
343
- const dx = here.x - longPressStart.x;
344
- const dy = here.y - longPressStart.y;
409
+ const dx = here.x - movedPress.longPressStart.x;
410
+ const dy = here.y - movedPress.longPressStart.y;
345
411
  if (Math.hypot(dx, dy) > LONG_PRESS_DEACTIVATION_DISTANCE) {
346
412
  dlog('longPress cancelled (moved past deactivation distance)');
347
- clearLongPress();
413
+ clearLongPress(movedPress);
348
414
  }
349
415
  }
350
416
  }
@@ -361,46 +427,53 @@ export function installEventHandler() {
361
427
  if (topLevelType === TOUCH_END) {
362
428
  recordTouchTrack('end', nativeEvent);
363
429
  attachTouchHistory(nativeEvent);
364
- const start = pressStart;
365
- pressStart = undefined;
430
+ const hadActivePress = activePresses.size > 0;
431
+ const completedPresses = [];
432
+ for (const press of activePresses) {
433
+ if (hasRemainingTouchWithin(press.owner, nativeEvent))
434
+ continue;
435
+ activePresses.delete(press);
436
+ clearLongPress(press);
437
+ completedPresses.push(press);
438
+ }
366
439
  const responder = currentResponder;
367
440
  // RN releases (and clears) the responder only when no remaining touch still down
368
441
  // started inside it; lifting ONE finger in a multi-touch gesture must NOT release.
369
442
  // onResponderEnd still fires on every finger-up. (ResponderEventPlugin: responderEnd
370
443
  // is unconditional, responderRelease is gated on noResponderTouches.)
371
444
  const releases = responder !== undefined &&
372
- !hasRemainingResponderTouch(responder, nativeEvent);
445
+ !hasRemainingTouchWithin(responder, nativeEvent);
373
446
  if (releases)
374
447
  currentResponder = undefined;
375
- // A completed long press eats the tap (RN), but pressOut still fires below.
376
- const wasLongPress = longPressFired;
377
- longPressFired = false;
378
- longPressStart = undefined;
379
- clearLongPress();
380
448
  runWrapped(() => {
381
- if (start) {
382
- // press fires only on an honest tap (ended within the responder); pressOut
383
- // always fires on the responder so its pressed-state can release.
384
- if (endsWithin(instanceHandle, start)) {
385
- if (wasLongPress) {
449
+ for (const press of completedPresses) {
450
+ // Each target completes independently. An honest tap ends inside that target; pressOut
451
+ // always releases its pressed state, including a drag-away end.
452
+ if (endsWithin(instanceHandle, press.owner)) {
453
+ if (press.longPressFired) {
386
454
  dlog('press suppressed (longPress already fired)');
387
455
  }
388
456
  else {
389
457
  dlog('event press -> dispatch');
390
- bubble(start, PRESS, nativeEvent);
458
+ bubble(press.owner, PRESS, nativeEvent);
391
459
  }
392
460
  }
393
- bubble(start, PRESS_OUT, nativeEvent);
461
+ bubble(press.owner, PRESS_OUT, nativeEvent);
394
462
  }
395
- else {
463
+ if (!hadActivePress) {
396
464
  dlog(`event ${TOUCH_END} ignored (no matching start)`);
397
465
  }
466
+ else if (completedPresses.length === 0) {
467
+ dlog('press retained (another touch remains inside its owner)');
468
+ }
398
469
  // onResponderEnd fires on every finger-up; onResponderRelease (the final
399
470
  // release) only when the last responder touch lifted.
400
471
  if (responder) {
401
472
  callOwnListener(responder, RESPONDER_END, nativeEvent);
402
- if (releases)
473
+ if (releases) {
403
474
  callOwnListener(responder, RESPONDER_RELEASE, nativeEvent);
475
+ handOverNativeResponder(responder, undefined, false);
476
+ }
404
477
  else
405
478
  dlog('responderEnd without release (touches remain inside responder)');
406
479
  }
@@ -413,20 +486,32 @@ export function installEventHandler() {
413
486
  if (topLevelType === TOUCH_CANCEL) {
414
487
  recordTouchTrack('end', nativeEvent);
415
488
  attachTouchHistory(nativeEvent);
416
- const start = pressStart;
417
- pressStart = undefined;
489
+ // A cancel is scoped to the finger(s) removed from `touches`, just like an end. An unrelated
490
+ // Pressable—or another finger under the same owner—must keep its press and long-press state.
491
+ const cancelledPresses = [];
492
+ for (const press of activePresses) {
493
+ if (hasRemainingTouchWithin(press.owner, nativeEvent))
494
+ continue;
495
+ activePresses.delete(press);
496
+ clearLongPress(press);
497
+ cancelledPresses.push(press);
498
+ }
418
499
  const responder = currentResponder;
419
- currentResponder = undefined;
420
- longPressFired = false;
421
- longPressStart = undefined;
422
- clearLongPress();
500
+ const terminatesResponder = responder !== undefined &&
501
+ !hasRemainingTouchWithin(responder, nativeEvent);
502
+ if (terminatesResponder)
503
+ currentResponder = undefined;
423
504
  runWrapped(() => {
424
- if (start)
425
- bubble(start, PRESS_OUT, nativeEvent);
426
- // A cancelled gesture ends then terminates (the responder was taken away).
505
+ for (const press of cancelledPresses)
506
+ bubble(press.owner, PRESS_OUT, nativeEvent);
427
507
  if (responder) {
508
+ // Like touch-end, every finger leaving emits responderEnd. Termination is final only
509
+ // when no touch remains inside the responder.
428
510
  callOwnListener(responder, RESPONDER_END, nativeEvent);
429
- callOwnListener(responder, RESPONDER_TERMINATE, nativeEvent);
511
+ if (terminatesResponder) {
512
+ callOwnListener(responder, RESPONDER_TERMINATE, nativeEvent);
513
+ handOverNativeResponder(responder, undefined, false);
514
+ }
430
515
  }
431
516
  });
432
517
  if (touchHistory.numberActiveTouches === 0)
@@ -4,8 +4,8 @@
4
4
  // responsibilities stop sharing one 300+ line file. Color processing itself lives in
5
5
  // ./platform-color (the stable leaf every color-touching module imports from); this file only
6
6
  // decides WHICH props are color props and wires the structured CSS-style processors.
7
+ import { foldAriaProps } from './accessibility-props.js';
7
8
  import { RAW_TEXT_COMPONENT } from './node.js';
8
- import { flattenStyle } from './style/index.js';
9
9
  import { registeredProcessor } from './registry.js';
10
10
  import { isProcessableColor, processColor } from './platform-color/index.js';
11
11
  import { processBoxShadow } from './process-box-shadow/index.js';
@@ -164,29 +164,198 @@ function processValue(component, key, value) {
164
164
  return processColor(value);
165
165
  return value;
166
166
  }
167
+ // A style object is SHARED, and that is the one place this file has a complexity problem rather
168
+ // than a constant-factor one. StyleSheet.create hands out one frozen object per rule, and the CSS
169
+ // class registry resolves a class name to one cached object - so 1 000 rows carry the SAME handful
170
+ // of style objects, and resolving each one per node costs O(nodes x styleKeys) to compute an
171
+ // answer that only varies with O(distinct styles). Cache it on the style object's identity.
172
+ //
173
+ // Keyed by component as well: processValue consults that component's ViewConfig processors, so one
174
+ // style object can legitimately resolve differently under two view names.
175
+ //
176
+ // The cache assumes a style object is not MUTATED IN PLACE, which is already the engine's contract:
177
+ // setProp compares with Object.is and skips a same-identity write, so an in-place style edit never
178
+ // marks the node dirty and never reaches Fabric today either. What narrows slightly is the case
179
+ // where some OTHER prop on the same node changed in the same commit - that used to pick the
180
+ // mutation up as a side effect, and now does not.
181
+ //
182
+ // KEEPING undefined-valued keys is deliberate: the resolved object is a faithful picture of ONE
183
+ // style entry, and addStyle below needs to see an explicit `undefined` to let a later entry clear
184
+ // an earlier one. Dropping them here would silently turn `[{flex:1},{flex:undefined}]` into
185
+ // `flex: 1`.
186
+ const styleCache = new Map();
187
+ function processedStyle(component, style) {
188
+ let perComponent = styleCache.get(component);
189
+ if (perComponent === undefined) {
190
+ perComponent = new WeakMap();
191
+ styleCache.set(component, perComponent);
192
+ }
193
+ const cached = perComponent.get(style);
194
+ if (cached !== undefined)
195
+ return cached;
196
+ const resolved = {};
197
+ for (const key of Object.keys(style)) {
198
+ const value = style[key];
199
+ resolved[key] =
200
+ value === undefined ? undefined : processValue(component, key, value);
201
+ }
202
+ perComponent.set(style, resolved);
203
+ return resolved;
204
+ }
205
+ /**
206
+ * Hoist one style slot's keys into the payload being built, recursing on POSITION only - the same
207
+ * rule flattenStyle follows, and for the same reason: `transform: [{translateX: 5}]` is an
208
+ * array-VALUED prop, not a nested style.
209
+ *
210
+ * The point is that there is no intermediate object. Every style entry's resolution is memoized on
211
+ * its own identity and its keys are written straight into `out`, so a thousand rows sharing one
212
+ * class-resolved style resolve it ONCE and each node pays a copy loop.
213
+ *
214
+ * This is also the shape React Native itself uses. `ReactNativeAttributePayload.addNestedProperty`
215
+ * (.vendors/react/packages/react-native-renderer/src/ReactNativeAttributePayload.js:208) recurses
216
+ * over the style array writing into a single `updatePayload`; upstream's `flattenStyle` appears
217
+ * ONLY in `diffNestedProperty`, i.e. the update path where an array meets an object - never on
218
+ * create. Our previous version flattened first and hoisted second, which allocated one merged
219
+ * object per node per commit that nothing else ever read.
220
+ *
221
+ * It also fixed a dead cache. `processedStyle` used to be reachable only when `props.style` was a
222
+ * bare object, and it never is: `commitClassStyle` (node.ts) always writes the two-element
223
+ * `[classStyle, explicitStyle]` array, by design. So the memo existed, was correct, and never ran.
224
+ *
225
+ * Later entries win, because a later write overwrites the same key on `out`. An explicit
226
+ * `undefined` CLEARS the key instead, matching the flatten path it replaces. One narrow
227
+ * divergence, recorded rather than hidden: the `delete` also clears a same-named TOP-LEVEL prop
228
+ * hoisted before the style pass, which flattening did not. Style keys and native prop keys do not
229
+ * overlap in practice (one is Yoga/visual, the other is testID/accessibility/source), so this is
230
+ * theoretical - but it is a difference, and `fabric-props.test.ts` pins both halves.
231
+ */
232
+ function addStyle(out, component, style) {
233
+ if (Array.isArray(style)) {
234
+ for (const entry of style)
235
+ addStyle(out, component, entry);
236
+ return;
237
+ }
238
+ if (!isRecord(style))
239
+ return;
240
+ const resolved = processedStyle(component, style);
241
+ for (const key of Object.keys(resolved)) {
242
+ const value = resolved[key];
243
+ if (value === undefined)
244
+ delete out[key];
245
+ else
246
+ out[key] = value;
247
+ }
248
+ }
167
249
  // Translate the retained node's logical props into the flat payload Fabric's C++
168
250
  // props expect: `style` keys are hoisted to the top level, event handlers and
169
251
  // undefined values are dropped.
252
+ // RN HAS NO `value` FABRIC PROP. A TextInput's controlled value rides as the private `text` prop,
253
+ // and the fold that produces it — `value ?? defaultValue` — lives in the component wrapper
254
+ // (`core/components/src/view/render-text-input.ts`, whose own comment says "There is no `value`
255
+ // Fabric prop; this is the whole controlled surface"). A LOWERED element has no wrapper, so a
256
+ // transform printing the author's `value={x}` yields a key no ViewConfig declares: silently dropped,
257
+ // `text` never set, and the field renders EMPTY. Nothing red anywhere — found 2026-08-31 by an agent
258
+ // reading what the render function actually emits rather than trusting a header comment.
259
+ //
260
+ // So the fold moves to the layer every path goes through, exactly like the aria fold above it. This
261
+ // is the third instance of one rule: a lowered element inherits NOTHING its wrapper did, and the
262
+ // repair belongs below the fork, never in the transform.
263
+ //
264
+ // GATED ON THE COMPONENT, NOT ON THE PROP. `value` is also a prop of `Switch` and `Slider`; a fold
265
+ // keyed on the prop name would write a bogus `text` onto both. Two string comparisons rather than a
266
+ // Set lookup — this runs per node per commit, and the set has exactly two members.
267
+ //
268
+ // The engine may hold this because both views are in `BUILTIN_COMPONENTS`, whose hand-tuned tables
269
+ // (`view-config.ts`'s TEXT_INPUT_EVENTS, commit's COLOR_PROPS) already live here for the same
270
+ // reason. Routing it through `registerComponent` was tried first and is WRONG: `resolve()`
271
+ // short-circuits every builtin to EMPTY, so the registration would have been accepted and never
272
+ // applied.
273
+ const SINGLELINE_TEXT_INPUT = 'RCTSinglelineTextInputView';
274
+ const MULTILINE_TEXT_INPUT = 'RCTMultilineTextInputView';
275
+ function foldTextInputValue(props) {
276
+ const hasValue = props.value !== undefined;
277
+ const hasDefault = props.defaultValue !== undefined;
278
+ if (!hasValue && !hasDefault)
279
+ return props;
280
+ const folded = { ...props };
281
+ // `value` WINS over `defaultValue` — `foldText`'s rule, kept identical rather than re-derived.
282
+ // An explicit `text` is left alone: that is the component path, where the wrapper already folded,
283
+ // and re-folding there would let a stale `value` overwrite what the wrapper computed.
284
+ if (folded.text === undefined) {
285
+ folded.text = hasValue ? props.value : props.defaultValue;
286
+ }
287
+ // Blanked, not deleted: `fabricProps` skips undefined, and neither name is a real Fabric prop.
288
+ folded.value = undefined;
289
+ folded.defaultValue = undefined;
290
+ return folded;
291
+ }
170
292
  export function fabricProps(node) {
171
293
  if (node.component === RAW_TEXT_COMPONENT) {
172
- return { text: node.props.text };
294
+ // A raw-text node gets its behavior's fold too — it TRANSFORMS the text that is already there
295
+ // (Button uppercases its label on Android) and may not SUPPLY one, which is narrower than this
296
+ // comment claimed when it landed. `isEmptyRawText` (node.ts) decides whether the node commits
297
+ // at all from `node.props.text`, before any fold runs, so a text that exists only as a fold
298
+ // result is dropped by `renderableChildren` and the fold never executes. Reported by the hook's
299
+ // first consumer, within the hour.
300
+ //
301
+ // Which is why the skip is NOT the thing to change: it runs for every raw-text node in every
302
+ // app, and consulting a fold there would put one on that walk. Get the value into `props.text`
303
+ // instead — Button routes the owner's `title` onto this node with `slotProps: {title: 'text'}`,
304
+ // so the skip and the fold read the same source and an empty title still commits nothing.
305
+ return {
306
+ text: node.payloadFold !== undefined
307
+ ? node.payloadFold(node.props).text
308
+ : node.props.text,
309
+ };
173
310
  }
311
+ // This runs once per node per commit - 9 000 times on one benchmark press - so the two loops
312
+ // below iterate with Object.keys rather than Object.entries: entries allocates a fresh
313
+ // two-element array PER KEY on top of the outer array, and the resulting garbage was 18% of the
314
+ // create path in a CPU profile.
315
+ //
316
+ // Do NOT "improve" this to `for...in`. It was tried and reverted 2026-08-23. On paper it is the
317
+ // strictly cheaper shape - Object.keys allocates one array per call, 10 007 of them on a
318
+ // 1 000-row create (counted), and for...in allocates none - and the headless V8 bench agreed,
319
+ // 12-13% off create/replace `min`. On DEVICE it lost: with the Fabric call counts and prop-key
320
+ // payload byte-identical either way (9000/5000/1009, 32001 keys), Release Create went 217.8 ->
321
+ // 243.2 ms while stock moved only inside its 4% noise floor. Hermes' for-in is not V8's enum
322
+ // cache. The general rule this bought: an allocation-count win measured on V8 is not a Hermes
323
+ // win, and only the on-device number decides (`perf-claims-need-numbers`).
174
324
  const out = {};
175
- for (const [key, value] of Object.entries(node.props)) {
325
+ // THE ONE POINT WHERE THE WHOLE BAG IS KNOWN ON EVERY PATH, which is what the aria fold needs:
326
+ // `aria-checked` has to be folded against a sibling `accessibilityState`, and `routeProp` sees
327
+ // one key at a time. Both commit paths — create and update — reach here, so a lowered element
328
+ // gets the fold it has no wrapper to run.
329
+ //
330
+ // NOT memoised on `node.props` identity. That object is stable and mutated IN PLACE, so an
331
+ // identity-keyed cache (the `processedStyle` pattern below) would be stale forever. The gate is
332
+ // the node's sticky flag instead: one boolean read for a node with no alias, which is nearly all
333
+ // of them, and the fold's own fast path returns by identity for the rest.
334
+ const aliasFolded = node.hasAriaAlias
335
+ ? foldAriaProps(node.props)
336
+ : node.props;
337
+ // The behavior's own fold, for a LOWERED element only — the two folds above are keyed on the
338
+ // component name, which a wrapper and its lowered twin share, so neither could carry a
339
+ // per-primitive fold without running it twice on the wrapper. See IPayloadFold.
340
+ const behaviorFolded = node.payloadFold !== undefined
341
+ ? node.payloadFold(aliasFolded)
342
+ : aliasFolded;
343
+ const props = node.component === SINGLELINE_TEXT_INPUT ||
344
+ node.component === MULTILINE_TEXT_INPUT
345
+ ? foldTextInputValue(behaviorFolded)
346
+ : behaviorFolded;
347
+ for (const key of Object.keys(props)) {
176
348
  if (key === 'style')
177
349
  continue;
350
+ const value = props[key];
178
351
  if (typeof value === 'function')
179
352
  continue;
180
353
  if (value === undefined)
181
354
  continue;
182
355
  out[key] = processValue(node.component, key, value);
183
356
  }
184
- // Collapse style (object | array | nested arrays) into one flat payload before
185
- // hoisting: `style={[base, override]}` is RN's idiom and Fabric wants it flat.
186
- const style = flattenStyle(node.props.style);
187
- for (const [key, value] of Object.entries(style)) {
188
- if (value !== undefined)
189
- out[key] = processValue(node.component, key, value);
190
- }
357
+ // Hoist the style slot (object | array | nested arrays) into the SAME payload object - no
358
+ // intermediate flatten. See addStyle for the shape and for the two things this fixed.
359
+ addStyle(out, node.component, props.style);
191
360
  return out;
192
361
  }