@symbiote-native/engine 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +39 -14
  2. package/android/CMakeLists.txt +51 -0
  3. package/android/build.gradle +90 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/cpp/SymbioteEngineJni.cpp +72 -0
  6. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEngineModule.kt +43 -0
  7. package/android/src/main/java/dev/symbiotenative/engine/SymbioteEnginePackage.kt +35 -0
  8. package/build/accessibility-info/shared.js +1 -1
  9. package/build/accessibility-props.d.ts +1 -7
  10. package/build/accessibility-props.js +22 -21
  11. package/build/animated/animations/composition.d.ts +1 -1
  12. package/build/animated/animations/composition.js +18 -4
  13. package/build/animated/easing.d.ts +3 -2
  14. package/build/animated/easing.js +17 -88
  15. package/build/animated/event.js +6 -1
  16. package/build/animated/graph.d.ts +2 -0
  17. package/build/animated/graph.js +14 -0
  18. package/build/animated/host-binding.d.ts +39 -0
  19. package/build/animated/host-binding.js +278 -0
  20. package/build/animated/index.d.ts +1 -1
  21. package/build/animated/leaf-lifecycle.js +10 -22
  22. package/build/animated/mock.d.ts +1 -19
  23. package/build/animated/props.js +1 -1
  24. package/build/animated/rgba.js +16 -50
  25. package/build/events/index.js +123 -33
  26. package/build/fabric-props.d.ts +1 -1
  27. package/build/fabric-props.js +129 -182
  28. package/build/fabric.d.ts +10 -0
  29. package/build/fabric.js +40 -0
  30. package/build/host-access.d.ts +125 -0
  31. package/build/host-access.js +280 -0
  32. package/build/host-behavior.d.ts +107 -7
  33. package/build/host-behavior.js +309 -31
  34. package/build/image-source-write.d.ts +16 -0
  35. package/build/image-source-write.js +65 -0
  36. package/build/imperative.d.ts +49 -0
  37. package/build/imperative.js +258 -0
  38. package/build/index.d.ts +18 -10
  39. package/build/index.js +65 -10
  40. package/build/mutation-buffer.d.ts +222 -0
  41. package/build/mutation-buffer.js +491 -0
  42. package/build/native-engine.d.ts +182 -0
  43. package/build/native-engine.js +178 -0
  44. package/build/native-tree-host.d.ts +25 -0
  45. package/build/native-tree-host.js +66 -0
  46. package/build/node.d.ts +191 -60
  47. package/build/node.js +1034 -327
  48. package/build/pan-responder/index.d.ts +2 -2
  49. package/build/pan-responder/index.js +37 -56
  50. package/build/platform-color/index.d.ts +1 -1
  51. package/build/platform-color/index.js +11 -4
  52. package/build/process-background-image/index.js +30 -566
  53. package/build/process-background-longhands.d.ts +4 -0
  54. package/build/process-background-longhands.js +44 -0
  55. package/build/process-box-shadow/index.js +23 -187
  56. package/build/process-filter.js +27 -300
  57. package/build/process-transform/index.d.ts +1 -1
  58. package/build/process-transform/index.js +25 -107
  59. package/build/process-transform-origin/index.d.ts +1 -1
  60. package/build/process-transform-origin/index.js +29 -102
  61. package/build/registry.d.ts +36 -0
  62. package/build/registry.js +73 -0
  63. package/build/sound-manager/index.d.ts +3 -0
  64. package/build/sound-manager/index.js +36 -0
  65. package/build/structured-style.d.ts +10 -0
  66. package/build/structured-style.js +180 -0
  67. package/build/style-registry/index.d.ts +14 -0
  68. package/build/style-registry/index.js +60 -11
  69. package/build/styles.d.ts +5 -1
  70. package/build/surface.d.ts +31 -2
  71. package/build/surface.js +138 -42
  72. package/build/text-input-state.d.ts +1 -0
  73. package/build/text-input-state.js +17 -3
  74. package/build/tree-host.d.ts +307 -0
  75. package/build/tree-host.js +211 -0
  76. package/build/view-config.js +4 -4
  77. package/codegen-specs/NativeSymbioteEngine.ts +27 -0
  78. package/cpp/SymbioteDebug.cpp +51 -0
  79. package/cpp/SymbioteDebug.h +54 -0
  80. package/cpp/SymbioteEngineBindings.cpp +232 -0
  81. package/cpp/SymbioteEngineBindings.h +59 -0
  82. package/cpp/SymbioteFabricProps.cpp +2619 -0
  83. package/cpp/SymbioteFabricProps.h +223 -0
  84. package/cpp/SymbioteTree.cpp +2478 -0
  85. package/cpp/SymbioteTree.h +257 -0
  86. package/ios/SymbioteEngineModule.h +25 -0
  87. package/ios/SymbioteEngineModule.mm +44 -0
  88. package/package.json +31 -3
  89. package/react-native.config.cjs +23 -0
  90. package/symbiote-engine.podspec +42 -0
  91. package/build/animated/bezier.d.ts +0 -1
  92. package/build/animated/bezier.js +0 -102
  93. package/build/commit.d.ts +0 -49
  94. package/build/commit.js +0 -1030
  95. package/build/tags.d.ts +0 -2
  96. package/build/tags.js +0 -40
@@ -8,45 +8,25 @@
8
8
  // which surfaces at load time as `TypeError: Class extends value undefined is not a
9
9
  // constructor` — AnimatedWithChildren is still uninitialized when color.ts evaluates. Keeping
10
10
  // the pure half in its own leaf module breaks it structurally rather than by import ordering.
11
+ // No types are published for this package, and an ambient .d.ts is not picked up by another
12
+ // package's separate TS program (see symbiote-rn-import-testability) - so the suppression is
13
+ // local, at the import site.
14
+ // @ts-expect-error - untyped, @noflow plain JS that every toolchain here parses as-is.
15
+ import normalizeColorUpstream from '@react-native/normalize-colors';
11
16
  export const DEFAULT_COLOR = { r: 0, g: 0, b: 0, a: 1 };
12
- // Decompose a #hex (3/4/6/8), rgb()/rgba(), or 0xRRGGBBAA number into channels.
13
- // undefined when unparseable (a named/platform color), so the caller falls back to
14
- // the default rather than throwing inside a render. Exported so interpolation's
15
- // color path parses through the same RGBA decoder (DRY) rather than duplicating it.
17
+ // Decompose any color RN itself accepts into channels: #hex, rgb()/rgba(), hsl()/hsla(), hwb(),
18
+ // the 150 CSS names, `transparent`, and a 0xRRGGBBAA number. undefined when unparseable, so the
19
+ // caller falls back to the default rather than throwing inside a render. Exported so
20
+ // interpolation's color path decodes through this one decoder rather than duplicating it.
21
+ //
22
+ // The parsing is upstream's. A hand-rolled version lived here and knew hex and rgb() only, so
23
+ // `new AnimatedColor('red')` animated from BLACK - the fallback, not an error, and nothing was
24
+ // red anywhere. Its packed output is rrggbbaa, which is the order the shifts below assume.
16
25
  export function normalizeColor(color) {
17
- if (typeof color === 'number') {
18
- const c = color >>> 0;
19
- return {
20
- r: (c >>> 24) & 255,
21
- g: (c >>> 16) & 255,
22
- b: (c >>> 8) & 255,
23
- a: (c & 255) / 255,
24
- };
25
- }
26
- const trimmed = color.trim();
27
- if (trimmed.startsWith('#'))
28
- return parseHex(trimmed);
29
- if (/^rgba?\(/i.test(trimmed))
30
- return parseRgb(trimmed);
31
- return undefined;
32
- }
33
- function parseHex(hex) {
34
- let body = hex.slice(1);
35
- if (body.length === 3 || body.length === 4) {
36
- body = body
37
- .split('')
38
- .map(c => c + c)
39
- .join('');
40
- }
41
- if (body.length !== 6 && body.length !== 8)
26
+ const packed = normalizeColorUpstream(color);
27
+ if (typeof packed !== 'number')
42
28
  return undefined;
43
- const int = Number.parseInt(body, 16);
44
- if (Number.isNaN(int))
45
- return undefined;
46
- if (body.length === 6) {
47
- return { r: (int >>> 16) & 255, g: (int >>> 8) & 255, b: int & 255, a: 1 };
48
- }
49
- const c = int >>> 0;
29
+ const c = packed >>> 0;
50
30
  return {
51
31
  r: (c >>> 24) & 255,
52
32
  g: (c >>> 16) & 255,
@@ -54,17 +34,3 @@ function parseHex(hex) {
54
34
  a: (c & 255) / 255,
55
35
  };
56
36
  }
57
- function parseRgb(str) {
58
- const match = /^rgba?\(([^)]+)\)$/i.exec(str);
59
- if (match === null)
60
- return undefined;
61
- const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()));
62
- if (parts.length < 3 || parts.some(n => Number.isNaN(n)))
63
- return undefined;
64
- return {
65
- r: parts[0],
66
- g: parts[1],
67
- b: parts[2],
68
- a: parts.length >= 4 ? parts[3] : 1,
69
- };
70
- }
@@ -5,11 +5,13 @@
5
5
  // node it started on (or a descendant). Bubbling events walk target -> root,
6
6
  // invoking each ancestor's listener until one calls stopPropagation. Layout is a
7
7
  // direct event in RN and is delivered only to its own target.
8
- import { dlog } from '../debug.js';
8
+ import { dlog, isDebug } from '../debug.js';
9
9
  import { runWrapped } from '../dispatch.js';
10
10
  import { getSlot } from '../fabric.js';
11
11
  import { isAnchor, isSymbioteNode, } from '../node.js';
12
12
  import { registeredNativeEvent } from '../registry.js';
13
+ import { ancestorsOf, parentOf } from '../host-access.js';
14
+ import { setIsJSResponder } from '../imperative.js';
13
15
  import { attachTouchHistory, recordTouchTrack, resetTouchHistory, touchHistory, } from '../touch-history.js';
14
16
  import { isRecord } from '../type-guards.js';
15
17
  // Raw Fabric event -> listener name, split by dispatch phase. Press is synthesized
@@ -168,7 +170,7 @@ function hasRemainingTouchWithin(owner, nativeEvent) {
168
170
  // Whether any node from `target` up to the root listens for `listenerName`, used to
169
171
  // arm the long-press timer only when a handler would actually receive it.
170
172
  function hasListenerInPath(target, listenerName) {
171
- for (let node = target; node; node = node.parent) {
173
+ for (let node = target; node; node = parentOf(node)) {
172
174
  if (node.listeners?.has(listenerName) === true)
173
175
  return true;
174
176
  }
@@ -190,17 +192,20 @@ function callOwnListener(node, listenerName, nativeEvent) {
190
192
  }
191
193
  // The node chain from `from` up to the root, deepest first. The single allocation
192
194
  // the two-phase walk indexes both ways (capture reads it reversed).
195
+ //
196
+ // ONE CROSSING, not one per level. This used to climb `parentOf`, which crosses the host boundary
197
+ // each step — 18 questions per event on a depth-8 chain, and again on every frame of every drag for
198
+ // the responder's scope. The host holds the tree, so the chain is one answer it can give: the same
199
+ // argument `parentsOf` / `subtreesOf` won for the teardown sweep (F-2) and `nextSiblingOf` for Vue's
200
+ // quadratic (F-19).
193
201
  function pathToRoot(from) {
194
- const path = [];
195
- for (let node = from; node; node = node.parent)
196
- path.push(node);
197
- return path;
202
+ return [...ancestorsOf(from)];
198
203
  }
199
204
  // Depth of a node below the root (root = 0). Aligns two nodes before the lockstep
200
205
  // climb to their lowest common ancestor.
201
206
  function depthOf(node) {
202
207
  let depth = 0;
203
- for (let n = node.parent; n; n = n.parent)
208
+ for (let n = parentOf(node); n; n = parentOf(n))
204
209
  depth++;
205
210
  return depth;
206
211
  }
@@ -213,18 +218,18 @@ function lowestCommonAncestor(a, b) {
213
218
  let na = a;
214
219
  let nb = b;
215
220
  while (na && da > db) {
216
- na = na.parent;
221
+ na = parentOf(na);
217
222
  da--;
218
223
  }
219
224
  while (nb && db > da) {
220
- nb = nb.parent;
225
+ nb = parentOf(nb);
221
226
  db--;
222
227
  }
223
228
  while (na && nb) {
224
229
  if (na === nb)
225
230
  return na;
226
- na = na.parent;
227
- nb = nb.parent;
231
+ na = parentOf(na);
232
+ nb = parentOf(nb);
228
233
  }
229
234
  return undefined;
230
235
  }
@@ -248,6 +253,33 @@ function findWantsResponder(path, captureName, bubbleName, nativeEvent, skip) {
248
253
  }
249
254
  return undefined;
250
255
  }
256
+ // Tell native which node owns the gesture, RN's `injectGlobalResponderHandler`
257
+ // (ReactFabric-dev.js:18862) — the OLD owner first, then the new one, both in one call so no
258
+ // site can do half of it.
259
+ //
260
+ // WITHOUT THIS A JS RESPONDER LOSES TO ANY SCROLL VIEW ABOVE IT, and it is invisible from JS:
261
+ // `onStartShouldSetResponder` returns true, the native UIScrollView never learns the gesture was
262
+ // claimed, and every subsequent move arrives as `topScroll` instead of `topTouchMove` — so the
263
+ // negotiation never even gets a move to grant on. Device-diagnosed 2026-09-08 on a PanResponder
264
+ // drag box inside the canary's ScrollView: `startShouldSet -> true` followed by
265
+ // `topScrollBeginDrag` and twenty `topScroll`, with no grant and no move.
266
+ //
267
+ // `blockNativeResponder` is the taker's own `onResponderGrant` return, exactly as RN reads it.
268
+ function handOverNativeResponder(from, to, blockNativeResponder) {
269
+ if (isDebug()) {
270
+ dlog(`setIsJSResponder from=${from === undefined ? 'none' : 'yes'} ` +
271
+ `to=${to === undefined ? 'none' : 'yes'} block=${blockNativeResponder}`);
272
+ }
273
+ if (from !== undefined)
274
+ setIsJSResponder(from, false, blockNativeResponder);
275
+ if (to !== undefined)
276
+ setIsJSResponder(to, true, blockNativeResponder);
277
+ }
278
+ // Whether the taker asked native to stand down. RN reads this off the grant dispatch's return;
279
+ // an absent listener means no claim, which is RN's `blockNativeResponder || false`.
280
+ function blocksNative(result) {
281
+ return result === true;
282
+ }
251
283
  // Negotiate (or re-negotiate) the responder for a touch start/move. If nobody holds
252
284
  // it, the winner is granted. If someone does, the incumbent is asked to relinquish
253
285
  // via onResponderTerminationRequest (absent listener = implicit yes); on yes it is
@@ -258,22 +290,56 @@ function negotiateResponder(target, phase, nativeEvent) {
258
290
  // the responder) and skips the deepest node when it IS the responder (Responder-
259
291
  // EventPlugin.setResponderAndExtractTransfer). At touch start currentResponder is
260
292
  // cleared, so this collapses to the plain target->root start walk.
293
+ //
294
+ // ONE WALK, and the common case never needs a second. `pathToRoot` crosses the host boundary per
295
+ // level, and the previous shape paid `lowestCommonAncestor` — `depthOf(a)` plus `depthOf(b)` plus
296
+ // a lockstep climb — BEFORE walking again from the answer. Measured on a depth-8 chain: 19
297
+ // crossings per move, of which 18 were the ancestor search, on a path that runs 60 times a second
298
+ // for the whole of a drag (`__tests__/touch-crossing-cost.test.ts`).
299
+ //
300
+ // The scope RN computes is the lowest common ancestor of responder and target, and while a finger
301
+ // stays inside the view that claimed it — which is what a drag IS — the responder is an ANCESTOR
302
+ // of the target, so the LCA is the responder itself and it is already in the target's own path.
303
+ // An index lookup answers it with nothing crossed, and the scoped path is that path's suffix.
304
+ // The genuine cross-subtree case (the finger left the responder's subtree) still falls back to
305
+ // the full search, and it is a rare frame rather than every frame.
306
+ const targetPath = pathToRoot(target);
307
+ const heldAt = currentResponder === undefined ? -1 : targetPath.indexOf(currentResponder);
261
308
  const from = currentResponder === undefined
262
309
  ? target
263
- : lowestCommonAncestor(currentResponder, target);
310
+ : heldAt >= 0
311
+ ? currentResponder
312
+ : lowestCommonAncestor(currentResponder, target);
264
313
  if (!from)
265
314
  return;
266
- const path = pathToRoot(from);
315
+ const path = from === target
316
+ ? targetPath
317
+ : heldAt >= 0
318
+ ? targetPath.slice(heldAt)
319
+ : pathToRoot(from);
267
320
  const skip = from === currentResponder ? from : undefined;
268
321
  const wants = phase === 'start'
269
322
  ? findWantsResponder(path, START_SHOULD_SET_CAPTURE, START_SHOULD_SET, nativeEvent, skip)
270
323
  : findWantsResponder(path, MOVE_SHOULD_SET_CAPTURE, MOVE_SHOULD_SET, nativeEvent, skip);
271
- if (!wants || wants === currentResponder)
324
+ // Every exit is logged: a negotiation that declines is indistinguishable from one that never
325
+ // ran, and the two have opposite causes.
326
+ if (!wants) {
327
+ if (isDebug()) {
328
+ dlog(`responder ${phase}: nobody wants it (path=${path.length}${skip === undefined ? '' : ', one skipped'})`);
329
+ }
272
330
  return;
331
+ }
332
+ if (wants === currentResponder) {
333
+ if (isDebug())
334
+ dlog(`responder ${phase}: ${wants.component} already holds it`);
335
+ return;
336
+ }
273
337
  if (currentResponder === undefined) {
274
338
  currentResponder = wants;
275
- dlog(`responder granted to ${wants.component}`);
276
- callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
339
+ if (isDebug())
340
+ dlog(`responder granted to ${wants.component}`);
341
+ const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
342
+ handOverNativeResponder(undefined, wants, blocksNative(granted));
277
343
  return;
278
344
  }
279
345
  const incumbent = currentResponder;
@@ -291,13 +357,16 @@ function negotiateResponder(target, phase, nativeEvent) {
291
357
  // visible no-op event with no behavioral counterpart. We therefore fire grant
292
358
  // before terminate on the consent path (matching RN's grant<terminate ordering) and
293
359
  // omit it on reject; the consent OUTCOME is unchanged either way.
294
- dlog(`responder transferred ${incumbent.component} -> ${wants.component}`);
295
- callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
360
+ if (isDebug())
361
+ dlog(`responder transferred ${incumbent.component} -> ${wants.component}`);
362
+ const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
296
363
  callOwnListener(incumbent, RESPONDER_TERMINATE, nativeEvent);
297
364
  currentResponder = wants;
365
+ handOverNativeResponder(incumbent, wants, blocksNative(granted));
298
366
  }
299
367
  else {
300
- dlog(`responder takeover of ${incumbent.component} rejected`);
368
+ if (isDebug())
369
+ dlog(`responder takeover of ${incumbent.component} rejected`);
301
370
  callOwnListener(wants, RESPONDER_REJECT, nativeEvent);
302
371
  }
303
372
  }
@@ -309,7 +378,10 @@ export function installEventHandler() {
309
378
  if (!isSymbioteNode(instanceHandle))
310
379
  return;
311
380
  if (topLevelType === TOUCH_START) {
312
- dlog(`event ${TOUCH_START}`);
381
+ // The ternary is gone with the gate: `isSymbioteNode(instanceHandle)` was re-asked here
382
+ // three lines after the early return above already proved it.
383
+ if (isDebug())
384
+ dlog(`event ${TOUCH_START} on ${instanceHandle.component}`);
313
385
  // Update the touch bank, then attach it so responder handlers (PanResponder)
314
386
  // read each touch's own previous->current delta; RN records before dispatch.
315
387
  recordTouchTrack('start', nativeEvent);
@@ -423,7 +495,8 @@ export function installEventHandler() {
423
495
  bubble(press.owner, PRESS_OUT, nativeEvent);
424
496
  }
425
497
  if (!hadActivePress) {
426
- dlog(`event ${TOUCH_END} ignored (no matching start)`);
498
+ if (isDebug())
499
+ dlog(`event ${TOUCH_END} ignored (no matching start)`);
427
500
  }
428
501
  else if (completedPresses.length === 0) {
429
502
  dlog('press retained (another touch remains inside its owner)');
@@ -432,8 +505,10 @@ export function installEventHandler() {
432
505
  // release) only when the last responder touch lifted.
433
506
  if (responder) {
434
507
  callOwnListener(responder, RESPONDER_END, nativeEvent);
435
- if (releases)
508
+ if (releases) {
436
509
  callOwnListener(responder, RESPONDER_RELEASE, nativeEvent);
510
+ handOverNativeResponder(responder, undefined, false);
511
+ }
437
512
  else
438
513
  dlog('responderEnd without release (touches remain inside responder)');
439
514
  }
@@ -468,8 +543,10 @@ export function installEventHandler() {
468
543
  // Like touch-end, every finger leaving emits responderEnd. Termination is final only
469
544
  // when no touch remains inside the responder.
470
545
  callOwnListener(responder, RESPONDER_END, nativeEvent);
471
- if (terminatesResponder)
546
+ if (terminatesResponder) {
472
547
  callOwnListener(responder, RESPONDER_TERMINATE, nativeEvent);
548
+ handOverNativeResponder(responder, undefined, false);
549
+ }
473
550
  }
474
551
  });
475
552
  if (touchHistory.numberActiveTouches === 0)
@@ -478,13 +555,17 @@ export function installEventHandler() {
478
555
  }
479
556
  const direct = DIRECT_EVENTS[topLevelType];
480
557
  if (direct !== undefined) {
481
- dlog(`event ${topLevelType} -> ${direct} (direct)`);
558
+ if (isDebug()) {
559
+ dlog(`event ${topLevelType} -> ${direct} (direct)`);
560
+ }
482
561
  runWrapped(() => deliverDirect(instanceHandle, direct, nativeEvent));
483
562
  return;
484
563
  }
485
564
  const bubbling = BUBBLING_EVENTS[topLevelType];
486
565
  if (bubbling !== undefined) {
487
- dlog(`event ${topLevelType} -> ${bubbling} (bubble)`);
566
+ if (isDebug()) {
567
+ dlog(`event ${topLevelType} -> ${bubbling} (bubble)`);
568
+ }
488
569
  runWrapped(() => bubble(instanceHandle, bubbling, nativeEvent));
489
570
  return;
490
571
  }
@@ -494,8 +575,10 @@ export function installEventHandler() {
494
575
  // bubble: same split as the built-ins.
495
576
  const registered = registeredNativeEvent(instanceHandle.component, topLevelType);
496
577
  if (registered !== undefined) {
497
- const phase = registered.direct ? 'direct' : 'bubble';
498
- dlog(`event ${topLevelType} -> ${registered.listener} (${phase}, registered)`);
578
+ if (isDebug()) {
579
+ const phase = registered.direct ? 'direct' : 'bubble';
580
+ dlog(`event ${topLevelType} -> ${registered.listener} (${phase}, registered)`);
581
+ }
499
582
  runWrapped(() => registered.direct
500
583
  ? deliverDirect(instanceHandle, registered.listener, nativeEvent)
501
584
  : bubble(instanceHandle, registered.listener, nativeEvent));
@@ -505,7 +588,9 @@ export function installEventHandler() {
505
588
  // config. A permanent diagnostic seam: if a native view fires something we drop
506
589
  // on the floor (an event the ViewConfig didn't surface, or a name mismatch),
507
590
  // this is where it shows up. Keeps "the handler silently did nothing" debuggable.
508
- dlog(`event ${topLevelType} UNMATCHED on ${instanceHandle.component} (dropped)`);
591
+ if (isDebug()) {
592
+ dlog(`event ${topLevelType} UNMATCHED on ${instanceHandle.component} (dropped)`);
593
+ }
509
594
  });
510
595
  }
511
596
  // A press is honest only if the touch ends on the node it started on, or a
@@ -517,7 +602,7 @@ function endsWithin(endTarget, start) {
517
602
  while (node) {
518
603
  if (node === start)
519
604
  return true;
520
- node = node.parent;
605
+ node = parentOf(node);
521
606
  }
522
607
  return false;
523
608
  }
@@ -549,7 +634,8 @@ function bubble(target, listenerName, nativeEvent) {
549
634
  ? undefined
550
635
  : node.listeners?.get(captureName);
551
636
  if (listener) {
552
- dlog(`event ${listenerName} capture on ${node.component}`);
637
+ if (isDebug())
638
+ dlog(`event ${listenerName} capture on ${node.component}`);
553
639
  listener({
554
640
  type: listenerName,
555
641
  target,
@@ -563,8 +649,13 @@ function bubble(target, listenerName, nativeEvent) {
563
649
  }
564
650
  // Bubble phase: target -> root, invoking each ancestor's plain listener. Anchors are
565
651
  // transparent here too, same reason (see the capture-phase comment above).
566
- let node = target;
567
- while (node) {
652
+ //
653
+ // THE SAME ARRAY, read forward. This used to re-walk `parentOf` from the target, which is a
654
+ // CROSSING of the host boundary per level — so one event asked the host for its ancestors twice,
655
+ // measured at 18 questions on a depth-8 chain where 9 is the floor. The comment above already
656
+ // says the path is built once "without a second allocation"; the second WALK is the expensive
657
+ // half, and it was the one left in. Every touch and every frame of every drag pays it.
658
+ for (const node of path) {
568
659
  const listener = isAnchor(node)
569
660
  ? undefined
570
661
  : node.listeners?.get(listenerName);
@@ -580,7 +671,6 @@ function bubble(target, listenerName, nativeEvent) {
580
671
  if (stopped)
581
672
  return;
582
673
  }
583
- node = node.parent;
584
674
  }
585
675
  }
586
676
  // Direct (non-bubbling) delivery: only the target's own listener fires.
@@ -1,3 +1,3 @@
1
1
  import type { IFabricProps } from './fabric';
2
2
  import { type ISymbioteNode } from './node';
3
- export declare function fabricProps(node: ISymbioteNode): IFabricProps;
3
+ export declare function fabricProps(node: ISymbioteNode, nodeProps: Readonly<Record<string, unknown>>): IFabricProps;