@symbiote-native/engine 0.5.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -14
- package/android/CMakeLists.txt +51 -0
- package/android/build.gradle +90 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/SymbioteEngineJni.cpp +72 -0
- package/android/src/main/java/dev/symbiotenative/engine/SymbioteEngineModule.kt +43 -0
- package/android/src/main/java/dev/symbiotenative/engine/SymbioteEnginePackage.kt +35 -0
- package/build/accessibility-info/shared.js +1 -1
- package/build/accessibility-props.d.ts +1 -8
- package/build/accessibility-props.js +13 -16
- package/build/animated/animations/composition.d.ts +1 -1
- package/build/animated/animations/composition.js +18 -4
- package/build/animated/easing.d.ts +3 -2
- package/build/animated/easing.js +17 -88
- package/build/animated/event.js +6 -1
- package/build/animated/host-binding.d.ts +1 -1
- package/build/animated/host-binding.js +19 -4
- package/build/animated/index.d.ts +1 -1
- package/build/animated/mock.d.ts +1 -19
- package/build/animated/props.js +1 -1
- package/build/animated/rgba.js +16 -50
- package/build/events/index.js +88 -40
- package/build/fabric-props.d.ts +1 -1
- package/build/fabric-props.js +116 -184
- package/build/fabric.d.ts +9 -0
- package/build/fabric.js +32 -0
- package/build/host-access.d.ts +145 -0
- package/build/host-access.js +315 -0
- package/build/host-behavior.d.ts +84 -21
- package/build/host-behavior.js +236 -51
- package/build/image-source-write.d.ts +16 -0
- package/build/image-source-write.js +65 -0
- package/build/imperative.d.ts +49 -0
- package/build/imperative.js +258 -0
- package/build/index.d.ts +14 -7
- package/build/index.js +53 -10
- package/build/mutation-buffer.d.ts +238 -0
- package/build/mutation-buffer.js +513 -0
- package/build/native-engine.d.ts +185 -0
- package/build/native-engine.js +182 -0
- package/build/native-tree-host.d.ts +25 -0
- package/build/native-tree-host.js +68 -0
- package/build/node.d.ts +195 -58
- package/build/node.js +852 -383
- package/build/pan-responder/index.js +27 -52
- package/build/platform-color/index.d.ts +1 -1
- package/build/platform-color/index.js +11 -4
- package/build/process-background-image/index.js +30 -566
- package/build/process-background-longhands.d.ts +4 -0
- package/build/process-background-longhands.js +44 -0
- package/build/process-box-shadow/index.js +23 -187
- package/build/process-filter.js +27 -300
- package/build/process-transform/index.d.ts +1 -1
- package/build/process-transform/index.js +25 -107
- package/build/process-transform-origin/index.d.ts +1 -1
- package/build/process-transform-origin/index.js +29 -102
- package/build/registry.d.ts +36 -0
- package/build/registry.js +73 -0
- package/build/sound-manager/index.d.ts +3 -0
- package/build/sound-manager/index.js +36 -0
- package/build/structured-style.d.ts +10 -0
- package/build/structured-style.js +180 -0
- package/build/style-registry/index.d.ts +14 -0
- package/build/style-registry/index.js +60 -11
- package/build/surface.d.ts +31 -2
- package/build/surface.js +138 -56
- package/build/text-input-state.d.ts +1 -0
- package/build/text-input-state.js +17 -3
- package/build/tree-host.d.ts +322 -0
- package/build/tree-host.js +211 -0
- package/build/view-config.js +4 -4
- package/codegen-specs/NativeSymbioteEngine.ts +27 -0
- package/cpp/SymbioteDebug.cpp +51 -0
- package/cpp/SymbioteDebug.h +54 -0
- package/cpp/SymbioteEngineBindings.cpp +234 -0
- package/cpp/SymbioteEngineBindings.h +59 -0
- package/cpp/SymbioteFabricProps.cpp +2619 -0
- package/cpp/SymbioteFabricProps.h +223 -0
- package/cpp/SymbioteTree.cpp +2593 -0
- package/cpp/SymbioteTree.h +294 -0
- package/ios/SymbioteEngineModule.h +25 -0
- package/ios/SymbioteEngineModule.mm +44 -0
- package/package.json +31 -3
- package/react-native.config.cjs +23 -0
- package/symbiote-engine.podspec +42 -0
- package/build/animated/bezier.d.ts +0 -1
- package/build/animated/bezier.js +0 -102
- package/build/commit.d.ts +0 -49
- package/build/commit.js +0 -1058
- package/build/tags.d.ts +0 -2
- package/build/tags.js +0 -40
|
@@ -0,0 +1,513 @@
|
|
|
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
|
+
// 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.
|
|
195
|
+
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`.
|
|
200
|
+
const valueIds = new Map();
|
|
201
|
+
// Booleans skip that table entirely — see `internValue`. Two slots, reset with the batch alongside
|
|
202
|
+
// everything else the tables hold.
|
|
203
|
+
const NOT_INTERNED = -1;
|
|
204
|
+
let trueId = NOT_INTERNED;
|
|
205
|
+
let falseId = NOT_INTERNED;
|
|
206
|
+
function intern(text) {
|
|
207
|
+
const existing = stringIds.get(text);
|
|
208
|
+
if (existing !== undefined)
|
|
209
|
+
return existing;
|
|
210
|
+
strings.push(text);
|
|
211
|
+
stringIds.set(text, strings.length - 1);
|
|
212
|
+
return strings.length - 1;
|
|
213
|
+
}
|
|
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
|
+
*/
|
|
239
|
+
function internValue(value) {
|
|
240
|
+
if (value === true) {
|
|
241
|
+
if (trueId === NOT_INTERNED)
|
|
242
|
+
trueId = pushValue(value);
|
|
243
|
+
return trueId;
|
|
244
|
+
}
|
|
245
|
+
if (value === false) {
|
|
246
|
+
if (falseId === NOT_INTERNED)
|
|
247
|
+
falseId = pushValue(value);
|
|
248
|
+
return falseId;
|
|
249
|
+
}
|
|
250
|
+
const kind = typeof value;
|
|
251
|
+
const isWorthInterning = kind === 'string' ||
|
|
252
|
+
kind === 'function' ||
|
|
253
|
+
(kind === 'object' && value !== null);
|
|
254
|
+
if (!isWorthInterning)
|
|
255
|
+
return pushValue(value);
|
|
256
|
+
const existing = valueIds.get(value);
|
|
257
|
+
if (existing !== undefined)
|
|
258
|
+
return existing;
|
|
259
|
+
const id = pushValue(value);
|
|
260
|
+
valueIds.set(value, id);
|
|
261
|
+
return id;
|
|
262
|
+
}
|
|
263
|
+
function pushValue(value) {
|
|
264
|
+
values.push(value);
|
|
265
|
+
return values.length - 1;
|
|
266
|
+
}
|
|
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
|
+
*/
|
|
290
|
+
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.
|
|
302
|
+
if (typeof handle !== 'object' || handle === null) {
|
|
303
|
+
throw new Error(`symbiote engine: a mutation named ${String(handle)}, which is not a node. An adapter is ` +
|
|
304
|
+
`passing a framework sentinel straight through — an absent insert anchor is spelled by ` +
|
|
305
|
+
`calling appendChild.`);
|
|
306
|
+
}
|
|
307
|
+
if (handle.slotBatch === batchId)
|
|
308
|
+
return handle.slot;
|
|
309
|
+
handles.push(handle);
|
|
310
|
+
handle.slot = handles.length - 1;
|
|
311
|
+
handle.slotBatch = batchId;
|
|
312
|
+
return handle.slot;
|
|
313
|
+
}
|
|
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
|
|
323
|
+
// it would make every commit look like it had work.
|
|
324
|
+
let changedSinceCommit = false;
|
|
325
|
+
function push(op, a = 0, b = 0, c = 0, d = 0, e = 0) {
|
|
326
|
+
if (op !== OP_COMMIT)
|
|
327
|
+
changedSinceCommit = true;
|
|
328
|
+
if (opCount + OP_STRIDE > ops.length) {
|
|
329
|
+
const grown = new Int32Array(ops.length * 2);
|
|
330
|
+
grown.set(ops);
|
|
331
|
+
ops = grown;
|
|
332
|
+
}
|
|
333
|
+
ops[opCount] = op;
|
|
334
|
+
ops[opCount + 1] = a;
|
|
335
|
+
ops[opCount + 2] = b;
|
|
336
|
+
ops[opCount + 3] = c;
|
|
337
|
+
ops[opCount + 4] = d;
|
|
338
|
+
ops[opCount + 5] = e;
|
|
339
|
+
opCount += OP_STRIDE;
|
|
340
|
+
}
|
|
341
|
+
/** Whether a commit would publish anything. See `changedSinceCommit`. */
|
|
342
|
+
export function hasChangedSinceCommit() {
|
|
343
|
+
return changedSinceCommit;
|
|
344
|
+
}
|
|
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
|
+
*/
|
|
350
|
+
export function noteCommitDrained() {
|
|
351
|
+
changedSinceCommit = false;
|
|
352
|
+
}
|
|
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
|
+
*/
|
|
358
|
+
export function noteHostSideChange() {
|
|
359
|
+
changedSinceCommit = true;
|
|
360
|
+
}
|
|
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.
|
|
379
|
+
let placementPending = new Set();
|
|
380
|
+
/** Does the pending batch hold anything that could change what the host says this node's parent is? */
|
|
381
|
+
export function hasPendingPlacement(handle) {
|
|
382
|
+
return placementPending.has(handle);
|
|
383
|
+
}
|
|
384
|
+
export function recordCreateElement(handle, viewName, isText, instanceHandle) {
|
|
385
|
+
instanceHandles.push(instanceHandle);
|
|
386
|
+
placementPending.add(handle);
|
|
387
|
+
push(OP_CREATE_ELEMENT, slotOf(handle), intern(viewName), isText ? 1 : 0, instanceHandles.length - 1);
|
|
388
|
+
}
|
|
389
|
+
export function recordCreateRawText(handle, text) {
|
|
390
|
+
placementPending.add(handle);
|
|
391
|
+
push(OP_CREATE_RAW_TEXT, slotOf(handle), intern(text));
|
|
392
|
+
}
|
|
393
|
+
export function recordCreateAnchor(handle) {
|
|
394
|
+
placementPending.add(handle);
|
|
395
|
+
push(OP_CREATE_ANCHOR, slotOf(handle));
|
|
396
|
+
}
|
|
397
|
+
export function recordCreateVoid(handle) {
|
|
398
|
+
placementPending.add(handle);
|
|
399
|
+
push(OP_CREATE_VOID, slotOf(handle));
|
|
400
|
+
}
|
|
401
|
+
export function recordAppendChild(parent, child) {
|
|
402
|
+
placementPending.add(child);
|
|
403
|
+
push(OP_APPEND_CHILD, slotOf(parent), slotOf(child));
|
|
404
|
+
}
|
|
405
|
+
export function recordInsertBefore(parent, child, before) {
|
|
406
|
+
placementPending.add(child);
|
|
407
|
+
push(OP_INSERT_BEFORE, slotOf(parent), slotOf(child), slotOf(before));
|
|
408
|
+
}
|
|
409
|
+
export function recordRemoveChild(parent, child) {
|
|
410
|
+
placementPending.add(child);
|
|
411
|
+
push(OP_REMOVE_CHILD, slotOf(parent), slotOf(child));
|
|
412
|
+
}
|
|
413
|
+
/** `undefined` DELETES the key — the collapse `setProp` has always performed, spelled on the wire. */
|
|
414
|
+
export function recordSetProp(handle, key, value) {
|
|
415
|
+
if (value === undefined) {
|
|
416
|
+
push(OP_SET_PROP, slotOf(handle), intern(key), NO_VALUE);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
push(OP_SET_PROP, slotOf(handle), intern(key), internValue(value));
|
|
420
|
+
}
|
|
421
|
+
export function recordSetText(handle, text) {
|
|
422
|
+
push(OP_SET_TEXT, slotOf(handle), intern(text));
|
|
423
|
+
}
|
|
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
|
+
*/
|
|
436
|
+
export function recordSetComponent(handle, viewName) {
|
|
437
|
+
push(OP_SET_COMPONENT, slotOf(handle), intern(viewName));
|
|
438
|
+
}
|
|
439
|
+
export function recordSetTag(handle, tag) {
|
|
440
|
+
push(OP_SET_TAG, slotOf(handle), intern(tag));
|
|
441
|
+
}
|
|
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
|
+
*/
|
|
460
|
+
export function recordSetOwnedListener(handle, name, isPresent) {
|
|
461
|
+
push(OP_SET_OWNED_LISTENER, slotOf(handle), intern(name), isPresent ? 1 : 0);
|
|
462
|
+
}
|
|
463
|
+
/** See `OP_SET_UNDERLAY_SHOWN`. Emitted on a flip only, from the behavior that owns the timer. */
|
|
464
|
+
export function recordSetUnderlayShown(handle, shown) {
|
|
465
|
+
push(OP_SET_UNDERLAY_SHOWN, slotOf(handle), shown ? 1 : 0);
|
|
466
|
+
}
|
|
467
|
+
export function recordCommit(rootTag, surface) {
|
|
468
|
+
push(OP_COMMIT, rootTag, slotOf(surface));
|
|
469
|
+
}
|
|
470
|
+
/** Whether anything is pending. The commit path asks before paying for a drain. */
|
|
471
|
+
export function hasPendingOps() {
|
|
472
|
+
return opCount > 0;
|
|
473
|
+
}
|
|
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
|
+
*/
|
|
486
|
+
export function takeBatch() {
|
|
487
|
+
const batch = {
|
|
488
|
+
ops: ops.slice(0, opCount),
|
|
489
|
+
strings,
|
|
490
|
+
values,
|
|
491
|
+
instanceHandles,
|
|
492
|
+
handles,
|
|
493
|
+
};
|
|
494
|
+
opCount = 0;
|
|
495
|
+
strings = [];
|
|
496
|
+
values = [];
|
|
497
|
+
instanceHandles = [];
|
|
498
|
+
handles = [];
|
|
499
|
+
// A fresh Set rather than `.clear()`: the old one is handed to nobody, and clearing a set that
|
|
500
|
+
// held ten thousand handles on a benchmark create costs more than dropping it.
|
|
501
|
+
placementPending = new Set();
|
|
502
|
+
stringIds.clear();
|
|
503
|
+
// Every slot standing on a handle dies here, without touching one of them — see `slotOf`.
|
|
504
|
+
batchId += 1;
|
|
505
|
+
valueIds.clear();
|
|
506
|
+
trueId = NOT_INTERNED;
|
|
507
|
+
falseId = NOT_INTERNED;
|
|
508
|
+
return batch;
|
|
509
|
+
}
|
|
510
|
+
/** Test seam. Drops everything pending without applying it. */
|
|
511
|
+
export function resetMutationBuffer() {
|
|
512
|
+
takeBatch();
|
|
513
|
+
}
|