@symbiote-native/engine 0.4.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 -7
- package/build/accessibility-props.js +22 -21
- 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/graph.d.ts +2 -0
- package/build/animated/graph.js +14 -0
- package/build/animated/host-binding.d.ts +39 -0
- package/build/animated/host-binding.js +278 -0
- package/build/animated/index.d.ts +1 -1
- package/build/animated/leaf-lifecycle.js +10 -22
- 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 +123 -33
- package/build/fabric-props.d.ts +1 -1
- package/build/fabric-props.js +129 -182
- package/build/fabric.d.ts +10 -0
- package/build/fabric.js +40 -0
- package/build/host-access.d.ts +125 -0
- package/build/host-access.js +280 -0
- package/build/host-behavior.d.ts +107 -7
- package/build/host-behavior.js +309 -31
- 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 +18 -10
- package/build/index.js +65 -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 +191 -60
- package/build/node.js +1034 -327
- package/build/pan-responder/index.d.ts +2 -2
- package/build/pan-responder/index.js +37 -56
- 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/styles.d.ts +5 -1
- package/build/surface.d.ts +31 -2
- package/build/surface.js +138 -42
- 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 -1030
- package/build/tags.d.ts +0 -2
- package/build/tags.js +0 -40
package/build/node.js
CHANGED
|
@@ -1,22 +1,35 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
1
|
+
// The mutation API. Adapters call it; every call appends an OPCODE to `mutation-buffer.ts` and
|
|
2
|
+
// nothing else. There is no tree here — no parent, no children, no props, no mirror. Turning the
|
|
3
|
+
// buffer into a tree is the HOST's job (`tree-host.ts`): native on device, the TypeScript applier in
|
|
4
|
+
// `@symbiote-native/test-utils` headlessly.
|
|
5
|
+
//
|
|
6
|
+
// What a node still legitimately owns is what the framework, not Fabric, put on it: the Fabric view
|
|
7
|
+
// name it was created as, whether it is a text container, its JS listener map, the declarative
|
|
8
|
+
// class/style halves the engine merges, and the two bookkeeping flags. An ADDRESS plus the state
|
|
9
|
+
// that never crosses.
|
|
10
|
+
import { recordAppendChild, recordCreateAnchor, recordCreateVoid, noteHostSideChange, recordCreateElement, recordCreateRawText, recordInsertBefore, recordRemoveChild, recordSetComponent, recordSetOwnedListener, recordSetUnderlayShown, recordSetProp, recordSetText, } from './mutation-buffer.js';
|
|
8
11
|
import { isEventFor } from './view-config.js';
|
|
9
|
-
import { canonicalClassName, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
|
|
10
|
-
import { dlog } from './debug.js';
|
|
11
|
-
import { attachHostBehavior, hasHostBehaviors, markDetachCandidate, ownsListener, reattachHostBehaviors, stashAppListener, } from './host-behavior.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
12
|
+
import { canonicalClassName, EMPTY_STYLE, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
|
|
13
|
+
import { dlog, isDebug } from './debug.js';
|
|
14
|
+
import { appListenerFor, attachHostBehavior, claimModeFor, hasAttachedBehaviors, hasHostBehaviors, markDetachCandidate, notifyChildInserted, notifyOwnedListenerChange, noteCommitHookNodeChanged, ownsListener, reattachHostBehaviors, derivedNodesOf, slotDerivesFrom, slotPropNameFor, slotTakesChildren, stashAppListener, } from './host-behavior.js';
|
|
15
|
+
import { configPayloadFold } from './registry.js';
|
|
16
|
+
import { resolveStructuredStyle } from './structured-style.js';
|
|
17
|
+
import { IMAGE_SOURCE_PROPS, IMAGE_LOAD_EVENT_NAMES, anyImageLoadEventListenerWired, resolveImageSourceProp, } from './image-source-write.js';
|
|
18
|
+
// A cycle, deliberately: `imperative.ts` imports this module for the node shape, and the prototype
|
|
19
|
+
// methods below call back into it. Neither side touches the other at module-evaluation time - only
|
|
20
|
+
// inside a function body - so every loader (tsc, vitest, Metro) resolves it fine. The alternative
|
|
21
|
+
// was a load-time `SymbioteNode.prototype.measure = ...` installed from elsewhere, which is exactly
|
|
22
|
+
// the registration-side-effect shape Metro's inlineRequires silently drops in release builds (see
|
|
23
|
+
// CLAUDE.md, "Never make correctness depend on a module's load-time side effect").
|
|
24
|
+
import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './imperative.js';
|
|
25
|
+
// The same deliberate cycle, for the same reason: `tree-host.ts` imports `takePropStats` from here
|
|
26
|
+
// and `censusRetainedTree` below asks it for the census. Function bodies only, on both sides.
|
|
27
|
+
import { EMPTY_CENSUS, flushOps, treeHost, } from './tree-host.js';
|
|
28
|
+
// The same deliberate cycle, for the same reason: `routeProp` resolves an AnimatedNode written
|
|
29
|
+
// into a prop, and the module that owns that resolution reaches back here for `setProp`. See
|
|
30
|
+
// `animated/host-binding.ts`'s header.
|
|
31
|
+
import { hasAnimatedNodes } from './animated/graph.js';
|
|
32
|
+
import { bindAnimatedEvent, bindAnimatedValue, hasAnimatedBindings, reattachAnimatedProps, } from './animated/host-binding.js';
|
|
20
33
|
const BRAND = Symbol('symbiote.node');
|
|
21
34
|
// A node carries the Fabric view name directly, so adding a primitive (Image,
|
|
22
35
|
// ScrollView, TextInput) is just a new string from the adapter, no core change.
|
|
@@ -37,6 +50,10 @@ export function isSymbioteEvent(value) {
|
|
|
37
50
|
}
|
|
38
51
|
const FOCUS_COMMAND = 'focus';
|
|
39
52
|
const BLUR_COMMAND = 'blur';
|
|
53
|
+
// Names and arg order mirror RN's ScrollViewCommands.
|
|
54
|
+
const SCROLL_TO_COMMAND = 'scrollTo';
|
|
55
|
+
const SCROLL_TO_END_COMMAND = 'scrollToEnd';
|
|
56
|
+
const FLASH_SCROLL_INDICATORS_COMMAND = 'flashScrollIndicators';
|
|
40
57
|
// The one shape every retained node has. A class, not an object literal, for two reasons: the six
|
|
41
58
|
// imperative methods live on the shared prototype instead of being allocated per node (see
|
|
42
59
|
// ISymbioteNode above), and both factories below mint the same hidden class.
|
|
@@ -46,35 +63,36 @@ const BLUR_COMMAND = 'blur';
|
|
|
46
63
|
// constructor assignment is the shape every engine (V8 and Hermes both) handles without a
|
|
47
64
|
// define-per-field.
|
|
48
65
|
class SymbioteNode {
|
|
49
|
-
constructor(component, isText
|
|
66
|
+
constructor(component, isText) {
|
|
50
67
|
this[BRAND] = true;
|
|
51
68
|
this.component = component;
|
|
52
69
|
this.isText = isText;
|
|
53
|
-
this.props = props;
|
|
54
70
|
this.listeners = undefined;
|
|
55
|
-
this.children = [];
|
|
56
|
-
this.parent = undefined;
|
|
57
|
-
// A node that has never committed must never take a fast path built on "the mirror already
|
|
58
|
-
// agrees with me", so all three flags start raised - including for createRawText, whose props
|
|
59
|
-
// are assigned here rather than through setText.
|
|
60
|
-
this.dirty = true;
|
|
61
|
-
this.propsDirty = true;
|
|
62
71
|
// Assigned here, not lazily on first use: every slot present from the constructor keeps one
|
|
63
|
-
// hidden class for every node. Adding
|
|
64
|
-
//
|
|
72
|
+
// hidden class for every node. Adding one on demand buys a shape transition per node that needs
|
|
73
|
+
// it, which is the opposite of what these fields are for.
|
|
65
74
|
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
75
|
+
// `attachHostBehavior` raises this a few lines later for the rare node whose behavior declares
|
|
76
|
+
// the recurring hook.
|
|
77
|
+
this.hasCommitHook = false;
|
|
78
|
+
// Same again; `attachHostBehavior` raises it for the one behavior that declares it, Image's.
|
|
79
|
+
this.resolvesImageSources = false;
|
|
80
|
+
// Same again; `attachHostBehavior` raises it for the one behavior that declares it,
|
|
81
|
+
// TouchableWithoutFeedback's.
|
|
82
|
+
this.nativeIdWinsOverId = false;
|
|
74
83
|
this.styleParts = undefined;
|
|
75
84
|
// Assigned here for the same hidden-class reason as `hasAriaAlias` above; `attachHostBehavior`
|
|
76
85
|
// overwrites it a few lines later for the rare node that has a behavior.
|
|
77
86
|
this.payloadFold = undefined;
|
|
87
|
+
// Same reason again, and here it is load-bearing rather than tidy: the redirect below is read
|
|
88
|
+
// on every append, so the slot must be a stable slot on one hidden class, not a property added
|
|
89
|
+
// to a few nodes after the fact.
|
|
90
|
+
this.childHost = undefined;
|
|
91
|
+
this.wrapper = undefined;
|
|
92
|
+
// Same hidden-class reason as the two above, and here it is the whole point: the fast path it
|
|
93
|
+
// guards is read on every `childrenOf`, so it must be a stable slot rather than a property that
|
|
94
|
+
// appears on some nodes later.
|
|
95
|
+
this.mayHaveChildren = false;
|
|
78
96
|
}
|
|
79
97
|
measure(callback) {
|
|
80
98
|
engineMeasure(this, callback);
|
|
@@ -98,53 +116,113 @@ class SymbioteNode {
|
|
|
98
116
|
blur() {
|
|
99
117
|
dispatchViewCommand(this, BLUR_COMMAND, []);
|
|
100
118
|
}
|
|
119
|
+
// The defaults live HERE and nowhere else. `buildScrollViewHandle`
|
|
120
|
+
// (`@symbiote-native/components`) delegates here, so a built handle and a node cannot drift on
|
|
121
|
+
// what `scrollTo()` with no argument means.
|
|
122
|
+
scrollTo(options) {
|
|
123
|
+
const x = options?.x ?? 0;
|
|
124
|
+
const y = options?.y ?? 0;
|
|
125
|
+
const animated = options?.animated ?? true;
|
|
126
|
+
dlog(`ScrollView.scrollTo x=${x} y=${y} animated=${animated}`);
|
|
127
|
+
dispatchViewCommand(this, SCROLL_TO_COMMAND, [x, y, animated]);
|
|
128
|
+
}
|
|
129
|
+
scrollToEnd(options) {
|
|
130
|
+
const animated = options?.animated ?? true;
|
|
131
|
+
dlog(`ScrollView.scrollToEnd animated=${animated}`);
|
|
132
|
+
dispatchViewCommand(this, SCROLL_TO_END_COMMAND, [animated]);
|
|
133
|
+
}
|
|
134
|
+
flashScrollIndicators() {
|
|
135
|
+
dlog('ScrollView.flashScrollIndicators');
|
|
136
|
+
dispatchViewCommand(this, FLASH_SCROLL_INDICATORS_COMMAND, []);
|
|
137
|
+
}
|
|
101
138
|
}
|
|
139
|
+
// The committed record — handle, tag, rootTag — is the host's to keep, and `committedRecordOf`
|
|
140
|
+
// (tree-host.ts) is how the imperative APIs ask for it. `IMirror` and `IContribution` were the JS
|
|
141
|
+
// re-implementations of `ShadowNode` and of the one thing `ShadowNode` cannot hold, an anchor. Both
|
|
142
|
+
// are gone with the tree; the host answers about both.
|
|
143
|
+
//
|
|
144
|
+
// The identity check `committedOf` used to make is gone with them, and it was worth something: a Vue
|
|
145
|
+
// `reactive()` / deep-`ref()` Proxy around a host element forwards a field read to its target, so a
|
|
146
|
+
// wrapped node used to hand back a real record. It cannot now — the host keys on the handle OBJECT,
|
|
147
|
+
// so a Proxy misses and every imperative call degrades to its "node not committed" log, which is
|
|
148
|
+
// the WeakMap's old behaviour restored. Hold host nodes with `shallowRef` (vue-adapter-reactivity).
|
|
102
149
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* engine identifies a node BY IDENTITY, and the classic way to break that is to hand the engine a
|
|
108
|
-
* wrapper instead of the node: a Vue `reactive()`/deep-`ref()` Proxy around a host element is the
|
|
109
|
-
* one that actually happens (see the vue-adapter-reactivity skill; `shallowRef` is the fix).
|
|
110
|
-
*
|
|
111
|
-
* The old WeakMap caught this for free - a Proxy is a different object, so `mirror.get(proxy)` missed
|
|
112
|
-
* and every imperative API bailed with a clear "node not committed". A plain property read does NOT:
|
|
113
|
-
* a Proxy forwards `proxy.committed` straight to the target and hands back a real record, whose
|
|
114
|
-
* `handle` Vue would then deep-wrap on the way out. That handle is a JSI host object; a Proxy around
|
|
115
|
-
* it reaches `cloneNodeWithNewProps` and fails somewhere deep in native, far from the cause.
|
|
116
|
-
*
|
|
117
|
-
* So the identity check that was implicit in the WeakMap is explicit here: a record written on the
|
|
118
|
-
* raw node names it, and `record.owner !== node` means whatever we were handed is not that node.
|
|
119
|
-
* One reference comparison, and the wrap now fails LOUDER than it used to rather than quieter.
|
|
150
|
+
* Mint an element and record its creation.
|
|
151
|
+
*
|
|
152
|
+
* The node object IS the handle: it is what the ops address, what the host attaches its native node
|
|
153
|
+
* to, and what Fabric hands back as an event target. Nothing else is allocated.
|
|
120
154
|
*/
|
|
121
|
-
export function committedOf(node) {
|
|
122
|
-
const record = node.committed;
|
|
123
|
-
if (record === undefined)
|
|
124
|
-
return undefined;
|
|
125
|
-
if (record.owner !== node) {
|
|
126
|
-
dlog(`node identity mismatch: committed record belongs to node=${debugNodeId(record.owner)}, ` +
|
|
127
|
-
`not to the object handed in. A wrapped/proxied node (Vue reactive() or deep ref() around ` +
|
|
128
|
-
`a host element) is the usual cause - hold host nodes with shallowRef.`);
|
|
129
|
-
return undefined;
|
|
130
|
-
}
|
|
131
|
-
return record;
|
|
132
|
-
}
|
|
133
155
|
export function createElement(component, isText = false,
|
|
134
156
|
// The intrinsic tag this node came from, when it differs from the Fabric view name above. The
|
|
135
157
|
// behavior registry is keyed by tag and the node only ever carries the resolved name, so an
|
|
136
|
-
// adapter
|
|
158
|
+
// adapter creating a `<pressable>` has to hand the tag over here or the registration cannot fire
|
|
137
159
|
// (host-behavior.ts, `attached`). Nothing is stored — the lookup happens once, right below.
|
|
138
160
|
tag = component) {
|
|
139
|
-
const node = new SymbioteNode(component, isText
|
|
161
|
+
const node = new SymbioteNode(component, isText);
|
|
162
|
+
// A primitive that commits NO VIEW resolves to the anchor component through `descriptorFor`
|
|
163
|
+
// (`touchable-without-feedback`, `touchable-native-feedback`), and reaches this function rather
|
|
164
|
+
// than `createAnchor` because the caller only knows it has a descriptor. The kind is an OPCODE
|
|
165
|
+
// now, not a name the commit walk reads, so the name alone would give the host an ordinary
|
|
166
|
+
// element called `#anchor` — one that really paints.
|
|
167
|
+
if (component === ANCHOR_COMPONENT)
|
|
168
|
+
recordCreateAnchor(node);
|
|
169
|
+
// A primitive whose ENTIRE subtree must vanish on this platform (`input-accessory-view` on
|
|
170
|
+
// Android, `InputAccessoryView.js`'s `return null`) resolves to the void component the same way —
|
|
171
|
+
// through `descriptorFor`'s per-platform component-name table, never a per-call branch here. An
|
|
172
|
+
// anchor hoists its children into Fabric in its place; a void node contributes neither itself nor
|
|
173
|
+
// them.
|
|
174
|
+
else if (component === VOID_COMPONENT)
|
|
175
|
+
recordCreateVoid(node);
|
|
176
|
+
// `instanceHandle` is the node itself: it round-trips through Fabric unchanged and comes back as
|
|
177
|
+
// the event target, and the BRAND below is how the event handler confirms it is one of ours.
|
|
178
|
+
else
|
|
179
|
+
recordCreateElement(node, component, isText, node);
|
|
140
180
|
// Gated on the boolean, not on the Map: this runs ~9 000 times per benchmark create, and an app
|
|
141
181
|
// that registers nothing must pay one boolean read rather than a hash lookup per node.
|
|
142
182
|
if (hasHostBehaviors())
|
|
143
183
|
attachHostBehavior(node, tag);
|
|
184
|
+
// A third-party view's own ViewConfig processors, as a fold. AFTER the behavior's, because that
|
|
185
|
+
// is the order the reference ran them in — the behavior rewrites the wrapper-body props, and
|
|
186
|
+
// `validAttributes[*].process` then converts what it produced. Composed rather than replaced:
|
|
187
|
+
// one component can legitimately have both.
|
|
188
|
+
//
|
|
189
|
+
// Costs a `Set.has` per node for a built-in, which is where `resolve` bails, and nothing else:
|
|
190
|
+
// the answer is cached per component name, not computed per node.
|
|
191
|
+
const configFold = configPayloadFold(component);
|
|
192
|
+
if (configFold !== undefined) {
|
|
193
|
+
const behaviorFold = node.payloadFold;
|
|
194
|
+
node.payloadFold =
|
|
195
|
+
behaviorFold === undefined
|
|
196
|
+
? configFold
|
|
197
|
+
: props => configFold(behaviorFold(props));
|
|
198
|
+
}
|
|
144
199
|
return node;
|
|
145
200
|
}
|
|
146
|
-
|
|
147
|
-
|
|
201
|
+
/**
|
|
202
|
+
* `tag` mirrors `createElement`'s, and a raw text needs it for the same reason an element does: the
|
|
203
|
+
* behavior registry is keyed by tag, so a node that does not hand one over cannot have a rule.
|
|
204
|
+
*
|
|
205
|
+
* A raw text carrying a tag looks odd and is not. It has no props an app can write — its whole
|
|
206
|
+
* payload is `text` — but its CONTENT can still be a function of the platform rather than of the
|
|
207
|
+
* app: Button renders its title uppercased on Android (`Button.js:352-353`), which is a user-agent
|
|
208
|
+
* decision about a control, not anything the app asked for. That rule needs the node to be
|
|
209
|
+
* identifiable, and a tag is how this codebase identifies one.
|
|
210
|
+
*
|
|
211
|
+
* Defaulted to the raw-text component, so every existing caller is unchanged and pays the same
|
|
212
|
+
* lookup miss `createElement` already pays for a node nobody registered.
|
|
213
|
+
*/
|
|
214
|
+
export function createRawText(text, tag = RAW_TEXT_COMPONENT) {
|
|
215
|
+
const node = new SymbioteNode(RAW_TEXT_COMPONENT, false);
|
|
216
|
+
recordCreateRawText(node, text);
|
|
217
|
+
// The TAG check comes first, and it is not the same guard `createElement` uses. There it asks
|
|
218
|
+
// `hasHostBehaviors()` because every element legitimately might have a behavior. Here almost none
|
|
219
|
+
// do — a raw text is the leaf under every `<Text>` on a screen, thousands of them, and exactly one
|
|
220
|
+
// kind is tagged. So an untagged raw text must pay a reference comparison against the default and
|
|
221
|
+
// not a registry lookup: `tag` is the same string literal in that case, so the compare is pointer
|
|
222
|
+
// equality and the intern, the op and the miss are all skipped.
|
|
223
|
+
if (tag !== RAW_TEXT_COMPONENT && hasHostBehaviors())
|
|
224
|
+
attachHostBehavior(node, tag);
|
|
225
|
+
return node;
|
|
148
226
|
}
|
|
149
227
|
// `instanceHandle` round-trips through Fabric unchanged: the object we pass to
|
|
150
228
|
// createNode comes back as the event target. We brand our nodes so the event
|
|
@@ -174,165 +252,279 @@ export function debugNodeId(node) {
|
|
|
174
252
|
// not a new field, so the hot SymbioteNode shape is untouched.
|
|
175
253
|
export const ANCHOR_COMPONENT = '#anchor';
|
|
176
254
|
export function createAnchor() {
|
|
177
|
-
|
|
255
|
+
const node = new SymbioteNode(ANCHOR_COMPONENT, false);
|
|
256
|
+
recordCreateAnchor(node);
|
|
257
|
+
return node;
|
|
258
|
+
}
|
|
259
|
+
// The sentinel a primitive resolves to when its ENTIRE subtree must vanish from Fabric on this
|
|
260
|
+
// platform — `input-accessory-view` on Android, mirroring `InputAccessoryView.js`'s `return null`.
|
|
261
|
+
// Unlike `ANCHOR_COMPONENT`, whose node hoists its children up in its own place, a void node's
|
|
262
|
+
// children never reach Fabric either: the commit walk stops at it, recursively.
|
|
263
|
+
export const VOID_COMPONENT = '#void';
|
|
264
|
+
export function createVoid() {
|
|
265
|
+
const node = new SymbioteNode(VOID_COMPONENT, false);
|
|
266
|
+
recordCreateVoid(node);
|
|
267
|
+
return node;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The sentinel a SURFACE's own root node carries, so `parentOf` can stop there.
|
|
271
|
+
*
|
|
272
|
+
* A top-level node must answer `undefined` for its parent, and adapters depend on the exact miss:
|
|
273
|
+
* Angular reads `null` as "defer, `<ng-content>` will place this" (answering the surface once
|
|
274
|
+
* mounted every FlatList cell at top level), while Vue and Solid spell `?? surface` at their call
|
|
275
|
+
* sites and would be handed an object that is not the `SymbioteSurface` they compare against. One
|
|
276
|
+
* component name, read in JS, keeps all three right without a second structure.
|
|
277
|
+
*
|
|
278
|
+
* It is a JS-side name only. What goes over the wire is `RCTView`, because this node is REAL.
|
|
279
|
+
*/
|
|
280
|
+
export const SURFACE_COMPONENT = '#surface';
|
|
281
|
+
/**
|
|
282
|
+
* One persistent root view per surface, mirroring RN's own AppContainer — `renderApplication` wraps
|
|
283
|
+
* the app in `<View style={{flex:1}} pointerEvents="box-none">`.
|
|
284
|
+
*
|
|
285
|
+
* It is not decoration. Without `flex: 1` a non-flex root collapses to content height, and without
|
|
286
|
+
* `box-none` a touch landing outside the app's own children has no escape. Living here rather than
|
|
287
|
+
* in each adapter's `mount()` gives every framework a full-screen root for free and keeps layout in
|
|
288
|
+
* the shared layer (`<adapters_stay_thin>`).
|
|
289
|
+
*
|
|
290
|
+
* The surface therefore commits as ONE node rather than hoisting its children into the child set —
|
|
291
|
+
* which is why the host materializes the node `OP_COMMIT` names instead of walking its children. An
|
|
292
|
+
* anchor in that position still hoists, so the host handles both without a special case.
|
|
293
|
+
*/
|
|
294
|
+
export function createSurfaceRoot() {
|
|
295
|
+
const node = new SymbioteNode(SURFACE_COMPONENT, false);
|
|
296
|
+
recordCreateElement(node, 'RCTView', false, node);
|
|
297
|
+
// Recorded straight, not through `routeProp`: these are literal Fabric props, not props an app
|
|
298
|
+
// authored, so they want none of the class merging or event routing that path exists for.
|
|
299
|
+
recordSetProp(node, 'style', { flex: 1 });
|
|
300
|
+
recordSetProp(node, 'pointerEvents', 'box-none');
|
|
301
|
+
return node;
|
|
178
302
|
}
|
|
179
303
|
export function isAnchor(node) {
|
|
180
304
|
return node.component === ANCHOR_COMPONENT;
|
|
181
305
|
}
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
// every node's Fabric props and deep-comparing them against the mirror. That walk costs ~13 us per
|
|
193
|
-
// node on device (Hermes, iOS Debug, examples/react benchmark screen), so without the flag a
|
|
194
|
-
// 1200-node tree burns a whole 16.6 ms frame no matter how small the change - the cost tracks TREE
|
|
195
|
-
// SIZE, not change size.
|
|
196
|
-
//
|
|
197
|
-
// Marking walks up to the first ALREADY-dirty ancestor and stops, so a burst of mutations under one
|
|
198
|
-
// subtree pays for one chain walk rather than one per mutation. Reconcile clears every node it
|
|
199
|
-
// visits, which keeps the invariant "an ancestor of a dirty node is dirty" across commits.
|
|
200
|
-
//
|
|
201
|
-
// Listener changes deliberately do NOT mark. `node.listeners` never reaches Fabric (event dispatch
|
|
202
|
-
// reads it straight off the retained node) and React hands us a fresh handler closure on nearly
|
|
203
|
-
// every render, so marking there would re-dirty the whole tree every commit and hand the win back.
|
|
204
|
-
// The one listener that DOES change a Fabric prop, `layout`, raises `onLayout` through setProp
|
|
205
|
-
// below and is marked that way.
|
|
306
|
+
// `isEmptyRawText` was here and is GONE: it read `node.props.text`, which JS no longer holds. The
|
|
307
|
+
// rule it expressed — a raw text with no characters must not reach Fabric, because
|
|
308
|
+
// AttributedString::appendFragment drops the fragment while the text walk has already flagged "the
|
|
309
|
+
// last child was raw text", so the NEXT raw sibling merges into `fragments.back()` of an empty
|
|
310
|
+
// vector and the process aborts — is now the host's, applied where the child set is built.
|
|
311
|
+
// Dirty-marking is GONE, along with the walk it existed to skip. An op names the node it changed,
|
|
312
|
+
// so the host marks exactly that node and its own ancestors; nothing on this side has to guess.
|
|
313
|
+
// Listener changes still record nothing, for the reason they always did: `node.listeners` never
|
|
314
|
+
// reaches Fabric, and the one listener that DOES change a Fabric prop, `layout`, raises `onLayout`
|
|
315
|
+
// through `setProp` below.
|
|
206
316
|
/**
|
|
207
317
|
* Change which Fabric view a node commits as, keeping the node's identity.
|
|
208
318
|
*
|
|
209
|
-
* The commit walk already re-creates a node whose `viewName` no longer matches its committed one —
|
|
210
|
-
* that is how a `<Text>` moving in or out of another `<Text>` flips between RCTText and
|
|
211
|
-
* RCTVirtualText (`commit.ts`, reason `view-kind`). This exposes the same door for a prop-driven
|
|
212
|
-
* view choice, so `intrinsicWhen` is honoured on UPDATE and not only at create.
|
|
213
|
-
*
|
|
214
319
|
* The POLICY stays out of the engine: which prop decides, and which view it decides between, lives
|
|
215
320
|
* in `HOST_PRIMITIVES` and is read by `resolveIntrinsicTag` in `@symbiote-native/components`. The
|
|
216
321
|
* engine only knows how to swap the name — the same split every other spec-driven fold has here.
|
|
217
322
|
*
|
|
218
323
|
* A no-op when the name is unchanged, so a renderer may call it on every update without comparing
|
|
219
324
|
* first.
|
|
325
|
+
*
|
|
326
|
+
* The JS field and the op BOTH move, and both are load-bearing. `node.component` is what the aria
|
|
327
|
+
* fold, the behavior registry and `fabricProps` key on; the op is what makes the host re-create the
|
|
328
|
+
* node under the new name, since no prop write moves a node between native views.
|
|
220
329
|
*/
|
|
221
330
|
export function setNodeComponent(node, component) {
|
|
222
331
|
if (node.component === component)
|
|
223
332
|
return;
|
|
224
333
|
node.component = component;
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// Note the two flags are raised INDEPENDENTLY rather than one implying the other. markDirty stops
|
|
242
|
-
// at the first already-dirty ancestor, so a node dirtied a moment ago by a child's change would
|
|
243
|
-
// otherwise have its own prop write silently dropped: the walk would exit before setting anything
|
|
244
|
-
// here. Setting propsDirty first, unconditionally, is what makes that ordering safe.
|
|
245
|
-
export function markPropsDirty(node) {
|
|
246
|
-
node.propsDirty = true;
|
|
247
|
-
markDirty(node);
|
|
248
|
-
}
|
|
249
|
-
// The structural twin. Raised on the PARENT whose child list changed - never on the moved child,
|
|
250
|
-
// for the same reason markDirty is not (see the structural ops below).
|
|
251
|
-
//
|
|
252
|
-
// Every caller must reach here BEFORE mutating `parent.children`, and that ordering is now
|
|
253
|
-
// load-bearing rather than stylistic. reconcile stores the reconciled child list in the committed
|
|
254
|
-
// record BY REFERENCE, so for a parent holding no anchors the record ALIASES `parent.children`;
|
|
255
|
-
// this call is the last moment the committed list can still be read. Taking the copy here means it
|
|
256
|
-
// is taken once per parent per commit->mutation cycle, and only for parents that actually change,
|
|
257
|
-
// instead of once per node per commit - 9 002 arrays on a 1 000-row create, all but a handful
|
|
258
|
-
// allocated only to be discarded unread.
|
|
259
|
-
//
|
|
260
|
-
// The identity test is what keeps it honest: a record whose `children` is NOT `parent.children`
|
|
261
|
-
// either already holds a copy (this cycle's first structural op ran) or holds the private array
|
|
262
|
-
// renderableChildren built to flatten anchors away, which nobody mutates. Neither needs saving.
|
|
263
|
-
export function markStructureDirty(parent) {
|
|
264
|
-
const record = parent.committed;
|
|
265
|
-
if (record !== undefined && record.children === parent.children) {
|
|
266
|
-
record.children = parent.children.slice();
|
|
267
|
-
}
|
|
268
|
-
parent.structureDirty = true;
|
|
269
|
-
markDirty(parent);
|
|
270
|
-
}
|
|
271
|
-
// How many prop writes actually landed, and how many the no-op guard below turned away.
|
|
272
|
-
// Read-and-zeroed through readCommitProfile() (commit.ts), which folds them into the same window
|
|
273
|
-
// as the walk numbers so one read prices both halves: `propNoops` is the waste an adapter is
|
|
274
|
-
// generating above the engine, `nodesVisited` is what that waste costs below it.
|
|
334
|
+
recordSetComponent(node, component);
|
|
335
|
+
}
|
|
336
|
+
// `isSkippedAtCommit`, `markPresenceIfFlipped`, `markDirty`, `markPropsDirty`, `markStructureDirty`,
|
|
337
|
+
// `markRenderableAncestor`, `markChildOp`, `markChildRemoved` and `markChildAppended` all lived here
|
|
338
|
+
// and are all GONE. Every one of them answered a question about a tree — which ancestor went stale,
|
|
339
|
+
// whether a node's PRESENCE in its parent's renderable list flipped, which anchor to climb past —
|
|
340
|
+
// and the host is the only thing that can answer those now. It marks from the ops themselves.
|
|
341
|
+
// How many prop writes an adapter pushed at the engine. Read-and-zeroed through
|
|
342
|
+
// readCommitProfile() (tree-host.ts), which prices the layer ABOVE the host.
|
|
343
|
+
//
|
|
344
|
+
// `noops` used to sit beside it and counted the writes the `Object.is` guard turned away — the
|
|
345
|
+
// Angular Pressable bag that pushed 104 000 setProp calls for a screen Solid built in 12 000, 90 000
|
|
346
|
+
// of them writing `undefined` over a key that was not there. The guard moved into the host, because
|
|
347
|
+
// keeping it here would mean reading the previous value BACK over the wire — ~44 001 reads on a
|
|
348
|
+
// 1 000-row create, exactly the crossings this design removes. So the number is no longer visible
|
|
349
|
+
// from JS, and it is not faked as a zero it would have to keep re-earning.
|
|
275
350
|
//
|
|
276
351
|
// Not gated behind isDebug(), for the same reason the commit profile is not: an integer increment
|
|
277
352
|
// is noise next to the prop write it counts, and the figure is only meaningful from a release
|
|
278
|
-
// build. A per-call dlog was the obvious alternative and is deliberately NOT here -
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
const propStats = { writes: 0, noops: 0 };
|
|
353
|
+
// build. A per-call dlog was the obvious alternative and is deliberately NOT here - a log line per
|
|
354
|
+
// write would measure the logging rather than the code (see the `perf-claims-need-numbers` rule).
|
|
355
|
+
const propStats = { writes: 0 };
|
|
282
356
|
export function takePropStats() {
|
|
283
|
-
const snapshot = { writes: propStats.writes
|
|
357
|
+
const snapshot = { writes: propStats.writes };
|
|
284
358
|
propStats.writes = 0;
|
|
285
|
-
|
|
359
|
+
return snapshot;
|
|
360
|
+
}
|
|
361
|
+
// `<component>.<key>` -> write count, gated behind `isDebug()` (a Map lookup per write is not the
|
|
362
|
+
// "log line per write" the comment above rules out, but it is still real cost on the hottest path,
|
|
363
|
+
// so it only runs when someone asked). Answers F-79's own recommended next step — "instrument
|
|
364
|
+
// recordSetProp call sites directly, not just before/after counts" — by naming exactly which
|
|
365
|
+
// (view, key) pair an adapter comparison's aggregate delta is hiding, the same ledger shape F-75's
|
|
366
|
+
// payload census already uses (`RCTView.accessible 2000`).
|
|
367
|
+
let propKeyTally;
|
|
368
|
+
export function takePropKeyTally() {
|
|
369
|
+
const snapshot = propKeyTally ?? new Map();
|
|
370
|
+
propKeyTally = undefined;
|
|
286
371
|
return snapshot;
|
|
287
372
|
}
|
|
288
373
|
// A pure prop set: no event inference. `onTintColor` is a Switch prop and reaches
|
|
289
374
|
// Fabric like any other; the event-vs-prop decision is made by routeProp, never by
|
|
290
375
|
// the key's name.
|
|
291
376
|
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// `undefined` is not an absent key: `delete` genuinely changes the record's shape, and
|
|
305
|
-
// `setNativeProps` writes node.props directly and can leave exactly such a key behind. Fabric
|
|
306
|
-
// itself cannot tell the two apart (fabricProps skips undefined values), but node.props is also
|
|
307
|
-
// read outside the commit path, so the retained tree keeps the shape callers asked for.
|
|
308
|
-
// - `Object.is`, not a deep compare. A style object, an array, or a handler closure is a fresh
|
|
309
|
-
// reference on nearly every render, so the guard simply never fires for them - correct, since an
|
|
310
|
-
// adapter is free to hand back the SAME reference with mutated contents and identity cannot see
|
|
311
|
-
// that. A deep compare on every prop write would cost more than the walk it saves.
|
|
312
|
-
// - The in-place-mutation hazard that leaves is already instrumented: a node skipped as clean whose
|
|
313
|
-
// props have drifted is exactly what `warnIfStale` reports as DIRTY-MISS under DEBUG (commit.ts).
|
|
377
|
+
// `undefined` DELETES the key, which is the collapse this function has always performed and which
|
|
378
|
+
// the wire now spells (`NO_VALUE`, mutation-buffer.ts). `null` is NOT the same thing: it is a
|
|
379
|
+
// legitimate Fabric value meaning "reset to the default", and a merge-on-clone host has to be able
|
|
380
|
+
// to tell a removed key from one that was never there.
|
|
381
|
+
//
|
|
382
|
+
// THE `Object.is` DEDUPE IS NOT HERE ANY MORE, and that is the one behaviour change in this file.
|
|
383
|
+
// It needed the value the node already holds, and JS no longer holds it — reading it back would be
|
|
384
|
+
// ~44 001 crossings on a 1 000-row create, which is the cost this whole design exists to remove. The
|
|
385
|
+
// guard lives in the host's `OP_SET_PROP` instead, where the previous value is a local field: same
|
|
386
|
+
// comparison, same `Object.is` reasoning (a style object or a handler closure is a fresh reference
|
|
387
|
+
// on nearly every render, so it simply never fires for them, which is correct because an adapter may
|
|
388
|
+
// hand back the SAME reference with mutated contents and identity cannot see that).
|
|
314
389
|
export function setProp(node, key, value) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
390
|
+
// THE ARIA GATE WAS THIS LINE AND IT IS GONE (2026-09-18). `node.hasAriaAlias` existed to let the
|
|
391
|
+
// headless payload builder skip `foldAriaProps` on the ~99% of nodes carrying no alias; that
|
|
392
|
+
// builder no longer folds aria at all — the rule is the device's, in `SymbioteFabricProps.cpp`,
|
|
393
|
+
// which recomputes the gate from the bag it already holds. So the flag became write-only, and its
|
|
394
|
+
// write ran `isAriaAliasKey` on EVERY prop write in the engine to maintain something nothing read.
|
|
395
|
+
//
|
|
396
|
+
// A composed primitive's slot — and its wrapper, where it has one — can carry a value DERIVED
|
|
397
|
+
// from an owner prop, and `markPropsDirty` bubbles up, so neither ever learns. Here rather than
|
|
398
|
+
// in `routeProp` because this is the one choke point every writer passes (a structural adapter's
|
|
399
|
+
// `setProperty` does not go through routeProp), and past the identity guard so a re-render
|
|
400
|
+
// writing an unchanged value costs them nothing. See `IHostBehavior.slotDerived`.
|
|
401
|
+
if (node.childHost !== undefined && slotDerivesFrom(node, key)) {
|
|
402
|
+
markPropsDirty(node.childHost);
|
|
403
|
+
// Past the slot: a `buildStructure` that builds a CHAIN registers the deeper nodes here, and
|
|
404
|
+
// each keeps its own pure fold reading the owner. See `addDerivedNode`.
|
|
405
|
+
const derived = derivedNodesOf(node);
|
|
406
|
+
if (derived !== undefined)
|
|
407
|
+
for (const each of derived)
|
|
408
|
+
markPropsDirty(each);
|
|
409
|
+
if (node.wrapper !== undefined)
|
|
410
|
+
markPropsDirty(node.wrapper);
|
|
321
411
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
412
|
+
propStats.writes += 1;
|
|
413
|
+
if (isDebug()) {
|
|
414
|
+
propKeyTally ??= new Map();
|
|
415
|
+
const tallyKey = `${node.component}.${key}`;
|
|
416
|
+
propKeyTally.set(tallyKey, (propKeyTally.get(tallyKey) ?? 0) + 1);
|
|
417
|
+
}
|
|
418
|
+
writeProp(node, key, value);
|
|
419
|
+
}
|
|
420
|
+
// Function props that never left JS, keyed by node.
|
|
421
|
+
//
|
|
422
|
+
// A function CANNOT cross this wire. The host stores props as a `folly::dynamic` and
|
|
423
|
+
// `jsi::dynamicFromValue` THROWS on a callable — "JS Functions are not convertible to dynamic" —
|
|
424
|
+
// so a single function prop kills the whole batch, and with it the commit that carried it.
|
|
425
|
+
//
|
|
426
|
+
// It has always been unsendable and it used to be unreachable, because `routeProp` diverts every
|
|
427
|
+
// REGISTERED `on*` name into the listener stash before this point. Two paths get past that and both
|
|
428
|
+
// are real: an unregistered `on*` that is an ordinary prop by design (`onValueChange`, which
|
|
429
|
+
// `fabricProps` drops on the way to native and a behavior reads back), and `setNativeProps`, which
|
|
430
|
+
// bypasses `routeProp` entirely. Device-found 2026-09-08 through the second: an `Animated.View`
|
|
431
|
+
// spread with `panResponder.panHandlers` hands `AnimatedProps.__getValue()` a bag of callbacks, and
|
|
432
|
+
// it copies every key it holds.
|
|
433
|
+
//
|
|
434
|
+
// So they live here, exactly as listeners already do — and `propOf` looks here first, which is what
|
|
435
|
+
// keeps `onValueChange` readable. Nothing is lost on the native side: `fabricProps` dropped function
|
|
436
|
+
// props on both hosts anyway, so the payload is byte-identical either way.
|
|
437
|
+
const functionProps = new WeakMap();
|
|
438
|
+
/**
|
|
439
|
+
* The one place a prop reaches the wire, and the only place that can keep a function off it.
|
|
440
|
+
*
|
|
441
|
+
* `setNativeProps` calls this rather than `recordSetProp` for that reason — it is the path that has
|
|
442
|
+
* no `routeProp` in front of it.
|
|
443
|
+
*/
|
|
444
|
+
export function writeProp(node, key, value) {
|
|
445
|
+
// The same strip `routeProp` does, repeated because THIS is the path with no `routeProp` in
|
|
446
|
+
// front of it (`imperative.ts` says so). One filter on the declarative path was never enough:
|
|
447
|
+
// `AnimatedProps` is built from a RAW prop bag and re-sends every key it holds on every frame,
|
|
448
|
+
// so on a JSX adapter `__self` rode straight past the strip into the host. See
|
|
449
|
+
// REACT_JSX_DEV_PROPS for what that costs on each platform.
|
|
450
|
+
if (REACT_JSX_DEV_PROPS.has(key))
|
|
451
|
+
return;
|
|
452
|
+
// Arms the node's recurring post-commit hook, for the rare node that has one. HERE rather than in
|
|
453
|
+
// `setProp`, because this is where both paths meet: `setNativeProps` reaches the wire through
|
|
454
|
+
// this function and not through that one, and a hook armed only by the declarative path missed
|
|
455
|
+
// the imperative write entirely (`__tests__/after-commit-lifecycle.test.ts` said so). The field
|
|
456
|
+
// read is the same shape as `hasAriaAlias` and for the same reason — this is the hottest path in
|
|
457
|
+
// the engine, and a Set lookup per write is not something it can carry.
|
|
458
|
+
if (node.hasCommitHook)
|
|
459
|
+
noteCommitHookNodeChanged(node);
|
|
460
|
+
// Resolved on the way IN, for the same reason the strip above lives here: this is where both
|
|
461
|
+
// paths meet. `boxShadow` / `filter` / `transform` and the four beside them are parsed in JS,
|
|
462
|
+
// and the C++ payload builder has no JS — so a value resolved at payload-build time is resolved
|
|
463
|
+
// headless only, and the device commits the raw CSS string, which Fabric drops in silence. See
|
|
464
|
+
// `structured-style.ts`; it hands the same object back when nothing needed resolving, which is
|
|
465
|
+
// what keeps the host's identity guard and `pushClassStyle` working.
|
|
466
|
+
// Image's three source props, resolved on the way in for the same reason and at the same seam —
|
|
467
|
+
// the asset lookup is Metro's registry, which exists only in JS. See `image-source-write.ts`.
|
|
468
|
+
// Gated on the node: the resolution normalises to Image's ARRAY shape, and a `WebView` spells
|
|
469
|
+
// `source` too.
|
|
470
|
+
let written = value;
|
|
471
|
+
if (key === 'style' || key === 'activeStyle') {
|
|
472
|
+
written = resolveStructuredStyle(value);
|
|
473
|
+
}
|
|
474
|
+
else if (node.resolvesImageSources && IMAGE_SOURCE_PROPS.has(key)) {
|
|
475
|
+
written = resolveImageSourceProp(value);
|
|
476
|
+
}
|
|
477
|
+
if (typeof written === 'function') {
|
|
478
|
+
let bag = functionProps.get(node);
|
|
479
|
+
if (bag === undefined) {
|
|
480
|
+
bag = new Map();
|
|
481
|
+
functionProps.set(node, bag);
|
|
326
482
|
}
|
|
327
|
-
|
|
483
|
+
bag.set(key, value);
|
|
484
|
+
// The host must not be left holding whatever stood under this key before — a stale value read
|
|
485
|
+
// back through `propOf` would beat the function this write just stashed.
|
|
486
|
+
recordSetProp(node, key, undefined);
|
|
487
|
+
return;
|
|
328
488
|
}
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
489
|
+
// Written over with a non-function: the stash must let go, or it keeps answering.
|
|
490
|
+
const bag = functionProps.get(node);
|
|
491
|
+
if (bag !== undefined)
|
|
492
|
+
bag.delete(key);
|
|
493
|
+
recordSetProp(node, key, written);
|
|
494
|
+
}
|
|
495
|
+
/** What `propOf` consults before asking the host. `undefined` when nothing was stashed. */
|
|
496
|
+
export function functionPropOf(node, key) {
|
|
497
|
+
return functionProps.get(node)?.get(key);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The same stash, whole — what `propsOf` layers over the host's answer.
|
|
501
|
+
*
|
|
502
|
+
* `undefined` rather than an empty Map for a node that stashed nothing, which is nearly every node:
|
|
503
|
+
* the caller then hands back the host's own object instead of copying it.
|
|
504
|
+
*/
|
|
505
|
+
export function functionPropsOf(node) {
|
|
506
|
+
return functionProps.get(node);
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* "Rebuild this node's payload — the fold reads state I just changed."
|
|
510
|
+
*
|
|
511
|
+
* A behavior whose payload is DERIVED has no prop to write: the sticky header's debounced
|
|
512
|
+
* translateY lives in its own runtime, not on the node, so nothing names the node and the host
|
|
513
|
+
* never marks it. This is the one route that says so directly.
|
|
514
|
+
*
|
|
515
|
+
* Dirtying is not publishing — pair it with `requestCommitFor` (imperative.ts).
|
|
516
|
+
*/
|
|
517
|
+
export function markPropsDirty(node) {
|
|
518
|
+
flushOps();
|
|
519
|
+
// Announced to the buffer even though it writes no op: this is the one route that dirties a node
|
|
520
|
+
// without one, and a commit that cannot see it would skip itself as idle.
|
|
521
|
+
noteHostSideChange();
|
|
522
|
+
// The other way a node's payload is rebuilt, and the beat's population must cover both or a
|
|
523
|
+
// behavior whose payload is DERIVED — the sticky header's debounced translateY has no prop to
|
|
524
|
+
// write — would be armed by nothing.
|
|
525
|
+
if (node.hasCommitHook)
|
|
526
|
+
noteCommitHookNodeChanged(node);
|
|
527
|
+
treeHost()?.markPropsDirty(node);
|
|
336
528
|
}
|
|
337
529
|
// Fabric gates a handful of events behind a BOOLEAN prop: unlike scroll / touch / change, which
|
|
338
530
|
// the native component emits unconditionally, these fire only when the shadow node carries the
|
|
@@ -371,12 +563,20 @@ const GATED_EVENT_PROPS = new Map([
|
|
|
371
563
|
* `setEventListener` diverts an owned name into the stash, which is right for an app listener and
|
|
372
564
|
* circular for the behavior's own dispatcher — it would stash itself and never occupy the slot it
|
|
373
565
|
* exists to hold. This is the one writer allowed past that gate.
|
|
566
|
+
*
|
|
567
|
+
* `undefined` removes it, gate flag included. A behavior whose dispatcher is conditional needs
|
|
568
|
+
* that as much as it needs the install: ScrollView takes the owner's `layout` only while the app
|
|
569
|
+
* or an inverted sticky header wants it, and a one-way installer leaves `onLayout: true` standing
|
|
570
|
+
* in the payload of a ScrollView that no longer reads the event.
|
|
374
571
|
*/
|
|
375
572
|
export function setBehaviorListener(node, name, listener) {
|
|
376
|
-
(
|
|
573
|
+
if (listener === undefined)
|
|
574
|
+
node.listeners?.delete(name);
|
|
575
|
+
else
|
|
576
|
+
(node.listeners ??= new Map()).set(name, listener);
|
|
377
577
|
const flagProp = GATED_EVENT_PROPS.get(name);
|
|
378
578
|
if (flagProp !== undefined)
|
|
379
|
-
setProp(node, flagProp, true);
|
|
579
|
+
setProp(node, flagProp, listener === undefined ? undefined : true);
|
|
380
580
|
}
|
|
381
581
|
export function setEventListener(node, name, value) {
|
|
382
582
|
const isHandler = typeof value === 'function';
|
|
@@ -385,10 +585,22 @@ export function setEventListener(node, name, value) {
|
|
|
385
585
|
// without this the two evict each other and the last writer wins with no diagnostic; and the
|
|
386
586
|
// keys at stake are the ones a gesture STARTS on, so the loser is silently pressless. The
|
|
387
587
|
// component wrapper used to mediate this by destructuring the app's callbacks out before they
|
|
388
|
-
// reached the node;
|
|
389
|
-
//
|
|
588
|
+
// reached the node; a tag has no mediator. Gated on the boolean first, so an app with no behavior
|
|
589
|
+
// registered pays one read.
|
|
390
590
|
if (hasHostBehaviors() && ownsListener(node, name)) {
|
|
591
|
+
// The PRESENCE only, never the identity: listeners deliberately do not notify (a framework
|
|
592
|
+
// hands a fresh closure nearly every render — see `markDirty`'s note on why that must stay
|
|
593
|
+
// free). A flip is a mount-time event, not a per-render one.
|
|
594
|
+
const wasWired = appListenerFor(node, name) !== undefined;
|
|
391
595
|
stashAppListener(node, name, isHandler ? value : undefined);
|
|
596
|
+
if (wasWired !== isHandler) {
|
|
597
|
+
// The BIT, on the flip only. A platform rule can then resolve a key that depends on whether
|
|
598
|
+
// the app wired anything — `focusable` on a touchable is the case that needed it — without
|
|
599
|
+
// the closure ever leaving JS. Ordered before the notify so a behavior that re-commits from
|
|
600
|
+
// that callback finds the host already holding the new answer.
|
|
601
|
+
recordSetOwnedListener(node, name, isHandler);
|
|
602
|
+
notifyOwnedListenerChange(node, name, isHandler);
|
|
603
|
+
}
|
|
392
604
|
const flagged = GATED_EVENT_PROPS.get(name);
|
|
393
605
|
if (flagged !== undefined)
|
|
394
606
|
setProp(node, flagged, isHandler ? true : undefined);
|
|
@@ -405,8 +617,28 @@ export function setEventListener(node, name, value) {
|
|
|
405
617
|
const flagProp = GATED_EVENT_PROPS.get(name);
|
|
406
618
|
if (flagProp !== undefined)
|
|
407
619
|
setProp(node, flagProp, isHandler ? true : undefined);
|
|
620
|
+
// `onLoad`/`onLoadStart`/`onLoadEnd`/`onError` are real Fabric events on `RCTImageView`
|
|
621
|
+
// (`view-config.ts`), so they land here rather than in `writeProp` — see
|
|
622
|
+
// `image-source-write.ts` for why Android's `shouldNotifyLoadEvents` has to be synthesized
|
|
623
|
+
// from the listener map instead of from a stashed function value.
|
|
624
|
+
if (node.resolvesImageSources && IMAGE_LOAD_EVENT_NAMES.has(name)) {
|
|
625
|
+
setProp(node, 'shouldNotifyLoadEvents', anyImageLoadEventListenerWired(node.listeners) ? true : undefined);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
// `/^on[A-Z]/` spelled out, because this runs on EVERY prop write and a regex is the one guard in
|
|
629
|
+
// that sequence that is not obviously cheap. Priced on `build-release`
|
|
630
|
+
// (`mutation-api-fill-cost.itest.ts`): 0.09 us for the regex against 0.03 for the character reads,
|
|
631
|
+
// on a prop write that costs 0.88 us end to end — so ~7% of a write, ~1% of a create. Small, and it
|
|
632
|
+
// is free: the boundary is pinned by its own tests in `node.test.ts`.
|
|
633
|
+
//
|
|
634
|
+
// 111 is 'o', 110 is 'n', and 65-90 is A-Z. `charCodeAt` past the end answers NaN, which fails every
|
|
635
|
+
// comparison — so a two-character `on` needs no length check.
|
|
636
|
+
function isOnEventName(key) {
|
|
637
|
+
if (key.charCodeAt(0) !== 111 || key.charCodeAt(1) !== 110)
|
|
638
|
+
return false;
|
|
639
|
+
const third = key.charCodeAt(2);
|
|
640
|
+
return third >= 65 && third <= 90;
|
|
408
641
|
}
|
|
409
|
-
const ON_PREFIX = /^on[A-Z]/;
|
|
410
642
|
// onChange -> change
|
|
411
643
|
function listenerName(propName) {
|
|
412
644
|
return propName.charAt(2).toLowerCase() + propName.slice(3);
|
|
@@ -436,9 +668,17 @@ const RESPONDER_EVENTS = new Set([
|
|
|
436
668
|
// consumes both and never forwards them. A JSX-based adapter (Vue JSX, Solid JSX) instead
|
|
437
669
|
// carries them onto the vnode as ordinary props, so they reach setProp and then Fabric,
|
|
438
670
|
// where Android's folly::dynamic rejects __self with "JS Functions are not convertible to
|
|
439
|
-
// dynamic" (the instance holds functions) and the surface paints black
|
|
440
|
-
//
|
|
441
|
-
//
|
|
671
|
+
// dynamic" (the instance holds functions) and the surface paints black.
|
|
672
|
+
//
|
|
673
|
+
// "WHILE IOS SILENTLY DROPS IT" IS WHAT THIS COMMENT USED TO SAY, AND IT IS WRONG. iOS converts
|
|
674
|
+
// the same value with `jsi::dynamicFromValue`, whose walk keeps no visited set, and `__self` is a
|
|
675
|
+
// module `this` — cyclic. That is not a drop, it is an endless walk inside `applyOps` that never
|
|
676
|
+
// returns and allocates as it goes: measured 2026-09-09 on examples/solid, one press on an
|
|
677
|
+
// Animated control, RAM to 15 GB and a dead JS thread. Android's loud rejection is the FRIENDLIER
|
|
678
|
+
// of the two platforms here.
|
|
679
|
+
//
|
|
680
|
+
// SFC/template authoring never produces them. Strip them here, once, so no adapter leaks React
|
|
681
|
+
// JSX dev metadata to the host, mirroring React's host config.
|
|
442
682
|
const REACT_JSX_DEV_PROPS = new Set([
|
|
443
683
|
'__self',
|
|
444
684
|
'__source',
|
|
@@ -460,6 +700,7 @@ function stylePartsOf(node) {
|
|
|
460
700
|
isPressed: false,
|
|
461
701
|
activeStyle: undefined,
|
|
462
702
|
activeStyleFromCallback: false,
|
|
703
|
+
published: undefined,
|
|
463
704
|
});
|
|
464
705
|
}
|
|
465
706
|
// What belongs in slot 0 right now. The pressed variant is a complete REPLACEMENT rather than an
|
|
@@ -530,19 +771,149 @@ function baseStyleOf(parts) {
|
|
|
530
771
|
// constant - StyleSheet.create, a module-level object - would then re-push an identity-equal half,
|
|
531
772
|
// get skipped by setProp's Object.is guard, and never restore the declarative style the animation
|
|
532
773
|
// overwrote. The re-push IS the restore path.
|
|
533
|
-
// Would `pushClassStyle` republish an array byte-identical to the one already standing?
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
// and
|
|
541
|
-
//
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
774
|
+
// Would `pushClassStyle` republish an array byte-identical to the one already standing?
|
|
775
|
+
//
|
|
776
|
+
// Sound because `pushClassStyle` is the ONLY writer of `parts.published` — both routeProp branches
|
|
777
|
+
// and setNodeHidden funnel through it — so a node that has published nothing holds `undefined` and
|
|
778
|
+
// the first write can never be swallowed.
|
|
779
|
+
// What a node publishes when NOTHING resolves — an unstyled node, or the benchmark row's
|
|
780
|
+
// `style={isSelected ? {…} : undefined}` on the 999 rows that are not selected. Length 0 is the
|
|
781
|
+
// marker and needs no second field: `pushClassStyle` never publishes an empty array otherwise, so
|
|
782
|
+
// the state is unambiguous, and it is distinct from `undefined`, which means "nothing published
|
|
783
|
+
// yet" and must never be turned away.
|
|
784
|
+
const PUBLISHED_NOTHING = Object.freeze([]);
|
|
785
|
+
// ── ONE ARRAY PER DISTINCT PAIR, SHARED ACROSS NODES ─────────────────────────────────────────────
|
|
786
|
+
//
|
|
787
|
+
// The array above is per-node and identical for every node styled the same way, which is the normal
|
|
788
|
+
// case for a list: one `StyleSheet.create` object, or one resolved CSS class, across a thousand rows.
|
|
789
|
+
// `mutation-buffer.ts` interns the values it is handed BY IDENTITY, so a thousand distinct-but-equal
|
|
790
|
+
// arrays are a thousand entries and a thousand JS -> `folly::dynamic` conversions on the far side.
|
|
791
|
+
// Measured on `build-release`: 4 000 of 12 005 `setProp` ops refused to fold, and they were exactly
|
|
792
|
+
// these.
|
|
793
|
+
//
|
|
794
|
+
// `WeakMap`, and both levels of it, so nothing here can grow without bound: a caller that builds a
|
|
795
|
+
// fresh style object per render gets a fresh cache entry that dies with the object. That caller also
|
|
796
|
+
// gets no sharing, which is correct — two structurally equal objects are two values to whoever reads
|
|
797
|
+
// them, and a deep compare would make every prop write cost the size of the style.
|
|
798
|
+
//
|
|
799
|
+
// The three-slot (hidden) form is deliberately NOT cached. `display: 'none'` is a state almost no
|
|
800
|
+
// node is ever in, so a third map would be paid for on every write to serve a case that is rare by
|
|
801
|
+
// construction.
|
|
802
|
+
const sharedPairByExplicit = new WeakMap();
|
|
803
|
+
const sharedPairByBase = new WeakMap();
|
|
804
|
+
/**
|
|
805
|
+
* The published array for this pair — the same object every time the same two parts are handed in.
|
|
806
|
+
*
|
|
807
|
+
* `undefined` when the pair cannot be keyed (a primitive half, or the hidden form), and the caller
|
|
808
|
+
* then builds its own array exactly as before. Sharing is an optimization here, never a requirement:
|
|
809
|
+
* every reader of `published` compares its SLOTS by identity, never the array itself.
|
|
810
|
+
*/
|
|
811
|
+
function sharedStylePair(base, explicit) {
|
|
812
|
+
const baseIsKeyable = typeof base === 'object' && base !== null;
|
|
813
|
+
const explicitIsKeyable = typeof explicit === 'object' && explicit !== null;
|
|
814
|
+
if (base === undefined && explicitIsKeyable) {
|
|
815
|
+
const cached = sharedPairByExplicit.get(explicit);
|
|
816
|
+
if (cached !== undefined)
|
|
817
|
+
return cached;
|
|
818
|
+
const made = [base, explicit];
|
|
819
|
+
sharedPairByExplicit.set(explicit, made);
|
|
820
|
+
return made;
|
|
821
|
+
}
|
|
822
|
+
if (!baseIsKeyable)
|
|
823
|
+
return undefined;
|
|
824
|
+
if (explicit === undefined) {
|
|
825
|
+
const cached = sharedPairByBase.get(base);
|
|
826
|
+
if (Array.isArray(cached))
|
|
827
|
+
return cached;
|
|
828
|
+
if (cached === undefined) {
|
|
829
|
+
const made = [base, explicit];
|
|
830
|
+
sharedPairByBase.set(base, made);
|
|
831
|
+
return made;
|
|
832
|
+
}
|
|
833
|
+
// A base that has already been seen WITH an explicit half holds the second-level map here, and
|
|
834
|
+
// the base-only array has nowhere to live beside it. Rare enough not to earn a third map.
|
|
835
|
+
return undefined;
|
|
836
|
+
}
|
|
837
|
+
if (!explicitIsKeyable)
|
|
838
|
+
return undefined;
|
|
839
|
+
const existing = sharedPairByBase.get(base);
|
|
840
|
+
const byExplicit = existing instanceof WeakMap ? existing : new WeakMap();
|
|
841
|
+
if (existing === undefined)
|
|
842
|
+
sharedPairByBase.set(base, byExplicit);
|
|
843
|
+
// Same clash as above, the other way round: this base is holding its base-only array. Leave it.
|
|
844
|
+
if (Array.isArray(existing))
|
|
845
|
+
return undefined;
|
|
846
|
+
const cached = byExplicit.get(explicit);
|
|
847
|
+
if (cached !== undefined)
|
|
848
|
+
return cached;
|
|
849
|
+
const made = [base, explicit];
|
|
850
|
+
byExplicit.set(explicit, made);
|
|
851
|
+
return made;
|
|
852
|
+
}
|
|
853
|
+
// A slot that contributes no keys to the payload: absent, or the registry's shared "this class
|
|
854
|
+
// styles nothing" object. An IDENTITY compare rather than a key count — `Object.keys(x).length`
|
|
855
|
+
// allocates an array, and this runs on every class and style write, ~14 000 times on one benchmark
|
|
856
|
+
// create. That is the F-12 shape: an expensive guard in front of cheap work.
|
|
857
|
+
/** A plain style bag — not an array of styles, not a callback, not null. */
|
|
858
|
+
function isStyleRecord(value) {
|
|
859
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Is this rebuilt style the same style, key for key?
|
|
863
|
+
*
|
|
864
|
+
* A component body that writes its style inline hands over a FRESH object every render, equal to
|
|
865
|
+
* the one already standing — the commonest shape any app produces, and one `Object.is` cannot see.
|
|
866
|
+
* Without this the write crosses into the host, becomes a `folly::dynamic`, and is only THEN found
|
|
867
|
+
* to be unchanged. Measured on `build-release` (`no-op-rerender-cost.itest.ts`), 1 000 rows
|
|
868
|
+
* re-rendered with nothing changed: 9.7 ms against 0.3 ms for the same app with its style hoisted,
|
|
869
|
+
* and 5.2 ms of that was the conversion. The cheapest place to refuse a write is the earliest place
|
|
870
|
+
* that can see it is a no-op.
|
|
871
|
+
*
|
|
872
|
+
* SHALLOW AND CONSERVATIVE, both deliberately. A nested value (a transform list, a shadow, a style
|
|
873
|
+
* array) reports "not the same" rather than being compared deeply, because a deep compare makes this
|
|
874
|
+
* guard cost the size of the style — which is the cost it exists to avoid. Those keep crossing and
|
|
875
|
+
* the host's own `diffProps` refuses them exactly as before, so being wrong here is slow, never
|
|
876
|
+
* incorrect.
|
|
877
|
+
*
|
|
878
|
+
* `undefined` on either side also reports "not the same", which is what lets the key COUNT stand in
|
|
879
|
+
* for a key-set comparison: equal counts plus every key of `next` matching a defined value in
|
|
880
|
+
* `standing` cannot leave a key unaccounted for.
|
|
881
|
+
*/
|
|
882
|
+
export function isSameShallowStyle(next, standing) {
|
|
883
|
+
if (!isStyleRecord(next) || !isStyleRecord(standing))
|
|
884
|
+
return false;
|
|
885
|
+
const keys = Object.keys(next);
|
|
886
|
+
if (keys.length !== Object.keys(standing).length)
|
|
545
887
|
return false;
|
|
888
|
+
for (const key of keys) {
|
|
889
|
+
const value = next[key];
|
|
890
|
+
if (value === undefined || isStyleRecord(value) || Array.isArray(value)) {
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
if (!Object.is(value, standing[key]))
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
return true;
|
|
897
|
+
}
|
|
898
|
+
function contributesNothing(slot) {
|
|
899
|
+
return slot === undefined || slot === EMPTY_STYLE;
|
|
900
|
+
}
|
|
901
|
+
// Does this node have a style at all? Read through the same two resolvers as the publication, for
|
|
902
|
+
// the reason the guard below states: guard and publication disagreeing is a silent wrong screen.
|
|
903
|
+
function hasNothingToPublish(parts) {
|
|
904
|
+
return (parts.hiddenStyle === undefined &&
|
|
905
|
+
contributesNothing(baseStyleOf(parts)) &&
|
|
906
|
+
contributesNothing(explicitStyleOf(parts)));
|
|
907
|
+
}
|
|
908
|
+
function isAlreadyPublished(parts) {
|
|
909
|
+
const published = parts.published;
|
|
910
|
+
if (published === undefined)
|
|
911
|
+
return false;
|
|
912
|
+
// The delete is already standing. Asked before the slot comparisons because an empty array would
|
|
913
|
+
// otherwise pass both of them on `undefined` and then fail the length check, republishing a
|
|
914
|
+
// delete the host already performed.
|
|
915
|
+
if (published.length === 0)
|
|
916
|
+
return hasNothingToPublish(parts);
|
|
546
917
|
// `baseStyleOf`, not `parts.classStyle` — the guard and the publication must read slot 0 the
|
|
547
918
|
// same way or a press is turned away as already-published and silently does nothing on device
|
|
548
919
|
// while the behavior fires correctly and nothing goes red.
|
|
@@ -561,31 +932,49 @@ function pushClassStyle(node, parts) {
|
|
|
561
932
|
// an UNCHANGED class still lands as a write AND marks the node dirty. Costs React / Vue / Svelte
|
|
562
933
|
// nothing — each diffs props before calling the engine — but Solid has no diff: a fine-grained
|
|
563
934
|
// effect re-runs whenever any signal it reads changes, so a list-wide signal makes every row
|
|
564
|
-
// re-push its own unchanged class. Measured on device 2026-08-23 (examples/solid,
|
|
565
|
-
//
|
|
935
|
+
// re-push its own unchanged class. Measured on device 2026-08-23 (examples/solid, once its
|
|
936
|
+
// primitives were tags): selecting one row of 1 000 read WRITES 1001 and a 10.3 ms reconcile
|
|
566
937
|
// window against Fabric's unmoved 0/0/10 — a thousand-node dirty walk for two nodes of change.
|
|
567
|
-
// Before lowering, the View component's splitProps/mergeProps memos had been absorbing it.
|
|
568
938
|
//
|
|
569
|
-
// This is NOT the naive skip the paragraph above forbids, and the
|
|
570
|
-
// Skipping on "the parts are unchanged" alone would break the restore path, because
|
|
571
|
-
// setNativeProps writes
|
|
572
|
-
// be restored. But setNativeProps
|
|
573
|
-
//
|
|
574
|
-
//
|
|
939
|
+
// This is NOT the naive skip the paragraph above forbids, and the published marker is the
|
|
940
|
+
// difference. Skipping on "the parts are unchanged" alone would break the restore path, because
|
|
941
|
+
// setNativeProps writes the style slot past this function and a hoisted style constant would then
|
|
942
|
+
// never be restored. But setNativeProps CLEARS `parts.published` — so after any bypass
|
|
943
|
+
// isAlreadyPublished is false, the re-push happens exactly as before, and the restore path is
|
|
944
|
+
// untouched.
|
|
575
945
|
//
|
|
576
946
|
// Exact rather than approximate: resolveClassName memoizes a class STRING to the same object, so
|
|
577
947
|
// an unchanged class yields an identity-equal classStyle. It deliberately does not fire for an
|
|
578
|
-
// object/array class value, which resolves fresh every call — the same place
|
|
579
|
-
//
|
|
580
|
-
if (isAlreadyPublished(
|
|
948
|
+
// object/array class value, which resolves fresh every call — the same place the host's own
|
|
949
|
+
// Object.is guard gives up on a style object, so no new asymmetry appears.
|
|
950
|
+
if (isAlreadyPublished(parts))
|
|
581
951
|
return;
|
|
952
|
+
// NOTHING RESOLVED, so say nothing. The buffer spells an absent prop as `NO_VALUE` and the host
|
|
953
|
+
// then takes a path that costs it literally one branch — `if (props.get_ptr(key) == nullptr)
|
|
954
|
+
// break` — while `[undefined, undefined]` is a real value it must convert into a `folly::dynamic`
|
|
955
|
+
// array, store, and re-compare on every later commit. Both are behaviourally "no style": the
|
|
956
|
+
// payload builder flattens the pair of undefineds into no keys at all.
|
|
957
|
+
//
|
|
958
|
+
// This is NOT the naive skip the note above forbids, and the distinction is the same one the
|
|
959
|
+
// published marker makes. A restore after `setNativeProps` arrives here with `published` cleared
|
|
960
|
+
// to `undefined`, so it is never turned away — and when the authored style is nothing, restoring
|
|
961
|
+
// it means DELETING the slot the imperative write put there, which is what this emits.
|
|
962
|
+
if (hasNothingToPublish(parts)) {
|
|
963
|
+
parts.published = PUBLISHED_NOTHING;
|
|
964
|
+
setProp(node, 'style', undefined);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
582
967
|
// The third slot is APPENDED ONLY WHILE HIDDEN. Writing a permanent three-element array would
|
|
583
968
|
// change the style payload of every node in every app for a state almost none of them are ever
|
|
584
969
|
// in — and this project spent a day removing per-frame allocations, so a slot that is undefined
|
|
585
970
|
// 99.9% of the time does not get to ride along on every style write.
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
971
|
+
const base = baseStyleOf(parts);
|
|
972
|
+
const explicit = explicitStyleOf(parts);
|
|
973
|
+
const published = parts.hiddenStyle === undefined
|
|
974
|
+
? (sharedStylePair(base, explicit) ?? [base, explicit])
|
|
975
|
+
: [base, explicit, parts.hiddenStyle];
|
|
976
|
+
parts.published = published;
|
|
977
|
+
setProp(node, 'style', published);
|
|
589
978
|
}
|
|
590
979
|
// `display: 'none'` is a real RN style value (Yoga's DisplayNone), so a hidden node keeps its
|
|
591
980
|
// place in the tree, its state and its children — it just stops laying out and painting.
|
|
@@ -620,6 +1009,35 @@ export function setNodePressed(node, pressed) {
|
|
|
620
1009
|
parts.isPressed = pressed;
|
|
621
1010
|
pushClassStyle(node, parts);
|
|
622
1011
|
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Tell the host a behavior's FEEDBACK is showing — TouchableHighlight's underlay, and only that.
|
|
1014
|
+
*
|
|
1015
|
+
* The sibling of `setNodePressed` and deliberately NOT the same bit. Press state drives `:active`
|
|
1016
|
+
* class resolution, which happens in JS because a class name resolves against a JS registry; this
|
|
1017
|
+
* drives a rule that lives in C++ (`foldTouchableHighlightUnderlay`), so it crosses as one op rather
|
|
1018
|
+
* than resolving to a style here. And the two are genuinely different facts: RN holds the underlay
|
|
1019
|
+
* past release so a fast tap still flashes, so `shown` LAGS `pressed` by a `delayPressOut` timer.
|
|
1020
|
+
*
|
|
1021
|
+
* No style is computed on this side at all, which is the whole point — the two props the rule reads
|
|
1022
|
+
* (`underlayColor`, `activeOpacity`) are ones the engine already strips from the payload, so the
|
|
1023
|
+
* values and their defaults live in one place instead of being erased in C++ and reached around for
|
|
1024
|
+
* in JS.
|
|
1025
|
+
*/
|
|
1026
|
+
export function setNodeUnderlayShown(node, shown) {
|
|
1027
|
+
recordSetUnderlayShown(node, shown);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Forget what was last published, so the next `pushClassStyle` cannot be turned away.
|
|
1031
|
+
*
|
|
1032
|
+
* The one caller is `setNativeProps` (imperative.ts), which writes the style slot past this file —
|
|
1033
|
+
* see the note on `IClassStyleParts.published` for why the restore path depends on this. A no-op for
|
|
1034
|
+
* a node nobody has styled, which is why it is not `stylePartsOf(node).published = undefined`: that
|
|
1035
|
+
* would allocate the parts on a node that has none.
|
|
1036
|
+
*/
|
|
1037
|
+
export function clearPublishedStyle(node) {
|
|
1038
|
+
if (node.styleParts !== undefined)
|
|
1039
|
+
node.styleParts.published = undefined;
|
|
1040
|
+
}
|
|
623
1041
|
// The explicit (non-class-derived) style half, for an adapter that builds its style prop up
|
|
624
1042
|
// key-by-key (Angular's Ivy ɵɵstyleProp/setStyle) instead of handing over one whole object —
|
|
625
1043
|
// it must merge onto this, not onto node.props.style directly, which may be the
|
|
@@ -627,20 +1045,129 @@ export function setNodePressed(node, pressed) {
|
|
|
627
1045
|
export function getExplicitStyle(node) {
|
|
628
1046
|
return node.styleParts?.explicitStyle;
|
|
629
1047
|
}
|
|
1048
|
+
/**
|
|
1049
|
+
* The `[classStyle, explicitStyle]` pair the node currently PUBLISHES — the same value
|
|
1050
|
+
* `commitClassStyle` writes, in the same order, so `flattenStyle` collapses it the way Fabric will.
|
|
1051
|
+
*
|
|
1052
|
+
* For a caller that wants the merged answer without a host: the pair reaches the payload as an op,
|
|
1053
|
+
* and only a host holds ops. A test that has not installed one — `core/css-parser` reaches into the
|
|
1054
|
+
* engine by relative path and depends on neither package — can read it here instead of reaching
|
|
1055
|
+
* into `styleParts`, which is engine-owned and not a shape anything outside may bind to.
|
|
1056
|
+
*/
|
|
1057
|
+
export function getPublishedStyle(node) {
|
|
1058
|
+
const parts = node.styleParts;
|
|
1059
|
+
if (parts === undefined)
|
|
1060
|
+
return [];
|
|
1061
|
+
return [parts.classStyle, parts.explicitStyle];
|
|
1062
|
+
}
|
|
630
1063
|
const CLASS_PROP_KEYS = new Set(['class', 'className']);
|
|
631
1064
|
// The flat-bag split (React / Vue / Solid): an `onX` prop becomes an event listener
|
|
632
1065
|
// ONLY when the node's component actually declares `x` as an event (per the shared
|
|
633
1066
|
// ViewConfig). Otherwise it is a plain prop, so `onTintColor` on a Switch, whose
|
|
634
1067
|
// only event is `change`, routes to setProp and reaches Fabric.
|
|
1068
|
+
// `id` is RN's W3C-named alias for `nativeID` and it WINS when both are set
|
|
1069
|
+
// (`View.js:77-79` — `processedProps.nativeID = id`). A raw `id` is declared by no ViewConfig, so
|
|
1070
|
+
// Fabric drops it in SILENCE: a rename that half-works loses the nativeID with nothing red anywhere.
|
|
1071
|
+
const ID_ALIAS_FROM = 'id';
|
|
1072
|
+
const ID_ALIAS_TO = 'nativeID';
|
|
1073
|
+
/**
|
|
1074
|
+
* The ONE place this rename happens, as of 2026-09-18. It used to happen in SEVEN.
|
|
1075
|
+
*
|
|
1076
|
+
* `foldHostBag` did it for React, Svelte and Angular off `HOST_PRIMITIVES[*].aliases` (seventeen
|
|
1077
|
+
* identical entries); Vue's `patchProp`, Solid's renderer and Angular's own `PROP_ALIASES` each did
|
|
1078
|
+
* it again per key; and `foldIdAlias` did it a seventh time in C++. Three different coverage sets,
|
|
1079
|
+
* so the answer depended on which adapter you were on and whether the node's tag happened to carry a
|
|
1080
|
+
* registered behavior — `core/engine/cpp/tests/js/id-alias-coverage.itest.ts` measured that split
|
|
1081
|
+
* before this collapsed it.
|
|
1082
|
+
*
|
|
1083
|
+
* HERE because this is the funnel: every adapter's prop write ends at `routeProp`, whatever shape it
|
|
1084
|
+
* starts in. A bag fold cannot serve the per-key renderers and a per-key fold cannot serve the bag
|
|
1085
|
+
* ones; the seam they SHARE can serve both.
|
|
1086
|
+
*
|
|
1087
|
+
* PRECEDENCE IS WHY THIS NEEDS STATE. Upstream reads both props at once, so `id ?? nativeID` is
|
|
1088
|
+
* decided in one expression. A per-key writer never sees both, so precedence would otherwise fall
|
|
1089
|
+
* out of write ORDER — `<view id nativeID>` keeping the stale legacy value while `<view nativeID id>`
|
|
1090
|
+
* did not. Solid had already built exactly this memory for exactly this reason; it is one copy now.
|
|
1091
|
+
*
|
|
1092
|
+
* The authored `nativeID` is REMEMBERED rather than discarded, so clearing the `id` hands the slot
|
|
1093
|
+
* back instead of latching. A framework that unsets a prop between renders must get the other
|
|
1094
|
+
* source back.
|
|
1095
|
+
*
|
|
1096
|
+
* PRECEDENCE ITSELF IS PER-COMPONENT. `View.js`'s `id ?? nativeID` is the default (`idWins` below),
|
|
1097
|
+
* but `TouchableWithoutFeedback.js`'s clone composes that and then runs a `PASSTHROUGH_PROPS` loop
|
|
1098
|
+
* that unconditionally overwrites `nativeID` with the raw authored value when set (`:279-282`) — an
|
|
1099
|
+
* app authoring both ends up with `nativeID` winning there. `node.nativeIdWinsOverId`, set by
|
|
1100
|
+
* `attachHostBehavior` for the one behavior that declares it, flips which side wins.
|
|
1101
|
+
*/
|
|
1102
|
+
const idAliased = new WeakMap();
|
|
1103
|
+
function routeIdAlias(node, key, value) {
|
|
1104
|
+
const state = idAliased.get(node) ?? {
|
|
1105
|
+
idValue: undefined,
|
|
1106
|
+
nativeIdValue: undefined,
|
|
1107
|
+
};
|
|
1108
|
+
if (key === ID_ALIAS_FROM)
|
|
1109
|
+
state.idValue = value;
|
|
1110
|
+
else
|
|
1111
|
+
state.nativeIdValue = value;
|
|
1112
|
+
idAliased.set(node, state);
|
|
1113
|
+
const published = node.nativeIdWinsOverId
|
|
1114
|
+
? (state.nativeIdValue ?? state.idValue)
|
|
1115
|
+
: (state.idValue ?? state.nativeIdValue);
|
|
1116
|
+
setProp(node, ID_ALIAS_TO, published);
|
|
1117
|
+
}
|
|
635
1118
|
export function routeProp(node, key, value) {
|
|
636
1119
|
if (REACT_JSX_DEV_PROPS.has(key))
|
|
637
1120
|
return;
|
|
1121
|
+
// The prop twin of the child redirect in `appendChild`. A composed primitive's owner is written
|
|
1122
|
+
// with props that belong to its internal slot — `contentContainerStyle` on a ScrollView styles
|
|
1123
|
+
// the content view — and the adapter names the OWNER for a prop for the same reason it names the
|
|
1124
|
+
// owner for a child: that is where the app wrote it.
|
|
1125
|
+
//
|
|
1126
|
+
// Gated on the FIELD, so a node with no slot pays one load and one branch and never touches the
|
|
1127
|
+
// registry. The redirected write recurses into the slot's own `routeProp`, which is single-hop
|
|
1128
|
+
// by construction: a slot has no slot of its own (`childHost` is documented single-hop, and
|
|
1129
|
+
// `buildStructure` is what would have to nest one).
|
|
1130
|
+
if (node.childHost !== undefined) {
|
|
1131
|
+
const slotKey = slotPropNameFor(node, key);
|
|
1132
|
+
if (slotKey !== undefined) {
|
|
1133
|
+
// A class NAME is a legal spelling of `contentContainerStyle` — every canary writes
|
|
1134
|
+
// `contentContainerStyle="scroll-content"` — so a string has to land on the slot as a
|
|
1135
|
+
// CLASS. Only the class branch consults the registry; renaming it verbatim would publish a
|
|
1136
|
+
// `style` holding a string, which is not a style and is dropped with nothing red. React's
|
|
1137
|
+
// wrapper resolves the name itself (components/scroll-view/shared.ts), so this gap could
|
|
1138
|
+
// only ever show on the tag path.
|
|
1139
|
+
routeProp(node.childHost, slotKey === 'style' && typeof value === 'string' ? 'class' : slotKey, value);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
// AFTER the slot redirect, and that order is load-bearing rather than tidy. A composed primitive
|
|
1144
|
+
// forwards most of its bag to an internal node — ImageBackground spreads everything but `style`
|
|
1145
|
+
// onto its image, exactly as RN does — so an `id` written on the OWNER belongs to the node the
|
|
1146
|
+
// redirect sends it to. Resolved before the redirect, the alias would publish a `nativeID` on the
|
|
1147
|
+
// wrapper and the inner node would never see it: the owner would answer to a testID the app
|
|
1148
|
+
// pointed at the image.
|
|
1149
|
+
if (key === ID_ALIAS_FROM || key === ID_ALIAS_TO) {
|
|
1150
|
+
routeIdAlias(node, key, value);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
// An AnimatedNode written straight into a prop — `<view style={{opacity: value}}/>` — is
|
|
1154
|
+
// resolved here into the value to PUBLISH, with the engine holding the subscription. Same
|
|
1155
|
+
// shape as the `style` callback below: a value the engine interprets rather than forwards.
|
|
1156
|
+
// Returns its input by identity when nothing is animated, so every branch under this line is
|
|
1157
|
+
// unchanged. See `animated/host-binding.ts`; the gate is one boolean for an app that animates
|
|
1158
|
+
// nothing.
|
|
1159
|
+
//
|
|
1160
|
+
// AFTER the slot redirect, so an animated `contentContainerStyle` binds on the node that
|
|
1161
|
+
// actually carries the style.
|
|
1162
|
+
const resolved = hasAnimatedNodes()
|
|
1163
|
+
? bindAnimatedValue(node, key, value)
|
|
1164
|
+
: value;
|
|
638
1165
|
if (CLASS_PROP_KEYS.has(key)) {
|
|
639
1166
|
const parts = stylePartsOf(node);
|
|
640
1167
|
// Canonicalised HERE so the stored value is what everything downstream keys on: an all-string
|
|
641
1168
|
// array becomes one string, and then the pressed variant and isAlreadyPublished work on it
|
|
642
1169
|
// exactly as on an authored string. One `typeof` for the common case.
|
|
643
|
-
parts.className = canonicalClassName(isClassNameValue(
|
|
1170
|
+
parts.className = canonicalClassName(isClassNameValue(resolved) ? resolved : undefined);
|
|
644
1171
|
parts.classStyle = resolveClassName(parts.className);
|
|
645
1172
|
pushClassStyle(node, parts);
|
|
646
1173
|
return;
|
|
@@ -648,11 +1175,9 @@ export function routeProp(node, key, value) {
|
|
|
648
1175
|
if (key === 'style') {
|
|
649
1176
|
const parts = stylePartsOf(node);
|
|
650
1177
|
// A FUNCTION `style` is `style={({pressed}) => …}`, the idiom this ecosystem actually writes.
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
// compile-time split an OPTIMIZATION rather than the mechanism, the same relationship
|
|
655
|
-
// `foldHostBag` has with the compile-time prop folds.
|
|
1178
|
+
// Nothing stands between an app and the tag, so the callback arrives here intact and is
|
|
1179
|
+
// resolved at both states — writing `style` + `activeStyle` as an explicit pair is the same
|
|
1180
|
+
// thing said by hand, and cheaper by one call per recompute.
|
|
656
1181
|
//
|
|
657
1182
|
// Without this the failure is silent and total: a function is not an `on*` name, so it misses
|
|
658
1183
|
// `setEventListener`, lands in `setProp` as a function value, and `fabricProps` drops function
|
|
@@ -660,17 +1185,31 @@ export function routeProp(node, key, value) {
|
|
|
660
1185
|
//
|
|
661
1186
|
// The callback must be PURE in `pressed`: its result is read once per state, here and under
|
|
662
1187
|
// every transform's emission (`core/components/src/state-style.ts` carries the same contract).
|
|
663
|
-
if (isStyleCallback(
|
|
664
|
-
parts.explicitStyle =
|
|
665
|
-
parts.activeStyle =
|
|
1188
|
+
if (isStyleCallback(resolved)) {
|
|
1189
|
+
parts.explicitStyle = resolved({ pressed: false });
|
|
1190
|
+
parts.activeStyle = resolved({ pressed: true });
|
|
666
1191
|
parts.activeStyleFromCallback = true;
|
|
667
1192
|
}
|
|
668
1193
|
else {
|
|
669
|
-
|
|
1194
|
+
// A REBUILT LITERAL EQUAL TO WHAT IS STANDING IS NOT A CHANGE — see `isSameShallowStyle`.
|
|
1195
|
+
//
|
|
1196
|
+
// Gated on something being PUBLISHED, which is what keeps the restore path intact: a
|
|
1197
|
+
// `setNativeProps` write bypasses the parts and clears `parts.published`, and after that this
|
|
1198
|
+
// must never turn a write away — the re-push IS the restore. Same mechanism `isAlreadyPublished`
|
|
1199
|
+
// relies on, and the same reason.
|
|
1200
|
+
//
|
|
1201
|
+
// Gated on the previous write NOT having come from a callback, because that one owns
|
|
1202
|
+
// `parts.activeStyle` and the branch below has to clear it. Returning early would leave the old
|
|
1203
|
+
// pressed look standing under a plain style.
|
|
1204
|
+
if (parts.published !== undefined &&
|
|
1205
|
+
!parts.activeStyleFromCallback &&
|
|
1206
|
+
isSameShallowStyle(resolved, parts.explicitStyle)) {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
parts.explicitStyle = resolved;
|
|
670
1210
|
// Only a variant WE derived is stale now. `style` switching from a callback to a plain value
|
|
671
|
-
// must not leave the old pressed look standing, and an `activeStyle`
|
|
672
|
-
//
|
|
673
|
-
// order.
|
|
1211
|
+
// must not leave the old pressed look standing, and an AUTHORED `activeStyle` must survive a
|
|
1212
|
+
// `style` write, because the two arrive as independent props in an unspecified order.
|
|
674
1213
|
if (parts.activeStyleFromCallback) {
|
|
675
1214
|
parts.activeStyle = undefined;
|
|
676
1215
|
parts.activeStyleFromCallback = false;
|
|
@@ -683,18 +1222,45 @@ export function routeProp(node, key, value) {
|
|
|
683
1222
|
// in the app carries an unknown key to native.
|
|
684
1223
|
if (key === 'activeStyle') {
|
|
685
1224
|
const parts = stylePartsOf(node);
|
|
686
|
-
parts.activeStyle =
|
|
1225
|
+
parts.activeStyle = resolved;
|
|
687
1226
|
// Slot 1 is no longer ours, by definition — whatever a callback derived earlier has just been
|
|
688
1227
|
// replaced. Without this the flag outlives the value it describes: a callback sets it, this
|
|
689
1228
|
// branch overwrites the slot silently, and a later plain `style` then clears a variant the
|
|
690
|
-
// engine never derived.
|
|
691
|
-
//
|
|
692
|
-
// and can deliver exactly that sequence.
|
|
1229
|
+
// engine never derived. An author writes either a callback or an explicit pair, never both for
|
|
1230
|
+
// one node — but a flat-bag adapter routes a bag key by key and can deliver that sequence.
|
|
693
1231
|
parts.activeStyleFromCallback = false;
|
|
694
1232
|
pushClassStyle(node, parts);
|
|
695
1233
|
return;
|
|
696
1234
|
}
|
|
697
|
-
|
|
1235
|
+
// RN's snapshot affordance (`Pressable.js:222` seeds `usePressState` with it): render the control
|
|
1236
|
+
// pressed with no gesture. It selects `activeStyle` and any `:active` class, which is exactly what
|
|
1237
|
+
// `isPressed` already decides — so it belongs beside `activeStyle` rather than in a behavior.
|
|
1238
|
+
//
|
|
1239
|
+
// HERE RATHER THAN IN `attachAfterCommit`, and the census caught the difference. A behavior hook
|
|
1240
|
+
// reading this prop costs a post-commit WAITER on every pressable in the app — a real boundary
|
|
1241
|
+
// crossing per node, which `adapters/solid/src/crossing-and-payload-census.probe.test.tsx` budgets
|
|
1242
|
+
// at two and which went to six. This branch crosses nothing: it is one string compare on a write
|
|
1243
|
+
// that already reached the tail of `routeProp`, and it lands on the FIRST commit rather than the
|
|
1244
|
+
// second. A JS compare is not a crossing, and weighing it as one is what sent the first attempt to
|
|
1245
|
+
// the wrong seam.
|
|
1246
|
+
//
|
|
1247
|
+
// TouchableHighlight's half of the same prop is NOT here: it PAINTS an underlay, so it is a rule
|
|
1248
|
+
// in `SymbioteFabricProps.cpp`. Two mechanisms, one prop name, because that is what upstream has.
|
|
1249
|
+
// A SIDE EFFECT AND A PASSTHROUGH, not a consume, and the difference is load-bearing in both
|
|
1250
|
+
// directions. The pressed state is set here; the prop ALSO goes on to `node.props`, because
|
|
1251
|
+
// TouchableHighlight's rule reads it off the authored bag to paint its underlay
|
|
1252
|
+
// (`foldTouchableHighlightUnderlay`). Returning early — the first spelling — left that rule blind
|
|
1253
|
+
// and turned three of its cases red. Keeping it out of the PAYLOAD is a separate job and already
|
|
1254
|
+
// done, by `kPressableMachineKeys` in `SymbioteFabricProps.cpp`.
|
|
1255
|
+
//
|
|
1256
|
+
// The same shape `GATED_EVENT_PROPS` uses above: act, then let the write continue.
|
|
1257
|
+
if (key === 'testOnly_pressed')
|
|
1258
|
+
setNodePressed(node, resolved === true);
|
|
1259
|
+
if (isOnEventName(key)) {
|
|
1260
|
+
// A native-driven `Animated.event` needs the native module as well as the listener map, and
|
|
1261
|
+
// registers under the PROP name — see `bindAnimatedEvent`, which no-ops for anything else.
|
|
1262
|
+
if (hasAnimatedNodes())
|
|
1263
|
+
bindAnimatedEvent(node, key, resolved);
|
|
698
1264
|
const name = listenerName(key);
|
|
699
1265
|
const isRegisteredEvent = RESPONDER_EVENTS.has(name) || isEventFor(node.component, name);
|
|
700
1266
|
// Investigation instrumentation (HeaderOptionsScreen unresponsive-buttons bug): RNS* views
|
|
@@ -707,106 +1273,247 @@ export function routeProp(node, key, value) {
|
|
|
707
1273
|
`registered=${isRegisteredEvent} at t=${Date.now()}`);
|
|
708
1274
|
}
|
|
709
1275
|
if (isRegisteredEvent) {
|
|
710
|
-
setEventListener(node, name,
|
|
1276
|
+
setEventListener(node, name, resolved);
|
|
711
1277
|
return;
|
|
712
1278
|
}
|
|
713
1279
|
}
|
|
714
|
-
setProp(node, key,
|
|
1280
|
+
setProp(node, key, resolved);
|
|
715
1281
|
}
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
// object or a handler. A framework that re-renders a subtree and hands back an unchanged label -
|
|
719
|
-
// every list row whose text did not move, on every update - stops stripping its ancestors of the
|
|
720
|
-
// commit walk's early exit. Counted in the same propStats, because a text write IS a prop write:
|
|
721
|
-
// it lands in node.props.text and reaches Fabric as RCTRawText's only prop.
|
|
1282
|
+
// Counted in the same propStats, because a text write IS a prop write: it reaches Fabric as
|
|
1283
|
+
// RCTRawText's only prop.
|
|
722
1284
|
//
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
1285
|
+
// Unguarded, unlike the old version, and for the same reason `setProp` is: comparing against the
|
|
1286
|
+
// standing text would mean reading it back from the host. The host holds it as a local field and
|
|
1287
|
+
// dedupes there. Two consequences it also absorbs, both of which used to be spelled here — a write
|
|
1288
|
+
// to or from `''` takes this node out of its parent's renderable child list or puts it back, and a
|
|
1289
|
+
// raw text REPARENTED under a `<Text>` commits as RCTVirtualText instead of RCTText.
|
|
727
1290
|
export function setText(node, text) {
|
|
728
|
-
if (Object.is(node.props.text, text)) {
|
|
729
|
-
propStats.noops += 1;
|
|
730
|
-
return;
|
|
731
|
-
}
|
|
732
|
-
node.props.text = text;
|
|
733
1291
|
propStats.writes += 1;
|
|
734
|
-
|
|
1292
|
+
recordSetText(node, text);
|
|
735
1293
|
}
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
1294
|
+
// The structural ops. Each is one op and nothing else: the host detaches the child from whatever
|
|
1295
|
+
// parent it currently has before linking it, which is the truth even when an adapter names a stale
|
|
1296
|
+
// one — frameworks spell a MOVE as remove-then-insert and can arrive after the insert already
|
|
1297
|
+
// re-parented the node. That is why there is no `detach` here any more; JS does not know the old
|
|
1298
|
+
// parent and does not need to.
|
|
739
1299
|
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
1300
|
+
// What they DO still decide in JS is which node an op names, and there are two such redirects — a
|
|
1301
|
+
// composed primitive's slot and a wrap claim. Both are read off a field on the node, so a tree with
|
|
1302
|
+
// neither pays one load and one branch per op.
|
|
1303
|
+
// The host's raw answer, surface INCLUDED — unlike `parentOf` (host-access.ts), which reports a
|
|
1304
|
+
// top-level node as parentless by design. The two swaps below have to NAME the holder in an op, and
|
|
1305
|
+
// for a wrapped node sitting directly under a surface that holder is the surface node.
|
|
1306
|
+
function holderOf(node) {
|
|
1307
|
+
flushOps();
|
|
1308
|
+
const parent = treeHost()?.parentOf(node);
|
|
1309
|
+
return isSymbioteNode(parent) ? parent : undefined;
|
|
1310
|
+
}
|
|
1311
|
+
// Which node a child actually lands on. See `ISymbioteNode.childHost`: the adapter always names the
|
|
1312
|
+
// OWNER, and a node whose behavior built an internal subtree redirects the app's children into it —
|
|
1313
|
+
// unless the behavior CLAIMS this particular child, which keeps it on the owner (`claimedChildren`).
|
|
1314
|
+
//
|
|
1315
|
+
// SINGLE HOP, not a loop, and the field's own comment says why — a chain would put a walk on the
|
|
1316
|
+
// engine's hottest path to express a depth no primitive has. A behavior needing depth points
|
|
1317
|
+
// `childHost` at the innermost node itself.
|
|
1318
|
+
//
|
|
1319
|
+
// Reads a field that is `undefined` on every node in every app that registers no composed
|
|
1320
|
+
// primitive, so the cost is one load and one branch — deliberately NOT behind `hasHostBehaviors()`,
|
|
1321
|
+
// which would be a second read to save nothing. The claim check sits BEHIND that branch, so only a
|
|
1322
|
+
// slot-bearing node ever pays the registry probe.
|
|
1323
|
+
function hostFor(parent, child) {
|
|
1324
|
+
const slot = parent.childHost;
|
|
1325
|
+
if (slot === undefined)
|
|
1326
|
+
return parent;
|
|
1327
|
+
// A slot that is a built SIBLING rather than a container — ImageBackground's absolutely-filled
|
|
1328
|
+
// image — keeps the app's children on the owner. See `IHostBehavior.slotTakesNoChildren`.
|
|
1329
|
+
if (!slotTakesChildren(parent))
|
|
1330
|
+
return parent;
|
|
1331
|
+
return claimModeFor(parent, child.component) === undefined ? slot : parent;
|
|
1332
|
+
}
|
|
1333
|
+
// The node a child must be inserted BEFORE, or `undefined` for an ordinary append.
|
|
1334
|
+
//
|
|
1335
|
+
// A host that STILL has a slot at this point is an owner taking a CLAIMED child, and that child
|
|
1336
|
+
// goes before the slot whatever the framework asked for. RN renders `{refreshControl}{content}` in
|
|
1337
|
+
// that order, and the node a framework names as `beforeChild` lives inside the slot, so the host
|
|
1338
|
+
// could not place against it here anyway.
|
|
1339
|
+
//
|
|
1340
|
+
// A SIBLING slot is the opposite placement: RN paints the background image first and the app's
|
|
1341
|
+
// children over it (ImageBackground.js:80-102), so they append past it rather than in front of it —
|
|
1342
|
+
// which is what `undefined` here leaves alone.
|
|
1343
|
+
function slotAnchorOf(host) {
|
|
1344
|
+
const slot = host.childHost;
|
|
1345
|
+
if (slot === undefined || !slotTakesChildren(host))
|
|
1346
|
+
return undefined;
|
|
1347
|
+
return slot;
|
|
1348
|
+
}
|
|
1349
|
+
// ── the two structural recorders, and why nothing here calls the raw ones ───────────────────────
|
|
1350
|
+
//
|
|
1351
|
+
// `mayHaveChildren` is only sound if EVERY op that gives a node a child raises it. There are five
|
|
1352
|
+
// such call sites in this file and a sixth is a plausible future edit, so the bit is raised here
|
|
1353
|
+
// rather than at each of them: a site that forgets would make `childrenOf` answer "empty" for a node
|
|
1354
|
+
// that has children, which is a wrong ANSWER rather than a slow one. `node.ts` is the only module
|
|
1355
|
+
// that records a structural op, so these two are a complete funnel.
|
|
1356
|
+
/**
|
|
1357
|
+
* Arm a parent's recurring post-commit hook for a STRUCTURAL change.
|
|
1358
|
+
*
|
|
1359
|
+
* A prop write is not the only thing a behavior can be waiting for, and the ScrollView sticky-header
|
|
1360
|
+
* machine is the case that proves it: it drops a wrapper when the framework takes the wrapped child
|
|
1361
|
+
* away, which writes no prop on the wrapper's owner at all. Narrowing the beat to prop writes alone
|
|
1362
|
+
* left it holding a wrapper around nothing, and its own test said so — the third behavior needing
|
|
1363
|
+
* the beat, and the only one whose source comment does not say why.
|
|
1364
|
+
*/
|
|
1365
|
+
function armCommitHookForChildChange(parent) {
|
|
1366
|
+
if (parent.hasCommitHook)
|
|
1367
|
+
noteCommitHookNodeChanged(parent);
|
|
1368
|
+
}
|
|
1369
|
+
function recordAppendInto(parent, child) {
|
|
1370
|
+
parent.mayHaveChildren = true;
|
|
1371
|
+
armCommitHookForChildChange(parent);
|
|
1372
|
+
recordAppendChild(parent, child);
|
|
1373
|
+
}
|
|
1374
|
+
function recordInsertInto(parent, child, beforeChild) {
|
|
1375
|
+
parent.mayHaveChildren = true;
|
|
1376
|
+
armCommitHookForChildChange(parent);
|
|
1377
|
+
recordInsertBefore(parent, child, beforeChild);
|
|
1378
|
+
}
|
|
1379
|
+
// What actually occupies this node's place in its parent's child list. See `ISymbioteNode.wrapper`:
|
|
1380
|
+
// a wrapped owner is what the adapter names and the wrapper is what the tree holds, so every
|
|
1381
|
+
// structural op takes the owner and moves the wrapper.
|
|
1382
|
+
function placedNode(node) {
|
|
1383
|
+
return node.wrapper ?? node;
|
|
751
1384
|
}
|
|
752
|
-
|
|
1385
|
+
// Make `child` the owner's parent, in place. Returns false when this is not a wrap claim, so the
|
|
1386
|
+
// two inserts fall through to the ordinary path on one call.
|
|
1387
|
+
//
|
|
1388
|
+
// The owner being UNATTACHED is the normal case rather than the edge one: every adapter fills a
|
|
1389
|
+
// node's children before appending it to its own parent, so the wrap usually happens while the
|
|
1390
|
+
// owner has no holder and only the second op runs. The later `appendChild(root, owner)` then
|
|
1391
|
+
// inserts the wrapper instead, because `placedNode` says so.
|
|
1392
|
+
function wrapsOwner(owner, child) {
|
|
1393
|
+
if (owner.childHost === undefined)
|
|
1394
|
+
return false;
|
|
1395
|
+
if (claimModeFor(owner, child.component) !== 'wrap')
|
|
1396
|
+
return false;
|
|
1397
|
+
if (hasHostBehaviors())
|
|
1398
|
+
reattachHostBehaviors(child);
|
|
1399
|
+
if (hasAnimatedBindings())
|
|
1400
|
+
reattachAnimatedProps(child);
|
|
1401
|
+
const holder = holderOf(owner);
|
|
1402
|
+
// Wrapper takes the owner's place first, then the owner moves under it — the host's own detach
|
|
1403
|
+
// on link is what unlinks the owner from `holder`, so no removal op is needed.
|
|
1404
|
+
if (holder !== undefined)
|
|
1405
|
+
recordInsertInto(holder, child, owner);
|
|
1406
|
+
owner.wrapper = child;
|
|
1407
|
+
recordAppendInto(child, owner);
|
|
1408
|
+
return true;
|
|
1409
|
+
}
|
|
1410
|
+
// Put the owner back where its wrapper stood — the mirror of `wrapsOwner`. It must leave the owner
|
|
1411
|
+
// ATTACHED: the framework is removing the RefreshControl, not the ScrollView.
|
|
1412
|
+
function unwrapsOwner(owner, child) {
|
|
1413
|
+
if (owner.wrapper !== child)
|
|
1414
|
+
return false;
|
|
1415
|
+
owner.wrapper = undefined;
|
|
1416
|
+
const holder = holderOf(child);
|
|
1417
|
+
if (holder === undefined) {
|
|
1418
|
+
// The wrapper never reached a parent, so there is no place to take back — the owner simply
|
|
1419
|
+
// stops hanging off it.
|
|
1420
|
+
recordRemoveChild(child, owner);
|
|
1421
|
+
}
|
|
1422
|
+
else {
|
|
1423
|
+
recordInsertInto(holder, owner, child);
|
|
1424
|
+
recordRemoveChild(holder, child);
|
|
1425
|
+
}
|
|
1426
|
+
return true;
|
|
1427
|
+
}
|
|
1428
|
+
export function appendChild(requestedParent, child) {
|
|
1429
|
+
if (wrapsOwner(requestedParent, child))
|
|
1430
|
+
return;
|
|
1431
|
+
const parent = hostFor(requestedParent, child);
|
|
753
1432
|
// A node the sweep tore down can be put back — Svelte parks live subtrees offscreen across
|
|
754
1433
|
// commits. A WeakSet miss for anything freshly built, so the create path pays nothing.
|
|
755
1434
|
if (hasHostBehaviors())
|
|
756
1435
|
reattachHostBehaviors(child);
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
parent
|
|
1436
|
+
if (hasAnimatedBindings())
|
|
1437
|
+
reattachAnimatedProps(child);
|
|
1438
|
+
const placed = placedNode(child);
|
|
1439
|
+
const anchor = slotAnchorOf(parent);
|
|
1440
|
+
if (anchor === undefined)
|
|
1441
|
+
recordAppendInto(parent, placed);
|
|
1442
|
+
else
|
|
1443
|
+
recordInsertInto(parent, placed, anchor);
|
|
1444
|
+
if (hasHostBehaviors())
|
|
1445
|
+
notifyChildInserted(parent, placed);
|
|
761
1446
|
}
|
|
762
|
-
|
|
1447
|
+
// NO ANCHOR MEANS APPEND, and it is a real call rather than a defensive guard: solid-js/universal
|
|
1448
|
+
// spells "insert at the end" as `insertNode(parent, node, null)` and Vue's runtime-core passes
|
|
1449
|
+
// `anchor` straight through as `null`. The old retained tree collapsed it silently — `indexOf(null)`
|
|
1450
|
+
// is -1, and the insert fell through to a push. On the wire it cannot: a slot has to name a node, so
|
|
1451
|
+
// an unanchored insert IS an append and is recorded as one.
|
|
1452
|
+
export function insertBefore(requestedParent, child, beforeChild) {
|
|
1453
|
+
if (wrapsOwner(requestedParent, child))
|
|
1454
|
+
return;
|
|
1455
|
+
const parent = hostFor(requestedParent, child);
|
|
763
1456
|
if (hasHostBehaviors())
|
|
764
1457
|
reattachHostBehaviors(child);
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
1458
|
+
if (hasAnimatedBindings())
|
|
1459
|
+
reattachAnimatedProps(child);
|
|
1460
|
+
const placed = placedNode(child);
|
|
1461
|
+
const anchor = slotAnchorOf(parent) ??
|
|
1462
|
+
(beforeChild === null || beforeChild === undefined
|
|
1463
|
+
? undefined
|
|
1464
|
+
: placedNode(beforeChild));
|
|
1465
|
+
if (anchor === undefined)
|
|
1466
|
+
recordAppendInto(parent, placed);
|
|
1467
|
+
else
|
|
1468
|
+
recordInsertInto(parent, placed, anchor);
|
|
1469
|
+
if (hasHostBehaviors())
|
|
1470
|
+
notifyChildInserted(parent, placed);
|
|
770
1471
|
}
|
|
771
1472
|
// Removal only NOMINATES a behavior for teardown; the commit sweep decides. A framework may spell
|
|
772
1473
|
// a move as remove-then-reinsert (Solid does), so tearing down here kills the machine of a node
|
|
773
1474
|
// that comes back alive in the same batch — see host-behavior.ts's markDetachCandidate.
|
|
774
|
-
export function removeChild(
|
|
775
|
-
|
|
1475
|
+
export function removeChild(requestedParent, child) {
|
|
1476
|
+
// A wrap claim leaving: the owner takes its own place back and stays in the tree. Nominated for
|
|
1477
|
+
// teardown like any other removed node, because the wrapper IS leaving.
|
|
1478
|
+
if (unwrapsOwner(requestedParent, child)) {
|
|
1479
|
+
if (hasAttachedBehaviors() || hasAnimatedBindings())
|
|
1480
|
+
markDetachCandidate(child);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
// A slot that IS the child being removed stops being one. Only a behavior that adopts an APP
|
|
1484
|
+
// child as its slot can reach this (`onChildInserted`); a `buildStructure` slot is internal and
|
|
1485
|
+
// no framework removes it. Without the clear, `hostFor` below redirects the removal INTO the very
|
|
1486
|
+
// node being removed, and the child stays committed under a parent the framework believes it
|
|
1487
|
+
// left — and the NEXT child appended nests inside the orphan.
|
|
1488
|
+
if (requestedParent.childHost === child)
|
|
1489
|
+
requestedParent.childHost = undefined;
|
|
1490
|
+
// Redirected for the same reason the two inserts are: the adapter removes from the node it
|
|
1491
|
+
// appended to, which is the OWNER, while the child actually lives in the slot.
|
|
1492
|
+
const parent = hostFor(requestedParent, child);
|
|
1493
|
+
// `hasAttachedBehaviors`, NOT `hasHostBehaviors`: the second is on from module load in every app,
|
|
1494
|
+
// because registering `Pressable` as a TYPE arms it. Nominating a candidate makes the commit sweep
|
|
1495
|
+
// cross every removed node into JS — 10 000 handles on a 1 000-row clear, measured at 3.2x the
|
|
1496
|
+
// whole teardown — and none of it can matter before a behavior has actually attached to something.
|
|
1497
|
+
if (hasAttachedBehaviors() || hasAnimatedBindings())
|
|
776
1498
|
markDetachCandidate(child);
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
1499
|
+
// BOTH, and the owner is the one that matters: a composed primitive's behavior lives on the node
|
|
1500
|
+
// the adapter named, while `hostFor` redirects the mutation into its internal slot. Arming only
|
|
1501
|
+
// the slot arms a node that has no behavior at all.
|
|
1502
|
+
armCommitHookForChildChange(requestedParent);
|
|
1503
|
+
armCommitHookForChildChange(parent);
|
|
1504
|
+
recordRemoveChild(parent, placedNode(child));
|
|
782
1505
|
}
|
|
1506
|
+
/**
|
|
1507
|
+
* A structural census of the tree the HOST holds — see `ITreeCensus` (tree-host.ts) for what each
|
|
1508
|
+
* number is for and why the anchor count says more about the adapter than about the app.
|
|
1509
|
+
*
|
|
1510
|
+
* It walks nothing here: the walk needs `props.text` to tell an empty raw text from a real one, and
|
|
1511
|
+
* a child list to measure a flatten width, and JS has neither. `undefined` from `treeHost()` means
|
|
1512
|
+
* nothing is installed, and the empty census is the honest answer — every probe that reads this
|
|
1513
|
+
* asserts against a mounted tree, so a zero from an uninstalled host cannot be mistaken for one from
|
|
1514
|
+
* an empty one.
|
|
1515
|
+
*/
|
|
783
1516
|
export function censusRetainedTree(roots) {
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
anchors: 0,
|
|
787
|
-
emptyRawTexts: 0,
|
|
788
|
-
renderable: 0,
|
|
789
|
-
flattenWidths: [],
|
|
790
|
-
};
|
|
791
|
-
// Explicit stack, not recursion: a deep list under a benchmark screen would blow the JS stack
|
|
792
|
-
// on the very tree this is meant to measure.
|
|
793
|
-
const stack = [...roots];
|
|
794
|
-
while (stack.length > 0) {
|
|
795
|
-
const node = stack.pop();
|
|
796
|
-
if (node === undefined)
|
|
797
|
-
break;
|
|
798
|
-
census.nodes += 1;
|
|
799
|
-
if (isAnchor(node))
|
|
800
|
-
census.anchors += 1;
|
|
801
|
-
else if (isEmptyRawText(node))
|
|
802
|
-
census.emptyRawTexts += 1;
|
|
803
|
-
else
|
|
804
|
-
census.renderable += 1;
|
|
805
|
-
if (node.children.some(child => isAnchor(child) || isEmptyRawText(child)))
|
|
806
|
-
census.flattenWidths.push(node.children.length);
|
|
807
|
-
for (const child of node.children)
|
|
808
|
-
stack.push(child);
|
|
809
|
-
}
|
|
810
|
-
census.flattenWidths.sort((left, right) => right - left);
|
|
811
|
-
return census;
|
|
1517
|
+
flushOps();
|
|
1518
|
+
return treeHost()?.census(roots) ?? EMPTY_CENSUS;
|
|
812
1519
|
}
|