@symbiote-native/engine 1.2.0 → 1.3.1

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 (110) hide show
  1. package/android/CMakeLists.txt +31 -1
  2. package/android/build.gradle +4 -1
  3. package/build/accessibility-info/index.android.js +21 -17
  4. package/build/accessibility-info/index.ios.js +2 -0
  5. package/build/accessibility-info/shared.d.ts +1 -1
  6. package/build/accessibility-props.d.ts +0 -11
  7. package/build/accessibility-props.js +30 -68
  8. package/build/alert/shared.d.ts +1 -1
  9. package/build/alert/shared.js +3 -7
  10. package/build/animated/graph.js +1 -1
  11. package/build/animated/leaf-lifecycle.js +2 -2
  12. package/build/app-state/index.d.ts +1 -1
  13. package/build/app-state/index.js +35 -27
  14. package/build/asset-source-resolver.d.ts +2 -0
  15. package/build/asset-source-resolver.js +13 -0
  16. package/build/back-handler/index.d.ts +7 -6
  17. package/build/back-handler/index.js +19 -16
  18. package/build/debug.js +8 -22
  19. package/build/dispatch.js +3 -9
  20. package/build/events/index.js +12 -3
  21. package/build/fabric-props.js +75 -180
  22. package/build/fabric.d.ts +2 -8
  23. package/build/fabric.js +27 -33
  24. package/build/host-access.d.ts +1 -128
  25. package/build/host-access.js +96 -205
  26. package/build/host-behavior.d.ts +1 -100
  27. package/build/host-behavior.js +125 -311
  28. package/build/image-loader.d.ts +4 -2
  29. package/build/image-loader.js +24 -48
  30. package/build/image-source-resolver.js +3 -7
  31. package/build/image-source-write.d.ts +1 -12
  32. package/build/image-source-write.js +28 -36
  33. package/build/imperative.d.ts +0 -28
  34. package/build/imperative.js +42 -92
  35. package/build/index.d.ts +3 -2
  36. package/build/index.js +30 -43
  37. package/build/invariant.d.ts +1 -0
  38. package/build/invariant.js +10 -0
  39. package/build/keyboard/index.js +11 -32
  40. package/build/linking/index.android.js +5 -3
  41. package/build/linking/shared.d.ts +1 -1
  42. package/build/linking/shared.js +16 -19
  43. package/build/mutation-buffer.d.ts +0 -177
  44. package/build/mutation-buffer.js +137 -308
  45. package/build/native-engine.d.ts +0 -102
  46. package/build/native-engine.js +38 -98
  47. package/build/native-events.d.ts +5 -0
  48. package/build/native-events.js +30 -18
  49. package/build/native-tree-host.d.ts +0 -21
  50. package/build/native-tree-host.js +14 -31
  51. package/build/node.d.ts +0 -212
  52. package/build/node.js +354 -774
  53. package/build/permissions-android/index.android.d.ts +59 -0
  54. package/build/permissions-android/index.android.js +47 -0
  55. package/build/permissions-android/index.d.ts +1 -115
  56. package/build/permissions-android/index.ios.d.ts +59 -0
  57. package/build/permissions-android/index.ios.js +31 -0
  58. package/build/permissions-android/index.js +3 -184
  59. package/build/permissions-android/shared.d.ts +63 -0
  60. package/build/permissions-android/shared.js +66 -0
  61. package/build/platform/index.android.js +3 -5
  62. package/build/platform/index.ios.js +3 -3
  63. package/build/platform/shared.d.ts +4 -0
  64. package/build/platform/shared.js +9 -0
  65. package/build/platform-color/index.android.d.ts +4 -0
  66. package/build/platform-color/index.android.js +7 -0
  67. package/build/platform-color/index.d.ts +2 -20
  68. package/build/platform-color/index.js +1 -45
  69. package/build/platform-color/shared.d.ts +21 -0
  70. package/build/platform-color/shared.js +41 -0
  71. package/build/post-commit.js +3 -8
  72. package/build/process-aspect-ratio.js +3 -7
  73. package/build/process-background-longhands.js +10 -19
  74. package/build/process-filter.js +11 -19
  75. package/build/process-font-variant.js +3 -7
  76. package/build/registry.d.ts +0 -33
  77. package/build/registry.js +22 -57
  78. package/build/report-error.js +4 -18
  79. package/build/settings/index.android.d.ts +6 -0
  80. package/build/settings/index.android.js +21 -0
  81. package/build/settings/index.d.ts +1 -8
  82. package/build/settings/index.ios.d.ts +8 -0
  83. package/build/settings/index.ios.js +122 -0
  84. package/build/settings/index.js +3 -122
  85. package/build/share/index.android.js +9 -32
  86. package/build/share/index.ios.js +14 -15
  87. package/build/share/shared.d.ts +4 -2
  88. package/build/share/shared.js +7 -10
  89. package/build/status-bar/index.android.js +1 -1
  90. package/build/status-bar/index.ios.js +4 -3
  91. package/build/structured-style.d.ts +0 -9
  92. package/build/structured-style.js +16 -31
  93. package/build/styles.js +3 -6
  94. package/build/surface.d.ts +0 -26
  95. package/build/surface.js +29 -76
  96. package/build/text-input-state.js +4 -8
  97. package/build/toast-android/index.android.d.ts +10 -0
  98. package/build/toast-android/index.android.js +108 -0
  99. package/build/toast-android/index.d.ts +1 -10
  100. package/build/toast-android/index.ios.d.ts +10 -0
  101. package/build/toast-android/index.ios.js +19 -0
  102. package/build/toast-android/index.js +3 -108
  103. package/build/touch-history.js +5 -11
  104. package/build/tree-host.d.ts +0 -270
  105. package/build/tree-host.js +63 -153
  106. package/build/view-config.js +17 -37
  107. package/cpp/SymbioteFabricProps.cpp +204 -120
  108. package/cpp/SymbioteFabricProps.h +5 -0
  109. package/cpp/SymbioteTree.cpp +20 -6
  110. package/package.json +2 -2
package/build/debug.js CHANGED
@@ -1,25 +1,11 @@
1
- // Opt-in diagnostic logging, off by default. Two switches, either flips it on:
2
- // - env: DEBUG=1 — read directly off process.env.DEBUG at call time (Node,
3
- // headless smokes); each example's index.js also mirrors it onto
4
- // globalThis.__SYMBIOTE_DEBUG__ once at start, so changing it needs a fresh
5
- // Metro start (--reset-cache), not a rebuild.
6
- // - runtime: globalThis.__SYMBIOTE_DEBUG__ = true, an escape hatch for hosts
7
- // where the env isn't reachable.
8
- // Production with neither set pays one property read per call and nothing else -
9
- // but ONLY if the caller does not build the message itself first. A template
10
- // literal is evaluated at the CALL SITE, before dlog can decide anything, so a
11
- // `dlog(\`… ${JSON.stringify(x)}\`)` on a per-frame path costs its full price with
12
- // logging off. On a hot path (a getter Angular re-reads every change-detection
13
- // pass, an Animated reconcile, a scroll-driven apply) pass a THUNK instead:
14
- // `dlog(() => \`…\`)` - it is only called once the switch is on.
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.
1
+ // Opt-in diagnostic logging, off by default. Two switches: env DEBUG=1 (Node, headless smokes;
2
+ // mirrored onto globalThis.__SYMBIOTE_DEBUG__ at bootstrap, so toggling it needs a Metro
3
+ // --reset-cache, not a rebuild), or setting globalThis.__SYMBIOTE_DEBUG__ directly.
4
+ // Read ONCE, at module load: `process.env` is not a plain object in Node, each property read
5
+ // crosses into the host environment, and isDebug() runs on the per-node commit path — costly in
6
+ // Node, cheap on a native host where `process` is just a shim.
7
+ // Safe to freeze: nothing toggles the env switch mid-process, every runtime toggle goes through
8
+ // the dynamic __SYMBIOTE_DEBUG__ instead.
23
9
  const envDebug = typeof process !== 'undefined' && process.env.DEBUG === '1';
24
10
  export function isDebug() {
25
11
  return envDebug || globalThis.__SYMBIOTE_DEBUG__ === true;
package/build/dispatch.js CHANGED
@@ -1,12 +1,6 @@
1
- // The one place a native event re-enters a framework's update loop. A native
2
- // event (a touch from Fabric, a keyboard event from the device hub) runs its
3
- // listener (which may call setState) OUTSIDE the framework's own loop. The
4
- // adapter injects a wrapper that runs the listener at the right priority and
5
- // flushes synchronously so the result paints (React: discrete lane +
6
- // flushSyncWork). Both event rails, Fabric events (events.ts) and device-module
7
- // events (native-events.ts), route through this single seam, so the adapter
8
- // wires it once and both are covered. Default is a pass-through for adapters (and
9
- // the headless harness) that need no wrapping.
1
+ // The one place a native event re-enters a framework's update loop: a native event runs its
2
+ // listener outside that loop, so the adapter injects a wrapper that runs it at the right priority
3
+ // and flushes synchronously. Both Fabric events and device-module events share this seam.
10
4
  let wrapDispatch = run => {
11
5
  run();
12
6
  };
@@ -260,8 +260,8 @@ function findWantsResponder(path, captureName, bubbleName, nativeEvent, skip) {
260
260
  // WITHOUT THIS A JS RESPONDER LOSES TO ANY SCROLL VIEW ABOVE IT, and it is invisible from JS:
261
261
  // `onStartShouldSetResponder` returns true, the native UIScrollView never learns the gesture was
262
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
263
+ // negotiation never even gets a move to grant on. Device-diagnosed on a PanResponder drag box
264
+ // inside the canary's ScrollView: `startShouldSet -> true` followed by
265
265
  // `topScrollBeginDrag` and twenty `topScroll`, with no grant and no move.
266
266
  //
267
267
  // `blockNativeResponder` is the taker's own `onResponderGrant` return, exactly as RN reads it.
@@ -380,8 +380,13 @@ export function installEventHandler() {
380
380
  if (topLevelType === TOUCH_START) {
381
381
  // The ternary is gone with the gate: `isSymbioteNode(instanceHandle)` was re-asked here
382
382
  // three lines after the early return above already proved it.
383
- if (isDebug())
383
+ if (isDebug()) {
384
384
  dlog(`event ${TOUCH_START} on ${instanceHandle.component}`);
385
+ // A responder surviving into a one-touch start lost its end/cancel. If it is an ancestor
386
+ // of the target, this whole tap goes to it and the release finally clears it.
387
+ if (currentResponder !== undefined)
388
+ dlog(`touchStart while ${currentResponder.component} still holds the responder`);
389
+ }
385
390
  // Update the touch bank, then attach it so responder handlers (PanResponder)
386
391
  // read each touch's own previous->current delta; RN records before dispatch.
387
392
  recordTouchTrack('start', nativeEvent);
@@ -519,6 +524,10 @@ export function installEventHandler() {
519
524
  return;
520
525
  }
521
526
  if (topLevelType === TOUCH_CANCEL) {
527
+ // Android: a ScrollView whose scroller has not finished (fling tail, spring-back) intercepts
528
+ // the next down natively, and the tap reaches JS as start + cancel with no press.
529
+ if (isDebug())
530
+ dlog(`event ${TOUCH_CANCEL} on ${instanceHandle.component}`);
522
531
  recordTouchTrack('end', nativeEvent);
523
532
  attachTouchHistory(nativeEvent);
524
533
  // A cancel is scoped to the finger(s) removed from `touches`, just like an end. An unrelated
@@ -1,41 +1,13 @@
1
1
  // Fabric-prop translation: turn a node's logical props into the flat payload Fabric's C++ props
2
- // expect. Color processing itself lives in ./platform-color (the stable leaf every color-touching
3
- // module imports from); this file only decides WHICH props are color props and wires the structured
4
- // CSS-style processors.
5
- //
6
- // IT IS CALLED BY THE HOST, and that is what the `props` parameter is for. The NODE still comes in
7
- // beside it for its authored component name and for a behavior's own `payloadFold`.
8
- //
9
- // **THIS IS THE HEADLESS BUILDER, and the device one is `SymbioteFabricProps.cpp`.** The header used
10
- // to say the C++ side "does not have this yet", which was true mid-branch and stopped being true
11
- // when the payload builder was ported; a reader who believed it would go looking for a blank screen.
12
- //
13
- // What follows from that, and it is the file's main rule: **no platform rule may live here.** The ten
14
- // tag rules never got a copy, and RN's two Text defaults lost theirs on 2026-09-18. A rule with a
15
- // copy on this side is a rule whose only test runs on this side, and the device copy can then break
16
- // with everything green — not hypothetical: it is how a disabled `touchable-highlight` shipped
17
- // `focusable: true`.
18
- //
19
- // **NONE IS LEFT, as of 2026-09-18.** Three went, one per commit, in that order: RN's Text defaults,
20
- // `value ?? defaultValue -> text`, and the aria fold. Separately on purpose — they had different test
21
- // topologies, and one commit removing several could not be attributed to any of them.
22
- //
23
- // SO THE HEADLESS PAYLOAD DIVERGES FROM THE DEVICE'S, deliberately and in named places: a text
24
- // input's carries `value` where the device's carries `text`, a text node's is missing two defaults,
25
- // and a bare tag's `aria-*` keys arrive unfolded. **That asymmetry is the harness working as
26
- // designed** — it is what forces a claim about a platform rule to be made where the rule runs. Do
27
- // not close it by adding a rule back.
28
- //
29
- // What is left is the framework-agnostic half: colour processing, the style hoist, and a node's own
30
- // `payloadFold`.
2
+ // expect. Color processing lives in ./platform-color (the stable leaf every color-touching module
3
+ // imports from); this file only decides WHICH props are color props.
31
4
  import { RAW_TEXT_COMPONENT } from './node.js';
32
- import { isProcessableColor, processColor } from './platform-color/index.js';
5
+ import { isProcessableColor, processColor } from './platform-color';
33
6
  import { configProcessedKeys } from './registry.js';
34
7
  import { isRecord } from './type-guards.js';
35
- // Color props must reach Fabric as platform ints, not CSS strings. Fabric's C++
36
- // color parser silently drops strings. The actual conversion (processColor) is
37
- // RN-platform-specific, so it is injected in platform-color.ts rather than imported,
38
- // keeping shared free of a react-native dependency (and the headless harness working).
8
+ // Color props must reach Fabric as platform ints, not CSS strings — Fabric's C++ color parser
9
+ // silently drops strings. processColor is RN-platform-specific, so it's injected from
10
+ // platform-color.ts rather than imported, keeping this module free of a react-native dependency.
39
11
  const COLOR_PROPS = new Set([
40
12
  'backgroundColor',
41
13
  'color',
@@ -68,11 +40,9 @@ const COLOR_PROPS = new Set([
68
40
  // Text decoration color (underline/strike): same Fabric strictness as any color.
69
41
  'textDecorationColor',
70
42
  'selectionHandleColor',
71
- // Switch track/thumb colors. RN processColors each via the Switch ViewConfig
72
- // (SwitchNativeComponent / AndroidSwitchNativeComponent validAttributes). iOS takes
73
- // onTintColor (ON) / tintColor (OFF); Android takes trackColorForTrue/False +
74
- // trackTintColor, and Android's ColorPropConverter is strict ("the value must be a
75
- // number or Object"), so a raw CSS string crashes. thumbTintColor reaches both.
43
+ // Switch track/thumb colors. RN processColors each via the Switch ViewConfig; Android's
44
+ // ColorPropConverter is strict ("must be a number or Object"), so a raw CSS string crashes.
45
+ // thumbTintColor reaches both platforms.
76
46
  'onTintColor',
77
47
  'thumbTintColor',
78
48
  'trackColorForTrue',
@@ -80,50 +50,33 @@ const COLOR_PROPS = new Set([
80
50
  'trackTintColor',
81
51
  ]);
82
52
  // Convert a prop to the shape Fabric's C++ expects: a CSS-string color runs through the injected
83
- // platform processor, because Fabric's C++ color parser silently drops strings.
84
- //
85
- // TWO FAMILIES OF PROCESSOR USED TO BE HERE AND BOTH MOVED, for one reason: on a device the payload
86
- // is built in C++ and this function does not run, so anything resolved only here is resolved only
87
- // headless. A third-party view's own `validAttributes[*].process` now runs in `configPayloadFold`
88
- // (installed on the node, called by the C++ through `payloadFold`); the structured style keys
89
- // — boxShadow, filter, transform, transformOrigin, aspectRatio, fontVariant,
90
- // experimental_backgroundImage — now run at WRITE time in `structured-style.ts`, so `node.props`
91
- // already holds the structured value whichever builder reads it.
92
- //
53
+ // platform processor, since Fabric's C++ color parser silently drops strings.
54
+ // Any other processor must NOT live here: on a device the payload is built in C++ and this
55
+ // function never runs, so anything resolved only here is resolved only headless. A third-party
56
+ // view's own config processors run in configPayloadFold; structured style keys run at write time.
93
57
  // Neither may come back here. Applying a processor twice is not a no-op: a color already converted
94
- // to a platform int, run through processColor again, is a DIFFERENT color.
58
+ // to a platform int, run through processColor again, is a different color.
95
59
  function processValue(key, value, alreadyProcessed) {
96
- // A key the component's own config already converted is DONE. Running the engine's colour pass
97
- // over it as well is not a no-op: processColor rotates, so a second rotation is a different
98
- // colour. This used to be prevented by accident — a processed colour is a number, and numbers
99
- // were not processable — until a numeric colour became an author's own rrggbbaa literal.
60
+ // A key the component's own config already converted is done. Running the colour pass over it
61
+ // too is not a no-op: processColor rotates, so a second rotation is a different colour.
100
62
  if (alreadyProcessed?.has(key) === true)
101
63
  return value;
102
64
  if (COLOR_PROPS.has(key) && isProcessableColor(value))
103
65
  return processColor(value);
104
66
  return value;
105
67
  }
106
- // A style object is SHARED, and that is the one place this file has a complexity problem rather
107
- // than a constant-factor one. StyleSheet.create hands out one frozen object per rule, and the CSS
108
- // class registry resolves a class name to one cached object - so 1 000 rows carry the SAME handful
109
- // of style objects, and resolving each one per node costs O(nodes x styleKeys) to compute an
110
- // answer that only varies with O(distinct styles). Cache it on the style object's identity.
111
- //
112
- // Keyed by the style object ALONE. It used to carry a per-component dimension because processValue
113
- // consulted that component's ViewConfig processors and one style object could resolve differently
114
- // under two view names; those processors moved to `configPayloadFold`, which runs on the top-level
115
- // bag before this, so what is left here is component-independent.
116
- //
117
- // The cache assumes a style object is not MUTATED IN PLACE, which is already the engine's contract:
118
- // setProp compares with Object.is and skips a same-identity write, so an in-place style edit never
119
- // marks the node dirty and never reaches Fabric today either. What narrows slightly is the case
120
- // where some OTHER prop on the same node changed in the same commit - that used to pick the
121
- // mutation up as a side effect, and now does not.
122
- //
123
- // KEEPING undefined-valued keys is deliberate: the resolved object is a faithful picture of ONE
124
- // style entry, and addStyle below needs to see an explicit `undefined` to let a later entry clear
125
- // an earlier one. Dropping them here would silently turn `[{flex:1},{flex:undefined}]` into
126
- // `flex: 1`.
68
+ // A style object is shared: StyleSheet.create hands out one frozen object per rule, and the CSS
69
+ // class registry resolves a class name to one cached object, so a thousand rows carry the same
70
+ // handful of style objects. Cache resolution on the style object's identity.
71
+ // Keyed by the style object alone: the per-component ViewConfig processors that once made one
72
+ // style object resolve differently under two view names moved to configPayloadFold, which runs on
73
+ // the top-level bag before this, so what's left here is component-independent.
74
+ // Assumes a style object is not mutated in place, which is already the engine's contract: setProp
75
+ // compares with Object.is and skips a same-identity write, so an in-place edit never marks the
76
+ // node dirty and never reaches Fabric either way.
77
+ // Keeping undefined-valued keys is deliberate: the resolved object is a faithful picture of one
78
+ // style entry, and addStyle below needs to see an explicit undefined to let a later entry clear an
79
+ // earlier one — dropping them would silently turn [{flex:1},{flex:undefined}] into `flex: 1`.
127
80
  const styleCache = new WeakMap();
128
81
  function processedStyle(style) {
129
82
  const cached = styleCache.get(style);
@@ -132,43 +85,23 @@ function processedStyle(style) {
132
85
  const resolved = {};
133
86
  for (const key of Object.keys(style)) {
134
87
  const value = style[key];
135
- // `undefined` for the already-processed set, and it must stay that way: this cache is keyed on
136
- // the style object ALONE (see the note above), so anything component-dependent read here would
137
- // be shared with every other component using the same style object. A config's processors are
138
- // keyed on top-level prop names and never reach inside a style anyway.
88
+ // undefined for the already-processed set: this cache is keyed on the style object alone, so
89
+ // anything component-dependent read here would be shared with every user of that object.
139
90
  resolved[key] =
140
91
  value === undefined ? undefined : processValue(key, value, undefined);
141
92
  }
142
93
  styleCache.set(style, resolved);
143
94
  return resolved;
144
95
  }
145
- /**
146
- * Hoist one style slot's keys into the payload being built, recursing on POSITION only - the same
147
- * rule flattenStyle follows, and for the same reason: `transform: [{translateX: 5}]` is an
148
- * array-VALUED prop, not a nested style.
149
- *
150
- * The point is that there is no intermediate object. Every style entry's resolution is memoized on
151
- * its own identity and its keys are written straight into `out`, so a thousand rows sharing one
152
- * class-resolved style resolve it ONCE and each node pays a copy loop.
153
- *
154
- * This is also the shape React Native itself uses. `ReactNativeAttributePayload.addNestedProperty`
155
- * (.vendors/react/packages/react-native-renderer/src/ReactNativeAttributePayload.js:208) recurses
156
- * over the style array writing into a single `updatePayload`; upstream's `flattenStyle` appears
157
- * ONLY in `diffNestedProperty`, i.e. the update path where an array meets an object - never on
158
- * create. Our previous version flattened first and hoisted second, which allocated one merged
159
- * object per node per commit that nothing else ever read.
160
- *
161
- * It also fixed a dead cache. `processedStyle` used to be reachable only when `props.style` was a
162
- * bare object, and it never is: `commitClassStyle` (node.ts) always writes the two-element
163
- * `[classStyle, explicitStyle]` array, by design. So the memo existed, was correct, and never ran.
164
- *
165
- * Later entries win, because a later write overwrites the same key on `out`. An explicit
166
- * `undefined` CLEARS the key instead, matching the flatten path it replaces. One narrow
167
- * divergence, recorded rather than hidden: the `delete` also clears a same-named TOP-LEVEL prop
168
- * hoisted before the style pass, which flattening did not. Style keys and native prop keys do not
169
- * overlap in practice (one is Yoga/visual, the other is testID/accessibility/source), so this is
170
- * theoretical - but it is a difference, and `fabric-props.test.ts` pins both halves.
171
- */
96
+ // Hoist one style slot's keys into the payload being built, recursing on position only — the same
97
+ // rule flattenStyle follows, and for the same reason: `transform: [{translateX: 5}]` is an
98
+ // array-valued prop, not a nested style.
99
+ // No intermediate object: every style entry's resolution is memoized on its own identity and its
100
+ // keys are written straight into `out`, so a thousand rows sharing one class-resolved style
101
+ // resolve it once and each node only pays a copy loop.
102
+ // Later entries win, because a later write overwrites the same key on `out`. An explicit
103
+ // undefined clears the key instead — the one narrow divergence from flattenStyle is that this also
104
+ // clears a same-named top-level prop hoisted before the style pass, pinned by fabric-props.test.ts.
172
105
  function addStyle(out, style) {
173
106
  if (Array.isArray(style)) {
174
107
  for (const entry of style)
@@ -186,93 +119,55 @@ function addStyle(out, style) {
186
119
  out[key] = value;
187
120
  }
188
121
  }
189
- // Translate the retained node's logical props into the flat payload Fabric's C++
190
- // props expect: `style` keys are hoisted to the top level, event handlers and
191
- // undefined values are dropped.
192
- // RN HAS NO `value` FABRIC PROP. A TextInput's controlled value rides as the private `text` prop,
193
- // and the fold that produces it — `value ?? defaultValue` — used to live in the component wrapper.
194
- // A tag has no wrapper, so an author's `value={x}` would reach Fabric as a key no ViewConfig
195
- // declares: silently dropped, `text` never set, and the field renders EMPTY. Nothing red anywhere —
196
- // found 2026-08-31 by an agent reading what the render function actually emitted rather than
197
- // trusting a header comment.
198
- //
199
- // So the fold lives in the layer every path goes through, exactly like the aria fold above it. This
200
- // is the third instance of one rule: a tag inherits NOTHING a wrapper did, and the repair belongs
201
- // below the fork.
202
- //
203
- // GATED ON THE COMPONENT, NOT ON THE PROP. `value` is also a prop of `Switch` and `Slider`; a fold
204
- // keyed on the prop name would write a bogus `text` onto both. Two string comparisons rather than a
122
+ // Translate the retained node's logical props into the flat payload Fabric's C++ props expect:
123
+ // style keys are hoisted to the top level, event handlers and undefined values are dropped.
124
+ // RN has no `value` Fabric prop: a TextInput's controlled value rides as the private `text` prop,
125
+ // via a `value ?? defaultValue` fold a wrapper used to run. A tag has no wrapper, so this fold
126
+ // lives here instead — the same "a tag inherits nothing a wrapper did" rule as the aria fold above.
127
+ // Gated on the component, not the prop: `value` is also a prop of Switch and Slider, and a fold
128
+ // keyed on the prop name would write a bogus `text` onto both.
205
129
  export function fabricProps(node, nodeProps) {
206
130
  if (node.component === RAW_TEXT_COMPONENT) {
207
- // A raw-text node gets its behavior's fold too — it TRANSFORMS the text that is already there
208
- // (Button uppercases its label on Android) and may not SUPPLY one, which is narrower than this
209
- // comment claimed when it landed. `isEmptyRawText` (node.ts) decides whether the node commits
210
- // at all from the node's own `text` prop, before any fold runs, so a text that exists only as a fold
211
- // result is dropped by `renderableChildren` and the fold never executes. Reported by the hook's
212
- // first consumer, within the hour.
213
- //
214
- // Which is why the skip is NOT the thing to change: it runs for every raw-text node in every
215
- // app, and consulting a fold there would put one on that walk. Get the value into `props.text`
216
- // instead — Button routes the owner's `title` onto this node with `slotProps: {title: 'text'}`,
217
- // so the skip and the fold read the same source and an empty title still commits nothing.
131
+ // A raw-text node gets its behavior's fold too — it transforms text already there (Button
132
+ // uppercases its label) but may not supply one: isEmptyRawText decides whether the node
133
+ // commits at all from the node's own text prop, before any fold runs.
134
+ // So the skip is not the thing to change — it runs for every raw-text node in every app. Get
135
+ // the value into props.text instead: Button routes its owner's title here via slotProps, so
136
+ // the skip and the fold read the same source.
218
137
  return {
219
138
  text: node.payloadFold !== undefined
220
139
  ? node.payloadFold(nodeProps).text
221
140
  : nodeProps.text,
222
141
  };
223
142
  }
224
- // This runs once per node per commit - 9 000 times on one benchmark press - so the two loops
225
- // below iterate with Object.keys rather than Object.entries: entries allocates a fresh
226
- // two-element array PER KEY on top of the outer array, and the resulting garbage was 18% of the
227
- // create path in a CPU profile.
228
- //
229
- // Do NOT "improve" this to `for...in`. It was tried and reverted 2026-08-23. On paper it is the
230
- // strictly cheaper shape - Object.keys allocates one array per call, 10 007 of them on a
231
- // 1 000-row create (counted), and for...in allocates none - and the headless V8 bench agreed,
232
- // 12-13% off create/replace `min`. On DEVICE it lost: with the Fabric call counts and prop-key
233
- // payload byte-identical either way (9000/5000/1009, 32001 keys), Release Create went 217.8 ->
234
- // 243.2 ms while stock moved only inside its 4% noise floor. Hermes' for-in is not V8's enum
235
- // cache. The general rule this bought: an allocation-count win measured on V8 is not a Hermes
236
- // win, and only the on-device number decides (`perf-claims-need-numbers`).
143
+ // This runs once per node per commit, so the two loops below iterate with Object.keys rather
144
+ // than Object.entries: entries allocates a fresh two-element array per key on top of the outer
145
+ // array, measured as a real share of the create path's garbage.
146
+ // Do not "improve" this to for...in: tried and reverted. It wins on allocation count and even on
147
+ // headless V8 benchmarks, but loses on device — Hermes' for-in is not V8's enum cache, and only
148
+ // the on-device number decides.
237
149
  const out = {};
238
- // THE ONE POINT WHERE THE WHOLE BAG IS KNOWN ON EVERY PATH, which is what the aria fold needs:
239
- // `aria-checked` has to be folded against a sibling `accessibilityState`, and `routeProp` sees
240
- // one key at a time. Both commit paths — create and update — reach here, so a tag gets the fold
241
- // it has no wrapper to run.
242
- //
243
- // NOT memoised on the bag's identity. The host mutates it IN PLACE, so an identity-keyed cache
244
- // (the `processedStyle` pattern below) would be stale forever. The gate is the node's sticky flag
245
- // instead: one boolean read for a node with no alias, which is nearly all of them, and the fold's
246
- // own fast path returns by identity for the rest.
247
- // THE ARIA FOLD IS NOT CALLED HERE ANY MORE (2026-09-18), and it is the last platform rule to
248
- // leave this builder. `foldAriaProps` itself STAYS in JS and is not a mirror: `pickAccessibilityProps`
249
- // (`@symbiote-native/components`) folds a bag and then picks fields BY NAME, which it cannot do
250
- // from a bag holding only `aria-label`. So the function has a real, load-bearing caller — what was
251
- // wrong was this CALL, which put a rule the device runs in C++ back into the headless payload and
252
- // invited 27 cases to assert it where the device copy is invisible.
253
- //
254
- // Where the claims live now: `core/engine/cpp/tests/js/aria-payload.itest.ts`, off a real payload.
150
+ // The one point where the whole bag is known on every path, which the aria fold needs:
151
+ // aria-checked must fold against a sibling accessibilityState, and routeProp sees one key at a
152
+ // time. Both commit paths reach here, so a tag gets the fold it has no wrapper to run.
153
+ // Not memoised on the bag's identity — the host mutates it in place, so an identity-keyed cache
154
+ // would be stale forever. The gate is the node's sticky flag instead, one boolean read for a
155
+ // node with no alias (nearly all of them); the fold's own fast path handles the rest.
255
156
  const aliasFolded = nodeProps;
256
- // The behavior's own fold, keyed on the TAG — the two folds above are keyed on the resolved
257
- // component name, which several tags share (`pressable` and a plain `view` are both `RCTView`),
258
- // so neither could carry a per-primitive fold. See IPayloadFold.
259
- //
260
- // THE FOLD'S RETURN REPLACES THE BAG, and it costs more than it looks — see
261
- // `payload-fold-merge.test.ts` for the measurement and for why the obvious fix does not fit yet.
262
- // Briefly: every fold returns `{ ...props, ...whatItChanged }`, and on device reading that back is
263
- // `jsi::dynamicFromValue`, 13.3 ms of a 17.8 ms fold phase against 1.6 ms to send the bag out and
264
- // 1.6 ms to run the fold.
157
+ // The behavior's own fold, keyed on the tag — the two folds above are keyed on the resolved
158
+ // component name, which several tags share (pressable and a plain view are both RCTView), so
159
+ // neither could carry a per-primitive fold. See IPayloadFold.
160
+ // The fold's return REPLACES the bag, which costs more than it looks (payload-fold-merge.test.ts
161
+ // has the measurement): every fold returns `{ ...props, ...whatItChanged }`, and reading that
162
+ // back on device dominates the fold phase.
265
163
  const behaviorFolded = node.payloadFold !== undefined
266
164
  ? node.payloadFold(aliasFolded)
267
165
  : aliasFolded;
268
- // RN'S TWO TEXT DEFAULTS USED TO BE APPLIED HERE AND ARE GONE (2026-09-18) — the rule lives in
269
- // `SymbioteFabricProps.cpp` alone, and its claims in `committed-payload.itest.ts`, read off the
270
- // payload a commit actually sent. It had FIVE other implementations that day (`resolveTextProps`,
271
- // Angular's `TextHost`, Vue's and Solid's renderers, and this one); the engine is the only layer
272
- // that can see the authored bag for every adapter at once, so it is the only one that needs to.
273
- //
274
- // So a text node's payload here is missing two keys the device's carries. That is a PROPERTY of
275
- // this harness rather than a gap in it — do not close it by adding the rule back.
166
+ // RN's two text defaults are NOT applied here — the rule lives in SymbioteFabricProps.cpp alone,
167
+ // pinned by committed-payload.itest.ts off a real payload. The engine is the only layer that can
168
+ // see the authored bag for every adapter at once, so it's the only one that needs the rule.
169
+ // So a text node's payload here is missing two keys the device's carries. That is a property of
170
+ // this harness, not a gap in it — do not close it by adding the rule back.
276
171
  const props = behaviorFolded;
277
172
  // Hoisted out of the loop: one cached lookup per node per commit, not one per key.
278
173
  const alreadyProcessed = configProcessedKeys(node.component);
package/build/fabric.d.ts CHANGED
@@ -32,18 +32,12 @@ interface IFabricHost extends Omit<IFabricSlot, 'cloneNodeWithNewChildren' | 'cl
32
32
  cloneNodeWithNewChildren(node: IFabricNode, children?: readonly IFabricNode[]): IFabricNode;
33
33
  cloneNodeWithNewChildrenAndProps(node: IFabricNode, newProps: IFabricProps): IFabricNode;
34
34
  cloneNodeWithNewChildrenAndProps(node: IFabricNode, children: readonly IFabricNode[], newProps: IFabricProps): IFabricNode;
35
+ findShadowNodeByTag_DEPRECATED?(tag: number): IFabricNode | null;
35
36
  }
37
+ export declare function sendAccessibilityEventByTag(tag: number, eventType: string): void;
36
38
  declare global {
37
39
  var nativeFabricUIManager: IFabricHost | undefined;
38
40
  }
39
- /**
40
- * Test seam: forget the bound slot, so a fixture can install a different host and be believed.
41
- *
42
- * `getSlot` caches the facade for the life of the module — the live binding re-mints a host function
43
- * on every property read, so caching is not an optimisation but the difference between reading each
44
- * method once and reading it per call. The cache has no invalidation in production because the
45
- * global is installed once, before anything commits.
46
- */
47
41
  export declare function resetSlot(): void;
48
42
  export declare function getSlot(): IFabricSlot;
49
43
  export {};
package/build/fabric.js CHANGED
@@ -1,28 +1,27 @@
1
- // The one seam symbiote drives. `global.nativeFabricUIManager` is the
2
- // framework-agnostic, JSI-bound mutation API that Fabric exposes; React's
3
- // renderer is just one client of it. We bind to it directly.
4
- //
5
- // The live object is a lazy caching proxy: every property access mints a fresh
6
- // host function, so we read each method once and cache a plain facade.
1
+ // The one seam symbiote drives. global.nativeFabricUIManager is the framework-agnostic, JSI-bound
2
+ // mutation API Fabric exposes; React's renderer is just one client of it. We bind to it directly.
3
+ // The live object is a lazy caching proxy, so we read each method once and cache a plain facade.
7
4
  import { dlog } from './debug.js';
8
5
  import { installNativeTreeHost } from './native-tree-host.js';
6
+ // An accessibility event addressed by a bare native tag, the way RN's bridgeless
7
+ // UIManager.sendAccessibilityEvent does it: resolve the tag to its committed shadow node, then
8
+ // send. An unknown tag is dropped, as RN drops it (with a log rather than a throw).
9
+ export function sendAccessibilityEventByTag(tag, eventType) {
10
+ const host = globalThis.nativeFabricUIManager;
11
+ const node = host?.findShadowNodeByTag_DEPRECATED?.(tag);
12
+ if (host === undefined || node === undefined || node === null) {
13
+ dlog(`sendAccessibilityEvent("${eventType}") dropped: no view with tag #${tag}`);
14
+ return;
15
+ }
16
+ host.sendAccessibilityEvent(node, eventType);
17
+ }
9
18
  let cached;
10
- // BATCHING IS GONE, and it is worth one paragraph because the idea recurs. `batching-slot.ts`
11
- // recorded this slot's calls and replayed them once per commit — three ways, the last handing the
12
- // bytes to `SymbioteApplier` in C++. It existed to remove per-call JSI crossings from a JS walk that
13
- // worked out the Fabric operations. That walk no longer exists: adapters record their own mutations
14
- // and the tree host derives everything, so there are no per-call crossings left to batch. Removed
15
- // 2026-09-08 along with `setBatchedCommits`, the C++ applier and their differential. Root CLAUDE.md
16
- // keeps the measurement that made it uninteresting even on the old path — Create 256.8 on / 258.5
17
- // off, i.e. it demonstrably worked and bought nothing.
18
- /**
19
- * Test seam: forget the bound slot, so a fixture can install a different host and be believed.
20
- *
21
- * `getSlot` caches the facade for the life of the module — the live binding re-mints a host function
22
- * on every property read, so caching is not an optimisation but the difference between reading each
23
- * method once and reading it per call. The cache has no invalidation in production because the
24
- * global is installed once, before anything commits.
25
- */
19
+ // Batching is gone: this slot's calls used to be recorded and replayed once per commit, to remove
20
+ // per-call JSI crossings from a JS walk that worked out the Fabric operations. That walk no longer
21
+ // exists — adapters record their own mutations — so there is nothing left to batch.
22
+ // Test seam: forget the bound slot, so a fixture can install a different host and be believed.
23
+ // getSlot caches the facade for the life of the module — the live binding re-mints a host function
24
+ // on every property read, so caching means reading each method once instead of per call.
26
25
  export function resetSlot() {
27
26
  cached = undefined;
28
27
  }
@@ -92,17 +91,12 @@ export function getSlot() {
92
91
  measureLayout: (node, relativeToNode, onFail, onSuccess) => measureLayout(node, relativeToNode, onFail, onSuccess),
93
92
  };
94
93
  dlog('slot bound to nativeFabricUIManager');
95
- // Resolve our own native module here, once, for its SIDE EFFECT: `RCTTurboModuleManager` runs
96
- // `installJSIBindingsWithRuntime:` when it CREATES a module, so without this call the module is
97
- // never created, the hook never runs, and `global.__symbioteEngineNative` is absent on a device
98
- // carrying a perfectly working binary. A capability that is unreachable until its first consumer
99
- // lands is indistinguishable from one that is broken, and the difference costs a build to find out.
100
- //
101
- // This is the right seam rather than a convenient one, and now for two reasons: binding the Fabric
102
- // slot is the moment the engine has established it is on a native host at all, AND it is the last
103
- // moment before a commit can happen — the tree host has to be in before `commitSurfaceOps` runs or
104
- // the ops it names stay pending. It cannot throw: with no module `installNativeTreeHost()` is a
105
- // no-op, which is most places (see `native-engine.ts`'s header).
94
+ // Resolve our own native module here, once, for its side effect: RCTTurboModuleManager runs
95
+ // installJSIBindingsWithRuntime: when it creates a module, so without this call the module is
96
+ // never created and the global bindings stay absent even on a working binary.
97
+ // The right seam, not just a convenient one: binding the Fabric slot is the moment the engine has
98
+ // established it's on a native host, and the last moment before commitSurfaceOps can run. Cannot
99
+ // throw: with no module, installNativeTreeHost() is a no-op (see native-engine.ts's header).
106
100
  installNativeTreeHost();
107
101
  return cached;
108
102
  }