@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.
Files changed (91) 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 -8
  10. package/build/accessibility-props.js +13 -16
  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/host-binding.d.ts +1 -1
  17. package/build/animated/host-binding.js +19 -4
  18. package/build/animated/index.d.ts +1 -1
  19. package/build/animated/mock.d.ts +1 -19
  20. package/build/animated/props.js +1 -1
  21. package/build/animated/rgba.js +16 -50
  22. package/build/events/index.js +88 -40
  23. package/build/fabric-props.d.ts +1 -1
  24. package/build/fabric-props.js +116 -184
  25. package/build/fabric.d.ts +9 -0
  26. package/build/fabric.js +32 -0
  27. package/build/host-access.d.ts +145 -0
  28. package/build/host-access.js +315 -0
  29. package/build/host-behavior.d.ts +84 -21
  30. package/build/host-behavior.js +236 -51
  31. package/build/image-source-write.d.ts +16 -0
  32. package/build/image-source-write.js +65 -0
  33. package/build/imperative.d.ts +49 -0
  34. package/build/imperative.js +258 -0
  35. package/build/index.d.ts +14 -7
  36. package/build/index.js +53 -10
  37. package/build/mutation-buffer.d.ts +238 -0
  38. package/build/mutation-buffer.js +513 -0
  39. package/build/native-engine.d.ts +185 -0
  40. package/build/native-engine.js +182 -0
  41. package/build/native-tree-host.d.ts +25 -0
  42. package/build/native-tree-host.js +68 -0
  43. package/build/node.d.ts +195 -58
  44. package/build/node.js +852 -383
  45. package/build/pan-responder/index.js +27 -52
  46. package/build/platform-color/index.d.ts +1 -1
  47. package/build/platform-color/index.js +11 -4
  48. package/build/process-background-image/index.js +30 -566
  49. package/build/process-background-longhands.d.ts +4 -0
  50. package/build/process-background-longhands.js +44 -0
  51. package/build/process-box-shadow/index.js +23 -187
  52. package/build/process-filter.js +27 -300
  53. package/build/process-transform/index.d.ts +1 -1
  54. package/build/process-transform/index.js +25 -107
  55. package/build/process-transform-origin/index.d.ts +1 -1
  56. package/build/process-transform-origin/index.js +29 -102
  57. package/build/registry.d.ts +36 -0
  58. package/build/registry.js +73 -0
  59. package/build/sound-manager/index.d.ts +3 -0
  60. package/build/sound-manager/index.js +36 -0
  61. package/build/structured-style.d.ts +10 -0
  62. package/build/structured-style.js +180 -0
  63. package/build/style-registry/index.d.ts +14 -0
  64. package/build/style-registry/index.js +60 -11
  65. package/build/surface.d.ts +31 -2
  66. package/build/surface.js +138 -56
  67. package/build/text-input-state.d.ts +1 -0
  68. package/build/text-input-state.js +17 -3
  69. package/build/tree-host.d.ts +322 -0
  70. package/build/tree-host.js +211 -0
  71. package/build/view-config.js +4 -4
  72. package/codegen-specs/NativeSymbioteEngine.ts +27 -0
  73. package/cpp/SymbioteDebug.cpp +51 -0
  74. package/cpp/SymbioteDebug.h +54 -0
  75. package/cpp/SymbioteEngineBindings.cpp +234 -0
  76. package/cpp/SymbioteEngineBindings.h +59 -0
  77. package/cpp/SymbioteFabricProps.cpp +2619 -0
  78. package/cpp/SymbioteFabricProps.h +223 -0
  79. package/cpp/SymbioteTree.cpp +2593 -0
  80. package/cpp/SymbioteTree.h +294 -0
  81. package/ios/SymbioteEngineModule.h +25 -0
  82. package/ios/SymbioteEngineModule.mm +44 -0
  83. package/package.json +31 -3
  84. package/react-native.config.cjs +23 -0
  85. package/symbiote-engine.podspec +42 -0
  86. package/build/animated/bezier.d.ts +0 -1
  87. package/build/animated/bezier.js +0 -102
  88. package/build/commit.d.ts +0 -49
  89. package/build/commit.js +0 -1058
  90. package/build/tags.d.ts +0 -2
  91. package/build/tags.js +0 -40
@@ -0,0 +1,322 @@
1
+ import type { IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, IMeasureOnSuccess, IRootTag } from './fabric';
2
+ import { type IMutationBatch, type IMutationHandle } from './mutation-buffer';
3
+ /** What Fabric currently holds for one node — the three fields every imperative call is aimed at. */
4
+ export type ICommittedRecord = {
5
+ /**
6
+ * OPAQUE, and typed that way because it is: each host puts its own thing here — the native host a
7
+ * `ShadowNode`, a headless one whatever it committed — and the field's only contract is identity.
8
+ * It used to say `IFabricNode`, which promised a Fabric node from every host and was true of one.
9
+ * `IFabricNode` is a brand with no members, so nothing a caller could do with it is lost.
10
+ */
11
+ handle: object;
12
+ tag: number;
13
+ rootTag: IRootTag;
14
+ };
15
+ export interface ITreeCensus {
16
+ nodes: number;
17
+ anchors: number;
18
+ emptyRawTexts: number;
19
+ /** Nodes that actually become a Fabric view: `nodes` minus everything the commit skips. */
20
+ renderable: number;
21
+ /** children.length of every parent holding at least one skipped child, widest first. */
22
+ flattenWidths: number[];
23
+ }
24
+ /**
25
+ * The census of a tree nobody counted — what a host with no walk of its own answers, and what
26
+ * `censusRetainedTree` answers when no host is installed at all.
27
+ *
28
+ * Not exported from the package barrel: it is a HOST-author constant, and an app reading a census
29
+ * asserts against a mounted tree, where a zero from here cannot be mistaken for a zero from a real
30
+ * empty tree.
31
+ */
32
+ export declare const EMPTY_CENSUS: ITreeCensus;
33
+ /**
34
+ * Everything JS asks of the tree it no longer owns.
35
+ *
36
+ * No read here is on a commit path — they run at GESTURE or lifecycle rate (a host behavior seeing
37
+ * the props it reacts to, an app measuring a ref, a framework seam navigating what it just built).
38
+ * This comment used to conclude from that that the crossing cost was irrelevant, at "~10 reads per
39
+ * touch". Counted, it was 18 per EVENT and 19 per drag FRAME — 60 times a second for as long as a
40
+ * finger is down — because a gesture is not one touch and a walk that asks per level pays per
41
+ * level. Both are 1 now.
42
+ *
43
+ * `parentsOf`, `subtreesOf` and `ancestorsOf` are what that cost: each answers exactly what its
44
+ * singular twin answers, in ONE crossing, for the three walks whose size is the TREE's rather than
45
+ * a node's — teardown down, dispatch and responder negotiation up.
46
+ */
47
+ export type ITreeHost = {
48
+ applyOps: (batch: IMutationBatch) => void;
49
+ propOf: (handle: object, key: string) => unknown;
50
+ propsOf: (handle: object) => Readonly<Record<string, unknown>>;
51
+ markPropsDirty: (handle: object) => void;
52
+ committedRecordOf: (handle: object) => ICommittedRecord | undefined;
53
+ /**
54
+ * A TEST read: the PAYLOAD the last commit handed Fabric for this node, `undefined` before one.
55
+ *
56
+ * It exists because the alternative reads are both blind. `Props::getDebugProps()` is a
57
+ * hand-written selection per component — `RCTSinglelineTextInputView` reports `testID` and nothing
58
+ * else — and RN's complete `Props::rawProps` needs `RN_SERIALIZABLE_STATE`, which pulls fbjni into
59
+ * `State` and does not compile on a host build. So the rules in `SymbioteFabricProps.cpp` were
60
+ * verifiable only through their TypeScript twins, which is the drift this read closes.
61
+ *
62
+ * Not on any commit path, and it adds no bookkeeping: the bag is already retained per node as the
63
+ * next commit's diff baseline.
64
+ *
65
+ * It answers what we SENT, not what Fabric parsed — a key no ViewConfig declares is still in here.
66
+ */
67
+ committedPayloadOf: (handle: object) => Readonly<Record<string, unknown>> | undefined;
68
+ parentOf: (handle: object) => object | undefined;
69
+ childrenOf: (handle: object) => readonly object[];
70
+ firstChildOf: (handle: object) => object | undefined;
71
+ nextSiblingOf: (handle: object) => object | undefined;
72
+ parentsOf: (handles: readonly object[]) => readonly (object | undefined)[];
73
+ /** Each root and every descendant, PRE-ORDER, concatenated in root order. */
74
+ subtreesOf: (roots: readonly object[]) => readonly object[];
75
+ /**
76
+ * The same walk, narrowed to the nodes a TEARDOWN has work for — each root, every node carrying
77
+ * an intrinsic tag, and every node between the two.
78
+ *
79
+ * It exists because the sweep's whole cost is how WIDE this walk is: a thousand-row clear crossed
80
+ * ten thousand handles to release a thousand machines, and eight in ten of those nodes were plain
81
+ * views the sweep marked and did nothing else with. An ancestor of a tagged node has to come back
82
+ * too, or a framework that returns an interior node on its own would never re-arm what hangs
83
+ * beneath it (`host-behavior.test.ts` guards exactly that shape).
84
+ *
85
+ * Not a replacement for `subtreesOf`: an animated binding is per node and carries no tag, so
86
+ * `host-access.ts` asks for the full walk whenever one exists.
87
+ */
88
+ teardownSubtreesOf: (roots: readonly object[]) => readonly object[];
89
+ /**
90
+ * The node itself and every ancestor above it, DEEPEST FIRST.
91
+ *
92
+ * The upward twin of `subtreesOf`, and it exists for the same reason: a walk that asks per LEVEL
93
+ * pays a crossing per level. Event dispatch needs this chain for every event — capture reads it
94
+ * reversed, bubble reads it forward — and the responder negotiation needs it again on every frame
95
+ * of every drag. Measured before it existed: 18 crossings per event on a depth-8 chain, then 9
96
+ * once the two phases shared one walk, against the 1 an answer from here costs.
97
+ *
98
+ * A SURFACE is included, exactly as `parentOf`'s answer is — stopping at one is
99
+ * `host-access.ts`'s job, and it does it by reading the answer's `component`.
100
+ */
101
+ ancestorsOf: (handle: object) => readonly object[];
102
+ census: (roots: readonly object[]) => ITreeCensus;
103
+ dispatchCommand: (handle: object, commandName: string, args: readonly unknown[]) => void;
104
+ sendAccessibilityEvent: (handle: object, eventType: string) => void;
105
+ measure: (handle: object, callback: IMeasureOnSuccess) => void;
106
+ measureInWindow: (handle: object, callback: IMeasureInWindowOnSuccess) => void;
107
+ measureLayout: (handle: object, relativeTo: object, onFail: () => void, onSuccess: IMeasureLayoutOnSuccess) => void;
108
+ setIsJSResponder: (handle: object, isResponder: boolean, blockNativeResponder: boolean) => void;
109
+ };
110
+ /**
111
+ * Install the tree host. `installFabric()` (test-utils) calls this with the TypeScript applier; the
112
+ * native module installs itself the same way once its bindings carry these reads.
113
+ *
114
+ * Passing `undefined` uninstalls, so a fixture can prove a path degrades rather than throws.
115
+ */
116
+ export declare function setTreeHost(next: ITreeHost | undefined): void;
117
+ /** The installed host, or `undefined` on a runtime that has none. */
118
+ export declare function treeHost(): ITreeHost | undefined;
119
+ export declare function registerBeforeFlush(listener: () => void): () => void;
120
+ /**
121
+ * Collect what the listeners are holding, WITHOUT draining.
122
+ *
123
+ * Separate from `flushOps` because a read may legitimately decide it needs no drain — `parentOf`
124
+ * skips one for a node whose placement is not pending — and skipping the drain must not also skip
125
+ * asking. A held write is still a write, and a reader that cannot see it is reading a stale tree.
126
+ */
127
+ export declare function settleBeforeFlush(): void;
128
+ export declare function flushOps(): void;
129
+ /**
130
+ * Record a surface's commit and drain the buffer into the host.
131
+ *
132
+ * A SURFACE IS AN ANCHOR: an anchor is a node whose children belong to its parent's list, and a
133
+ * surface is a node whose children belong to the root child set. Same shape, which is why `OP_COMMIT`
134
+ * names one node and the host needs no `rootTag -> map`.
135
+ *
136
+ * `others` is every OTHER live surface, and it exists because the buffer is GLOBAL while a commit
137
+ * names ONE root. A framework can mutate a tree that belongs to a different surface than the one
138
+ * whose renderer it is holding — a portal, a tunnel, any cross-surface move — and every adapter
139
+ * then asks its OWN surface to commit. The ops reach the host either way, so the other surface is
140
+ * left correctly updated in the tree and never handed to `completeRoot`: a stale Fabric root that
141
+ * nothing will refresh until something unrelated dirties it. Naming every root here is what makes a
142
+ * commit mean "flush what changed" rather than "flush the surface I happen to be bound to".
143
+ *
144
+ * A single-surface app — every example, and the overwhelming case — passes an empty list and the
145
+ * behaviour is byte-identical to naming only itself.
146
+ */
147
+ export declare function commitSurfaceOps(rootTag: IRootTag | undefined, surface: IMutationHandle, others?: readonly (readonly [IRootTag, IMutationHandle])[]): void;
148
+ export interface ICommitProfile {
149
+ commits: number;
150
+ propWrites: number;
151
+ }
152
+ export declare function readCommitProfile(): ICommitProfile;
153
+ export type ISurfaceTelemetry = {
154
+ layoutMs: number;
155
+ textMs: number;
156
+ /**
157
+ * `ShadowTree::commit`'s own window — and **NOT** `materialize`'s.
158
+ *
159
+ * This field's doc used to claim it was the clone-on-write walk, and F-80/F-81/F-82 each read it
160
+ * that way and concluded the native pipeline was too small to matter. `materialize` runs inside
161
+ * `kOpCommit` BEFORE `completeSurface` is called, so it is outside both this window and layout's.
162
+ * Pricing our own walk means timing `applyOps` from JS and subtracting these two.
163
+ */
164
+ commitMs: number;
165
+ layoutNodes: number;
166
+ textMeasures: number;
167
+ /**
168
+ * How many parents took the targeted-replace path since the last read, zeroed on read.
169
+ *
170
+ * OURS, not React Native's. It is a LIVENESS signal, not a performance one: every test in this
171
+ * repository stays green when `canReplaceInPlace` is off, which is how it spent eighteen months
172
+ * disabled. Assert it is non-zero wherever the fast path is the point of the test.
173
+ */
174
+ targetedReplaces: number;
175
+ /**
176
+ * `materialize`'s own walk, and the fields below break it down. OURS, zeroed on read.
177
+ *
178
+ * The doc above says the walk falls outside every window React Native times, which left it
179
+ * priceable only by subtraction — and a subtraction gives a budget, not an address. Measured
180
+ * 2026-09-17: ~200 ms of a 327 ms headless create sat here with nothing inside it named.
181
+ *
182
+ * `walkMs` is the single entry point in `kOpCommit`; `propsMs` / `rawPropsMs` / `createNodeMs` /
183
+ * `appendChildMs` / `diffPropsMs` are per-node sums inside it and do NOT add up to it — what is
184
+ * left over is the walk's own bookkeeping.
185
+ */
186
+ walkMs: number;
187
+ /** `fabricProps` alone, on both the create and the clone path. The fold LOOKUP is billed apart. */
188
+ propsMs: number;
189
+ /**
190
+ * Asking a node whether it carries a `payloadFold`: a JSI property read, and on a hit a
191
+ * `jsi::Function` allocation. The fold's own CALL is inside `propsMs`, where `fabricProps` makes
192
+ * it. Separated because the two answer different questions — how big the payload is, against how
193
+ * much the seam to JS costs to reach.
194
+ */
195
+ foldLookupMs: number;
196
+ /** How many nodes the lookup found one on. Zero makes `foldLookupMs` pure probe cost. */
197
+ foldsFound: number;
198
+ /**
199
+ * Inside a fold that runs, split three ways because a fold's CONTRACT is bag in, bag out: both
200
+ * conversions walk every key of the node whatever the fold actually reads. If the conversions
201
+ * dominate, the fix is a narrower contract; if `foldCallMs` does, the fix is not having a fold.
202
+ */
203
+ foldToJsMs: number;
204
+ foldCallMs: number;
205
+ foldFromJsMs: number;
206
+ /** The payload copy Fabric consumes, kept because `committedProps` is next commit's baseline. */
207
+ rawPropsMs: number;
208
+ createNodeMs: number;
209
+ appendChildMs: number;
210
+ diffPropsMs: number;
211
+ /** Nodes that minted a fresh Fabric family, were cloned, or were returned untouched. */
212
+ nodesCreated: number;
213
+ nodesCloned: number;
214
+ nodesReused: number;
215
+ /**
216
+ * `applyOps`' own decode, per created element, and its three biggest parts.
217
+ *
218
+ * A different question from the walk's. The walk asks what Fabric charges; this asks what it costs
219
+ * US to turn one op into one node — and the buffer architecture only pays for itself if that is
220
+ * well under the per-node JSI call it replaces. On `build-release` it was not, which is why these
221
+ * exist. `publishMs` contains `nativeStateMs`; neither contains `instanceHandleMs`.
222
+ */
223
+ decodeMs: number;
224
+ instanceHandleMs: number;
225
+ publishMs: number;
226
+ nativeStateMs: number;
227
+ nodesDecoded: number;
228
+ /**
229
+ * `kOpSetProp` and, inside it, the JS value -> `folly::dynamic` conversion.
230
+ *
231
+ * `setPropMs` skips the two early exits (deleting an absent key, and a value equal to the one
232
+ * standing), so it under-counts exactly the cheap paths; `propConvertMs` has no such hole.
233
+ */
234
+ setPropMs: number;
235
+ propConvertMs: number;
236
+ setProps: number;
237
+ /**
238
+ * The two `setProp` ops that changed nothing, counted apart because they are not the same waste.
239
+ *
240
+ * `deletesOfAbsent` leaves before the value conversion and costs a hash lookup. `writesOfUnchanged`
241
+ * leaves AFTER it, so the adapter has already paid the JSI -> `folly::dynamic` crossing for a value
242
+ * that changes nothing — that is the expensive one, and it is what the device benchmark's
243
+ * `WRITES n/m` second figure reports.
244
+ */
245
+ deletesOfAbsent: number;
246
+ writesOfUnchanged: number;
247
+ /**
248
+ * How well the buffer's value interning worked: entries in the batch's value table, and how many
249
+ * of them an op actually reached and converted.
250
+ *
251
+ * `setProps / valueEntries` is the dedup achieved. A ratio near 1 means the values are unique —
252
+ * which is a fact about what the caller HANDS the buffer, not about the interning: a style slot
253
+ * rebuilt per node arrives as a fresh reference and cannot be folded with anything.
254
+ */
255
+ valueEntries: number;
256
+ valueConversions: number;
257
+ /**
258
+ * `applyOps` end to end, plus the two parts of it that are neither a create nor a prop write.
259
+ *
260
+ * `applyMs` is the whole native call, so `applyMs` minus `decodeMs` / `setPropMs` /
261
+ * `stringDecodeMs` / `structureMs` is what the op loop itself costs — the books close here.
262
+ */
263
+ applyMs: number;
264
+ stringDecodeMs: number;
265
+ /** Every append / insert / remove op together. */
266
+ structureMs: number;
267
+ /** Inside `structureMs`: promoting a node's weak handle reference to a strong one. */
268
+ holdHandleMs: number;
269
+ /**
270
+ * `subtreesOf` — the batched host read the teardown sweep makes, and how many handles it returned.
271
+ *
272
+ * Not on a commit path and timed anyway: it hands JS a handle for every node in every removed
273
+ * subtree, which on a 1 000-row clear is ten thousand. Whether that time is the crossing or the JS
274
+ * loop above it decides whether the torn-down mark is worth moving into C++.
275
+ */
276
+ hostReadMs: number;
277
+ hostReadHandles: number;
278
+ /**
279
+ * How many times `applyOps` was entered since the last read.
280
+ *
281
+ * The string and value tables are interned PER BATCH, so a driver that flushes in many small
282
+ * batches cannot fold a repeated value across them. This is what distinguishes "this adapter sends
283
+ * more values" from "this adapter sends the same values in more batches" — two very different
284
+ * findings that look identical in `valueEntries` alone.
285
+ */
286
+ applyCalls: number;
287
+ };
288
+ /**
289
+ * RN's own commit telemetry for a surface THIS HOST NEED NOT HAVE DRIVEN, or `undefined` when the
290
+ * runtime cannot answer.
291
+ *
292
+ * It exists for one comparison and should not be reached for anything else: `readCommitProfile`
293
+ * accumulates inside our own commit, so it can only ever describe a tree we built, and the standing
294
+ * open question is whether a tree-wide text re-measure is something we cause or something a Fabric
295
+ * commit costs whoever drives it. Point this at a surface React Native's OWN renderer committed and
296
+ * the two numbers are directly comparable — same device, same RN, same tree.
297
+ *
298
+ * `undefined` rather than zeroes on a runtime without the binding, deliberately: zeroes would read
299
+ * as "the other renderer measures no text", which is precisely the claim under test.
300
+ */
301
+ export declare function readSurfaceTelemetry(surfaceId: number): ISurfaceTelemetry | undefined;
302
+ /**
303
+ * Arm the C++ half's diagnostics (`SymbioteDebug.h`), which `installBindings` already did from
304
+ * `DEBUG=1` / `globalThis.__SYMBIOTE_DEBUG__` at install.
305
+ *
306
+ * This is for the LATER toggle — the runtime escape hatch `debug.ts` documents for hosts where the
307
+ * env is not reachable. Without it a `globalThis.__SYMBIOTE_DEBUG__ = true` typed into a running app
308
+ * would flip the JS half and silently leave the engine's own half dark, which is the surprise worth
309
+ * the six lines.
310
+ */
311
+ export declare function setNativeDebug(enabled: boolean): void;
312
+ /**
313
+ * Drain what the C++ half has logged since the last call.
314
+ *
315
+ * The reason those lines are retained at all rather than only written to stderr: a diagnostic nobody
316
+ * can assert on is one that rots. This is what lets a test say "the engine warned about that" —
317
+ * see `core/engine/cpp/tests/js/native-debug-log.itest.ts`.
318
+ *
319
+ * An empty array on a runtime without the binding, not `undefined`: the question "what was logged"
320
+ * has an honest empty answer, unlike the telemetry read above, where a zero would be a false claim.
321
+ */
322
+ export declare function takeNativeDebugLog(): readonly string[];
@@ -0,0 +1,211 @@
1
+ // The TREE HOST seam — the one place JS asks about a tree it does not hold.
2
+ //
3
+ // `@symbiote-native/engine` keeps NO tree. Every adapter mutation appends an opcode to
4
+ // `mutation-buffer.ts`, and turning that buffer into a tree is the HOST's job: native on device, and
5
+ // headlessly the TypeScript applier in `@symbiote-native/test-utils`, installed by `installFabric()`.
6
+ // That applier lives there for the same reason the fake `nativeFabricUIManager` does — it is a JS
7
+ // stand-in for a native thing, and `core/test-utils` is a devDependency of the adapters rather than a
8
+ // runtime one, so nothing an app loads contains a JS tree.
9
+ //
10
+ // Shaped after `fabric.ts`'s slot seam on purpose: a resolver plus a test-time installer.
11
+ //
12
+ // `undefined` / empty is an ORDINARY answer from every read here, not an error — the same contract
13
+ // `committedRecordOf` already carried. A runtime with no host, a node the host has not seen, and a
14
+ // genuinely absent value are indistinguishable to a caller, and all three degrade.
15
+ import { hasChangedSinceCommit, hasPendingOps, noteCommitDrained, recordCommit, takeBatch, } from './mutation-buffer.js';
16
+ import { nativeEngine } from './native-engine.js';
17
+ import { takePropStats } from './node.js';
18
+ /**
19
+ * The census of a tree nobody counted — what a host with no walk of its own answers, and what
20
+ * `censusRetainedTree` answers when no host is installed at all.
21
+ *
22
+ * Not exported from the package barrel: it is a HOST-author constant, and an app reading a census
23
+ * asserts against a mounted tree, where a zero from here cannot be mistaken for a zero from a real
24
+ * empty tree.
25
+ */
26
+ export const EMPTY_CENSUS = {
27
+ nodes: 0,
28
+ anchors: 0,
29
+ emptyRawTexts: 0,
30
+ renderable: 0,
31
+ flattenWidths: [],
32
+ };
33
+ let host;
34
+ /**
35
+ * Install the tree host. `installFabric()` (test-utils) calls this with the TypeScript applier; the
36
+ * native module installs itself the same way once its bindings carry these reads.
37
+ *
38
+ * Passing `undefined` uninstalls, so a fixture can prove a path degrades rather than throws.
39
+ */
40
+ export function setTreeHost(next) {
41
+ host = next;
42
+ }
43
+ /** The installed host, or `undefined` on a runtime that has none. */
44
+ export function treeHost() {
45
+ return host;
46
+ }
47
+ /**
48
+ * Push everything recorded since the last apply into the host.
49
+ *
50
+ * Every READ goes through here first. A reconciler navigates the tree it is mid-way through
51
+ * BUILDING — Vue, Solid, Svelte and Angular all ask for a parent or a sibling between mutations and
52
+ * long before the commit — so a host that only learned of ops at `completeRoot` would answer about a
53
+ * tree several operations stale. The ops are structural, so applying them early costs nothing: only
54
+ * `OP_COMMIT` reaches Fabric, and one is recorded solely by `commitSurfaceOps` below, immediately
55
+ * before its own drain.
56
+ */
57
+ /**
58
+ * Adapters that COALESCE writes, given the last moment to record what they are holding.
59
+ *
60
+ * An adapter cannot always publish a write the instant its framework hands it over. Angular's
61
+ * styling engine has no whole-value call — `ɵɵstyleMap` delivers one key per `Renderer2.setStyle`
62
+ * — so the renderer accumulates the run and writes RN's one `style` prop once. That accumulator has
63
+ * to be emptied before anything can observe the tree, and the adapter cannot know when that is: a
64
+ * read and a commit both arrive from elsewhere.
65
+ *
66
+ * Both of them come through `flushOps`, which is what makes this the right seam and a cheap one — it
67
+ * is the single door in front of every drain, `commit` included.
68
+ *
69
+ * NOT A COMMIT HOOK. It fires before every read as well, so a listener must be idempotent and must
70
+ * do nothing when it holds nothing. It runs BEFORE the `hasPendingOps` check on purpose: a listener
71
+ * holding a write has ops that are not in the buffer yet, so an empty buffer is no reason to skip it.
72
+ */
73
+ const beforeFlush = new Set();
74
+ export function registerBeforeFlush(listener) {
75
+ beforeFlush.add(listener);
76
+ return () => beforeFlush.delete(listener);
77
+ }
78
+ // A listener records ops, and `routeProp` can reach a read on the way — which would re-enter here
79
+ // and ask the same listener for what it has already handed over. One flag rather than per-listener
80
+ // bookkeeping: the whole set is being drained, and re-entering any of it is the same mistake.
81
+ let settling = false;
82
+ /**
83
+ * Collect what the listeners are holding, WITHOUT draining.
84
+ *
85
+ * Separate from `flushOps` because a read may legitimately decide it needs no drain — `parentOf`
86
+ * skips one for a node whose placement is not pending — and skipping the drain must not also skip
87
+ * asking. A held write is still a write, and a reader that cannot see it is reading a stale tree.
88
+ */
89
+ export function settleBeforeFlush() {
90
+ if (settling || beforeFlush.size === 0)
91
+ return;
92
+ settling = true;
93
+ try {
94
+ for (const listener of beforeFlush)
95
+ listener();
96
+ }
97
+ finally {
98
+ settling = false;
99
+ }
100
+ }
101
+ export function flushOps() {
102
+ settleBeforeFlush();
103
+ if (host === undefined || !hasPendingOps())
104
+ return;
105
+ host.applyOps(takeBatch());
106
+ }
107
+ // Commits this window, for readCommitProfile below.
108
+ let commits = 0;
109
+ /**
110
+ * Record a surface's commit and drain the buffer into the host.
111
+ *
112
+ * A SURFACE IS AN ANCHOR: an anchor is a node whose children belong to its parent's list, and a
113
+ * surface is a node whose children belong to the root child set. Same shape, which is why `OP_COMMIT`
114
+ * names one node and the host needs no `rootTag -> map`.
115
+ *
116
+ * `others` is every OTHER live surface, and it exists because the buffer is GLOBAL while a commit
117
+ * names ONE root. A framework can mutate a tree that belongs to a different surface than the one
118
+ * whose renderer it is holding — a portal, a tunnel, any cross-surface move — and every adapter
119
+ * then asks its OWN surface to commit. The ops reach the host either way, so the other surface is
120
+ * left correctly updated in the tree and never handed to `completeRoot`: a stale Fabric root that
121
+ * nothing will refresh until something unrelated dirties it. Naming every root here is what makes a
122
+ * commit mean "flush what changed" rather than "flush the surface I happen to be bound to".
123
+ *
124
+ * A single-surface app — every example, and the overwhelming case — passes an empty list and the
125
+ * behaviour is byte-identical to naming only itself.
126
+ */
127
+ export function commitSurfaceOps(rootTag, surface, others = []) {
128
+ commits += 1;
129
+ // No host: the ops STAY PENDING. Draining them here would be silent data loss — a surface created
130
+ // before a host is installed would have its own `createElement` thrown away, and the next commit
131
+ // that DOES reach a host names a node that host never saw. Nothing is red until then, and the
132
+ // throw when it comes names the commit rather than the discard.
133
+ //
134
+ // The commit op is not recorded either, for the same reason: it would sit at the head of the next
135
+ // batch, ahead of the creates it depends on.
136
+ if (host === undefined)
137
+ return;
138
+ // NOTHING TO PUBLISH — return before the host is asked to do anything.
139
+ //
140
+ // The host already declines `completeRoot` when the root child set comes back identical, but it
141
+ // decides that AFTER rebuilding the set: every child of the committed surface is visited to
142
+ // rediscover that none of them moved. Measured on the reference applier, a commit with nothing
143
+ // pending cost 0.0145 ms over 500 rows and 0.0822 over 4 000 — linear in the width of the surface,
144
+ // for a commit that publishes nothing.
145
+ //
146
+ // Who pays it: any frame where a framework re-ran an effect and produced no change, which for a
147
+ // reactive adapter is most frames.
148
+ //
149
+ // The post-commit hooks are NOT affected. `notifyCommitted`, `runPostCommitHooks`,
150
+ // `runDeferredAttaches` and the `afterCommit` drain all run in `surface.ts` AFTER this call and
151
+ // are deliberately not gated on the commit having made native calls — a fold that strips a prop
152
+ // makes its own commit byte-identical, and the hook reacting to that flip must still fire.
153
+ if (!hasChangedSinceCommit())
154
+ return;
155
+ for (const [otherTag, otherSurface] of others) {
156
+ recordCommit(otherTag, otherSurface);
157
+ }
158
+ // `undefined` means this surface no longer OWNS its root — a re-mount on the same rootTag took
159
+ // it. Its ops still drain, because a teardown is what carries the removals; completing the root
160
+ // would hand Fabric the dead surface's emptied tree over the live one's.
161
+ if (rootTag !== undefined)
162
+ recordCommit(rootTag, surface);
163
+ host.applyOps(takeBatch());
164
+ noteCommitDrained();
165
+ }
166
+ export function readCommitProfile() {
167
+ const snapshot = { commits, propWrites: takePropStats().writes };
168
+ commits = 0;
169
+ return snapshot;
170
+ }
171
+ /**
172
+ * RN's own commit telemetry for a surface THIS HOST NEED NOT HAVE DRIVEN, or `undefined` when the
173
+ * runtime cannot answer.
174
+ *
175
+ * It exists for one comparison and should not be reached for anything else: `readCommitProfile`
176
+ * accumulates inside our own commit, so it can only ever describe a tree we built, and the standing
177
+ * open question is whether a tree-wide text re-measure is something we cause or something a Fabric
178
+ * commit costs whoever drives it. Point this at a surface React Native's OWN renderer committed and
179
+ * the two numbers are directly comparable — same device, same RN, same tree.
180
+ *
181
+ * `undefined` rather than zeroes on a runtime without the binding, deliberately: zeroes would read
182
+ * as "the other renderer measures no text", which is precisely the claim under test.
183
+ */
184
+ export function readSurfaceTelemetry(surfaceId) {
185
+ return nativeEngine()?.readSurfaceTelemetry?.(surfaceId);
186
+ }
187
+ /**
188
+ * Arm the C++ half's diagnostics (`SymbioteDebug.h`), which `installBindings` already did from
189
+ * `DEBUG=1` / `globalThis.__SYMBIOTE_DEBUG__` at install.
190
+ *
191
+ * This is for the LATER toggle — the runtime escape hatch `debug.ts` documents for hosts where the
192
+ * env is not reachable. Without it a `globalThis.__SYMBIOTE_DEBUG__ = true` typed into a running app
193
+ * would flip the JS half and silently leave the engine's own half dark, which is the surprise worth
194
+ * the six lines.
195
+ */
196
+ export function setNativeDebug(enabled) {
197
+ nativeEngine()?.setDebugEnabled?.(enabled);
198
+ }
199
+ /**
200
+ * Drain what the C++ half has logged since the last call.
201
+ *
202
+ * The reason those lines are retained at all rather than only written to stderr: a diagnostic nobody
203
+ * can assert on is one that rots. This is what lets a test say "the engine warned about that" —
204
+ * see `core/engine/cpp/tests/js/native-debug-log.itest.ts`.
205
+ *
206
+ * An empty array on a runtime without the binding, not `undefined`: the question "what was logged"
207
+ * has an honest empty answer, unlike the telemetry read above, where a zero would be a false claim.
208
+ */
209
+ export function takeNativeDebugLog() {
210
+ return nativeEngine()?.takeDebugLog?.() ?? [];
211
+ }
@@ -31,10 +31,10 @@ const BASE_EVENTS = [
31
31
  'pressIn',
32
32
  'pressOut',
33
33
  // Synthesized from the touch stream like its four siblings, and omitted here until 2026-09-02.
34
- // A name the press machine OWNS but the engine does not route is dead on the lowered path only:
35
- // `routeProp` hands an `on*` prop to `setEventListener` (and thus to the behavior's stash) only
36
- // for a registered event, so `onPressMove` landed in `node.props` where nothing reads it, while
37
- // a wrapper passes the same callback to the machine directly and stayed correct.
34
+ // A name the press machine OWNS but the engine does not route is dead: `routeProp` hands an `on*`
35
+ // prop to `setEventListener` (and thus to the behavior's stash) only for a registered event, so
36
+ // `onPressMove` landed in `node.props` where nothing reads it. The wrapper that used to pass the
37
+ // same callback to the machine directly hid this.
38
38
  'pressMove',
39
39
  'longPress',
40
40
  'layout',
@@ -0,0 +1,27 @@
1
+ // The TurboModule spec, and it exists almost entirely so that the module gets REGISTERED — the
2
+ // capability it carries is installed by `installJSIBindingsWithRuntime:`, not by any method here.
3
+ //
4
+ // Why a spec at all, when nothing calls through it: `RCTTurboModuleManager` only runs the JSI-binding
5
+ // hook when it CREATES the module, and it only creates a module some name resolves to. Codegen with
6
+ // `ios.modulesProvider` is what puts `SymbioteEngine -> SymbioteEngineModule` into the generated
7
+ // `RCTModuleProviders`, which is the lookup bridgeless mode actually consults. `RCT_EXPORT_MODULE`'s
8
+ // load-time class registration is the legacy path and is not something to rely on here.
9
+ //
10
+ // `getVersion` is therefore not ceremony: it is the liveness probe. The JS side calls it to force
11
+ // creation and to learn whether the binary it is talking to is old — a native module and the JS that
12
+ // drives it ship in two different artefacts (a pod and an npm package), so they can disagree, and
13
+ // nothing else in this repo would notice.
14
+ //
15
+ // This file lives OUTSIDE `src/` deliberately. `core/engine/tsconfig.json` includes only `src`, and
16
+ // `core/engine/src` holds zero imports from `react-native` — an invariant the RN-port-elimination
17
+ // work depends on. Codegen parses this file; nothing bundles it.
18
+
19
+ import type { TurboModule } from 'react-native';
20
+ import { TurboModuleRegistry } from 'react-native';
21
+
22
+ export interface Spec extends TurboModule {
23
+ /** The native ABI version. Bumped whenever the host object's shape changes. */
24
+ getVersion(): number;
25
+ }
26
+
27
+ export default TurboModuleRegistry.getEnforcing<Spec>('SymbioteEngine');
@@ -0,0 +1,51 @@
1
+ #include "SymbioteDebug.h"
2
+
3
+ #include <atomic>
4
+ #include <cstdio>
5
+ #include <mutex>
6
+
7
+ namespace symbiote {
8
+ namespace {
9
+
10
+ std::atomic<bool> gEnabled{false};
11
+
12
+ // Guarded separately from the flag: the flag is read on every call site and must stay a lock-free
13
+ // load, while the buffer is touched only by calls that already passed the gate.
14
+ std::mutex gMutex;
15
+ std::vector<std::string> gLines;
16
+
17
+ // A drain that never happens must not grow without bound — an app with the switch on and no test
18
+ // reading it would otherwise retain every line for the life of the process. Oldest go first, which
19
+ // is the right end to lose: a diagnostic is read after the thing it describes.
20
+ constexpr size_t kMaxRetained = 512;
21
+
22
+ } // namespace
23
+
24
+ bool debugEnabled() {
25
+ return gEnabled.load(std::memory_order_relaxed);
26
+ }
27
+
28
+ void setDebugEnabled(bool enabled) {
29
+ gEnabled.store(enabled, std::memory_order_relaxed);
30
+ }
31
+
32
+ void debugLog(const std::string &message) {
33
+ const std::string line = "[symbiote] " + message;
34
+ // stderr rather than stdout: unbuffered by default, so a line written just before a crash is not
35
+ // lost with the buffer — which is the case a diagnostic is most often read for.
36
+ std::fputs(line.c_str(), stderr);
37
+ std::fputc('\n', stderr);
38
+
39
+ const std::lock_guard<std::mutex> lock(gMutex);
40
+ if (gLines.size() >= kMaxRetained) gLines.erase(gLines.begin());
41
+ gLines.push_back(line);
42
+ }
43
+
44
+ std::vector<std::string> takeDebugLog() {
45
+ const std::lock_guard<std::mutex> lock(gMutex);
46
+ std::vector<std::string> drained;
47
+ drained.swap(gLines);
48
+ return drained;
49
+ }
50
+
51
+ } // namespace symbiote