@symbiote-native/engine 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -11
- package/build/accessibility-props.d.ts +19 -0
- package/build/accessibility-props.js +209 -0
- package/build/action-sheet-ios/index.js +4 -1
- package/build/animated/animations/composition.js +3 -1
- package/build/animated/animations/spring-config.js +4 -1
- package/build/animated/animations/spring.js +23 -9
- package/build/animated/animations/timing.js +3 -2
- package/build/animated/bezier.js +2 -1
- package/build/animated/color.js +4 -2
- package/build/animated/event.js +7 -3
- package/build/animated/graph.js +8 -3
- package/build/animated/index.d.ts +2 -2
- package/build/animated/index.js +2 -2
- package/build/animated/interpolation.js +7 -2
- package/build/animated/mock.js +1 -1
- package/build/animated/native/native-animated.js +1 -1
- package/build/animated/operators.js +27 -6
- package/build/animated/props.js +1 -1
- package/build/animated/rgba.js +18 -3
- package/build/animated/style.js +13 -3
- package/build/animated/value-xy.d.ts +8 -5
- package/build/animated/value-xy.js +4 -1
- package/build/app-registry/index.d.ts +1 -0
- package/build/app-registry/index.js +15 -5
- package/build/appearance/index.js +4 -1
- package/build/commit.d.ts +35 -0
- package/build/commit.js +613 -74
- package/build/debug.js +10 -4
- package/build/events/index.js +142 -88
- package/build/fabric-props.js +171 -11
- package/build/fabric.d.ts +10 -3
- package/build/fabric.js +11 -2
- package/build/host-behavior.d.ts +47 -0
- package/build/host-behavior.js +262 -0
- package/build/host-instance/index.d.ts +2 -10
- package/build/host-instance/index.js +13 -46
- package/build/image-loader.js +3 -2
- package/build/index.d.ts +24 -17
- package/build/index.js +27 -13
- package/build/interaction-manager/index.js +2 -1
- package/build/layout-animation/index.js +12 -4
- package/build/native-events.js +2 -1
- package/build/native-modules/index.js +3 -1
- package/build/node.d.ts +121 -2
- package/build/node.js +623 -41
- package/build/pan-responder/index.js +20 -9
- package/build/permissions-android/index.js +3 -1
- package/build/platform/index.android.d.ts +1 -1
- package/build/platform/index.android.js +1 -1
- package/build/platform/index.ios.d.ts +1 -1
- package/build/platform-color/index.js +3 -1
- package/build/post-commit.d.ts +1 -0
- package/build/post-commit.js +6 -0
- package/build/process-background-image/index.js +30 -9
- package/build/process-filter.js +11 -4
- package/build/registry.js +10 -2
- package/build/report-error.js +4 -1
- package/build/share/index.android.js +1 -1
- package/build/share/index.ios.js +1 -1
- package/build/status-bar/index.android.js +3 -4
- package/build/status-bar/index.ios.js +1 -1
- package/build/style-registry/index.d.ts +11 -2
- package/build/style-registry/index.js +223 -169
- package/build/style-registry/scope.d.ts +15 -1
- package/build/style-registry/scope.js +29 -27
- package/build/styles.d.ts +11 -1
- package/build/surface.d.ts +1 -1
- package/build/surface.js +8 -0
- package/build/tags.d.ts +1 -0
- package/build/tags.js +31 -1
- package/build/touch-history.js +9 -2
- package/build/vibration/index.ios.js +1 -1
- package/build/view-config.js +20 -2
- package/package.json +12 -2
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
|
-
|
|
16
|
-
|
|
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
|
|
25
|
+
return envDebug || globalThis.__SYMBIOTE_DEBUG__ === true;
|
|
20
26
|
}
|
|
21
27
|
export function dlog(message) {
|
|
22
28
|
if (!isDebug())
|
package/build/events/index.js
CHANGED
|
@@ -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 { 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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
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,
|
|
163
|
+
if (isSymbioteNode(target) && endsWithin(target, owner))
|
|
156
164
|
return true;
|
|
157
165
|
}
|
|
158
166
|
return false;
|
|
@@ -228,12 +236,14 @@ function lowestCommonAncestor(a, b) {
|
|
|
228
236
|
// handler, so asking the responder again would zero its move).
|
|
229
237
|
function findWantsResponder(path, captureName, bubbleName, nativeEvent, skip) {
|
|
230
238
|
for (let i = path.length - 1; i >= 0; i--) {
|
|
231
|
-
if (path[i] !== skip &&
|
|
239
|
+
if (path[i] !== skip &&
|
|
240
|
+
callOwnListener(path[i], captureName, nativeEvent) === true) {
|
|
232
241
|
return path[i];
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
244
|
for (const node of path) {
|
|
236
|
-
if (node !== skip &&
|
|
245
|
+
if (node !== skip &&
|
|
246
|
+
callOwnListener(node, bubbleName, nativeEvent) === true)
|
|
237
247
|
return node;
|
|
238
248
|
}
|
|
239
249
|
return undefined;
|
|
@@ -248,7 +258,9 @@ function negotiateResponder(target, phase, nativeEvent) {
|
|
|
248
258
|
// the responder) and skips the deepest node when it IS the responder (Responder-
|
|
249
259
|
// EventPlugin.setResponderAndExtractTransfer). At touch start currentResponder is
|
|
250
260
|
// cleared, so this collapses to the plain target->root start walk.
|
|
251
|
-
const from = currentResponder === undefined
|
|
261
|
+
const from = currentResponder === undefined
|
|
262
|
+
? target
|
|
263
|
+
: lowestCommonAncestor(currentResponder, target);
|
|
252
264
|
if (!from)
|
|
253
265
|
return;
|
|
254
266
|
const path = pathToRoot(from);
|
|
@@ -268,7 +280,9 @@ function negotiateResponder(target, phase, nativeEvent) {
|
|
|
268
280
|
// A missing termination-request listener means implicit consent (RN default true);
|
|
269
281
|
// only an explicit non-true answer keeps the incumbent and rejects the taker.
|
|
270
282
|
const guarded = incumbent.listeners?.has(RESPONDER_TERMINATION_REQUEST) === true;
|
|
271
|
-
const allowed = !guarded ||
|
|
283
|
+
const allowed = !guarded ||
|
|
284
|
+
callOwnListener(incumbent, RESPONDER_TERMINATION_REQUEST, nativeEvent) ===
|
|
285
|
+
true;
|
|
272
286
|
if (allowed) {
|
|
273
287
|
// RN's transfer order (setResponderAndExtractTransfer): grant the TAKER first, then
|
|
274
288
|
// terminate the incumbent. RN dispatches grant ahead of the terminationRequest too,
|
|
@@ -300,23 +314,41 @@ export function installEventHandler() {
|
|
|
300
314
|
// read each touch's own previous->current delta; RN records before dispatch.
|
|
301
315
|
recordTouchTrack('start', nativeEvent);
|
|
302
316
|
attachTouchHistory(nativeEvent);
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
+
const canJoinExistingPress = Array.isArray(nativeEvent.touches) && nativeEvent.touches.length > 1;
|
|
318
|
+
const joinedPress = canJoinExistingPress
|
|
319
|
+
? findActivePress(instanceHandle)
|
|
320
|
+
: undefined;
|
|
321
|
+
// A one-touch or identifier-less frame starts a new physical gesture. Any surviving press
|
|
322
|
+
// is stale (its end/cancel was lost), so release it instead of letting it suppress this start.
|
|
323
|
+
const stalePresses = canJoinExistingPress ? [] : takeAllPresses();
|
|
324
|
+
let startedPress;
|
|
325
|
+
if (joinedPress === undefined) {
|
|
326
|
+
startedPress = {
|
|
327
|
+
owner: instanceHandle,
|
|
328
|
+
longPressTimer: undefined,
|
|
329
|
+
longPressFired: false,
|
|
330
|
+
longPressStart: readTouchPoint(nativeEvent),
|
|
331
|
+
};
|
|
332
|
+
activePresses.add(startedPress);
|
|
333
|
+
if (hasListenerInPath(instanceHandle, LONG_PRESS)) {
|
|
334
|
+
const press = startedPress;
|
|
335
|
+
press.longPressTimer = setTimeout(() => {
|
|
336
|
+
if (!activePresses.has(press))
|
|
337
|
+
return;
|
|
338
|
+
press.longPressTimer = undefined;
|
|
339
|
+
press.longPressFired = true;
|
|
340
|
+
dlog('synthesized longPress -> dispatch');
|
|
341
|
+
runWrapped(() => bubble(press.owner, LONG_PRESS, nativeEvent));
|
|
342
|
+
}, DEFAULT_LONG_PRESS_MS);
|
|
343
|
+
}
|
|
317
344
|
}
|
|
318
345
|
runWrapped(() => {
|
|
319
|
-
|
|
346
|
+
for (const stale of stalePresses)
|
|
347
|
+
bubble(stale.owner, PRESS_OUT, nativeEvent);
|
|
348
|
+
if (startedPress)
|
|
349
|
+
bubble(startedPress.owner, PRESS_IN, nativeEvent);
|
|
350
|
+
else
|
|
351
|
+
dlog('pressIn retained (another touch joined the active press)');
|
|
320
352
|
// Responder negotiation runs alongside press synthesis: a View can be both
|
|
321
353
|
// a Pressable (press) and a PanResponder target (responder).
|
|
322
354
|
negotiateResponder(instanceHandle, 'start', nativeEvent);
|
|
@@ -329,16 +361,18 @@ export function installEventHandler() {
|
|
|
329
361
|
if (topLevelType === TOUCH_MOVE) {
|
|
330
362
|
recordTouchTrack('move', nativeEvent);
|
|
331
363
|
attachTouchHistory(nativeEvent);
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
364
|
+
// Only the press owning this move can lose its long-press timer; movement on an unrelated
|
|
365
|
+
// simultaneously held Pressable must not disturb another target's clock.
|
|
366
|
+
const movedPress = findActivePress(instanceHandle);
|
|
367
|
+
if (movedPress?.longPressTimer !== undefined &&
|
|
368
|
+
movedPress.longPressStart) {
|
|
335
369
|
const here = readTouchPoint(nativeEvent);
|
|
336
370
|
if (here) {
|
|
337
|
-
const dx = here.x - longPressStart.x;
|
|
338
|
-
const dy = here.y - longPressStart.y;
|
|
371
|
+
const dx = here.x - movedPress.longPressStart.x;
|
|
372
|
+
const dy = here.y - movedPress.longPressStart.y;
|
|
339
373
|
if (Math.hypot(dx, dy) > LONG_PRESS_DEACTIVATION_DISTANCE) {
|
|
340
374
|
dlog('longPress cancelled (moved past deactivation distance)');
|
|
341
|
-
clearLongPress();
|
|
375
|
+
clearLongPress(movedPress);
|
|
342
376
|
}
|
|
343
377
|
}
|
|
344
378
|
}
|
|
@@ -355,39 +389,45 @@ export function installEventHandler() {
|
|
|
355
389
|
if (topLevelType === TOUCH_END) {
|
|
356
390
|
recordTouchTrack('end', nativeEvent);
|
|
357
391
|
attachTouchHistory(nativeEvent);
|
|
358
|
-
const
|
|
359
|
-
|
|
392
|
+
const hadActivePress = activePresses.size > 0;
|
|
393
|
+
const completedPresses = [];
|
|
394
|
+
for (const press of activePresses) {
|
|
395
|
+
if (hasRemainingTouchWithin(press.owner, nativeEvent))
|
|
396
|
+
continue;
|
|
397
|
+
activePresses.delete(press);
|
|
398
|
+
clearLongPress(press);
|
|
399
|
+
completedPresses.push(press);
|
|
400
|
+
}
|
|
360
401
|
const responder = currentResponder;
|
|
361
402
|
// RN releases (and clears) the responder only when no remaining touch still down
|
|
362
403
|
// started inside it; lifting ONE finger in a multi-touch gesture must NOT release.
|
|
363
404
|
// onResponderEnd still fires on every finger-up. (ResponderEventPlugin: responderEnd
|
|
364
405
|
// is unconditional, responderRelease is gated on noResponderTouches.)
|
|
365
|
-
const releases = responder !== undefined &&
|
|
406
|
+
const releases = responder !== undefined &&
|
|
407
|
+
!hasRemainingTouchWithin(responder, nativeEvent);
|
|
366
408
|
if (releases)
|
|
367
409
|
currentResponder = undefined;
|
|
368
|
-
// A completed long press eats the tap (RN), but pressOut still fires below.
|
|
369
|
-
const wasLongPress = longPressFired;
|
|
370
|
-
longPressFired = false;
|
|
371
|
-
longPressStart = undefined;
|
|
372
|
-
clearLongPress();
|
|
373
410
|
runWrapped(() => {
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
// always
|
|
377
|
-
if (endsWithin(instanceHandle,
|
|
378
|
-
if (
|
|
411
|
+
for (const press of completedPresses) {
|
|
412
|
+
// Each target completes independently. An honest tap ends inside that target; pressOut
|
|
413
|
+
// always releases its pressed state, including a drag-away end.
|
|
414
|
+
if (endsWithin(instanceHandle, press.owner)) {
|
|
415
|
+
if (press.longPressFired) {
|
|
379
416
|
dlog('press suppressed (longPress already fired)');
|
|
380
417
|
}
|
|
381
418
|
else {
|
|
382
419
|
dlog('event press -> dispatch');
|
|
383
|
-
bubble(
|
|
420
|
+
bubble(press.owner, PRESS, nativeEvent);
|
|
384
421
|
}
|
|
385
422
|
}
|
|
386
|
-
bubble(
|
|
423
|
+
bubble(press.owner, PRESS_OUT, nativeEvent);
|
|
387
424
|
}
|
|
388
|
-
|
|
425
|
+
if (!hadActivePress) {
|
|
389
426
|
dlog(`event ${TOUCH_END} ignored (no matching start)`);
|
|
390
427
|
}
|
|
428
|
+
else if (completedPresses.length === 0) {
|
|
429
|
+
dlog('press retained (another touch remains inside its owner)');
|
|
430
|
+
}
|
|
391
431
|
// onResponderEnd fires on every finger-up; onResponderRelease (the final
|
|
392
432
|
// release) only when the last responder touch lifted.
|
|
393
433
|
if (responder) {
|
|
@@ -406,20 +446,30 @@ export function installEventHandler() {
|
|
|
406
446
|
if (topLevelType === TOUCH_CANCEL) {
|
|
407
447
|
recordTouchTrack('end', nativeEvent);
|
|
408
448
|
attachTouchHistory(nativeEvent);
|
|
409
|
-
|
|
410
|
-
|
|
449
|
+
// A cancel is scoped to the finger(s) removed from `touches`, just like an end. An unrelated
|
|
450
|
+
// Pressable—or another finger under the same owner—must keep its press and long-press state.
|
|
451
|
+
const cancelledPresses = [];
|
|
452
|
+
for (const press of activePresses) {
|
|
453
|
+
if (hasRemainingTouchWithin(press.owner, nativeEvent))
|
|
454
|
+
continue;
|
|
455
|
+
activePresses.delete(press);
|
|
456
|
+
clearLongPress(press);
|
|
457
|
+
cancelledPresses.push(press);
|
|
458
|
+
}
|
|
411
459
|
const responder = currentResponder;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
460
|
+
const terminatesResponder = responder !== undefined &&
|
|
461
|
+
!hasRemainingTouchWithin(responder, nativeEvent);
|
|
462
|
+
if (terminatesResponder)
|
|
463
|
+
currentResponder = undefined;
|
|
416
464
|
runWrapped(() => {
|
|
417
|
-
|
|
418
|
-
bubble(
|
|
419
|
-
// A cancelled gesture ends then terminates (the responder was taken away).
|
|
465
|
+
for (const press of cancelledPresses)
|
|
466
|
+
bubble(press.owner, PRESS_OUT, nativeEvent);
|
|
420
467
|
if (responder) {
|
|
468
|
+
// Like touch-end, every finger leaving emits responderEnd. Termination is final only
|
|
469
|
+
// when no touch remains inside the responder.
|
|
421
470
|
callOwnListener(responder, RESPONDER_END, nativeEvent);
|
|
422
|
-
|
|
471
|
+
if (terminatesResponder)
|
|
472
|
+
callOwnListener(responder, RESPONDER_TERMINATE, nativeEvent);
|
|
423
473
|
}
|
|
424
474
|
});
|
|
425
475
|
if (touchHistory.numberActiveTouches === 0)
|
|
@@ -495,7 +545,9 @@ function bubble(target, listenerName, nativeEvent) {
|
|
|
495
545
|
// machinery (already delivered directly, e.g. Angular's EventEmitter.subscribe) also
|
|
496
546
|
// registered it through Renderer2.listen. Bubbling into it would refire that same
|
|
497
547
|
// callback a second time, so anchors are transparent to listener lookup, not just paint.
|
|
498
|
-
const listener = isAnchor(node)
|
|
548
|
+
const listener = isAnchor(node)
|
|
549
|
+
? undefined
|
|
550
|
+
: node.listeners?.get(captureName);
|
|
499
551
|
if (listener) {
|
|
500
552
|
dlog(`event ${listenerName} capture on ${node.component}`);
|
|
501
553
|
listener({
|
|
@@ -513,7 +565,9 @@ function bubble(target, listenerName, nativeEvent) {
|
|
|
513
565
|
// transparent here too, same reason (see the capture-phase comment above).
|
|
514
566
|
let node = target;
|
|
515
567
|
while (node) {
|
|
516
|
-
const listener = isAnchor(node)
|
|
568
|
+
const listener = isAnchor(node)
|
|
569
|
+
? undefined
|
|
570
|
+
: node.listeners?.get(listenerName);
|
|
517
571
|
if (listener) {
|
|
518
572
|
const event = {
|
|
519
573
|
type: listenerName,
|
package/build/fabric-props.js
CHANGED
|
@@ -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';
|
|
@@ -70,11 +70,17 @@ const COLOR_PROPS = new Set([
|
|
|
70
70
|
const STYLE_PROCESSORS = new Map([
|
|
71
71
|
['boxShadow', value => processBoxShadow(asBoxShadowInput(value))],
|
|
72
72
|
['filter', value => processFilter(asFilterInput(value))],
|
|
73
|
-
[
|
|
73
|
+
[
|
|
74
|
+
'transformOrigin',
|
|
75
|
+
value => processTransformOrigin(asTransformOriginInput(value)),
|
|
76
|
+
],
|
|
74
77
|
['transform', processTransformValue],
|
|
75
78
|
['aspectRatio', value => processAspectRatio(asAspectRatioInput(value))],
|
|
76
79
|
['fontVariant', value => processFontVariant(asFontVariantInput(value))],
|
|
77
|
-
[
|
|
80
|
+
[
|
|
81
|
+
'experimental_backgroundImage',
|
|
82
|
+
value => processBackgroundImage(asBackgroundImageInput(value)),
|
|
83
|
+
],
|
|
78
84
|
]);
|
|
79
85
|
// boxShadow accepts a CSS string or an array of shadow objects; anything else is
|
|
80
86
|
// undefined to processBoxShadow (which returns []). Narrowing avoids an `as` cast.
|
|
@@ -158,29 +164,183 @@ function processValue(component, key, value) {
|
|
|
158
164
|
return processColor(value);
|
|
159
165
|
return value;
|
|
160
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
|
+
}
|
|
161
249
|
// Translate the retained node's logical props into the flat payload Fabric's C++
|
|
162
250
|
// props expect: `style` keys are hoisted to the top level, event handlers and
|
|
163
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
|
+
}
|
|
164
292
|
export function fabricProps(node) {
|
|
165
293
|
if (node.component === RAW_TEXT_COMPONENT) {
|
|
166
294
|
return { text: node.props.text };
|
|
167
295
|
}
|
|
296
|
+
// This runs once per node per commit - 9 000 times on one benchmark press - so the two loops
|
|
297
|
+
// below iterate with Object.keys rather than Object.entries: entries allocates a fresh
|
|
298
|
+
// two-element array PER KEY on top of the outer array, and the resulting garbage was 18% of the
|
|
299
|
+
// create path in a CPU profile.
|
|
300
|
+
//
|
|
301
|
+
// Do NOT "improve" this to `for...in`. It was tried and reverted 2026-08-23. On paper it is the
|
|
302
|
+
// strictly cheaper shape - Object.keys allocates one array per call, 10 007 of them on a
|
|
303
|
+
// 1 000-row create (counted), and for...in allocates none - and the headless V8 bench agreed,
|
|
304
|
+
// 12-13% off create/replace `min`. On DEVICE it lost: with the Fabric call counts and prop-key
|
|
305
|
+
// payload byte-identical either way (9000/5000/1009, 32001 keys), Release Create went 217.8 ->
|
|
306
|
+
// 243.2 ms while stock moved only inside its 4% noise floor. Hermes' for-in is not V8's enum
|
|
307
|
+
// cache. The general rule this bought: an allocation-count win measured on V8 is not a Hermes
|
|
308
|
+
// win, and only the on-device number decides (`perf-claims-need-numbers`).
|
|
168
309
|
const out = {};
|
|
169
|
-
|
|
310
|
+
// THE ONE POINT WHERE THE WHOLE BAG IS KNOWN ON EVERY PATH, which is what the aria fold needs:
|
|
311
|
+
// `aria-checked` has to be folded against a sibling `accessibilityState`, and `routeProp` sees
|
|
312
|
+
// one key at a time. Both commit paths — create and update — reach here, so a lowered element
|
|
313
|
+
// gets the fold it has no wrapper to run.
|
|
314
|
+
//
|
|
315
|
+
// NOT memoised on `node.props` identity. That object is stable and mutated IN PLACE, so an
|
|
316
|
+
// identity-keyed cache (the `processedStyle` pattern below) would be stale forever. The gate is
|
|
317
|
+
// the node's sticky flag instead: one boolean read for a node with no alias, which is nearly all
|
|
318
|
+
// of them, and the fold's own fast path returns by identity for the rest.
|
|
319
|
+
const aliasFolded = node.hasAriaAlias
|
|
320
|
+
? foldAriaProps(node.props)
|
|
321
|
+
: node.props;
|
|
322
|
+
// The behavior's own fold, for a LOWERED element only — the two folds above are keyed on the
|
|
323
|
+
// component name, which a wrapper and its lowered twin share, so neither could carry a
|
|
324
|
+
// per-primitive fold without running it twice on the wrapper. See IPayloadFold.
|
|
325
|
+
const behaviorFolded = node.payloadFold !== undefined
|
|
326
|
+
? node.payloadFold(aliasFolded)
|
|
327
|
+
: aliasFolded;
|
|
328
|
+
const props = node.component === SINGLELINE_TEXT_INPUT ||
|
|
329
|
+
node.component === MULTILINE_TEXT_INPUT
|
|
330
|
+
? foldTextInputValue(behaviorFolded)
|
|
331
|
+
: behaviorFolded;
|
|
332
|
+
for (const key of Object.keys(props)) {
|
|
170
333
|
if (key === 'style')
|
|
171
334
|
continue;
|
|
335
|
+
const value = props[key];
|
|
172
336
|
if (typeof value === 'function')
|
|
173
337
|
continue;
|
|
174
338
|
if (value === undefined)
|
|
175
339
|
continue;
|
|
176
340
|
out[key] = processValue(node.component, key, value);
|
|
177
341
|
}
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
for (const [key, value] of Object.entries(style)) {
|
|
182
|
-
if (value !== undefined)
|
|
183
|
-
out[key] = processValue(node.component, key, value);
|
|
184
|
-
}
|
|
342
|
+
// Hoist the style slot (object | array | nested arrays) into the SAME payload object - no
|
|
343
|
+
// intermediate flatten. See addStyle for the shape and for the two things this fixed.
|
|
344
|
+
addStyle(out, node.component, props.style);
|
|
185
345
|
return out;
|
|
186
346
|
}
|
package/build/fabric.d.ts
CHANGED
|
@@ -13,8 +13,9 @@ export type IMeasureLayoutOnSuccess = (left: number, top: number, width: number,
|
|
|
13
13
|
export interface IFabricSlot {
|
|
14
14
|
createNode(reactTag: number, viewName: string, rootTag: IRootTag, props: IFabricProps, instanceHandle: unknown): IFabricNode;
|
|
15
15
|
cloneNodeWithNewProps(node: IFabricNode, newProps: IFabricProps): IFabricNode;
|
|
16
|
-
cloneNodeWithNewChildren(node: IFabricNode): IFabricNode;
|
|
17
|
-
cloneNodeWithNewChildrenAndProps(node: IFabricNode, newProps: IFabricProps): IFabricNode;
|
|
16
|
+
cloneNodeWithNewChildren(node: IFabricNode, children?: readonly IFabricNode[]): IFabricNode;
|
|
17
|
+
cloneNodeWithNewChildrenAndProps(node: IFabricNode, newProps: IFabricProps, children?: readonly IFabricNode[]): IFabricNode;
|
|
18
|
+
supportsCloneWithChildren: boolean;
|
|
18
19
|
createChildSet(rootTag: IRootTag): IFabricChildSet;
|
|
19
20
|
appendChild(parent: IFabricNode, child: IFabricNode): IFabricNode;
|
|
20
21
|
appendChildToSet(childSet: IFabricChildSet, child: IFabricNode): void;
|
|
@@ -26,7 +27,13 @@ export interface IFabricSlot {
|
|
|
26
27
|
measureInWindow(node: IFabricNode, callback: IMeasureInWindowOnSuccess): void;
|
|
27
28
|
measureLayout(node: IFabricNode, relativeToNode: IFabricNode, onFail: () => void, onSuccess: IMeasureLayoutOnSuccess): void;
|
|
28
29
|
}
|
|
30
|
+
interface IFabricHost extends Omit<IFabricSlot, 'cloneNodeWithNewChildren' | 'cloneNodeWithNewChildrenAndProps' | 'supportsCloneWithChildren'> {
|
|
31
|
+
cloneNodeWithNewChildren(node: IFabricNode, children?: readonly IFabricNode[]): IFabricNode;
|
|
32
|
+
cloneNodeWithNewChildrenAndProps(node: IFabricNode, newProps: IFabricProps): IFabricNode;
|
|
33
|
+
cloneNodeWithNewChildrenAndProps(node: IFabricNode, children: readonly IFabricNode[], newProps: IFabricProps): IFabricNode;
|
|
34
|
+
}
|
|
29
35
|
declare global {
|
|
30
|
-
var nativeFabricUIManager:
|
|
36
|
+
var nativeFabricUIManager: IFabricHost | undefined;
|
|
31
37
|
}
|
|
32
38
|
export declare function getSlot(): IFabricSlot;
|
|
39
|
+
export {};
|