@symbiote-native/engine 1.1.0 → 1.2.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/build/native-engine.d.ts +1 -0
- package/build/node.js +16 -0
- package/build/tree-host.d.ts +47 -0
- package/build/tree-host.js +32 -2
- package/cpp/SymbioteFabricProps.cpp +17 -0
- package/cpp/SymbioteTree.cpp +24 -0
- package/package.json +2 -2
package/build/native-engine.d.ts
CHANGED
package/build/node.js
CHANGED
|
@@ -893,6 +893,22 @@ function isStyleRecord(value) {
|
|
|
893
893
|
* `standing` cannot leave a key unaccounted for.
|
|
894
894
|
*/
|
|
895
895
|
export function isSameShallowStyle(next, standing) {
|
|
896
|
+
// THE SAME OBJECT IS THE SAME STYLE, and saying so first is worth a line: without it a re-push of
|
|
897
|
+
// a hoisted constant — the commonest shape there is, and what Solid does on every signal change
|
|
898
|
+
// because it has no diff — allocates TWO key arrays and walks them to reach the same answer.
|
|
899
|
+
// Measured on Hermes (`style-write-cost.itest.ts`): the walk to this guard cost 1.12 us
|
|
900
|
+
// per write against a 0.84 us buffer write, so the compare was dearer than the write it protects.
|
|
901
|
+
// With this line, 0.32.
|
|
902
|
+
//
|
|
903
|
+
// IT CHANGES NOTHING OBSERVABLE, and that was break-tested rather than assumed. Inverting it — an
|
|
904
|
+
// identical object reporting "changed" — leaves all 1 044 unit tests and all 512 itests green,
|
|
905
|
+
// because `pushClassStyle`'s own `isAlreadyPublished` catches the republish downstream:
|
|
906
|
+
// `sharedStylePair` memoizes the pair by the explicit object, so the array it rebuilds is
|
|
907
|
+
// identity-equal to the one standing. The saving is the two key arrays and the walk, and nothing
|
|
908
|
+
// else. A speed change with no behaviour signature has to be justified by a measurement alone
|
|
909
|
+
// (method §8), which is why the number above is in this comment rather than in a commit message.
|
|
910
|
+
if (next === standing)
|
|
911
|
+
return isStyleRecord(next);
|
|
896
912
|
if (!isStyleRecord(next) || !isStyleRecord(standing))
|
|
897
913
|
return false;
|
|
898
914
|
const keys = Object.keys(next);
|
package/build/tree-host.d.ts
CHANGED
|
@@ -148,7 +148,45 @@ export declare function commitSurfaceOps(rootTag: IRootTag | undefined, surface:
|
|
|
148
148
|
export interface ICommitProfile {
|
|
149
149
|
commits: number;
|
|
150
150
|
propWrites: number;
|
|
151
|
+
/**
|
|
152
|
+
* Fresh Fabric families minted in this window — the SAME quantity a stock React Native app counts
|
|
153
|
+
* by wrapping `global.nativeFabricUIManager.createNode`, and the only like-for-like census left
|
|
154
|
+
* between the two stacks: our creates are issued from C++ (`SymbioteTree.cpp`,
|
|
155
|
+
* `uiManager.createNode`) and never touch that global, so a JS wrapper over it reads zero here by
|
|
156
|
+
* construction. Two arms whose node counts differ are not one workload, whatever their
|
|
157
|
+
* milliseconds say, so this belongs next to the writes rather than behind a surface id.
|
|
158
|
+
*/
|
|
159
|
+
nodesCreated: number;
|
|
160
|
+
/**
|
|
161
|
+
* How many times `applyOps` was ENTERED for this window — the number of JSI crossings the buffer
|
|
162
|
+
* actually cost, as against the one the architecture promises.
|
|
163
|
+
*
|
|
164
|
+
* A whole create should read 2. It reads more when something READS the tree while the tree is
|
|
165
|
+
* being built, because a read is a batch boundary: the buffer has to drain before the answer can
|
|
166
|
+
* be given, so a framework navigating what it is inserting enters `applyOps` once per mutation.
|
|
167
|
+
* `small-batch-crossing-cost.itest.ts` prices an empty prologue at 1.5-4.4 us, so ten thousand
|
|
168
|
+
* boundaries is tens of milliseconds that no node count and no write count can see — which is
|
|
169
|
+
* exactly the shape of a cost that shows up on a device and not in a fixture.
|
|
170
|
+
*/
|
|
171
|
+
applyCalls: number;
|
|
172
|
+
/**
|
|
173
|
+
* `applyOps` end to end, and the part of it that reads the buffer out of JS.
|
|
174
|
+
*
|
|
175
|
+
* THE ONE QUESTION A DEVICE HAS TO ANSWER and a fixture cannot. Our crossing is a single call
|
|
176
|
+
* carrying a 12 000-entry array that C++ walks element by element through JSI; stock's is ten
|
|
177
|
+
* thousand calls carrying scalars. On the harness's JavaScriptCore that walk is ~4 ms of a ~48 ms
|
|
178
|
+
* `applyOps`. Hermes is a different JSI implementation with different array-read costs and nothing
|
|
179
|
+
* headless can price it, so the number has to be read on a phone — near 4 ms and the buffer is
|
|
180
|
+
* innocent, tens of milliseconds and it is most of the gap the device reports.
|
|
181
|
+
*/
|
|
182
|
+
applyMs: number;
|
|
183
|
+
decodeMs: number;
|
|
151
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* NOTE: reading this now DRAINS the surface telemetry too — `nodesCreated` is folded in from
|
|
187
|
+
* `readSurfaceTelemetry`, which zeroes on read in C++. A sampler polling this on an interval
|
|
188
|
+
* therefore empties what a later `readSurfaceTelemetry` call would have reported.
|
|
189
|
+
*/
|
|
152
190
|
export declare function readCommitProfile(): ICommitProfile;
|
|
153
191
|
export type ISurfaceTelemetry = {
|
|
154
192
|
layoutMs: number;
|
|
@@ -261,6 +299,15 @@ export type ISurfaceTelemetry = {
|
|
|
261
299
|
* `stringDecodeMs` / `structureMs` is what the op loop itself costs — the books close here.
|
|
262
300
|
*/
|
|
263
301
|
applyMs: number;
|
|
302
|
+
/**
|
|
303
|
+
* How many nodes the C++ tree is holding right now — a LEVEL, not a total, and the one counter
|
|
304
|
+
* here that is not drained on read.
|
|
305
|
+
*
|
|
306
|
+
* What it is for: JS ownership anchors the C++ side (a node lives while a parent holds it or while
|
|
307
|
+
* JS names it through `NativeState`), so a heap reading proves the JS half was released and only
|
|
308
|
+
* infers the other. This is the other half as a reading.
|
|
309
|
+
*/
|
|
310
|
+
liveNodes: number;
|
|
264
311
|
stringDecodeMs: number;
|
|
265
312
|
/** Every append / insert / remove op together. */
|
|
266
313
|
structureMs: number;
|
package/build/tree-host.js
CHANGED
|
@@ -106,6 +106,11 @@ export function flushOps() {
|
|
|
106
106
|
}
|
|
107
107
|
// Commits this window, for readCommitProfile below.
|
|
108
108
|
let commits = 0;
|
|
109
|
+
// A surface to ask for this window's node counts. They live in C++ and this profile is id-less, so
|
|
110
|
+
// the window has to keep a tag to ask WITH, and exactly one: `walkCost_` is file-scope in
|
|
111
|
+
// `SymbioteTree.cpp`, shared by every surface and drained on read, so asking a second surface in the
|
|
112
|
+
// same window reads zeroes and a sum would be wrong rather than merely redundant.
|
|
113
|
+
let lastCommittedTag;
|
|
109
114
|
/**
|
|
110
115
|
* Record a surface's commit and drain the buffer into the host.
|
|
111
116
|
*
|
|
@@ -158,13 +163,38 @@ export function commitSurfaceOps(rootTag, surface, others = []) {
|
|
|
158
163
|
// `undefined` means this surface no longer OWNS its root — a re-mount on the same rootTag took
|
|
159
164
|
// it. Its ops still drain, because a teardown is what carries the removals; completing the root
|
|
160
165
|
// would hand Fabric the dead surface's emptied tree over the live one's.
|
|
161
|
-
if (rootTag !== undefined)
|
|
166
|
+
if (rootTag !== undefined) {
|
|
162
167
|
recordCommit(rootTag, surface);
|
|
168
|
+
lastCommittedTag = rootTag;
|
|
169
|
+
}
|
|
163
170
|
host.applyOps(takeBatch());
|
|
164
171
|
noteCommitDrained();
|
|
165
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* NOTE: reading this now DRAINS the surface telemetry too — `nodesCreated` is folded in from
|
|
175
|
+
* `readSurfaceTelemetry`, which zeroes on read in C++. A sampler polling this on an interval
|
|
176
|
+
* therefore empties what a later `readSurfaceTelemetry` call would have reported.
|
|
177
|
+
*/
|
|
166
178
|
export function readCommitProfile() {
|
|
167
|
-
|
|
179
|
+
// ONE read, not two: `readSurfaceTelemetry` zeroes the C++ accumulator, so asking it twice hands
|
|
180
|
+
// the second caller zeroes and the field would read as "the buffer never crossed".
|
|
181
|
+
//
|
|
182
|
+
// Gated on a commit having LANDED in this window, and not merely on a tag being known. Fabric's
|
|
183
|
+
// `TransactionTelemetry::getCommitStartTime` asserts that a commit has started, so asking a
|
|
184
|
+
// surface that has not committed since the last read aborts the process in a debug build and
|
|
185
|
+
// reads an undefined time point in a release one. The window's own `commits` is the only thing
|
|
186
|
+
// that answers "is there anything to ask about" without asking.
|
|
187
|
+
const telemetry = commits === 0 || lastCommittedTag === undefined
|
|
188
|
+
? undefined
|
|
189
|
+
: readSurfaceTelemetry(lastCommittedTag);
|
|
190
|
+
const snapshot = {
|
|
191
|
+
commits,
|
|
192
|
+
propWrites: takePropStats().writes,
|
|
193
|
+
nodesCreated: telemetry?.nodesCreated ?? 0,
|
|
194
|
+
applyCalls: telemetry?.applyCalls ?? 0,
|
|
195
|
+
applyMs: telemetry?.applyMs ?? 0,
|
|
196
|
+
decodeMs: telemetry?.decodeMs ?? 0,
|
|
197
|
+
};
|
|
168
198
|
commits = 0;
|
|
169
199
|
return snapshot;
|
|
170
200
|
}
|
|
@@ -2573,6 +2573,10 @@ dynamic fabricProps(
|
|
|
2573
2573
|
dynamic aliasResolved;
|
|
2574
2574
|
if (isTextInput) {
|
|
2575
2575
|
aliasResolved = foldTextInputAliases(*bag, component == kMultilineTextInput);
|
|
2576
|
+
// `TextInput.js:583` — `props.accessible !== false`, handed to both the singleline and the
|
|
2577
|
+
// multiline view (`:703`, `:772`). The same shape as the Text rule below, and found the same
|
|
2578
|
+
// way: diffing one bench row's committed payload against React Native's own.
|
|
2579
|
+
aliasResolved["accessible"] = boolAt(aliasResolved, "accessible").value_or(true);
|
|
2576
2580
|
bag = &aliasResolved;
|
|
2577
2581
|
}
|
|
2578
2582
|
|
|
@@ -2598,6 +2602,19 @@ dynamic fabricProps(
|
|
|
2598
2602
|
const bool optedOut =
|
|
2599
2603
|
scaling != nullptr && scaling->isBool() && scaling->getBool() == false;
|
|
2600
2604
|
textDefaulted["allowFontScaling"] = !optedOut;
|
|
2605
|
+
// `Text.js:145` — `accessible !== false` on iOS, the same "only a literal false opts out" shape
|
|
2606
|
+
// as the two above. Android resolves it off the press handlers instead, which is a behavior's
|
|
2607
|
+
// job and not this rule's.
|
|
2608
|
+
textDefaulted["accessible"] = boolAt(textDefaulted, "accessible").value_or(true);
|
|
2609
|
+
// `Text.js:547` puts this in the component's own default STYLE, and its comment says why:
|
|
2610
|
+
// "native components have historically acted like overflow: hidden ... to let client
|
|
2611
|
+
// differentiate with overflow: 'visible'". Written at the TOP LEVEL rather than into the style
|
|
2612
|
+
// slot so an authored `style.overflow` still beats it — `addStyle` hoists the slot over this
|
|
2613
|
+
// payload after the copy loop below, which is what makes it a fallback rather than an override.
|
|
2614
|
+
const dynamic *overflow = textDefaulted.get_ptr("overflow");
|
|
2615
|
+
if (overflow == nullptr || overflow->isNull()) {
|
|
2616
|
+
textDefaulted["overflow"] = "hidden";
|
|
2617
|
+
}
|
|
2601
2618
|
bag = &textDefaulted;
|
|
2602
2619
|
}
|
|
2603
2620
|
|
package/cpp/SymbioteTree.cpp
CHANGED
|
@@ -327,7 +327,27 @@ struct Node : jsi::NativeState {
|
|
|
327
327
|
//
|
|
328
328
|
// The reference applier has no such window (a JS child's `parent` reference keeps the parent
|
|
329
329
|
// alive), so nothing headless can reach this and there is no test to write for it.
|
|
330
|
+
/**
|
|
331
|
+
* How many nodes this process is holding, right now.
|
|
332
|
+
*
|
|
333
|
+
* The one reading nothing else can produce. Ownership here is JS-anchored — `children` is strong,
|
|
334
|
+
* `parent` is raw, and a node lives while a parent holds it or while JS names it through
|
|
335
|
+
* `NativeState` — so "JS gave its half back" IMPLIES the C++ half went with it. That is an
|
|
336
|
+
* inference from the ownership model, and the shape it would miss is the one
|
|
337
|
+
* software-mansion/react-native-reanimated#10527 describes: a container keyed by surface that
|
|
338
|
+
* nothing empties, holding a root alive after the surface is gone. We have no such container; this
|
|
339
|
+
* counter is what turns "we have no such container" from a reading of the code into a reading of
|
|
340
|
+
* the process.
|
|
341
|
+
*
|
|
342
|
+
* Not atomic, and deliberately: a commit walk is single-threaded, and the counter exists for a
|
|
343
|
+
* test rather than for a report anything acts on.
|
|
344
|
+
*/
|
|
345
|
+
static inline int64_t live = 0;
|
|
346
|
+
|
|
347
|
+
Node() { live += 1; }
|
|
348
|
+
|
|
330
349
|
~Node() {
|
|
350
|
+
live -= 1;
|
|
331
351
|
for (const NodePtr &child : children) {
|
|
332
352
|
// A HOLE, from a detach nothing has read past yet. The destructor is the one reader that does
|
|
333
353
|
// not compact first: compaction renumbers, and renumbering a vector whose owner is being
|
|
@@ -2578,6 +2598,10 @@ jsi::Value Tree::readSurfaceTelemetry(
|
|
|
2578
2598
|
result.setProperty(
|
|
2579
2599
|
runtime, "valueConversions", jsi::Value(static_cast<double>(walkCost_.valueConversions)));
|
|
2580
2600
|
result.setProperty(runtime, "applyMs", millis(walkCost_.applyNs));
|
|
2601
|
+
// NOT DRAINED ON READ, unlike every counter around it: this is a level rather than a total, and a
|
|
2602
|
+
// level that zeroed itself when read would answer about nothing. `retention-after-clear.itest.ts`
|
|
2603
|
+
// reads it across cycles.
|
|
2604
|
+
result.setProperty(runtime, "liveNodes", jsi::Value(static_cast<double>(Node::live)));
|
|
2581
2605
|
result.setProperty(runtime, "stringDecodeMs", millis(walkCost_.stringDecodeNs));
|
|
2582
2606
|
result.setProperty(runtime, "structureMs", millis(walkCost_.structureNs));
|
|
2583
2607
|
result.setProperty(runtime, "holdHandleMs", millis(walkCost_.holdHandleNs));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/engine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "SymbioteNative's retained shadow-tree engine — clone-on-write commit path + event normalization over React Native Fabric, shared by every framework adapter.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
},
|
|
72
72
|
"peerDependenciesMeta": {},
|
|
73
73
|
"devDependencies": {
|
|
74
|
-
"@symbiote-native/test-utils": "0.4.
|
|
74
|
+
"@symbiote-native/test-utils": "0.4.1"
|
|
75
75
|
},
|
|
76
76
|
"scripts": {
|
|
77
77
|
"typecheck": "tsc --build",
|