@symbiote-native/engine 1.3.0 β†’ 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/build/accessibility-props.d.ts +0 -11
  2. package/build/accessibility-props.js +30 -68
  3. package/build/animated/graph.js +1 -1
  4. package/build/animated/leaf-lifecycle.js +2 -2
  5. package/build/asset-source-resolver.d.ts +2 -0
  6. package/build/asset-source-resolver.js +13 -0
  7. package/build/back-handler/index.d.ts +1 -5
  8. package/build/back-handler/index.js +0 -6
  9. package/build/debug.js +8 -22
  10. package/build/dispatch.js +3 -9
  11. package/build/events/delivery.d.ts +9 -0
  12. package/build/events/delivery.js +143 -0
  13. package/build/events/index.js +199 -660
  14. package/build/events/names.d.ts +24 -0
  15. package/build/events/names.js +80 -0
  16. package/build/events/press.d.ts +20 -0
  17. package/build/events/press.js +89 -0
  18. package/build/events/responder.d.ts +6 -0
  19. package/build/events/responder.js +124 -0
  20. package/build/fabric-props.js +74 -179
  21. package/build/fabric.d.ts +0 -13
  22. package/build/fabric.js +18 -38
  23. package/build/host-access.d.ts +1 -128
  24. package/build/host-access.js +96 -205
  25. package/build/host-behavior.d.ts +0 -100
  26. package/build/host-behavior.js +125 -311
  27. package/build/image-loader.js +10 -23
  28. package/build/image-source-resolver.js +3 -7
  29. package/build/image-source-write.d.ts +0 -11
  30. package/build/image-source-write.js +14 -34
  31. package/build/imperative.d.ts +2 -28
  32. package/build/imperative.js +60 -93
  33. package/build/index.d.ts +6 -2
  34. package/build/index.js +29 -39
  35. package/build/mutation-buffer.d.ts +3 -177
  36. package/build/mutation-buffer.js +162 -316
  37. package/build/native-engine.d.ts +6 -102
  38. package/build/native-engine.js +60 -141
  39. package/build/native-events.js +9 -18
  40. package/build/native-tree-host.d.ts +0 -21
  41. package/build/native-tree-host.js +15 -31
  42. package/build/node-events.d.ts +11 -0
  43. package/build/node-events.js +145 -0
  44. package/build/node-instance.d.ts +8 -0
  45. package/build/node-instance.js +168 -0
  46. package/build/node-props.d.ts +13 -0
  47. package/build/node-props.js +131 -0
  48. package/build/node-route.d.ts +2 -0
  49. package/build/node-route.js +151 -0
  50. package/build/node-style.d.ts +15 -0
  51. package/build/node-style.js +214 -0
  52. package/build/node-tree.d.ts +6 -0
  53. package/build/node-tree.js +159 -0
  54. package/build/node-types.d.ts +70 -0
  55. package/build/node-types.js +36 -0
  56. package/build/node.d.ts +7 -309
  57. package/build/node.js +9 -1564
  58. package/build/post-commit.js +3 -8
  59. package/build/process-aspect-ratio.js +3 -7
  60. package/build/process-background-longhands.js +10 -19
  61. package/build/process-filter.js +11 -19
  62. package/build/process-font-variant.js +3 -7
  63. package/build/registry.d.ts +0 -33
  64. package/build/registry.js +22 -57
  65. package/build/report-error.js +4 -18
  66. package/build/structured-style.d.ts +0 -9
  67. package/build/structured-style.js +16 -31
  68. package/build/styles.js +3 -6
  69. package/build/surface.d.ts +0 -26
  70. package/build/surface.js +29 -76
  71. package/build/text-input-state.js +4 -8
  72. package/build/touch-history.js +5 -11
  73. package/build/tree-host.d.ts +7 -270
  74. package/build/tree-host.js +63 -153
  75. package/build/view-config.js +17 -37
  76. package/cpp/SymbioteEngineBindings.cpp +19 -18
  77. package/cpp/SymbioteTree.cpp +81 -156
  78. package/cpp/SymbioteTree.h +6 -0
  79. package/package.json +2 -2
@@ -1,70 +1,29 @@
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
- */
1
+ // The contract: native owns the tree, JS emits nothing but a command buffer. This file is the
2
+ // spec β€” core/engine/cpp/SymbioteTree.cpp must agree with it, since a wire format described in
3
+ // prose gets reimplemented, written as code gets exercised.
4
+ // ── what changed, and why it is smaller than it sounds ───────────────────────────────────────────
5
+ // The adapter used to mutate a JS tree that commit.ts walked to re-derive a diff the framework
6
+ // already knew. The buffer now carries the adapter's alphabet directly: the walk, the diffing,
7
+ // and the JS shadow tree (IMirror) all disappear rather than move.
8
+ // ── the alphabet ─────────────────────────────────────────────────────────────────────────────────
9
+ // Nine operations β€” the mutation API node.ts already exports, minus what was never primitive:
10
+ // setNodeHidden/setNodePressed resolve a CSS class to a style in JS then call setProp; a listener
11
+ // can't be folly::dynamic, so setEventListener crosses only its boolean gate flag as a setProp.
12
+ // ── what stays in JS, and it is not a tree ──────────────────────────────────────────────────────
13
+ // The handle per node: a bare {} carrying native's node on NativeState β€” no parent/children/order,
14
+ // just an address. Listeners and the CSS class registry stay flat maps; host behaviors read props
15
+ // back over JSI at gesture rate, not commit rate.
16
+ // ── why props go native, decided the other way first ────────────────────────────────────────────
17
+ // Keeping prop values in JS assumed RawProps is lazy so handing Fabric a JS object costs nothing β€”
18
+ // wrong: ConcreteComponentDescriptor::cloneProps calls parse() unconditionally, and the preparse
19
+ // walks every key of every node through JSI regardless of mode.
20
+ // So a create already pays a JSI property read and a string allocation per prop, per node, inside
21
+ // createNode. Props held natively as folly::dynamic, built incrementally as setProp ops arrive,
22
+ // are cheaper than what shipped before, not more expensive.
23
+ // The opcodes. These numbers ARE the contract: renumbering here without renumbering the C++ side
24
+ // commits a different tree, silently, and no test in either language can see across the boundary.
25
+ // Stride is fixed so the ops array is addressable as memory rather than parsed. Operands are
26
+ // either a slot (an index into the batch's handles array) or an index into a side table.
68
27
  export const OP_STRIDE = 6;
69
28
  export const OP_CREATE_ELEMENT = 0; // [slot, viewName, isText, tag, instanceHandle]
70
29
  export const OP_CREATE_RAW_TEXT = 1; // [slot, text]
@@ -76,107 +35,54 @@ export const OP_SET_PROP = 6; // [slot, key, value] β€” value = NO_VALUE deletes
76
35
  export const OP_SET_TEXT = 7; // [slot, text]
77
36
  export const OP_COMMIT = 8; // [rootTag, surface]
78
37
  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
- */
38
+ // The intrinsic tag this node came from β€” pressable, not RCTView. [slot, tag]. Emitted only for a
39
+ // node a host behavior actually attached to; every other node pays nothing.
40
+ // Native's platform-prop rules key off what the node IS, and the Fabric view name alone doesn't
41
+ // always answer that β€” pressable commits as RCTView, byte-identical to a plain view, so the tag
42
+ // is the only fact that separates them.
91
43
  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
- */
44
+ // An app callback appeared on, or disappeared from, an event name the behavior owns.
45
+ // [slot, name, present]. See recordSetOwnedListener for why the bit crosses, not the closure.
97
46
  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
- */
47
+ // A behavior's feedback state flipped β€” TouchableHighlight's underlay is showing, or stopped.
48
+ // [slot, shown]. The second bit to cross for the same reason the first did: OP_SET_OWNED_LISTENER
49
+ // carries whether the app wired a handler, this carries whether the control is giving feedback.
50
+ // The timer stays in JS, and that's the boundary, not an omission: WHEN the bit flips is
51
+ // Pressability plus a delayPressOut hold at gesture rate; WHAT a showing underlay looks like is
52
+ // the platform's (foldTouchableHighlightUnderlay).
53
+ // A flip is a gesture-rate event rather than a per-render one β€” twice a tap, against a payloadFold
54
+ // that used to be charged on every commit the node was dirty in for the life of the screen.
118
55
  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
- */
56
+ // A void node: commits nothing of its own and does not hoist its children into Fabric either β€”
57
+ // unlike an anchor, which hoists. [slot]. InputAccessoryView.js renders null on Android, so the
58
+ // whole component (children included) must vanish, which an anchor can't express.
59
+ // Everything else about it (the retained JS tree, appendChild, adapter bookkeeping) is unaffected;
60
+ // only the commit walk treats it as contributing zero Fabric nodes, recursively.
129
61
  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
- */
62
+ // A setProp whose value slot is this deletes the key. undefined can't carry it: null is a
63
+ // legitimate Fabric prop value meaning "reset to default", and the two must stay distinguishable
64
+ // since cloneNodeWithNewProps merges β€” a removed key must be sent as explicit null, never omitted.
138
65
  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
- */
66
+ // A node kind. Native needs it because two of the three never become a Fabric node the same way,
67
+ // decided at insert or child-set build, never by a walk. ELEMENT is an ordinary view, except a
68
+ // text inside another text commits as RCTVirtualText, resolved when it acquires a parent.
69
+ // RAW_TEXT is an RCTRawText leaf, skipped from its parent's child set when empty. ANCHOR never
70
+ // becomes a Fabric node: native hoists its children into its parent's child set instead, which is
71
+ // why our own store cannot BE the Fabric tree.
72
+ // A surface is an anchor too: its children belong to the root's child set the same way an
73
+ // anchor's belong to its parent's list, so OP_COMMIT names one and native needs no rootTag -> node
74
+ // map β€” keeping the applier stateless.
161
75
  export const KIND_ELEMENT = 0;
162
76
  export const KIND_RAW_TEXT = 1;
163
77
  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.
78
+ // ── the recorder ─────────────────────────────────────────────────────────────────────────────────
79
+ // One buffer per process, drained by takeBatch() at commit. Not per surface: a node belongs to
80
+ // exactly one surface, so ops for another surface are inert until that surface commits.
81
+ // Typed from the start, not a number[] converted at drain time β€” a plain array walks every element
82
+ // through the iterator protocol on every commit, and Int32Array.from takes the same path. The
83
+ // escape is not building an Array at all.
84
+ // Capacity doubles and is never given back: a commit that once needed this many slots will need
85
+ // them again, and re-growing from a small start would pay the same copies every commit.
180
86
  const INITIAL_OP_CAPACITY = 1_024;
181
87
  let ops = new Int32Array(INITIAL_OP_CAPACITY);
182
88
  let opCount = 0;
@@ -187,16 +93,14 @@ let handles = [];
187
93
  // Interning matters more here than it looks: a 1 000-row create emits about a dozen distinct view
188
94
  // names across 10 000 elements, and every prop KEY is drawn from a set of a few hundred.
189
95
  const stringIds = new Map();
190
- // Which batch the `slot` standing on a handle belongs to. Bumped by `takeBatch`, which is what
191
- // invalidates every slot at once without walking the handles that hold them.
192
- //
193
- // It starts at 1 because a fresh node's `slotBatch` is 0, so an untouched handle can never match a
194
- // live batch and needs no separate "is it in this batch" flag.
96
+ // Which batch the slot standing on a handle belongs to. Bumped by takeBatch, which invalidates
97
+ // every slot at once without walking the handles that hold them.
98
+ // Starts at 1 because a fresh node's slotBatch is 0, so an untouched handle can never match a live
99
+ // batch and needs no separate "is it in this batch" flag.
195
100
  let batchId = 1;
196
- // The same table for prop VALUES, and the reason it pays is the far side rather than this one: the
197
- // host turns each entry into a `folly::dynamic` when the op is applied, so a style object reused
198
- // across a thousand rows was a thousand conversions of one object. Measured on `build-release`,
199
- // 12 005 `setProp` ops spent 20-29 ms converting inside a 35 ms `applyOps`.
101
+ // The same table for prop values, and it pays off on the far side: the host converts each entry
102
+ // to folly::dynamic when the op is applied, so a style object reused across many rows was many
103
+ // conversions of the same object without it.
200
104
  const valueIds = new Map();
201
105
  // Booleans skip that table entirely β€” see `internValue`. Two slots, reset with the batch alongside
202
106
  // everything else the tables hold.
@@ -211,31 +115,17 @@ function intern(text) {
211
115
  stringIds.set(text, strings.length - 1);
212
116
  return strings.length - 1;
213
117
  }
214
- /**
215
- * The index the ops address this value by β€” deduplicated when it is worth deduplicating.
216
- *
217
- * Objects, functions and strings go through the `Map`: they are the ones that repeat (one
218
- * `StyleSheet.create` object per screen, `ellipsizeMode: 'tail'` on every text node) and the ones
219
- * whose conversion costs something.
220
- *
221
- * BOOLEANS ARE FOLDED WITHOUT THE MAP, because there are two of them. A dedicated slot each is a
222
- * branch rather than a hash, so the argument that once excluded them β€” a lookup costs what the
223
- * conversion costs β€” does not reach this shape. And the conversion was never the whole cost:
224
- * `values` is a JSI array the host reads entry by entry, so a duplicate is a crossing whatever it
225
- * holds. Measured on the create fixture, three adapters seed `allowFontScaling: true` at
226
- * `createElement`, which alone wrote one entry per text node on the screen.
227
- *
228
- * NUMBERS STAY OUT. They would need the `Map`, whose keys compare by SameValueZero β€” that folds
229
- * `-0` into `0` and `NaN` into itself, and a value this cheap to convert is not worth opening the
230
- * question for.
231
- *
232
- * Identity, never structural equality: comparing deeply would make the buffer's cost depend on the
233
- * size of what it is handed, which is the opposite of the point.
234
- *
235
- * Reusing an index is safe against MUTATION of the value between two ops, and not by luck β€” the host
236
- * converts at apply time, after the batch has closed, so both ops already saw the object's final
237
- * state whether they shared an index or not.
238
- */
118
+ // The index the ops address this value by β€” deduplicated when it is worth deduplicating. Objects,
119
+ // functions and strings go through the Map: they repeat (one StyleSheet.create object per screen)
120
+ // and their conversion costs something.
121
+ // Booleans are folded without the Map, since there are only two of them β€” a dedicated slot each is
122
+ // a branch rather than a hash. The conversion was never the whole cost either: values is a JSI
123
+ // array the host reads entry by entry, so a duplicate is a crossing whatever it holds.
124
+ // Numbers stay out: they'd need the Map, whose keys compare by SameValueZero (folding -0 into 0,
125
+ // NaN into itself), and a value this cheap to convert isn't worth opening that question for.
126
+ // Identity, never structural equality β€” comparing deeply would make the buffer's cost depend on
127
+ // the size of what it's handed. Reusing an index is safe against mutation between two ops since
128
+ // the host converts at apply time, after the batch closes, when both already reflect final state.
239
129
  function internValue(value) {
240
130
  if (value === true) {
241
131
  if (trueId === NOT_INTERNED)
@@ -264,41 +154,22 @@ function pushValue(value) {
264
154
  values.push(value);
265
155
  return values.length - 1;
266
156
  }
267
- /**
268
- * The index the ops address this handle by, for the duration of THIS batch.
269
- *
270
- * Assigned on first mention rather than at creation, so a batch carries exactly the nodes it names.
271
- * A handle created by an earlier batch arrives already owning its native node, which is what lets a
272
- * clone source three commits old resolve with no bookkeeping on either side.
273
- *
274
- * THE SLOT LIVES ON THE HANDLE, not in a `Map` keyed by it, and that is a measured decision. This
275
- * function runs once per handle OPERAND β€” every `setProp` names its node and every append names
276
- * two, so a thousand-row create mentions handles about forty thousand times β€” and a `Map<object,
277
- * number>` charges a hash for each one, plus a second for the `set` on a miss. Two fields on a shape
278
- * the node already carries turn that into a compare.
279
- *
280
- * Measured on `build-release` by `mutation-api-fill-cost.itest.ts`, both arms in one sitting, three
281
- * runs each: `createRawText` 0.70 -> 0.51 us, `appendChild` 0.65 -> 0.58, `recordSetProp` 0.34 ->
282
- * 0.30, and the whole `fill` phase of a 10 001-node create 25.0 -> 23.2 ms. `createElement` barely
283
- * moved (0.72 -> 0.70), which is the control: it does enough else that one lookup is noise in it.
284
- *
285
- * WHY AN EPOCH RATHER THAN CLEARING: a slot is meaningless outside its batch (see `IMutationBatch`),
286
- * so every slot must die when the batch drains. Walking the handles to reset them would cost exactly
287
- * what the `Map.clear` cost; bumping one counter invalidates all of them at once, and a handle that
288
- * is never mentioned again is never touched.
289
- */
157
+ // The index the ops address this handle by, for the duration of THIS batch. Assigned on first
158
+ // mention rather than at creation, so a batch carries exactly the nodes it names β€” a handle from
159
+ // an earlier batch arrives already owning its native node.
160
+ // The slot lives on the handle, not in a Map keyed by it, a measured decision: this runs once per
161
+ // handle operand, and a Map<object, number> charges a hash per lookup plus a set on a miss. Two
162
+ // fields on a shape the node already carries turn that into a compare.
163
+ // An epoch rather than clearing: a slot is meaningless outside its batch, so every slot must die
164
+ // on drain. Walking handles to reset them costs what Map.clear costs; bumping one counter
165
+ // invalidates all of them at once, and an unmentioned handle is never touched.
290
166
  function slotOf(handle) {
291
- // The retained tree used to ABSORB a handle that was not a node: `children.indexOf(x)` returned
292
- // -1 and the mutation was a silent no-op. A buffer cannot β€” the op is recorded, and the failure
293
- // surfaces in the HOST, on a later op, in another batch, as a node whose create it appears never
294
- // to have seen. Both of the real cases found this way were framework spellings the old tree had
295
- // been swallowing for months: solid-js passing `null` as "insert at the end", and Angular asking
296
- // to remove the SURFACE at teardown.
297
- //
298
- // Deliberately only "an object", not "one of OUR nodes": a handle is an identity to this file and
299
- // nothing more, and the applier's own fixtures address bare `{}`. The wrong-KIND-of-object case
300
- // (Angular handing over a `SymbioteSurface` at teardown) is caught one layer on, where the host
301
- // fails to resolve it and can name the opcode.
167
+ // A buffer cannot silently absorb a handle that isn't a node the way the old retained tree did
168
+ // (children.indexOf(x) === -1, no-op) β€” the op is recorded, and the failure surfaces in the
169
+ // host, on a later op, as a node whose create it never saw.
170
+ // Deliberately only "an object", not "one of our nodes": a handle is an identity to this file and
171
+ // nothing more. The wrong-kind-of-object case is caught one layer on, where the host fails to
172
+ // resolve it and can name the opcode.
302
173
  if (typeof handle !== 'object' || handle === null) {
303
174
  throw new Error(`symbiote engine: a mutation named ${String(handle)}, which is not a node. An adapter is ` +
304
175
  `passing a framework sentinel straight through β€” an absent insert anchor is spelled by ` +
@@ -311,18 +182,13 @@ function slotOf(handle) {
311
182
  handle.slotBatch = batchId;
312
183
  return handle.slot;
313
184
  }
314
- // Has anything changed the TREE since the last commit drained?
315
- //
316
- // NOT the same question as `hasPendingOps()`, and the difference is the whole reason this exists:
317
- // every structural READ calls `flushOps`, so a reconciler that navigates the tree it is building
318
- // empties the buffer many times between commits. `hasPendingOps()` then answers "no" for a surface
319
- // with a screen's worth of unpublished work. This survives the drain and is cleared only by a
320
- // commit, which is what "is there anything to publish" actually means.
321
- //
322
- // `OP_COMMIT` is excluded deliberately: recording a commit is not a change to the tree, and counting
185
+ // Has anything changed the tree since the last commit drained? Not the same question as
186
+ // hasPendingOps(): every structural read calls flushOps, which empties the buffer many times
187
+ // between commits, so hasPendingOps() alone would answer "no" for unpublished work.
188
+ // OP_COMMIT is excluded deliberately: recording a commit is not a change to the tree, and counting
323
189
  // it would make every commit look like it had work.
324
190
  let changedSinceCommit = false;
325
- function push(op, a = 0, b = 0, c = 0, d = 0, e = 0) {
191
+ function push(op, a = 0, b = 0, c = 0) {
326
192
  if (op !== OP_COMMIT)
327
193
  changedSinceCommit = true;
328
194
  if (opCount + OP_STRIDE > ops.length) {
@@ -334,68 +200,73 @@ function push(op, a = 0, b = 0, c = 0, d = 0, e = 0) {
334
200
  ops[opCount + 1] = a;
335
201
  ops[opCount + 2] = b;
336
202
  ops[opCount + 3] = c;
337
- ops[opCount + 4] = d;
338
- ops[opCount + 5] = e;
203
+ ops[opCount + 4] = 0;
204
+ ops[opCount + 5] = 0;
339
205
  opCount += OP_STRIDE;
340
206
  }
341
- /** Whether a commit would publish anything. See `changedSinceCommit`. */
207
+ // `OP_CREATE_ELEMENT` is the only op with a FOURTH operand, and it writes it itself rather than
208
+ // widening `push` for one call site. A rest parameter or an options bag would allocate per op,
209
+ // which is the one thing this buffer exists to avoid
210
+ function pushFourth(value) {
211
+ ops[opCount - OP_STRIDE + 4] = value;
212
+ }
213
+ // Whether a commit would publish anything. See changedSinceCommit.
342
214
  export function hasChangedSinceCommit() {
343
215
  return changedSinceCommit;
344
216
  }
345
- /**
346
- * Called by `commitSurfaceOps` once it has drained. Separate from `takeBatch` because a READ drains
347
- * too, and a read is not a commit β€” clearing there would make the next commit believe its work had
348
- * already been published.
349
- */
217
+ // Called by commitSurfaceOps once it has drained. Separate from takeBatch because a read drains
218
+ // too, and a read is not a commit β€” clearing there would make the next commit believe its work had
219
+ // already been published.
350
220
  export function noteCommitDrained() {
351
221
  changedSinceCommit = false;
352
222
  }
353
- /**
354
- * The one dirtying route that writes no op: a behavior with a DERIVED payload asks the host to
355
- * rebuild a node the buffer never named. Without this the commit that follows would look idle and
356
- * be skipped, and the derived payload would sit unpublished until something unrelated changed.
357
- */
223
+ // The one dirtying route that writes no op: a behavior with a derived payload asks the host to
224
+ // rebuild a node the buffer never named. Without this the commit that follows would look idle and
225
+ // be skipped, and the derived payload would sit unpublished until something unrelated changed.
358
226
  export function noteHostSideChange() {
359
227
  changedSinceCommit = true;
360
228
  }
361
- // Nodes whose PLACEMENT this batch has not published yet β€” created, or named as the child of a
362
- // structural op.
363
- //
364
- // It exists so a read does not have to drain. `parentOf` is unconditional otherwise, and Angular's
365
- // 1 000-row create measured 1 002 drains for 1 000 reads of which ONE asked about a node the pending
366
- // batch had touched (`adapters/angular/src/read-fragmentation.probe.test.ts`).
367
- //
368
- // THE CLAIM THAT MAKES IT SOUND: a node's parent link changes only through an op that names that
369
- // node AS THE CHILD. `before` on an insert is a position reference and moves nothing; a parent
370
- // argument moves the parent's list, not the parent's own link. So a handle absent from here has the
371
- // same parent in the host as it has here.
372
- //
373
- // Creation is in it because the host cannot answer about a node it has never been told exists, which
374
- // is the other half β€” and the half a "was it re-parented" set alone would get wrong.
375
- //
376
- // NOT A TREE, and that is the line this has to stay on the right side of (`node.ts:2` β€” no parent,
377
- // no children, no mirror). It answers about the BUFFER: is this node's placement unpublished. It is
378
- // emptied by `takeBatch`, so it never outlives one batch and can never disagree with the host.
229
+ // Nodes whose placement this batch has not published yet β€” created, or named as the child of a
230
+ // structural op. Exists so a read doesn't have to drain: parentOf is unconditional otherwise, and
231
+ // most reads never touch a node the pending batch has.
232
+ // Sound because a node's parent link changes only through an op that names that node as the child
233
+ // β€” `before` on an insert is a position reference and moves nothing, so a handle absent from here
234
+ // has the same parent in the host as it has here.
235
+ // Creation is in it too, since the host can't answer about a node it's never been told exists β€” the
236
+ // half a "was it re-parented" set alone would get wrong.
237
+ // Not a tree: it answers about the buffer, whether this node's placement is unpublished. Emptied
238
+ // by takeBatch, so it never outlives one batch and can never disagree with the host.
379
239
  let placementPending = new Set();
380
240
  /** Does the pending batch hold anything that could change what the host says this node's parent is? */
381
241
  export function hasPendingPlacement(handle) {
382
242
  return placementPending.has(handle);
383
243
  }
244
+ // A field rather than a second Set, Ρ‚.ΠΊ. this is read on the create path and a `Set.add` per node
245
+ // would charge the thing it saves. `createdBatch` starts at 0, which no live batch carries
246
+ function noteCreated(handle) {
247
+ placementPending.add(handle);
248
+ handle.createdBatch = batchId;
249
+ }
250
+ /** Has the host never heard of this node, Ρ‚.ΠΊ. the op that creates it is still in the buffer? */
251
+ export function isPendingCreate(handle) {
252
+ return handle.createdBatch === batchId;
253
+ }
384
254
  export function recordCreateElement(handle, viewName, isText, instanceHandle) {
385
255
  instanceHandles.push(instanceHandle);
386
- placementPending.add(handle);
387
- push(OP_CREATE_ELEMENT, slotOf(handle), intern(viewName), isText ? 1 : 0, instanceHandles.length - 1);
256
+ noteCreated(handle);
257
+ push(OP_CREATE_ELEMENT, slotOf(handle), intern(viewName), isText ? 1 : 0);
258
+ pushFourth(instanceHandles.length - 1);
388
259
  }
389
260
  export function recordCreateRawText(handle, text) {
390
- placementPending.add(handle);
261
+ noteCreated(handle);
391
262
  push(OP_CREATE_RAW_TEXT, slotOf(handle), intern(text));
392
263
  }
393
264
  export function recordCreateAnchor(handle) {
394
- placementPending.add(handle);
265
+ noteCreated(handle);
395
266
  push(OP_CREATE_ANCHOR, slotOf(handle));
396
267
  }
397
268
  export function recordCreateVoid(handle) {
398
- placementPending.add(handle);
269
+ noteCreated(handle);
399
270
  push(OP_CREATE_VOID, slotOf(handle));
400
271
  }
401
272
  export function recordAppendChild(parent, child) {
@@ -421,42 +292,24 @@ export function recordSetProp(handle, key, value) {
421
292
  export function recordSetText(handle, text) {
422
293
  push(OP_SET_TEXT, slotOf(handle), intern(text));
423
294
  }
424
- /**
425
- * Change a node's Fabric view name after creation.
426
- *
427
- * It exists for exactly one thing and would not otherwise: `TextInput`'s `multiline` decides between
428
- * `RCTSinglelineTextInputView` and `RCTMultilineTextInputView`, and an app can flip it. No prop write
429
- * moves a node between native views, so the host has to RE-CREATE the node under the new name and
430
- * re-parent its children β€” which is the same path a reparent already takes, and why this needs no
431
- * new machinery on the far side beyond honouring the name.
432
- *
433
- * A JS-only workaround was the alternative and it is the one thing this design rules out: to know
434
- * what to rebuild, JS would have to hold the tree.
435
- */
295
+ // Change a node's Fabric view name after creation. Exists for one thing: TextInput's multiline
296
+ // decides between RCTSinglelineTextInputView and RCTMultilineTextInputView, and an app can flip it.
297
+ // No prop write moves a node between native views, so the host re-creates it under the new name
298
+ // and re-parents its children. A JS-only workaround would need JS to hold the tree β€” the one thing
299
+ // this design rules out.
436
300
  export function recordSetComponent(handle, viewName) {
437
301
  push(OP_SET_COMPONENT, slotOf(handle), intern(viewName));
438
302
  }
439
303
  export function recordSetTag(handle, tag) {
440
304
  push(OP_SET_TAG, slotOf(handle), intern(tag));
441
305
  }
442
- /**
443
- * Whether an app callback is currently wired to an event name the BEHAVIOR owns. `[slot, name,
444
- * present]`, `present` being 1 or 0.
445
- *
446
- * The EXISTENCE, never the function. A name a behavior owns is diverted into a JS stash by
447
- * `setEventListener` and never becomes a prop, so the payload builder sees no trace of it β€” and
448
- * `focusable` on a touchable is `focusable !== false && onPress !== undefined && !disabled`
449
- * (`TouchableOpacity.js:336-339`), two props and one thing only JS knew. This is the one bit that
450
- * closes that gap.
451
- *
452
- * The browser is the argument rather than convenience: a UA computes focusability itself and CAN,
453
- * because `addEventListener` is its own API β€” it knows which elements carry a click handler, while
454
- * the handler's body stays the application's. Same split.
455
- *
456
- * Emitted on a FLIP only, from `setEventListener`, which already refuses to notify on listener
457
- * identity because a framework hands a fresh closure nearly every render. So this is a mount-time
458
- * op, not a per-render one β€” against the per-commit fold it replaces.
459
- */
306
+ // Whether an app callback is currently wired to an event name the behavior owns. [slot, name,
307
+ // present]. The existence, never the function β€” a name a behavior owns is diverted into a JS
308
+ // stash and never becomes a prop, so native needs this bit to compute focusable itself.
309
+ // The browser is the precedent: a UA computes focusability itself because addEventListener is its
310
+ // own API, so it knows which elements carry a handler while the handler's body stays the app's.
311
+ // Emitted on a flip only, from setEventListener, which already ignores listener identity since a
312
+ // framework hands a fresh closure nearly every render β€” a mount-time op, not a per-render one.
460
313
  export function recordSetOwnedListener(handle, name, isPresent) {
461
314
  push(OP_SET_OWNED_LISTENER, slotOf(handle), intern(name), isPresent ? 1 : 0);
462
315
  }
@@ -471,18 +324,11 @@ export function recordCommit(rootTag, surface) {
471
324
  export function hasPendingOps() {
472
325
  return opCount > 0;
473
326
  }
474
- /**
475
- * Drain the buffer.
476
- *
477
- * Fresh arrays rather than reused ones: the batch outlives this call on the native path (`applyOps`
478
- * reads `handles` while attaching state), and a recycled array would be mutated under it by the next
479
- * mutation the adapter makes.
480
- *
481
- * `slice` for the ops and not `subarray` for exactly that reason β€” a subarray would share the
482
- * backing store the very next `push` writes into. It is a typed-array copy rather than the
483
- * element-by-element iterator walk this used to be, and the ops buffer itself is KEPT so its
484
- * capacity survives the drain.
485
- */
327
+ // Drain the buffer. Fresh arrays rather than reused ones: the batch outlives this call on the
328
+ // native path (applyOps reads handles while attaching state), and a recycled array would be
329
+ // mutated under it by the next mutation the adapter makes.
330
+ // slice for the ops, not subarray, for the same reason β€” subarray would share the backing store
331
+ // the very next push writes into. The ops buffer itself is kept so its capacity survives the drain.
486
332
  export function takeBatch() {
487
333
  const batch = {
488
334
  ops: ops.slice(0, opCount),