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