@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
@@ -0,0 +1,491 @@
1
+ // THE CONTRACT. Native owns the tree; JS emits nothing but a command buffer.
2
+ //
3
+ // This file is the spec and `core/engine/cpp/SymbioteTree.cpp` must agree with it, for the reason a
4
+ // wire format is written as code at all: described in prose it gets reimplemented, written as code
5
+ // it gets exercised.
6
+ //
7
+ // ── WHAT CHANGED, AND WHY IT IS SMALLER THAN IT SOUNDS ───────────────────────────────────────────
8
+ //
9
+ // The adapter used to mutate a JS tree, `commit.ts` walked it to work out what changed, and the
10
+ // batch that crossed carried FABRIC operations — `createNode` / `cloneNode` / `appendChild`. The
11
+ // walk is
12
+ // 2 041 lines because it re-derives a diff.
13
+ //
14
+ // The framework already had that diff. A reconciler's entire job is knowing what changed, and every
15
+ // adapter hands us the answer call by call before we throw it away and recompute it. So the buffer
16
+ // carries the ADAPTER's alphabet instead, and the derivation disappears rather than moving:
17
+ //
18
+ // the walk over dirty subtrees gone — the framework names the nodes
19
+ // desired-vs-committed diffing gone — the framework names the operations
20
+ // IMirror gone — it was a JS re-implementation of `ShadowNode`, which
21
+ // already carries children and props (ShadowNode.h:133-134),
22
+ // duplicated only because reading it from JS costs a crossing
23
+ // the parent table, the edit log gone — native holds parent and children itself
24
+ // clone-on-write STAYS, in C++, along the paths the ops marked
25
+ //
26
+ // ── THE ALPHABET ─────────────────────────────────────────────────────────────────────────────────
27
+ //
28
+ // Nine operations. It is the mutation API `node.ts` already exports, minus the four that were never
29
+ // primitive:
30
+ //
31
+ // setNodeHidden / setNodePressed resolve a CSS class to a style IN JS and then call setProp.
32
+ // The class registry stays in JS, so what crosses is the result.
33
+ // setEventListener / a listener is a closure and cannot be `folly::dynamic`. It
34
+ // setBehaviorListener stays in a JS registry; what crosses is the boolean gate flag
35
+ // (`GATED_EVENT_PROPS`), which is an ordinary setProp.
36
+ //
37
+ // ── WHAT STAYS IN JS, AND IT IS NOT A TREE ───────────────────────────────────────────────────────
38
+ //
39
+ // the handle per node a bare `{}` carrying native's node on `NativeState`. No parent, no
40
+ // children, no order — an address. Its lifetime IS the node's lifetime,
41
+ // which is how `nativeFabricUIManager` and a browser both work.
42
+ // listeners Map<handle, Map<name, fn>>. Flat.
43
+ // the CSS class registry class -> style, resolved before any op is written. Native never sees a
44
+ // class name.
45
+ // host behaviors the press / text-input / switch machines. They read props back over
46
+ // JSI at GESTURE rate — ~10 reads per touch, not 19 009 per commit.
47
+ //
48
+ // ── WHY PROPS GO NATIVE, WHICH WAS DECIDED THE OTHER WAY FIRST ───────────────────────────────────
49
+ //
50
+ // The first draft of this contract kept prop VALUES in JS and gave native only the structure, on
51
+ // the reasoning that `RawProps` is lazy so handing Fabric a JS object costs nothing. That is wrong.
52
+ // `RawProps` is lazy only until `parse()`, and `ConcreteComponentDescriptor::cloneProps` calls it
53
+ // UNCONDITIONALLY. In `Mode::JSI` the preparse walks every key of every node —
54
+ // `getPropertyNames`, then per key `getValueAtIndex` + `getString` + `utf8()` (a `std::string`
55
+ // allocation) + `getProperty` — and `enableCppPropsIteratorSetter()` defaults to FALSE in 0.86, so
56
+ // that is the live path. `Mode::Dynamic` does the identical work with zero JSI.
57
+ //
58
+ // So a 1 000-row create already pays ~44 001 JSI property reads and as many string allocations
59
+ // INSIDE `createNode`. Props held natively as `folly::dynamic`, built incrementally as `setProp`
60
+ // ops arrive, are cheaper than what ships today, not more expensive.
61
+ /**
62
+ * The opcodes. These numbers ARE the contract: renumbering here without renumbering the C++ commits
63
+ * a different tree, silently, and no test in either language can see across the boundary.
64
+ *
65
+ * Stride is fixed so the ops array is addressable as memory rather than parsed. Operands are either
66
+ * a SLOT (an index into the batch's `handles` array — see below) or an index into a side table.
67
+ */
68
+ export const OP_STRIDE = 6;
69
+ export const OP_CREATE_ELEMENT = 0; // [slot, viewName, isText, tag, instanceHandle]
70
+ export const OP_CREATE_RAW_TEXT = 1; // [slot, text]
71
+ export const OP_CREATE_ANCHOR = 2; // [slot]
72
+ export const OP_APPEND_CHILD = 3; // [parent, child]
73
+ export const OP_INSERT_BEFORE = 4; // [parent, child, before]
74
+ export const OP_REMOVE_CHILD = 5; // [parent, child]
75
+ export const OP_SET_PROP = 6; // [slot, key, value] — value = NO_VALUE deletes the key
76
+ export const OP_SET_TEXT = 7; // [slot, text]
77
+ export const OP_COMMIT = 8; // [rootTag, surface]
78
+ export const OP_SET_COMPONENT = 9; // [slot, viewName]
79
+ /**
80
+ * The INTRINSIC TAG this node came from — `pressable`, not `RCTView`. `[slot, tag]`.
81
+ *
82
+ * Emitted only for a node a host behavior actually attached to, from `attachHostBehavior`, which is
83
+ * where the tag is already in hand and already matched. Every other node pays nothing.
84
+ *
85
+ * WHY THE TAG HAS TO CROSS AT ALL. A tag's platform props are resolved natively now, and the native
86
+ * side keys them off what it knows the node IS. For `<text-input>` the Fabric view name answers
87
+ * that by itself (`RCTSinglelineTextInputView` names nothing else); for `<pressable>` it does not —
88
+ * it commits as `RCTView`, byte-identical to a plain view. The tag is the only fact that separates
89
+ * them, and it is the same fact a browser keys user-agent behavior off.
90
+ */
91
+ export const OP_SET_TAG = 10; // [slot, tag]
92
+ /**
93
+ * An app callback appeared on, or disappeared from, an event name the behavior OWNS.
94
+ * `[slot, name, present]`. See `recordSetOwnedListener` for why the bit crosses and the closure
95
+ * does not.
96
+ */
97
+ export const OP_SET_OWNED_LISTENER = 11; // [slot, name, present]
98
+ /**
99
+ * A behavior's FEEDBACK state flipped — TouchableHighlight's underlay is showing, or stopped.
100
+ * `[slot, shown]`.
101
+ *
102
+ * The second bit to cross for the same reason the first did. `OP_SET_OWNED_LISTENER` carries whether
103
+ * the app wired a handler; this carries whether the control is currently giving feedback, which is
104
+ * the platform's own `:active` in everything but the timing. `setNodePressed`'s header already calls
105
+ * the press state "the engine-owned half of what `:active` is on the web", and this is its twin for
106
+ * the one control whose feedback does NOT track the press exactly: RN holds the underlay past
107
+ * release so a fast tap still flashes (`TouchableHighlight.js:270-293`).
108
+ *
109
+ * THE TIMER STAYS IN JS and that is the boundary, not an omission. WHEN the bit flips is Pressability
110
+ * plus a `delayPressOut` hold, running at gesture rate and calling back into app code
111
+ * (`onShowUnderlay` / `onHideUnderlay`). WHAT a showing underlay looks like — a background colour and
112
+ * a dimmed child, from two props no ViewConfig declares — is the platform's, and it is
113
+ * `foldTouchableHighlightUnderlay` now.
114
+ *
115
+ * A flip is a GESTURE-rate event rather than a per-render one: twice a tap, against a `payloadFold`
116
+ * that was charged on every commit the node was dirty in for the life of the screen.
117
+ */
118
+ export const OP_SET_UNDERLAY_SHOWN = 12; // [slot, shown]
119
+ /**
120
+ * A VOID node: commits nothing of its own AND does not hoist its children into Fabric either —
121
+ * unlike an anchor, which hoists. `[slot]`.
122
+ *
123
+ * `InputAccessoryView.js` renders `null` on Android: the whole component, children included,
124
+ * contributes nothing to the host tree. An anchor cannot express that — it exists precisely to hand
125
+ * its children up in its own place — so a node whose entire subtree must vanish from Fabric needs
126
+ * its own kind. Everything else about it (the retained JS tree, `appendChild`, adapter bookkeeping)
127
+ * is unaffected; only the commit walk treats it as contributing zero Fabric nodes, recursively.
128
+ */
129
+ export const OP_CREATE_VOID = 13; // [slot]
130
+ /**
131
+ * A `setProp` whose value slot is this DELETES the key.
132
+ *
133
+ * `undefined` cannot carry it: `null` is a legitimate Fabric prop value meaning "reset to the
134
+ * default", and the two must stay distinguishable — `cloneNodeWithNewProps` merges, so a removed
135
+ * key has to be sent as an explicit `null` while a key that was never there must not be sent at
136
+ * all. The JS `setProp` already collapses `undefined` to a delete before anything is encoded.
137
+ */
138
+ export const NO_VALUE = -1;
139
+ /**
140
+ * A node kind. Native needs it because two of the three never become a Fabric node the same way,
141
+ * and both rules are decided at INSERT or at child-set build — never by a walk:
142
+ *
143
+ * ELEMENT an ordinary view. Its Fabric view name is fixed at creation, with ONE exception:
144
+ * a text element inside another text element commits as `RCTVirtualText` instead of
145
+ * `RCTText`. Native resolves that when the node acquires a parent, because that is when
146
+ * it first knows the answer, and re-resolves it on a reparent.
147
+ * RAW_TEXT an `RCTRawText` leaf. Skipped from its parent's child set when its text is empty —
148
+ * an empty one would paint.
149
+ * ANCHOR a position marker the frameworks insert (`{#if}`, a fragment, a `{@render}` slot).
150
+ * It never becomes a Fabric node at all: native keeps it in ITS OWN structure so
151
+ * `insertBefore(parent, node, anchor)` resolves, and hoists its children into its
152
+ * parent's child list when building the child set. This is why our own store cannot BE
153
+ * the Fabric tree — there is no Fabric node to hold an anchor, which is the one thing
154
+ * `IMirror` was genuinely for.
155
+ *
156
+ * A SURFACE is an anchor too, and that is not a trick: an anchor is a node whose children belong to
157
+ * its parent's list, and a surface is a node whose children belong to the root's child set. Same
158
+ * shape, so `OP_COMMIT` names one and the native side needs no `rootTag -> node` map — which keeps
159
+ * the applier stateless, the property the whole lifetime design rests on.
160
+ */
161
+ export const KIND_ELEMENT = 0;
162
+ export const KIND_RAW_TEXT = 1;
163
+ export const KIND_ANCHOR = 2;
164
+ // ── THE RECORDER ─────────────────────────────────────────────────────────────────────────────────
165
+ //
166
+ // One buffer per process, drained by `takeBatch()` at commit. Not per surface: a node belongs to
167
+ // exactly one surface, so ops for another surface are inert until that surface commits — the same
168
+ // argument `edit-buffer.ts` made for not threading a surface through every mutation site, and the
169
+ // same conclusion.
170
+ // TYPED FROM THE START, because the alternative is to walk the whole thing once per commit.
171
+ //
172
+ // This was a `number[]` and `takeBatch` ended in `Int32Array.from(ops)`. Measured on a 1 000-row
173
+ // create through the work ledger: **210 042 slots**, every one of them converted element by element
174
+ // through the ITERATOR PROTOCOL on every commit. `new Int32Array(array)` is not the escape — it
175
+ // takes the same path, checked rather than assumed (F-55). The escape is not building an Array.
176
+ //
177
+ // Capacity DOUBLES and is never given back: a commit that needed 210 042 slots once will need them
178
+ // again, and re-growing from a small start would pay the same copies every commit for the memory of
179
+ // a single benchmark row list.
180
+ const INITIAL_OP_CAPACITY = 1_024;
181
+ let ops = new Int32Array(INITIAL_OP_CAPACITY);
182
+ let opCount = 0;
183
+ let strings = [];
184
+ let values = [];
185
+ let instanceHandles = [];
186
+ let handles = [];
187
+ // Interning matters more here than it looks: a 1 000-row create emits about a dozen distinct view
188
+ // names across 10 000 elements, and every prop KEY is drawn from a set of a few hundred.
189
+ const stringIds = new Map();
190
+ const slots = new Map();
191
+ // The same table for prop VALUES, and the reason it pays is the far side rather than this one: the
192
+ // host turns each entry into a `folly::dynamic` when the op is applied, so a style object reused
193
+ // across a thousand rows was a thousand conversions of one object. Measured on `build-release`,
194
+ // 12 005 `setProp` ops spent 20-29 ms converting inside a 35 ms `applyOps`.
195
+ const valueIds = new Map();
196
+ // Booleans skip that table entirely — see `internValue`. Two slots, reset with the batch alongside
197
+ // everything else the tables hold.
198
+ const NOT_INTERNED = -1;
199
+ let trueId = NOT_INTERNED;
200
+ let falseId = NOT_INTERNED;
201
+ function intern(text) {
202
+ const existing = stringIds.get(text);
203
+ if (existing !== undefined)
204
+ return existing;
205
+ strings.push(text);
206
+ stringIds.set(text, strings.length - 1);
207
+ return strings.length - 1;
208
+ }
209
+ /**
210
+ * The index the ops address this value by — deduplicated when it is worth deduplicating.
211
+ *
212
+ * Objects, functions and strings go through the `Map`: they are the ones that repeat (one
213
+ * `StyleSheet.create` object per screen, `ellipsizeMode: 'tail'` on every text node) and the ones
214
+ * whose conversion costs something.
215
+ *
216
+ * BOOLEANS ARE FOLDED WITHOUT THE MAP, because there are two of them. A dedicated slot each is a
217
+ * branch rather than a hash, so the argument that once excluded them — a lookup costs what the
218
+ * conversion costs — does not reach this shape. And the conversion was never the whole cost:
219
+ * `values` is a JSI array the host reads entry by entry, so a duplicate is a crossing whatever it
220
+ * holds. Measured on the create fixture, three adapters seed `allowFontScaling: true` at
221
+ * `createElement`, which alone wrote one entry per text node on the screen.
222
+ *
223
+ * NUMBERS STAY OUT. They would need the `Map`, whose keys compare by SameValueZero — that folds
224
+ * `-0` into `0` and `NaN` into itself, and a value this cheap to convert is not worth opening the
225
+ * question for.
226
+ *
227
+ * Identity, never structural equality: comparing deeply would make the buffer's cost depend on the
228
+ * size of what it is handed, which is the opposite of the point.
229
+ *
230
+ * Reusing an index is safe against MUTATION of the value between two ops, and not by luck — the host
231
+ * converts at apply time, after the batch has closed, so both ops already saw the object's final
232
+ * state whether they shared an index or not.
233
+ */
234
+ function internValue(value) {
235
+ if (value === true) {
236
+ if (trueId === NOT_INTERNED)
237
+ trueId = pushValue(value);
238
+ return trueId;
239
+ }
240
+ if (value === false) {
241
+ if (falseId === NOT_INTERNED)
242
+ falseId = pushValue(value);
243
+ return falseId;
244
+ }
245
+ const kind = typeof value;
246
+ const isWorthInterning = kind === 'string' ||
247
+ kind === 'function' ||
248
+ (kind === 'object' && value !== null);
249
+ if (!isWorthInterning)
250
+ return pushValue(value);
251
+ const existing = valueIds.get(value);
252
+ if (existing !== undefined)
253
+ return existing;
254
+ const id = pushValue(value);
255
+ valueIds.set(value, id);
256
+ return id;
257
+ }
258
+ function pushValue(value) {
259
+ values.push(value);
260
+ return values.length - 1;
261
+ }
262
+ /**
263
+ * The index the ops address this handle by, for the duration of THIS batch.
264
+ *
265
+ * Assigned on first mention rather than at creation, so a batch carries exactly the nodes it names.
266
+ * A handle created by an earlier batch arrives already owning its native node, which is what lets a
267
+ * clone source three commits old resolve with no bookkeeping on either side.
268
+ */
269
+ function slotOf(handle) {
270
+ // The retained tree used to ABSORB a handle that was not a node: `children.indexOf(x)` returned
271
+ // -1 and the mutation was a silent no-op. A buffer cannot — the op is recorded, and the failure
272
+ // surfaces in the HOST, on a later op, in another batch, as a node whose create it appears never
273
+ // to have seen. Both of the real cases found this way were framework spellings the old tree had
274
+ // been swallowing for months: solid-js passing `null` as "insert at the end", and Angular asking
275
+ // to remove the SURFACE at teardown.
276
+ //
277
+ // Deliberately only "an object", not "one of OUR nodes": a handle is an identity to this file and
278
+ // nothing more, and the applier's own fixtures address bare `{}`. The wrong-KIND-of-object case
279
+ // (Angular handing over a `SymbioteSurface` at teardown) is caught one layer on, where the host
280
+ // fails to resolve it and can name the opcode.
281
+ if (typeof handle !== 'object' || handle === null) {
282
+ throw new Error(`symbiote engine: a mutation named ${String(handle)}, which is not a node. An adapter is ` +
283
+ `passing a framework sentinel straight through — an absent insert anchor is spelled by ` +
284
+ `calling appendChild.`);
285
+ }
286
+ const existing = slots.get(handle);
287
+ if (existing !== undefined)
288
+ return existing;
289
+ handles.push(handle);
290
+ slots.set(handle, handles.length - 1);
291
+ return handles.length - 1;
292
+ }
293
+ // Has anything changed the TREE since the last commit drained?
294
+ //
295
+ // NOT the same question as `hasPendingOps()`, and the difference is the whole reason this exists:
296
+ // every structural READ calls `flushOps`, so a reconciler that navigates the tree it is building
297
+ // empties the buffer many times between commits. `hasPendingOps()` then answers "no" for a surface
298
+ // with a screen's worth of unpublished work. This survives the drain and is cleared only by a
299
+ // commit, which is what "is there anything to publish" actually means.
300
+ //
301
+ // `OP_COMMIT` is excluded deliberately: recording a commit is not a change to the tree, and counting
302
+ // it would make every commit look like it had work.
303
+ let changedSinceCommit = false;
304
+ function push(op, a = 0, b = 0, c = 0, d = 0, e = 0) {
305
+ if (op !== OP_COMMIT)
306
+ changedSinceCommit = true;
307
+ if (opCount + OP_STRIDE > ops.length) {
308
+ const grown = new Int32Array(ops.length * 2);
309
+ grown.set(ops);
310
+ ops = grown;
311
+ }
312
+ ops[opCount] = op;
313
+ ops[opCount + 1] = a;
314
+ ops[opCount + 2] = b;
315
+ ops[opCount + 3] = c;
316
+ ops[opCount + 4] = d;
317
+ ops[opCount + 5] = e;
318
+ opCount += OP_STRIDE;
319
+ }
320
+ /** Whether a commit would publish anything. See `changedSinceCommit`. */
321
+ export function hasChangedSinceCommit() {
322
+ return changedSinceCommit;
323
+ }
324
+ /**
325
+ * Called by `commitSurfaceOps` once it has drained. Separate from `takeBatch` because a READ drains
326
+ * too, and a read is not a commit — clearing there would make the next commit believe its work had
327
+ * already been published.
328
+ */
329
+ export function noteCommitDrained() {
330
+ changedSinceCommit = false;
331
+ }
332
+ /**
333
+ * The one dirtying route that writes no op: a behavior with a DERIVED payload asks the host to
334
+ * rebuild a node the buffer never named. Without this the commit that follows would look idle and
335
+ * be skipped, and the derived payload would sit unpublished until something unrelated changed.
336
+ */
337
+ export function noteHostSideChange() {
338
+ changedSinceCommit = true;
339
+ }
340
+ // Nodes whose PLACEMENT this batch has not published yet — created, or named as the child of a
341
+ // structural op.
342
+ //
343
+ // It exists so a read does not have to drain. `parentOf` is unconditional otherwise, and Angular's
344
+ // 1 000-row create measured 1 002 drains for 1 000 reads of which ONE asked about a node the pending
345
+ // batch had touched (`adapters/angular/src/read-fragmentation.probe.test.ts`).
346
+ //
347
+ // THE CLAIM THAT MAKES IT SOUND: a node's parent link changes only through an op that names that
348
+ // node AS THE CHILD. `before` on an insert is a position reference and moves nothing; a parent
349
+ // argument moves the parent's list, not the parent's own link. So a handle absent from here has the
350
+ // same parent in the host as it has here.
351
+ //
352
+ // Creation is in it because the host cannot answer about a node it has never been told exists, which
353
+ // is the other half — and the half a "was it re-parented" set alone would get wrong.
354
+ //
355
+ // NOT A TREE, and that is the line this has to stay on the right side of (`node.ts:2` — no parent,
356
+ // no children, no mirror). It answers about the BUFFER: is this node's placement unpublished. It is
357
+ // emptied by `takeBatch`, so it never outlives one batch and can never disagree with the host.
358
+ let placementPending = new Set();
359
+ /** Does the pending batch hold anything that could change what the host says this node's parent is? */
360
+ export function hasPendingPlacement(handle) {
361
+ return placementPending.has(handle);
362
+ }
363
+ export function recordCreateElement(handle, viewName, isText, instanceHandle) {
364
+ instanceHandles.push(instanceHandle);
365
+ placementPending.add(handle);
366
+ push(OP_CREATE_ELEMENT, slotOf(handle), intern(viewName), isText ? 1 : 0, instanceHandles.length - 1);
367
+ }
368
+ export function recordCreateRawText(handle, text) {
369
+ placementPending.add(handle);
370
+ push(OP_CREATE_RAW_TEXT, slotOf(handle), intern(text));
371
+ }
372
+ export function recordCreateAnchor(handle) {
373
+ placementPending.add(handle);
374
+ push(OP_CREATE_ANCHOR, slotOf(handle));
375
+ }
376
+ export function recordCreateVoid(handle) {
377
+ placementPending.add(handle);
378
+ push(OP_CREATE_VOID, slotOf(handle));
379
+ }
380
+ export function recordAppendChild(parent, child) {
381
+ placementPending.add(child);
382
+ push(OP_APPEND_CHILD, slotOf(parent), slotOf(child));
383
+ }
384
+ export function recordInsertBefore(parent, child, before) {
385
+ placementPending.add(child);
386
+ push(OP_INSERT_BEFORE, slotOf(parent), slotOf(child), slotOf(before));
387
+ }
388
+ export function recordRemoveChild(parent, child) {
389
+ placementPending.add(child);
390
+ push(OP_REMOVE_CHILD, slotOf(parent), slotOf(child));
391
+ }
392
+ /** `undefined` DELETES the key — the collapse `setProp` has always performed, spelled on the wire. */
393
+ export function recordSetProp(handle, key, value) {
394
+ if (value === undefined) {
395
+ push(OP_SET_PROP, slotOf(handle), intern(key), NO_VALUE);
396
+ return;
397
+ }
398
+ push(OP_SET_PROP, slotOf(handle), intern(key), internValue(value));
399
+ }
400
+ export function recordSetText(handle, text) {
401
+ push(OP_SET_TEXT, slotOf(handle), intern(text));
402
+ }
403
+ /**
404
+ * Change a node's Fabric view name after creation.
405
+ *
406
+ * It exists for exactly one thing and would not otherwise: `TextInput`'s `multiline` decides between
407
+ * `RCTSinglelineTextInputView` and `RCTMultilineTextInputView`, and an app can flip it. No prop write
408
+ * moves a node between native views, so the host has to RE-CREATE the node under the new name and
409
+ * re-parent its children — which is the same path a reparent already takes, and why this needs no
410
+ * new machinery on the far side beyond honouring the name.
411
+ *
412
+ * A JS-only workaround was the alternative and it is the one thing this design rules out: to know
413
+ * what to rebuild, JS would have to hold the tree.
414
+ */
415
+ export function recordSetComponent(handle, viewName) {
416
+ push(OP_SET_COMPONENT, slotOf(handle), intern(viewName));
417
+ }
418
+ export function recordSetTag(handle, tag) {
419
+ push(OP_SET_TAG, slotOf(handle), intern(tag));
420
+ }
421
+ /**
422
+ * Whether an app callback is currently wired to an event name the BEHAVIOR owns. `[slot, name,
423
+ * present]`, `present` being 1 or 0.
424
+ *
425
+ * The EXISTENCE, never the function. A name a behavior owns is diverted into a JS stash by
426
+ * `setEventListener` and never becomes a prop, so the payload builder sees no trace of it — and
427
+ * `focusable` on a touchable is `focusable !== false && onPress !== undefined && !disabled`
428
+ * (`TouchableOpacity.js:336-339`), two props and one thing only JS knew. This is the one bit that
429
+ * closes that gap.
430
+ *
431
+ * The browser is the argument rather than convenience: a UA computes focusability itself and CAN,
432
+ * because `addEventListener` is its own API — it knows which elements carry a click handler, while
433
+ * the handler's body stays the application's. Same split.
434
+ *
435
+ * Emitted on a FLIP only, from `setEventListener`, which already refuses to notify on listener
436
+ * identity because a framework hands a fresh closure nearly every render. So this is a mount-time
437
+ * op, not a per-render one — against the per-commit fold it replaces.
438
+ */
439
+ export function recordSetOwnedListener(handle, name, isPresent) {
440
+ push(OP_SET_OWNED_LISTENER, slotOf(handle), intern(name), isPresent ? 1 : 0);
441
+ }
442
+ /** See `OP_SET_UNDERLAY_SHOWN`. Emitted on a flip only, from the behavior that owns the timer. */
443
+ export function recordSetUnderlayShown(handle, shown) {
444
+ push(OP_SET_UNDERLAY_SHOWN, slotOf(handle), shown ? 1 : 0);
445
+ }
446
+ export function recordCommit(rootTag, surface) {
447
+ push(OP_COMMIT, rootTag, slotOf(surface));
448
+ }
449
+ /** Whether anything is pending. The commit path asks before paying for a drain. */
450
+ export function hasPendingOps() {
451
+ return opCount > 0;
452
+ }
453
+ /**
454
+ * Drain the buffer.
455
+ *
456
+ * Fresh arrays rather than reused ones: the batch outlives this call on the native path (`applyOps`
457
+ * reads `handles` while attaching state), and a recycled array would be mutated under it by the next
458
+ * mutation the adapter makes.
459
+ *
460
+ * `slice` for the ops and not `subarray` for exactly that reason — a subarray would share the
461
+ * backing store the very next `push` writes into. It is a typed-array copy rather than the
462
+ * element-by-element iterator walk this used to be, and the ops buffer itself is KEPT so its
463
+ * capacity survives the drain.
464
+ */
465
+ export function takeBatch() {
466
+ const batch = {
467
+ ops: ops.slice(0, opCount),
468
+ strings,
469
+ values,
470
+ instanceHandles,
471
+ handles,
472
+ };
473
+ opCount = 0;
474
+ strings = [];
475
+ values = [];
476
+ instanceHandles = [];
477
+ handles = [];
478
+ // A fresh Set rather than `.clear()`: the old one is handed to nobody, and clearing a set that
479
+ // held ten thousand handles on a benchmark create costs more than dropping it.
480
+ placementPending = new Set();
481
+ stringIds.clear();
482
+ slots.clear();
483
+ valueIds.clear();
484
+ trueId = NOT_INTERNED;
485
+ falseId = NOT_INTERNED;
486
+ return batch;
487
+ }
488
+ /** Test seam. Drops everything pending without applying it. */
489
+ export function resetMutationBuffer() {
490
+ takeBatch();
491
+ }
@@ -0,0 +1,182 @@
1
+ import type { ICommittedRecord } from './tree-host';
2
+ /**
3
+ * The ABI this file knows how to talk to. A binary reporting anything else is refused outright rather
4
+ * than probed method by method — the two artefacts ship separately (a pod and an npm package), so
5
+ * disagreement is routine, and a partial match is the shape that corrupts memory quietly.
6
+ *
7
+ * Exported for the tests, which must DERIVE their supported and unsupported arms from it rather than
8
+ * restate the number: a bump that leaves a fixture behind reads as "the binary is stale", which is
9
+ * exactly the message this constant exists to produce, so the failure looks like the feature working.
10
+ */
11
+ export declare const SUPPORTED_NATIVE_VERSION = 4;
12
+ /**
13
+ * What `installJSIBindingsWithRuntime:` puts on the global.
14
+ *
15
+ * `allocInt32Array` hands back a view over memory NATIVE owns — not a copy — which is the only reason
16
+ * any of this exists. Every other route from JS to native structure costs a JSI crossing per element,
17
+ * and the census that sized this design counted ~4 000 reads for a two-row swap.
18
+ */
19
+ export type INativeEngineBindings = {
20
+ version: number;
21
+ allocInt32Array: (lengthInElements: number) => Int32Array;
22
+ /**
23
+ * Item 8c-1's bring-up probe: shadow trees the real `UIManager` holds, or -1 when we could not
24
+ * reach one at all. Not a capability the engine uses — it exists so one device run answers whether
25
+ * our pod compiles against ReactCommon's renderer, links against the prebuilt framework, and can
26
+ * resolve the UIManager from a plain JSI runtime. See the C++ side for why -1 and 0 differ.
27
+ */
28
+ probeUIManager: () => number;
29
+ /**
30
+ * Replay one recorded mutation batch — the five fields of `IMutationBatch`, spread, because JSI
31
+ * reads five arguments cheaper than it reads five properties off one object.
32
+ *
33
+ * `ops` is read as MEMORY on the native side and never becomes JS values; the four side tables
34
+ * carry what JSI has to marshal either way.
35
+ *
36
+ * `handles` is the load-bearing one and it travels OUT, not back. It holds the placeholder object
37
+ * for every slot the ops address, and the host attaches each created node's
38
+ * `shared_ptr<const ShadowNode>` to the object at that slot as JSI `NativeState` — so the objects
39
+ * the adapter is already holding become the real handles in place. Nothing is returned.
40
+ *
41
+ * That is the whole lifetime design, and it is RN's own: `nativeFabricUIManager.createNode()`
42
+ * hands back an object whose NativeState owns the node, so Hermes collecting the object is what
43
+ * frees it. The version this replaced kept a `Map<int, shared_ptr>` in C++ instead — a second
44
+ * owner nothing could tell to let go, which cost 792 -> 1492 MB across one benchmark suite.
45
+ */
46
+ applyOps: (ops: Int32Array, strings: readonly string[], values: readonly unknown[], instanceHandles: readonly unknown[], handles: readonly object[]) => void;
47
+ /**
48
+ * The four reads of `ITreeHost`, taking the same placeholder object `applyOps` put the node on.
49
+ *
50
+ * None is on a commit path — they run at GESTURE or lifecycle rate (a host behavior seeing the
51
+ * props it reacts to, an app measuring a ref, a framework seam navigating what it just built), so
52
+ * the crossing cost is irrelevant. `undefined` / empty is an ordinary answer from all of them: a
53
+ * handle native has not seen and a genuinely absent value are indistinguishable here, and both
54
+ * degrade.
55
+ *
56
+ * `getViewName` answers the RESOLVED name, which native may have changed at insert — a `<Text>`
57
+ * inside another `<Text>` commits as `RCTVirtualText`, and only the side holding the parent link
58
+ * knows.
59
+ */
60
+ getProp: (handle: object, key: string) => unknown;
61
+ getProps: (handle: object) => Readonly<Record<string, unknown>>;
62
+ /** The one WRITE among them: dirty a node no op named. See `markPropsDirty` (node.ts). */
63
+ markPropsDirty: (handle: object) => void;
64
+ getViewName: (handle: object) => string;
65
+ parentOf: (handle: object) => object | undefined;
66
+ childrenOf: (handle: object) => readonly object[];
67
+ /** One entry, not the whole list — see `ITreeHost` for the quadratic it replaces. */
68
+ nextSiblingOf: (handle: object) => object | undefined;
69
+ /** The batched twins of `parentOf` / `childrenOf`. See `ITreeHost` for why the sweep needs them. */
70
+ parentsOf: (handles: readonly object[]) => readonly (object | undefined)[];
71
+ subtreesOf: (roots: readonly object[]) => readonly object[];
72
+ /** The upward twin, deepest first — one crossing for a chain the event path walks per event. */
73
+ ancestorsOf: (handle: object) => readonly object[];
74
+ committedRecordOf: (handle: object) => ICommittedRecord | undefined;
75
+ /** A TEST read — the payload the last commit sent. See `ITreeHost.committedPayloadOf`. */
76
+ committedPayloadOf: (handle: object) => Readonly<Record<string, unknown>> | undefined;
77
+ /**
78
+ * The imperative six, taking the same placeholder object `applyOps` put the node on.
79
+ *
80
+ * They are here because `nativeFabricUIManager`'s own copies unwrap a handle IT minted, and under
81
+ * the batched applier every handle in play was minted by the batching slot. Ours carry the node on
82
+ * their `NativeState` exactly as RN's do, so these six are the same code reading a different
83
+ * object. An app calling `measure()` on a ref reaches native through here or not at all.
84
+ *
85
+ * The callback protocol is Fabric's own, not ours: `measure` answers six numbers, `measureInWindow`
86
+ * four, and `measureLayout` calls `onFail` when the surface has no committed revision — copied
87
+ * from `UIManagerBinding` so a component that already handles those cases keeps working.
88
+ */
89
+ dispatchCommand: (handle: object, commandName: string, args: readonly unknown[]) => void;
90
+ sendAccessibilityEvent: (handle: object, eventType: string) => void;
91
+ measure: (handle: object, callback: (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void) => void;
92
+ measureInWindow: (handle: object, callback: (x: number, y: number, width: number, height: number) => void) => void;
93
+ measureLayout: (handle: object, relativeTo: object, onFail: () => void, onSuccess: (x: number, y: number, width: number, height: number) => void) => void;
94
+ setIsJSResponder: (handle: object, isResponder: boolean, blockNativeResponder: boolean) => void;
95
+ /**
96
+ * RN's own commit telemetry for ANY surface, including one this host never drove.
97
+ *
98
+ * OPTIONAL: it is read with `?.`, so a pod predating it degrades instead of throwing, unlike the
99
+ * members above that `isBindings` requires. Treat an absent member as "no answer", never as
100
+ * zeroes — a zero reads as "React's commit measures no text", the claim this exists to test.
101
+ */
102
+ /**
103
+ * The C++ half's diagnostics (`SymbioteDebug.h`). OPTIONAL, like `readSurfaceTelemetry` and for
104
+ * the same reason: an older native binary that predates them must still pass `isBindings` rather
105
+ * than failing bring-up over a member nothing on the critical path needs.
106
+ */
107
+ setDebugEnabled?: (enabled: boolean) => void;
108
+ takeDebugLog?: () => readonly string[];
109
+ readSurfaceTelemetry?: (surfaceId: number) => {
110
+ /**
111
+ * The inside of a commit, read out of RN's OWN `TransactionTelemetry` rather than timed by us
112
+ * — a `ShadowTreeRevision` carries the telemetry of the commit that produced it.
113
+ *
114
+ * `layoutNodes` is the one that answers the question the timings only pose: Yoga reports how
115
+ * many layoutable nodes it actually touched, so a one-row change that reports the whole tree is
116
+ * a re-layout, not a slow layout.
117
+ */
118
+ layoutMs: number;
119
+ textMs: number;
120
+ /**
121
+ * `ShadowTree::commit`'s own window, and NOT `materialize`'s — this used to say otherwise, and
122
+ * three rounds of investigation read the number that way. `materialize` runs in `kOpCommit`
123
+ * before `completeSurface` is called, so it falls outside this window and layout's alike.
124
+ */
125
+ commitMs: number;
126
+ layoutNodes: number;
127
+ textMeasures: number;
128
+ /**
129
+ * Parents that took the targeted-replace path since the last read, zeroed on read. OURS, not
130
+ * RN's — a LIVENESS signal for a fast path whose absence no correctness test can see.
131
+ */
132
+ targetedReplaces: number;
133
+ /** `materialize`'s own walk and its breakdown — ours, zeroed on read. See `ISurfaceTelemetry`. */
134
+ walkMs: number;
135
+ propsMs: number;
136
+ foldLookupMs: number;
137
+ foldsFound: number;
138
+ foldToJsMs: number;
139
+ foldCallMs: number;
140
+ foldFromJsMs: number;
141
+ rawPropsMs: number;
142
+ createNodeMs: number;
143
+ appendChildMs: number;
144
+ diffPropsMs: number;
145
+ nodesCreated: number;
146
+ nodesCloned: number;
147
+ nodesReused: number;
148
+ decodeMs: number;
149
+ instanceHandleMs: number;
150
+ publishMs: number;
151
+ nativeStateMs: number;
152
+ nodesDecoded: number;
153
+ setPropMs: number;
154
+ propConvertMs: number;
155
+ setProps: number;
156
+ deletesOfAbsent: number;
157
+ writesOfUnchanged: number;
158
+ valueEntries: number;
159
+ valueConversions: number;
160
+ applyMs: number;
161
+ stringDecodeMs: number;
162
+ structureMs: number;
163
+ holdHandleMs: number;
164
+ hostReadMs: number;
165
+ hostReadHandles: number;
166
+ applyCalls: number;
167
+ };
168
+ };
169
+ declare global {
170
+ var __symbioteEngineNative: unknown;
171
+ }
172
+ /**
173
+ * The native bindings, or `undefined` when this platform has none.
174
+ *
175
+ * Resolving the TurboModule is done for its SIDE EFFECT: `RCTTurboModuleManager` runs
176
+ * `installJSIBindingsWithRuntime:` at the moment it creates a module, so touching the module by name
177
+ * is what puts the global there. The module's own methods are not the capability and are not called
178
+ * here — which is why a reader looking for the payload in the spec file will not find it.
179
+ */
180
+ export declare function nativeEngine(): INativeEngineBindings | undefined;
181
+ /** Test seam: forget what was resolved, so a fixture can install or remove the global between cases. */
182
+ export declare function resetNativeEngine(): void;