@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.
@@ -161,6 +161,7 @@ export type INativeEngineBindings = {
161
161
  valueEntries: number;
162
162
  valueConversions: number;
163
163
  applyMs: number;
164
+ liveNodes: number;
164
165
  stringDecodeMs: number;
165
166
  structureMs: number;
166
167
  holdHandleMs: number;
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);
@@ -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;
@@ -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
- const snapshot = { commits, propWrites: takePropStats().writes };
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
 
@@ -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.1.0",
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.0"
74
+ "@symbiote-native/test-utils": "0.4.1"
75
75
  },
76
76
  "scripts": {
77
77
  "typecheck": "tsc --build",