@symbiote-native/engine 0.5.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +125 -0
  28. package/build/host-access.js +280 -0
  29. package/build/host-behavior.d.ts +84 -21
  30. package/build/host-behavior.js +196 -30
  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 +222 -0
  38. package/build/mutation-buffer.js +491 -0
  39. package/build/native-engine.d.ts +182 -0
  40. package/build/native-engine.js +178 -0
  41. package/build/native-tree-host.d.ts +25 -0
  42. package/build/native-tree-host.js +66 -0
  43. package/build/node.d.ts +172 -57
  44. package/build/node.js +839 -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 +307 -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 +232 -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 +2478 -0
  80. package/cpp/SymbioteTree.h +257 -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,307 @@
1
+ import type { IMeasureInWindowOnSuccess, IMeasureLayoutOnSuccess, IMeasureOnSuccess, IRootTag } from './fabric';
2
+ import { type IMutationBatch } 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
+ nextSiblingOf: (handle: object) => object | undefined;
71
+ parentsOf: (handles: readonly object[]) => readonly (object | undefined)[];
72
+ /** Each root and every descendant, PRE-ORDER, concatenated in root order. */
73
+ subtreesOf: (roots: readonly object[]) => readonly object[];
74
+ /**
75
+ * The node itself and every ancestor above it, DEEPEST FIRST.
76
+ *
77
+ * The upward twin of `subtreesOf`, and it exists for the same reason: a walk that asks per LEVEL
78
+ * pays a crossing per level. Event dispatch needs this chain for every event — capture reads it
79
+ * reversed, bubble reads it forward — and the responder negotiation needs it again on every frame
80
+ * of every drag. Measured before it existed: 18 crossings per event on a depth-8 chain, then 9
81
+ * once the two phases shared one walk, against the 1 an answer from here costs.
82
+ *
83
+ * A SURFACE is included, exactly as `parentOf`'s answer is — stopping at one is
84
+ * `host-access.ts`'s job, and it does it by reading the answer's `component`.
85
+ */
86
+ ancestorsOf: (handle: object) => readonly object[];
87
+ census: (roots: readonly object[]) => ITreeCensus;
88
+ dispatchCommand: (handle: object, commandName: string, args: readonly unknown[]) => void;
89
+ sendAccessibilityEvent: (handle: object, eventType: string) => void;
90
+ measure: (handle: object, callback: IMeasureOnSuccess) => void;
91
+ measureInWindow: (handle: object, callback: IMeasureInWindowOnSuccess) => void;
92
+ measureLayout: (handle: object, relativeTo: object, onFail: () => void, onSuccess: IMeasureLayoutOnSuccess) => void;
93
+ setIsJSResponder: (handle: object, isResponder: boolean, blockNativeResponder: boolean) => void;
94
+ };
95
+ /**
96
+ * Install the tree host. `installFabric()` (test-utils) calls this with the TypeScript applier; the
97
+ * native module installs itself the same way once its bindings carry these reads.
98
+ *
99
+ * Passing `undefined` uninstalls, so a fixture can prove a path degrades rather than throws.
100
+ */
101
+ export declare function setTreeHost(next: ITreeHost | undefined): void;
102
+ /** The installed host, or `undefined` on a runtime that has none. */
103
+ export declare function treeHost(): ITreeHost | undefined;
104
+ export declare function registerBeforeFlush(listener: () => void): () => void;
105
+ /**
106
+ * Collect what the listeners are holding, WITHOUT draining.
107
+ *
108
+ * Separate from `flushOps` because a read may legitimately decide it needs no drain — `parentOf`
109
+ * skips one for a node whose placement is not pending — and skipping the drain must not also skip
110
+ * asking. A held write is still a write, and a reader that cannot see it is reading a stale tree.
111
+ */
112
+ export declare function settleBeforeFlush(): void;
113
+ export declare function flushOps(): void;
114
+ /**
115
+ * Record a surface's commit and drain the buffer into the host.
116
+ *
117
+ * A SURFACE IS AN ANCHOR: an anchor is a node whose children belong to its parent's list, and a
118
+ * surface is a node whose children belong to the root child set. Same shape, which is why `OP_COMMIT`
119
+ * names one node and the host needs no `rootTag -> map`.
120
+ *
121
+ * `others` is every OTHER live surface, and it exists because the buffer is GLOBAL while a commit
122
+ * names ONE root. A framework can mutate a tree that belongs to a different surface than the one
123
+ * whose renderer it is holding — a portal, a tunnel, any cross-surface move — and every adapter
124
+ * then asks its OWN surface to commit. The ops reach the host either way, so the other surface is
125
+ * left correctly updated in the tree and never handed to `completeRoot`: a stale Fabric root that
126
+ * nothing will refresh until something unrelated dirties it. Naming every root here is what makes a
127
+ * commit mean "flush what changed" rather than "flush the surface I happen to be bound to".
128
+ *
129
+ * A single-surface app — every example, and the overwhelming case — passes an empty list and the
130
+ * behaviour is byte-identical to naming only itself.
131
+ */
132
+ export declare function commitSurfaceOps(rootTag: IRootTag | undefined, surface: object, others?: readonly (readonly [IRootTag, object])[]): void;
133
+ export interface ICommitProfile {
134
+ commits: number;
135
+ propWrites: number;
136
+ }
137
+ export declare function readCommitProfile(): ICommitProfile;
138
+ export type ISurfaceTelemetry = {
139
+ layoutMs: number;
140
+ textMs: number;
141
+ /**
142
+ * `ShadowTree::commit`'s own window — and **NOT** `materialize`'s.
143
+ *
144
+ * This field's doc used to claim it was the clone-on-write walk, and F-80/F-81/F-82 each read it
145
+ * that way and concluded the native pipeline was too small to matter. `materialize` runs inside
146
+ * `kOpCommit` BEFORE `completeSurface` is called, so it is outside both this window and layout's.
147
+ * Pricing our own walk means timing `applyOps` from JS and subtracting these two.
148
+ */
149
+ commitMs: number;
150
+ layoutNodes: number;
151
+ textMeasures: number;
152
+ /**
153
+ * How many parents took the targeted-replace path since the last read, zeroed on read.
154
+ *
155
+ * OURS, not React Native's. It is a LIVENESS signal, not a performance one: every test in this
156
+ * repository stays green when `canReplaceInPlace` is off, which is how it spent eighteen months
157
+ * disabled. Assert it is non-zero wherever the fast path is the point of the test.
158
+ */
159
+ targetedReplaces: number;
160
+ /**
161
+ * `materialize`'s own walk, and the fields below break it down. OURS, zeroed on read.
162
+ *
163
+ * The doc above says the walk falls outside every window React Native times, which left it
164
+ * priceable only by subtraction — and a subtraction gives a budget, not an address. Measured
165
+ * 2026-09-17: ~200 ms of a 327 ms headless create sat here with nothing inside it named.
166
+ *
167
+ * `walkMs` is the single entry point in `kOpCommit`; `propsMs` / `rawPropsMs` / `createNodeMs` /
168
+ * `appendChildMs` / `diffPropsMs` are per-node sums inside it and do NOT add up to it — what is
169
+ * left over is the walk's own bookkeeping.
170
+ */
171
+ walkMs: number;
172
+ /** `fabricProps` alone, on both the create and the clone path. The fold LOOKUP is billed apart. */
173
+ propsMs: number;
174
+ /**
175
+ * Asking a node whether it carries a `payloadFold`: a JSI property read, and on a hit a
176
+ * `jsi::Function` allocation. The fold's own CALL is inside `propsMs`, where `fabricProps` makes
177
+ * it. Separated because the two answer different questions — how big the payload is, against how
178
+ * much the seam to JS costs to reach.
179
+ */
180
+ foldLookupMs: number;
181
+ /** How many nodes the lookup found one on. Zero makes `foldLookupMs` pure probe cost. */
182
+ foldsFound: number;
183
+ /**
184
+ * Inside a fold that runs, split three ways because a fold's CONTRACT is bag in, bag out: both
185
+ * conversions walk every key of the node whatever the fold actually reads. If the conversions
186
+ * dominate, the fix is a narrower contract; if `foldCallMs` does, the fix is not having a fold.
187
+ */
188
+ foldToJsMs: number;
189
+ foldCallMs: number;
190
+ foldFromJsMs: number;
191
+ /** The payload copy Fabric consumes, kept because `committedProps` is next commit's baseline. */
192
+ rawPropsMs: number;
193
+ createNodeMs: number;
194
+ appendChildMs: number;
195
+ diffPropsMs: number;
196
+ /** Nodes that minted a fresh Fabric family, were cloned, or were returned untouched. */
197
+ nodesCreated: number;
198
+ nodesCloned: number;
199
+ nodesReused: number;
200
+ /**
201
+ * `applyOps`' own decode, per created element, and its three biggest parts.
202
+ *
203
+ * A different question from the walk's. The walk asks what Fabric charges; this asks what it costs
204
+ * US to turn one op into one node — and the buffer architecture only pays for itself if that is
205
+ * well under the per-node JSI call it replaces. On `build-release` it was not, which is why these
206
+ * exist. `publishMs` contains `nativeStateMs`; neither contains `instanceHandleMs`.
207
+ */
208
+ decodeMs: number;
209
+ instanceHandleMs: number;
210
+ publishMs: number;
211
+ nativeStateMs: number;
212
+ nodesDecoded: number;
213
+ /**
214
+ * `kOpSetProp` and, inside it, the JS value -> `folly::dynamic` conversion.
215
+ *
216
+ * `setPropMs` skips the two early exits (deleting an absent key, and a value equal to the one
217
+ * standing), so it under-counts exactly the cheap paths; `propConvertMs` has no such hole.
218
+ */
219
+ setPropMs: number;
220
+ propConvertMs: number;
221
+ setProps: number;
222
+ /**
223
+ * The two `setProp` ops that changed nothing, counted apart because they are not the same waste.
224
+ *
225
+ * `deletesOfAbsent` leaves before the value conversion and costs a hash lookup. `writesOfUnchanged`
226
+ * leaves AFTER it, so the adapter has already paid the JSI -> `folly::dynamic` crossing for a value
227
+ * that changes nothing — that is the expensive one, and it is what the device benchmark's
228
+ * `WRITES n/m` second figure reports.
229
+ */
230
+ deletesOfAbsent: number;
231
+ writesOfUnchanged: number;
232
+ /**
233
+ * How well the buffer's value interning worked: entries in the batch's value table, and how many
234
+ * of them an op actually reached and converted.
235
+ *
236
+ * `setProps / valueEntries` is the dedup achieved. A ratio near 1 means the values are unique —
237
+ * which is a fact about what the caller HANDS the buffer, not about the interning: a style slot
238
+ * rebuilt per node arrives as a fresh reference and cannot be folded with anything.
239
+ */
240
+ valueEntries: number;
241
+ valueConversions: number;
242
+ /**
243
+ * `applyOps` end to end, plus the two parts of it that are neither a create nor a prop write.
244
+ *
245
+ * `applyMs` is the whole native call, so `applyMs` minus `decodeMs` / `setPropMs` /
246
+ * `stringDecodeMs` / `structureMs` is what the op loop itself costs — the books close here.
247
+ */
248
+ applyMs: number;
249
+ stringDecodeMs: number;
250
+ /** Every append / insert / remove op together. */
251
+ structureMs: number;
252
+ /** Inside `structureMs`: promoting a node's weak handle reference to a strong one. */
253
+ holdHandleMs: number;
254
+ /**
255
+ * `subtreesOf` — the batched host read the teardown sweep makes, and how many handles it returned.
256
+ *
257
+ * Not on a commit path and timed anyway: it hands JS a handle for every node in every removed
258
+ * subtree, which on a 1 000-row clear is ten thousand. Whether that time is the crossing or the JS
259
+ * loop above it decides whether the torn-down mark is worth moving into C++.
260
+ */
261
+ hostReadMs: number;
262
+ hostReadHandles: number;
263
+ /**
264
+ * How many times `applyOps` was entered since the last read.
265
+ *
266
+ * The string and value tables are interned PER BATCH, so a driver that flushes in many small
267
+ * batches cannot fold a repeated value across them. This is what distinguishes "this adapter sends
268
+ * more values" from "this adapter sends the same values in more batches" — two very different
269
+ * findings that look identical in `valueEntries` alone.
270
+ */
271
+ applyCalls: number;
272
+ };
273
+ /**
274
+ * RN's own commit telemetry for a surface THIS HOST NEED NOT HAVE DRIVEN, or `undefined` when the
275
+ * runtime cannot answer.
276
+ *
277
+ * It exists for one comparison and should not be reached for anything else: `readCommitProfile`
278
+ * accumulates inside our own commit, so it can only ever describe a tree we built, and the standing
279
+ * open question is whether a tree-wide text re-measure is something we cause or something a Fabric
280
+ * commit costs whoever drives it. Point this at a surface React Native's OWN renderer committed and
281
+ * the two numbers are directly comparable — same device, same RN, same tree.
282
+ *
283
+ * `undefined` rather than zeroes on a runtime without the binding, deliberately: zeroes would read
284
+ * as "the other renderer measures no text", which is precisely the claim under test.
285
+ */
286
+ export declare function readSurfaceTelemetry(surfaceId: number): ISurfaceTelemetry | undefined;
287
+ /**
288
+ * Arm the C++ half's diagnostics (`SymbioteDebug.h`), which `installBindings` already did from
289
+ * `DEBUG=1` / `globalThis.__SYMBIOTE_DEBUG__` at install.
290
+ *
291
+ * This is for the LATER toggle — the runtime escape hatch `debug.ts` documents for hosts where the
292
+ * env is not reachable. Without it a `globalThis.__SYMBIOTE_DEBUG__ = true` typed into a running app
293
+ * would flip the JS half and silently leave the engine's own half dark, which is the surprise worth
294
+ * the six lines.
295
+ */
296
+ export declare function setNativeDebug(enabled: boolean): void;
297
+ /**
298
+ * Drain what the C++ half has logged since the last call.
299
+ *
300
+ * The reason those lines are retained at all rather than only written to stderr: a diagnostic nobody
301
+ * can assert on is one that rots. This is what lets a test say "the engine warned about that" —
302
+ * see `core/engine/cpp/tests/js/native-debug-log.itest.ts`.
303
+ *
304
+ * An empty array on a runtime without the binding, not `undefined`: the question "what was logged"
305
+ * has an honest empty answer, unlike the telemetry read above, where a zero would be a false claim.
306
+ */
307
+ 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
@@ -0,0 +1,54 @@
1
+ #pragma once
2
+
3
+ #include <string>
4
+ #include <vector>
5
+
6
+ // Diagnostic logging for the C++ half of the engine — the twin of `core/engine/src/debug.ts`, and
7
+ // until 2026-09-18 it did not exist at all.
8
+ //
9
+ // WHY IT HAD TO. The commit path, the payload builder and every tag rule live here now, and this
10
+ // translation unit's only way to say anything was `throw jsi::JSError` — so a C++ rule could crash
11
+ // or stay silent, with nothing in between. `<keep_logs_gate_behind_DEBUG>` asks new code with
12
+ // non-trivial runtime behavior to log at its seam as a matter of course; that was unsatisfiable on
13
+ // this side of the wire, and the first rule that wanted a developer WARNING rather than a crash
14
+ // (ScrollView's ignored `horizontal`) is what made the gap block a port.
15
+ //
16
+ // THE SWITCH IS THE SAME ONE, pushed down rather than re-invented: `DEBUG=1` in the environment or
17
+ // `globalThis.__SYMBIOTE_DEBUG__` in JS. `installBindings` reads both at install, and
18
+ // `setDebugEnabled` is exposed so a later JS toggle reaches this side too — otherwise the runtime
19
+ // escape hatch that exists for hosts where the env is unreachable would silently stop at the
20
+ // boundary.
21
+ //
22
+ // THE MESSAGE IS BUILT ONLY WHEN THE SWITCH IS ON, and the macro is what guarantees it. C++ has the
23
+ // same trap the JS module's header describes: an argument is evaluated at the CALL SITE, so a
24
+ // `debugLog("x " + std::to_string(y))` on the per-node commit path costs its concatenation whether
25
+ // or not anything is listening. `SYMBIOTE_DLOG` tests the flag first, so the expression is not
26
+ // evaluated at all when logging is off — which makes the cheap thing the DEFAULT rather than a rule
27
+ // every call site has to remember. Call `debugLog` directly only where the argument is already a
28
+ // built string.
29
+ //
30
+ // WHERE IT GOES. stderr, so a device build surfaces it in the Xcode console and in logcat without
31
+ // any bridge of its own. It is also RETAINED while the switch is on, which is what makes a log
32
+ // assertable from a test rather than merely visible to a human — `takeDebugLog` drains it. Nothing
33
+ // is retained while the switch is off, because nothing is called.
34
+
35
+ namespace symbiote {
36
+
37
+ /** One relaxed atomic read. The gate on every call site; see `SYMBIOTE_DLOG`. */
38
+ bool debugEnabled();
39
+
40
+ void setDebugEnabled(bool enabled);
41
+
42
+ /** Prefixed, written to stderr, and retained for `takeDebugLog`. */
43
+ void debugLog(const std::string &message);
44
+
45
+ /** Drains the retained lines. The test-facing read, and the reason retention exists. */
46
+ std::vector<std::string> takeDebugLog();
47
+
48
+ } // namespace symbiote
49
+
50
+ // Guards BEFORE evaluating, so building the message costs nothing with logging off.
51
+ #define SYMBIOTE_DLOG(expr) \
52
+ do { \
53
+ if (::symbiote::debugEnabled()) ::symbiote::debugLog((expr)); \
54
+ } while (false)