@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.
- 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 +125 -0
- package/build/host-access.js +280 -0
- package/build/host-behavior.d.ts +84 -21
- package/build/host-behavior.js +196 -30
- 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 +222 -0
- package/build/mutation-buffer.js +491 -0
- package/build/native-engine.d.ts +182 -0
- package/build/native-engine.js +178 -0
- package/build/native-tree-host.d.ts +25 -0
- package/build/native-tree-host.js +66 -0
- package/build/node.d.ts +172 -57
- package/build/node.js +839 -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 +307 -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 +232 -0
- package/cpp/SymbioteEngineBindings.h +59 -0
- package/cpp/SymbioteFabricProps.cpp +2619 -0
- package/cpp/SymbioteFabricProps.h +223 -0
- package/cpp/SymbioteTree.cpp +2478 -0
- package/cpp/SymbioteTree.h +257 -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,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
|
+
}
|
package/build/view-config.js
CHANGED
|
@@ -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
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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)
|