@symbiote-native/engine 0.3.0 → 0.5.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 +7 -1
- package/build/accessibility-props.d.ts +20 -0
- package/build/accessibility-props.js +213 -0
- package/build/animated/graph.d.ts +2 -0
- package/build/animated/graph.js +14 -0
- package/build/animated/host-binding.d.ts +39 -0
- package/build/animated/host-binding.js +263 -0
- package/build/animated/leaf-lifecycle.js +10 -22
- package/build/app-registry/index.d.ts +1 -0
- package/build/app-registry/index.js +15 -5
- package/build/commit.d.ts +22 -0
- package/build/commit.js +509 -78
- package/build/debug.js +10 -4
- package/build/events/index.js +172 -87
- package/build/fabric-props.js +179 -10
- package/build/fabric.d.ts +11 -3
- package/build/fabric.js +19 -2
- package/build/host-behavior.d.ts +84 -0
- package/build/host-behavior.js +374 -0
- package/build/host-instance/index.d.ts +2 -10
- package/build/host-instance/index.js +13 -46
- package/build/index.d.ts +7 -1
- package/build/index.js +23 -4
- package/build/node.d.ts +115 -5
- package/build/node.js +737 -70
- package/build/pan-responder/index.d.ts +2 -2
- package/build/pan-responder/index.js +10 -4
- package/build/style-registry/index.d.ts +2 -0
- package/build/style-registry/index.js +79 -0
- package/build/styles.d.ts +5 -1
- package/build/surface.js +21 -4
- package/build/view-config.js +6 -0
- package/package.json +12 -2
package/build/node.js
CHANGED
|
@@ -4,9 +4,24 @@
|
|
|
4
4
|
// tree mutable while the Fabric mirror stays persistent lets every adapter mutate
|
|
5
5
|
// freely without touching Fabric's clone-on-write protocol directly, and it
|
|
6
6
|
// lives here in shared so no adapter re-implements it.
|
|
7
|
+
import { isAriaAliasKey } from './accessibility-props.js';
|
|
7
8
|
import { isEventFor } from './view-config.js';
|
|
8
|
-
import { isClassNameValue, resolveClassName } from './style-registry/index.js';
|
|
9
|
+
import { canonicalClassName, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
|
|
9
10
|
import { dlog } from './debug.js';
|
|
11
|
+
import { appListenerFor, attachHostBehavior, claimModeFor, hasHostBehaviors, markDetachCandidate, notifyChildInserted, notifyOwnedListenerChange, notifyWrapChange, ownsListener, reattachHostBehaviors, derivedNodesOf, slotDerivesFrom, slotPropNameFor, slotTakesChildren, stashAppListener, } from './host-behavior.js';
|
|
12
|
+
// A cycle, deliberately: commit.ts imports this module for the node shape, and the imperative
|
|
13
|
+
// methods below call back into it. Neither side touches the other at module-evaluation time -
|
|
14
|
+
// only inside a function body - so every loader (tsc, vitest, Metro) resolves it fine. The
|
|
15
|
+
// alternative was a load-time `SymbioteNode.prototype.measure = ...` installed from elsewhere,
|
|
16
|
+
// which is exactly the registration-side-effect shape Metro's inlineRequires silently drops in
|
|
17
|
+
// release builds (see CLAUDE.md, "Never make correctness depend on a module's load-time side
|
|
18
|
+
// effect").
|
|
19
|
+
import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './commit.js';
|
|
20
|
+
// The same deliberate cycle, for the same reason: `routeProp` resolves an AnimatedNode written
|
|
21
|
+
// into a prop, and the module that owns that resolution reaches back here for `setProp`. See
|
|
22
|
+
// `animated/host-binding.ts`'s header.
|
|
23
|
+
import { hasAnimatedNodes } from './animated/graph.js';
|
|
24
|
+
import { bindAnimatedEvent, bindAnimatedValue, hasAnimatedBindings, reattachAnimatedProps, } from './animated/host-binding.js';
|
|
10
25
|
const BRAND = Symbol('symbiote.node');
|
|
11
26
|
// A node carries the Fabric view name directly, so adding a primitive (Image,
|
|
12
27
|
// ScrollView, TextInput) is just a new string from the adapter, no core change.
|
|
@@ -25,29 +40,144 @@ export function isSymbioteEvent(value) {
|
|
|
25
40
|
const nativeEvent = Reflect.get(value, 'nativeEvent');
|
|
26
41
|
return typeof nativeEvent === 'object' && nativeEvent !== null;
|
|
27
42
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
const FOCUS_COMMAND = 'focus';
|
|
44
|
+
const BLUR_COMMAND = 'blur';
|
|
45
|
+
// Names and arg order mirror RN's ScrollViewCommands.
|
|
46
|
+
const SCROLL_TO_COMMAND = 'scrollTo';
|
|
47
|
+
const SCROLL_TO_END_COMMAND = 'scrollToEnd';
|
|
48
|
+
const FLASH_SCROLL_INDICATORS_COMMAND = 'flashScrollIndicators';
|
|
49
|
+
// The one shape every retained node has. A class, not an object literal, for two reasons: the six
|
|
50
|
+
// imperative methods live on the shared prototype instead of being allocated per node (see
|
|
51
|
+
// ISymbioteNode above), and both factories below mint the same hidden class.
|
|
52
|
+
//
|
|
53
|
+
// Fields are `declare`d and assigned in the constructor rather than written as class fields: with
|
|
54
|
+
// ES2022 field semantics the two are equivalent in meaning but not in emit, and a plain
|
|
55
|
+
// constructor assignment is the shape every engine (V8 and Hermes both) handles without a
|
|
56
|
+
// define-per-field.
|
|
57
|
+
class SymbioteNode {
|
|
58
|
+
constructor(component, isText, props) {
|
|
59
|
+
this[BRAND] = true;
|
|
60
|
+
this.component = component;
|
|
61
|
+
this.isText = isText;
|
|
62
|
+
this.props = props;
|
|
63
|
+
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
|
+
// Assigned here, not lazily on first use: every slot present from the constructor keeps one
|
|
72
|
+
// hidden class for every node. Adding it on demand buys a shape transition per aria-bearing
|
|
73
|
+
// node, which is the opposite of what this field is for.
|
|
74
|
+
//
|
|
75
|
+
// Starts false, and that is COMPLETE rather than optimistic: the only two constructions are
|
|
76
|
+
// `createElement`'s `{}` and `createRawText`'s `{ text }`, so no aria key can arrive here. It
|
|
77
|
+
// was first written as `hasAriaAliases(props)` — a probe that reads as a safeguard and can
|
|
78
|
+
// never fire, which the break-test caught by staying green with it removed. If a construction
|
|
79
|
+
// path is ever added that passes real props, this line owes that probe back.
|
|
80
|
+
this.hasAriaAlias = false;
|
|
81
|
+
this.structureDirty = true;
|
|
82
|
+
this.committed = undefined;
|
|
83
|
+
this.styleParts = undefined;
|
|
84
|
+
// Assigned here for the same hidden-class reason as `hasAriaAlias` above; `attachHostBehavior`
|
|
85
|
+
// overwrites it a few lines later for the rare node that has a behavior.
|
|
86
|
+
this.payloadFold = undefined;
|
|
87
|
+
// Same reason again, and here it is load-bearing rather than tidy: the redirect below is read
|
|
88
|
+
// on every append, so the slot must be a stable slot on one hidden class, not a property added
|
|
89
|
+
// to a few nodes after the fact.
|
|
90
|
+
this.childHost = undefined;
|
|
91
|
+
this.wrapper = undefined;
|
|
92
|
+
}
|
|
93
|
+
measure(callback) {
|
|
94
|
+
engineMeasure(this, callback);
|
|
95
|
+
}
|
|
96
|
+
measureInWindow(callback) {
|
|
97
|
+
engineMeasureInWindow(this, callback);
|
|
98
|
+
}
|
|
99
|
+
measureLayout(relativeToNativeNode, onSuccess, onFail) {
|
|
100
|
+
if (!isSymbioteNode(relativeToNativeNode)) {
|
|
101
|
+
dlog('measureLayout: relative target must be a host ref');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
engineMeasureLayout(this, relativeToNativeNode, onSuccess, onFail);
|
|
105
|
+
}
|
|
106
|
+
setNativeProps(nativeProps) {
|
|
107
|
+
engineSetNativeProps(this, nativeProps);
|
|
108
|
+
}
|
|
109
|
+
focus() {
|
|
110
|
+
dispatchViewCommand(this, FOCUS_COMMAND, []);
|
|
111
|
+
}
|
|
112
|
+
blur() {
|
|
113
|
+
dispatchViewCommand(this, BLUR_COMMAND, []);
|
|
114
|
+
}
|
|
115
|
+
// The defaults live HERE and nowhere else. `buildScrollViewHandle`
|
|
116
|
+
// (`@symbiote-native/components`) used to own them and now delegates, so the wrapper's handle and
|
|
117
|
+
// a lowered element's node cannot drift on what `scrollTo()` with no argument means.
|
|
118
|
+
scrollTo(options) {
|
|
119
|
+
const x = options?.x ?? 0;
|
|
120
|
+
const y = options?.y ?? 0;
|
|
121
|
+
const animated = options?.animated ?? true;
|
|
122
|
+
dlog(`ScrollView.scrollTo x=${x} y=${y} animated=${animated}`);
|
|
123
|
+
dispatchViewCommand(this, SCROLL_TO_COMMAND, [x, y, animated]);
|
|
124
|
+
}
|
|
125
|
+
scrollToEnd(options) {
|
|
126
|
+
const animated = options?.animated ?? true;
|
|
127
|
+
dlog(`ScrollView.scrollToEnd animated=${animated}`);
|
|
128
|
+
dispatchViewCommand(this, SCROLL_TO_END_COMMAND, [animated]);
|
|
129
|
+
}
|
|
130
|
+
flashScrollIndicators() {
|
|
131
|
+
dlog('ScrollView.flashScrollIndicators');
|
|
132
|
+
dispatchViewCommand(this, FLASH_SCROLL_INDICATORS_COMMAND, []);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The committed record for `node`, or `undefined` if it has never been committed - or if `node` is
|
|
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.
|
|
149
|
+
*
|
|
150
|
+
* So the identity check that was implicit in the WeakMap is explicit here: a record written on the
|
|
151
|
+
* raw node names it, and `record.owner !== node` means whatever we were handed is not that node.
|
|
152
|
+
* One reference comparison, and the wrap now fails LOUDER than it used to rather than quieter.
|
|
153
|
+
*/
|
|
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
|
+
export function createElement(component, isText = false,
|
|
167
|
+
// The intrinsic tag this node came from, when it differs from the Fabric view name above. The
|
|
168
|
+
// behavior registry is keyed by tag and the node only ever carries the resolved name, so an
|
|
169
|
+
// adapter lowering `<Pressable>` has to hand the tag over here or the registration cannot fire
|
|
170
|
+
// (host-behavior.ts, `attached`). Nothing is stored — the lookup happens once, right below.
|
|
171
|
+
tag = component) {
|
|
172
|
+
const node = new SymbioteNode(component, isText, {});
|
|
173
|
+
// Gated on the boolean, not on the Map: this runs ~9 000 times per benchmark create, and an app
|
|
174
|
+
// that registers nothing must pay one boolean read rather than a hash lookup per node.
|
|
175
|
+
if (hasHostBehaviors())
|
|
176
|
+
attachHostBehavior(node, tag);
|
|
177
|
+
return node;
|
|
39
178
|
}
|
|
40
179
|
export function createRawText(text) {
|
|
41
|
-
return {
|
|
42
|
-
[BRAND]: true,
|
|
43
|
-
component: RAW_TEXT_COMPONENT,
|
|
44
|
-
isText: false,
|
|
45
|
-
props: { text },
|
|
46
|
-
listeners: undefined,
|
|
47
|
-
children: [],
|
|
48
|
-
parent: undefined,
|
|
49
|
-
dirty: true,
|
|
50
|
-
};
|
|
180
|
+
return new SymbioteNode(RAW_TEXT_COMPONENT, false, { text });
|
|
51
181
|
}
|
|
52
182
|
// `instanceHandle` round-trips through Fabric unchanged: the object we pass to
|
|
53
183
|
// createNode comes back as the event target. We brand our nodes so the event
|
|
@@ -106,6 +236,30 @@ export function isEmptyRawText(node) {
|
|
|
106
236
|
// every render, so marking there would re-dirty the whole tree every commit and hand the win back.
|
|
107
237
|
// The one listener that DOES change a Fabric prop, `layout`, raises `onLayout` through setProp
|
|
108
238
|
// below and is marked that way.
|
|
239
|
+
/**
|
|
240
|
+
* Change which Fabric view a node commits as, keeping the node's identity.
|
|
241
|
+
*
|
|
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
|
+
* The POLICY stays out of the engine: which prop decides, and which view it decides between, lives
|
|
248
|
+
* in `HOST_PRIMITIVES` and is read by `resolveIntrinsicTag` in `@symbiote-native/components`. The
|
|
249
|
+
* engine only knows how to swap the name — the same split every other spec-driven fold has here.
|
|
250
|
+
*
|
|
251
|
+
* A no-op when the name is unchanged, so a renderer may call it on every update without comparing
|
|
252
|
+
* first.
|
|
253
|
+
*/
|
|
254
|
+
export function setNodeComponent(node, component) {
|
|
255
|
+
if (node.component === component)
|
|
256
|
+
return;
|
|
257
|
+
node.component = component;
|
|
258
|
+
// `dirty` alone is not enough: the walk's reuse test also requires the node be visited at all,
|
|
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
|
+
}
|
|
109
263
|
export function markDirty(node) {
|
|
110
264
|
let current = node;
|
|
111
265
|
while (current !== undefined && !current.dirty) {
|
|
@@ -113,6 +267,40 @@ export function markDirty(node) {
|
|
|
113
267
|
current = current.parent;
|
|
114
268
|
}
|
|
115
269
|
}
|
|
270
|
+
// The prop-write twin of markDirty: raises this node's OWN props flag and then bubbles the subtree
|
|
271
|
+
// flag as usual. Every path that writes `node.props` must come through here - setProp and setText
|
|
272
|
+
// below, setNativeProps in commit.ts (which writes the record directly and so owes its own mark).
|
|
273
|
+
//
|
|
274
|
+
// Note the two flags are raised INDEPENDENTLY rather than one implying the other. markDirty stops
|
|
275
|
+
// at the first already-dirty ancestor, so a node dirtied a moment ago by a child's change would
|
|
276
|
+
// otherwise have its own prop write silently dropped: the walk would exit before setting anything
|
|
277
|
+
// here. Setting propsDirty first, unconditionally, is what makes that ordering safe.
|
|
278
|
+
export function markPropsDirty(node) {
|
|
279
|
+
node.propsDirty = true;
|
|
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
|
+
}
|
|
116
304
|
// How many prop writes actually landed, and how many the no-op guard below turned away.
|
|
117
305
|
// Read-and-zeroed through readCommitProfile() (commit.ts), which folds them into the same window
|
|
118
306
|
// as the walk numbers so one read prices both halves: `propNoops` is the waste an adapter is
|
|
@@ -171,22 +359,104 @@ export function setProp(node, key, value) {
|
|
|
171
359
|
}
|
|
172
360
|
node.props[key] = value;
|
|
173
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;
|
|
367
|
+
// A composed primitive's slot — and its wrapper, where it has one — can carry a value DERIVED
|
|
368
|
+
// from an owner prop, and `markPropsDirty` bubbles up, so neither ever learns. Here rather than
|
|
369
|
+
// in `routeProp` because this is the one choke point every writer passes (a structural adapter's
|
|
370
|
+
// `setProperty` does not go through routeProp), and past the identity guard so a re-render
|
|
371
|
+
// writing an unchanged value costs them nothing. See `IHostBehavior.slotDerived`.
|
|
372
|
+
if (node.childHost !== undefined && slotDerivesFrom(node, key)) {
|
|
373
|
+
markPropsDirty(node.childHost);
|
|
374
|
+
// Past the slot: a `buildStructure` that builds a CHAIN registers the deeper nodes here, and
|
|
375
|
+
// each keeps its own pure fold reading the owner. See `addDerivedNode`.
|
|
376
|
+
const derived = derivedNodesOf(node);
|
|
377
|
+
if (derived !== undefined)
|
|
378
|
+
for (const each of derived)
|
|
379
|
+
markPropsDirty(each);
|
|
380
|
+
if (node.wrapper !== undefined)
|
|
381
|
+
markPropsDirty(node.wrapper);
|
|
382
|
+
}
|
|
174
383
|
propStats.writes += 1;
|
|
175
|
-
|
|
384
|
+
markPropsDirty(node);
|
|
176
385
|
}
|
|
177
|
-
// Fabric gates
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
386
|
+
// Fabric gates a handful of events behind a BOOLEAN prop: unlike scroll / touch / change, which
|
|
387
|
+
// the native component emits unconditionally, these fire only when the shadow node carries the
|
|
388
|
+
// flag. RN raises them with an `on*: true` validAttribute; we drop function props from the
|
|
389
|
+
// payload, so a gated handler attaches on our side and the native event simply never arrives.
|
|
390
|
+
// That is silent - a test asserting the listener is present passes, and only a device shows it.
|
|
391
|
+
//
|
|
392
|
+
// The list is exhaustive as of react-native 0.86: every `bool on*` field in Fabric's C++ props
|
|
393
|
+
// (`ReactCommon/react/renderer/components/**`), each read behind an `if` before the emitter runs:
|
|
394
|
+
//
|
|
395
|
+
// BaseViewProps.onLayout ParagraphShadowNode.cpp / RCTViewComponentView
|
|
396
|
+
// AccessibilityProps.onAccessibilityTap RCTViewComponentView.mm:1603
|
|
397
|
+
// AccessibilityProps.onAccessibilityMagicTap RCTViewComponentView.mm:1613
|
|
398
|
+
// AccessibilityProps.onAccessibilityEscape RCTViewComponentView.mm:1623
|
|
399
|
+
// AccessibilityProps.onAccessibilityAction RCTViewComponentView.mm:1633
|
|
400
|
+
// BaseParagraphProps.onTextLayout ParagraphShadowNode.cpp:351
|
|
401
|
+
//
|
|
402
|
+
// Keyed by the post-`listenerName` event name, valued with the payload key. `magicTap` maps to
|
|
403
|
+
// `onMagicTap` and NOT to the C++ member name `onAccessibilityMagicTap`, because `onMagicTap` is
|
|
404
|
+
// what RN's own view config declares (BaseViewConfig.ios.js) - the two disagree upstream, and
|
|
405
|
+
// matching stock is the only defensible choice until RN resolves it.
|
|
406
|
+
const GATED_EVENT_PROPS = new Map([
|
|
407
|
+
['layout', 'onLayout'],
|
|
408
|
+
['textLayout', 'onTextLayout'],
|
|
409
|
+
['accessibilityTap', 'onAccessibilityTap'],
|
|
410
|
+
['magicTap', 'onMagicTap'],
|
|
411
|
+
['accessibilityEscape', 'onAccessibilityEscape'],
|
|
412
|
+
['accessibilityAction', 'onAccessibilityAction'],
|
|
413
|
+
]);
|
|
185
414
|
// The explicit event channel. Structural adapters (Svelte addEventListener, Angular
|
|
186
415
|
// Renderer2.listen) call this directly with an already-known event name; flat-bag
|
|
187
416
|
// adapters reach it through routeProp. A non-function value clears the listener.
|
|
417
|
+
/**
|
|
418
|
+
* Install a listener the BEHAVIOR owns, bypassing the ownership check.
|
|
419
|
+
*
|
|
420
|
+
* `setEventListener` diverts an owned name into the stash, which is right for an app listener and
|
|
421
|
+
* circular for the behavior's own dispatcher — it would stash itself and never occupy the slot it
|
|
422
|
+
* exists to hold. This is the one writer allowed past that gate.
|
|
423
|
+
*
|
|
424
|
+
* `undefined` removes it, gate flag included. A behavior whose dispatcher is conditional needs
|
|
425
|
+
* that as much as it needs the install: ScrollView takes the owner's `layout` only while the app
|
|
426
|
+
* or an inverted sticky header wants it, and a one-way installer leaves `onLayout: true` standing
|
|
427
|
+
* in the payload of a ScrollView that no longer reads the event.
|
|
428
|
+
*/
|
|
429
|
+
export function setBehaviorListener(node, name, listener) {
|
|
430
|
+
if (listener === undefined)
|
|
431
|
+
node.listeners?.delete(name);
|
|
432
|
+
else
|
|
433
|
+
(node.listeners ??= new Map()).set(name, listener);
|
|
434
|
+
const flagProp = GATED_EVENT_PROPS.get(name);
|
|
435
|
+
if (flagProp !== undefined)
|
|
436
|
+
setProp(node, flagProp, listener === undefined ? undefined : true);
|
|
437
|
+
}
|
|
188
438
|
export function setEventListener(node, name, value) {
|
|
189
439
|
const isHandler = typeof value === 'function';
|
|
440
|
+
// A name a host behavior OWNS never reaches `node.listeners` — the behavior's dispatcher holds
|
|
441
|
+
// that slot and the app's callback is stashed beside it. `node.listeners` is single-slot, so
|
|
442
|
+
// without this the two evict each other and the last writer wins with no diagnostic; and the
|
|
443
|
+
// keys at stake are the ones a gesture STARTS on, so the loser is silently pressless. The
|
|
444
|
+
// component wrapper used to mediate this by destructuring the app's callbacks out before they
|
|
445
|
+
// reached the node; lowering removes the mediator. Gated on the boolean first, so an app with no
|
|
446
|
+
// behavior registered pays one read.
|
|
447
|
+
if (hasHostBehaviors() && ownsListener(node, name)) {
|
|
448
|
+
// The PRESENCE only, never the identity: listeners deliberately do not notify (a framework
|
|
449
|
+
// hands a fresh closure nearly every render — see `markDirty`'s note on why that must stay
|
|
450
|
+
// free). A flip is a mount-time event, not a per-render one.
|
|
451
|
+
const wasWired = appListenerFor(node, name) !== undefined;
|
|
452
|
+
stashAppListener(node, name, isHandler ? value : undefined);
|
|
453
|
+
if (wasWired !== isHandler)
|
|
454
|
+
notifyOwnedListenerChange(node, name, isHandler);
|
|
455
|
+
const flagged = GATED_EVENT_PROPS.get(name);
|
|
456
|
+
if (flagged !== undefined)
|
|
457
|
+
setProp(node, flagged, isHandler ? true : undefined);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
190
460
|
if (isHandler) {
|
|
191
461
|
const handler = value;
|
|
192
462
|
const listeners = (node.listeners ??= new Map());
|
|
@@ -195,8 +465,9 @@ export function setEventListener(node, name, value) {
|
|
|
195
465
|
else {
|
|
196
466
|
node.listeners?.delete(name);
|
|
197
467
|
}
|
|
198
|
-
|
|
199
|
-
|
|
468
|
+
const flagProp = GATED_EVENT_PROPS.get(name);
|
|
469
|
+
if (flagProp !== undefined)
|
|
470
|
+
setProp(node, flagProp, isHandler ? true : undefined);
|
|
200
471
|
}
|
|
201
472
|
const ON_PREFIX = /^on[A-Z]/;
|
|
202
473
|
// onChange -> change
|
|
@@ -235,24 +506,149 @@ const REACT_JSX_DEV_PROPS = new Set([
|
|
|
235
506
|
'__self',
|
|
236
507
|
'__source',
|
|
237
508
|
]);
|
|
238
|
-
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// `
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
function
|
|
247
|
-
|
|
248
|
-
|
|
509
|
+
// All slots are present from the start rather than added as they are written: one hidden class for
|
|
510
|
+
// every styled node in the app, instead of a shape transition per slot.
|
|
511
|
+
// Narrowed rather than cast: `routeProp` takes `unknown`, and a bare `typeof v === 'function'`
|
|
512
|
+
// leaves TS with `Function`, which is callable with anything. This states the shape the contract
|
|
513
|
+
// actually promises.
|
|
514
|
+
function isStyleCallback(value) {
|
|
515
|
+
return typeof value === 'function';
|
|
516
|
+
}
|
|
517
|
+
function stylePartsOf(node) {
|
|
518
|
+
return (node.styleParts ??= {
|
|
519
|
+
classStyle: undefined,
|
|
520
|
+
explicitStyle: undefined,
|
|
521
|
+
hiddenStyle: undefined,
|
|
522
|
+
className: undefined,
|
|
523
|
+
isPressed: false,
|
|
524
|
+
activeStyle: undefined,
|
|
525
|
+
activeStyleFromCallback: false,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
// What belongs in slot 0 right now. The pressed variant is a complete REPLACEMENT rather than an
|
|
529
|
+
// overlay: `resolveActiveClassName` resolves the element's tokens PLUS `:active` through the same
|
|
530
|
+
// matcher, so a `.btn:active` rule joins the cascade exactly as its specificity says and the
|
|
531
|
+
// result already contains everything `.btn` gave. That is why pressing needs no extra style slot
|
|
532
|
+
// and leaves the published array's SHAPE untouched.
|
|
533
|
+
//
|
|
534
|
+
// Resolved LAZILY, at press time, never beside `classStyle`. Eager would mean two resolutions per
|
|
535
|
+
// class WRITE — ~14 000 of them on one benchmark create — and twice the distinct keys in a cache
|
|
536
|
+
// that clears whole on overflow, to serve a state almost no node is ever in. A press is one event
|
|
537
|
+
// on one node, so the second lookup is invisible there.
|
|
538
|
+
//
|
|
539
|
+
// `:active` applies only to a class that reaches the engine as a STRING, and the reason it is a
|
|
540
|
+
// footnote rather than a gap is that essentially nothing delivers anything else.
|
|
541
|
+
//
|
|
542
|
+
// Vue createVNode normalises class to a string before patchProp ever sees it — in
|
|
543
|
+
// @vue/runtime-core, `if (klass && !isString(klass)) props.class =
|
|
544
|
+
// normalizeClass(klass)`. So `:class="{btn:true}"` arrives as `"btn"`. Cited by the
|
|
545
|
+
// expression, not a line: the package ships several builds of that file and the same
|
|
546
|
+
// statement sits on a different line in each, so two readers comparing notes see a
|
|
547
|
+
// contradiction that is not one.
|
|
548
|
+
// Angular Ivy compiles every class form to per-token addClass/removeClass, and the renderer
|
|
549
|
+
// joins the accumulated tokens into ONE string before routeProp.
|
|
550
|
+
// React `className` is a string by convention.
|
|
551
|
+
// Svelte `normalizeSvelteClass` (adapters/svelte/src/class-value.ts) joins a clsx-shaped
|
|
552
|
+
// value, and hands anything else through UNCHANGED — so Svelte never sends a class
|
|
553
|
+
// MAP, but it does send a non-string class, deliberately, and it is the one live
|
|
554
|
+
// producer of the branch below.
|
|
555
|
+
//
|
|
556
|
+
// An OBJECT here is not a class map at all — `IClassNameValue` types it as an IResolvedStyle, the
|
|
557
|
+
// channel ScrollView / VirtualizedList / FlatList / ImageBackground use to hand a style through
|
|
558
|
+
// the class prop, and Svelte's `resolveSvelteClass` exists to feed it. Canonicalising that into
|
|
559
|
+
// tokens would not have been a category error only in theory: it would have hit a live producer on
|
|
560
|
+
// four components, and they would have silently lost their styling. Do not "simplify" the object
|
|
561
|
+
// branch away.
|
|
562
|
+
//
|
|
563
|
+
// What remains is an ARRAY of plain strings, which no adapter produces today and which reduces
|
|
564
|
+
// fresh on every call, so it gets neither a pressed variant nor `isAlreadyPublished`. Narrow, and
|
|
565
|
+
// closable by joining an all-string array before the string path — not done here.
|
|
566
|
+
//
|
|
567
|
+
// The identity reasoning underneath: the registry memoises a class STRING to the same object, and
|
|
568
|
+
// `isAlreadyPublished` compares slot 0 with Object.is. A variant built from a value that resolves
|
|
569
|
+
// fresh each call could never be turned away by the guard, and 1 000 unpressed rows would
|
|
570
|
+
// republish and re-dirty — the storm the guard exists to stop.
|
|
571
|
+
// Slot 1's twin of `baseStyleOf`. The variant stands in for the AUTHORED style, so it replaces
|
|
572
|
+
// slot 1 and not slot 0 — it must beat the class cascade exactly the way the authored style does,
|
|
573
|
+
// and a `:active` class rule must still be able to win slot 0 underneath it.
|
|
574
|
+
function explicitStyleOf(parts) {
|
|
575
|
+
return parts.isPressed && parts.activeStyle !== undefined
|
|
576
|
+
? parts.activeStyle
|
|
577
|
+
: parts.explicitStyle;
|
|
578
|
+
}
|
|
579
|
+
function baseStyleOf(parts) {
|
|
580
|
+
return parts.isPressed && typeof parts.className === 'string'
|
|
581
|
+
? resolveActiveClassName(parts.className)
|
|
582
|
+
: parts.classStyle;
|
|
583
|
+
}
|
|
584
|
+
// Republish the merged style after one half changed. The halves are written IN PLACE by the
|
|
585
|
+
// callers below - there is no patch object and no spread, because this is the hottest function in
|
|
586
|
+
// the mutation API (9.3 ms self time and a large share of GC on a 4 000-row create, when it still
|
|
587
|
+
// allocated a patch literal plus a merged copy per write).
|
|
588
|
+
//
|
|
589
|
+
// The fresh ARRAY is the one allocation that stays, and that is DELIBERATE - do not "finish the
|
|
590
|
+
// optimization" by skipping when both halves are unchanged. The parts are a shadow copy of the
|
|
591
|
+
// declarative style, and setNativeProps bypasses them (it writes node.props.style directly,
|
|
592
|
+
// merging an Animated frame onto whatever is there). An app that hands over a hoisted style
|
|
593
|
+
// constant - StyleSheet.create, a module-level object - would then re-push an identity-equal half,
|
|
594
|
+
// get skipped by setProp's Object.is guard, and never restore the declarative style the animation
|
|
595
|
+
// overwrote. The re-push IS the restore path.
|
|
596
|
+
// Would `pushClassStyle` republish an array byte-identical to the one already standing? Reads the
|
|
597
|
+
// last published array back out of `node.props.style` rather than remembering it in a field: that
|
|
598
|
+
// array IS the record of what was published, so there is nothing to keep in sync, and no shape
|
|
599
|
+
// change to the node or to IClassStyleParts.
|
|
600
|
+
//
|
|
601
|
+
// Sound because `pushClassStyle` is the ONLY writer of an array into that slot — both routeProp
|
|
602
|
+
// branches and setNodeHidden funnel through it — so a foreign array cannot be mistaken for ours,
|
|
603
|
+
// and a node whose props are still empty holds `undefined`, which is not an array, so the first
|
|
604
|
+
// write can never be swallowed.
|
|
605
|
+
function isAlreadyPublished(node, parts) {
|
|
606
|
+
const published = node.props.style;
|
|
607
|
+
if (!Array.isArray(published))
|
|
608
|
+
return false;
|
|
609
|
+
// `baseStyleOf`, not `parts.classStyle` — the guard and the publication must read slot 0 the
|
|
610
|
+
// same way or a press is turned away as already-published and silently does nothing on device
|
|
611
|
+
// while the behavior fires correctly and nothing goes red.
|
|
612
|
+
if (!Object.is(published[0], baseStyleOf(parts)))
|
|
613
|
+
return false;
|
|
614
|
+
// Through the resolver for the same reason as slot 0 above: guard and publication must agree, or
|
|
615
|
+
// a press is turned away as already-published and does nothing on device with nothing red.
|
|
616
|
+
if (!Object.is(published[1], explicitStyleOf(parts)))
|
|
617
|
+
return false;
|
|
618
|
+
return parts.hiddenStyle === undefined
|
|
619
|
+
? published.length === 2
|
|
620
|
+
: published.length === 3 && Object.is(published[2], parts.hiddenStyle);
|
|
621
|
+
}
|
|
622
|
+
function pushClassStyle(node, parts) {
|
|
623
|
+
// The fresh array below can never be turned away by setProp's Object.is guard, so without this
|
|
624
|
+
// an UNCHANGED class still lands as a write AND marks the node dirty. Costs React / Vue / Svelte
|
|
625
|
+
// nothing — each diffs props before calling the engine — but Solid has no diff: a fine-grained
|
|
626
|
+
// 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, after
|
|
628
|
+
// host-primitive lowering): selecting one row of 1 000 read WRITES 1001 and a 10.3 ms reconcile
|
|
629
|
+
// 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
|
+
//
|
|
632
|
+
// This is NOT the naive skip the paragraph above forbids, and the array check is the difference.
|
|
633
|
+
// Skipping on "the parts are unchanged" alone would break the restore path, because
|
|
634
|
+
// setNativeProps writes node.props.style directly and a hoisted style constant would then never
|
|
635
|
+
// be restored. But setNativeProps writes an OBJECT (commit.ts: `{...flattenStyle(prev),
|
|
636
|
+
// ...flattenStyle(value)}`), never an array — so after any bypass isAlreadyPublished is false,
|
|
637
|
+
// the re-push happens exactly as before, and the restore path is untouched.
|
|
638
|
+
//
|
|
639
|
+
// Exact rather than approximate: resolveClassName memoizes a class STRING to the same object, so
|
|
640
|
+
// 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 setProp's Object.is
|
|
642
|
+
// already gives up on a style object, so no new asymmetry appears.
|
|
643
|
+
if (isAlreadyPublished(node, parts))
|
|
644
|
+
return;
|
|
249
645
|
// The third slot is APPENDED ONLY WHILE HIDDEN. Writing a permanent three-element array would
|
|
250
646
|
// change the style payload of every node in every app for a state almost none of them are ever
|
|
251
647
|
// in — and this project spent a day removing per-frame allocations, so a slot that is undefined
|
|
252
648
|
// 99.9% of the time does not get to ride along on every style write.
|
|
253
|
-
setProp(node, 'style',
|
|
254
|
-
? [
|
|
255
|
-
: [
|
|
649
|
+
setProp(node, 'style', parts.hiddenStyle === undefined
|
|
650
|
+
? [baseStyleOf(parts), explicitStyleOf(parts)]
|
|
651
|
+
: [baseStyleOf(parts), explicitStyleOf(parts), parts.hiddenStyle]);
|
|
256
652
|
}
|
|
257
653
|
// `display: 'none'` is a real RN style value (Yoga's DisplayNone), so a hidden node keeps its
|
|
258
654
|
// place in the tree, its state and its children — it just stops laying out and painting.
|
|
@@ -265,14 +661,34 @@ const HIDDEN_STYLE = { display: 'none' };
|
|
|
265
661
|
* author's style byte for byte — belongs to whoever owns the style merge, and that is here.
|
|
266
662
|
*/
|
|
267
663
|
export function setNodeHidden(node, hidden) {
|
|
268
|
-
|
|
664
|
+
const parts = stylePartsOf(node);
|
|
665
|
+
parts.hiddenStyle = hidden ? HIDDEN_STYLE : undefined;
|
|
666
|
+
pushClassStyle(node, parts);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Put a node into (or out of) its pressed state, so `.x:active` rules apply.
|
|
670
|
+
*
|
|
671
|
+
* The engine-owned half of what `:active` is on the web: the press state resolves BELOW the
|
|
672
|
+
* framework and never crosses into it, which is what lets a pressable be an intrinsic tag rather
|
|
673
|
+
* than a component (`.claude/rules/host-primitive-tier.md`, tier 2). A component is forced only
|
|
674
|
+
* when the TEMPLATE must read the state — `v-slot="{ pressed }"` and the function form of `style`
|
|
675
|
+
* — and this exists so the common case does not have to.
|
|
676
|
+
*
|
|
677
|
+
* Costs nothing when no `:active` rule is registered anywhere: `resolveActiveClassName` hands back
|
|
678
|
+
* the very same object the unpressed path returns, so `isAlreadyPublished` turns the re-push away
|
|
679
|
+
* and the node is never dirtied.
|
|
680
|
+
*/
|
|
681
|
+
export function setNodePressed(node, pressed) {
|
|
682
|
+
const parts = stylePartsOf(node);
|
|
683
|
+
parts.isPressed = pressed;
|
|
684
|
+
pushClassStyle(node, parts);
|
|
269
685
|
}
|
|
270
686
|
// The explicit (non-class-derived) style half, for an adapter that builds its style prop up
|
|
271
687
|
// key-by-key (Angular's Ivy ɵɵstyleProp/setStyle) instead of handing over one whole object —
|
|
272
688
|
// it must merge onto this, not onto node.props.style directly, which may be the
|
|
273
689
|
// [classStyle, explicitStyle] array commitClassStyle writes above.
|
|
274
690
|
export function getExplicitStyle(node) {
|
|
275
|
-
return
|
|
691
|
+
return node.styleParts?.explicitStyle;
|
|
276
692
|
}
|
|
277
693
|
const CLASS_PROP_KEYS = new Set(['class', 'className']);
|
|
278
694
|
// The flat-bag split (React / Vue / Solid): an `onX` prop becomes an event listener
|
|
@@ -282,17 +698,104 @@ const CLASS_PROP_KEYS = new Set(['class', 'className']);
|
|
|
282
698
|
export function routeProp(node, key, value) {
|
|
283
699
|
if (REACT_JSX_DEV_PROPS.has(key))
|
|
284
700
|
return;
|
|
701
|
+
// The prop twin of the child redirect in `appendChild`. A composed primitive's owner is written
|
|
702
|
+
// with props that belong to its internal slot — `contentContainerStyle` on a ScrollView styles
|
|
703
|
+
// the content view — and the adapter names the OWNER for a prop for the same reason it names the
|
|
704
|
+
// owner for a child: that is where the app wrote it.
|
|
705
|
+
//
|
|
706
|
+
// Gated on the FIELD, so a node with no slot pays one load and one branch and never touches the
|
|
707
|
+
// registry. The redirected write recurses into the slot's own `routeProp`, which is single-hop
|
|
708
|
+
// by construction: a slot has no slot of its own (`childHost` is documented single-hop, and
|
|
709
|
+
// `buildStructure` is what would have to nest one).
|
|
710
|
+
if (node.childHost !== undefined) {
|
|
711
|
+
const slotKey = slotPropNameFor(node, key);
|
|
712
|
+
if (slotKey !== undefined) {
|
|
713
|
+
// A class NAME is a legal spelling of `contentContainerStyle` — every canary writes
|
|
714
|
+
// `contentContainerStyle="scroll-content"` — so a string has to land on the slot as a
|
|
715
|
+
// CLASS. Only the class branch consults the registry; renaming it verbatim would publish a
|
|
716
|
+
// `style` holding a string, which is not a style and is dropped with nothing red. React's
|
|
717
|
+
// wrapper resolves the name itself (components/scroll-view/shared.ts), so this gap could
|
|
718
|
+
// only ever show on the tag path.
|
|
719
|
+
routeProp(node.childHost, slotKey === 'style' && typeof value === 'string' ? 'class' : slotKey, value);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
// An AnimatedNode written straight into a prop — `<view style={{opacity: value}}/>` — is
|
|
724
|
+
// resolved here into the value to PUBLISH, with the engine holding the subscription. Same
|
|
725
|
+
// shape as the `style` callback below: a value the engine interprets rather than forwards.
|
|
726
|
+
// Returns its input by identity when nothing is animated, so every branch under this line is
|
|
727
|
+
// unchanged. See `animated/host-binding.ts`; the gate is one boolean for an app that animates
|
|
728
|
+
// nothing.
|
|
729
|
+
//
|
|
730
|
+
// AFTER the slot redirect, so an animated `contentContainerStyle` binds on the node that
|
|
731
|
+
// actually carries the style.
|
|
732
|
+
const resolved = hasAnimatedNodes()
|
|
733
|
+
? bindAnimatedValue(node, key, value)
|
|
734
|
+
: value;
|
|
285
735
|
if (CLASS_PROP_KEYS.has(key)) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
736
|
+
const parts = stylePartsOf(node);
|
|
737
|
+
// Canonicalised HERE so the stored value is what everything downstream keys on: an all-string
|
|
738
|
+
// array becomes one string, and then the pressed variant and isAlreadyPublished work on it
|
|
739
|
+
// exactly as on an authored string. One `typeof` for the common case.
|
|
740
|
+
parts.className = canonicalClassName(isClassNameValue(resolved) ? resolved : undefined);
|
|
741
|
+
parts.classStyle = resolveClassName(parts.className);
|
|
742
|
+
pushClassStyle(node, parts);
|
|
289
743
|
return;
|
|
290
744
|
}
|
|
291
745
|
if (key === 'style') {
|
|
292
|
-
|
|
746
|
+
const parts = stylePartsOf(node);
|
|
747
|
+
// A FUNCTION `style` is `style={({pressed}) => …}`, the idiom this ecosystem actually writes.
|
|
748
|
+
// A lowering transform normally splits it at build time into `style` + `activeStyle`, so the
|
|
749
|
+
// engine never sees the callback — but a PUBLIC primitive tag has no transform in front of it
|
|
750
|
+
// on three adapters, and there the callback arrives here intact. Resolving it makes the
|
|
751
|
+
// compile-time split an OPTIMIZATION rather than the mechanism, the same relationship
|
|
752
|
+
// `foldHostBag` has with the compile-time prop folds.
|
|
753
|
+
//
|
|
754
|
+
// Without this the failure is silent and total: a function is not an `on*` name, so it misses
|
|
755
|
+
// `setEventListener`, lands in `setProp` as a function value, and `fabricProps` drops function
|
|
756
|
+
// props — the node commits with NO style at all. Traced by the Solid session, 2026-09-01.
|
|
757
|
+
//
|
|
758
|
+
// The callback must be PURE in `pressed`: its result is read once per state, here and under
|
|
759
|
+
// every transform's emission (`core/components/src/state-style.ts` carries the same contract).
|
|
760
|
+
if (isStyleCallback(resolved)) {
|
|
761
|
+
parts.explicitStyle = resolved({ pressed: false });
|
|
762
|
+
parts.activeStyle = resolved({ pressed: true });
|
|
763
|
+
parts.activeStyleFromCallback = true;
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
parts.explicitStyle = resolved;
|
|
767
|
+
// 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` the transform wrote must
|
|
769
|
+
// survive a `style` write, because the two arrive as independent props in an unspecified
|
|
770
|
+
// order.
|
|
771
|
+
if (parts.activeStyleFromCallback) {
|
|
772
|
+
parts.activeStyle = undefined;
|
|
773
|
+
parts.activeStyleFromCallback = false;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
pushClassStyle(node, parts);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
// Ours, never Fabric's — it is consumed here and must not reach the payload, or every pressable
|
|
780
|
+
// in the app carries an unknown key to native.
|
|
781
|
+
if (key === 'activeStyle') {
|
|
782
|
+
const parts = stylePartsOf(node);
|
|
783
|
+
parts.activeStyle = resolved;
|
|
784
|
+
// Slot 1 is no longer ours, by definition — whatever a callback derived earlier has just been
|
|
785
|
+
// replaced. Without this the flag outlives the value it describes: a callback sets it, this
|
|
786
|
+
// branch overwrites the slot silently, and a later plain `style` then clears a variant the
|
|
787
|
+
// engine never derived. Not reachable from a lowering transform (it emits either a callback or
|
|
788
|
+
// an explicit pair, never both for one node), but a flat-bag adapter routes a bag key by key
|
|
789
|
+
// and can deliver exactly that sequence.
|
|
790
|
+
parts.activeStyleFromCallback = false;
|
|
791
|
+
pushClassStyle(node, parts);
|
|
293
792
|
return;
|
|
294
793
|
}
|
|
295
794
|
if (ON_PREFIX.test(key)) {
|
|
795
|
+
// A native-driven `Animated.event` needs the native module as well as the listener map, and
|
|
796
|
+
// registers under the PROP name — see `bindAnimatedEvent`, which no-ops for anything else.
|
|
797
|
+
if (hasAnimatedNodes())
|
|
798
|
+
bindAnimatedEvent(node, key, resolved);
|
|
296
799
|
const name = listenerName(key);
|
|
297
800
|
const isRegisteredEvent = RESPONDER_EVENTS.has(name) || isEventFor(node.component, name);
|
|
298
801
|
// Investigation instrumentation (HeaderOptionsScreen unresponsive-buttons bug): RNS* views
|
|
@@ -305,11 +808,11 @@ export function routeProp(node, key, value) {
|
|
|
305
808
|
`registered=${isRegisteredEvent} at t=${Date.now()}`);
|
|
306
809
|
}
|
|
307
810
|
if (isRegisteredEvent) {
|
|
308
|
-
setEventListener(node, name,
|
|
811
|
+
setEventListener(node, name, resolved);
|
|
309
812
|
return;
|
|
310
813
|
}
|
|
311
814
|
}
|
|
312
|
-
setProp(node, key,
|
|
815
|
+
setProp(node, key, resolved);
|
|
313
816
|
}
|
|
314
817
|
// The same no-op guard as setProp, and here it is strictly stronger: `text` is a string, so
|
|
315
818
|
// `Object.is` is a real value comparison rather than the reference check it degrades to for a style
|
|
@@ -329,40 +832,204 @@ export function setText(node, text) {
|
|
|
329
832
|
}
|
|
330
833
|
node.props.text = text;
|
|
331
834
|
propStats.writes += 1;
|
|
332
|
-
|
|
835
|
+
markPropsDirty(node);
|
|
333
836
|
}
|
|
334
837
|
// Structural ops mark the PARENT chain (both the old and the new one), never the moved child:
|
|
335
838
|
// a child that only changed position may legitimately still be clean, and reconcile re-checks
|
|
336
839
|
// `committed.parent` on its early-exit path, so a reparent is caught there rather than by a flag.
|
|
840
|
+
//
|
|
841
|
+
// Each marks BEFORE touching `parent.children`, never after: the committed record may be aliasing
|
|
842
|
+
// that array, and markStructureDirty is what copies it out of the way. See there.
|
|
337
843
|
function detach(child) {
|
|
338
844
|
const parent = child.parent;
|
|
339
845
|
if (!parent)
|
|
340
846
|
return;
|
|
847
|
+
markStructureDirty(parent);
|
|
341
848
|
const index = parent.children.indexOf(child);
|
|
342
849
|
if (index >= 0)
|
|
343
850
|
parent.children.splice(index, 1);
|
|
344
851
|
child.parent = undefined;
|
|
345
|
-
markDirty(parent);
|
|
346
852
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
// Which node a child actually lands on. See `ISymbioteNode.childHost`: the adapter always names the
|
|
865
|
+
// OWNER, and a node whose behavior built an internal subtree redirects the app's children into it —
|
|
866
|
+
// unless the behavior CLAIMS this particular child, which keeps it on the owner (`claimedChildren`).
|
|
867
|
+
//
|
|
868
|
+
// SINGLE HOP, not a loop, and the field's own comment says why — a chain would put a walk on the
|
|
869
|
+
// engine's hottest path to express a depth no primitive has. A behavior needing depth points
|
|
870
|
+
// `childHost` at the innermost node itself.
|
|
871
|
+
//
|
|
872
|
+
// Reads a field that is `undefined` on every node in every app that registers no composed
|
|
873
|
+
// primitive, so the cost is one load and one branch — deliberately NOT behind `hasHostBehaviors()`,
|
|
874
|
+
// which would be a second read to save nothing. The claim check sits BEHIND that branch, so only a
|
|
875
|
+
// slot-bearing node ever pays the registry probe.
|
|
876
|
+
function hostFor(parent, child) {
|
|
877
|
+
const slot = parent.childHost;
|
|
878
|
+
if (slot === undefined)
|
|
879
|
+
return parent;
|
|
880
|
+
// A slot that is a built SIBLING rather than a container — ImageBackground's absolutely-filled
|
|
881
|
+
// image — keeps the app's children on the owner. See `IHostBehavior.slotTakesNoChildren`.
|
|
882
|
+
if (!slotTakesChildren(parent))
|
|
883
|
+
return parent;
|
|
884
|
+
return claimModeFor(parent, child.component) === undefined ? slot : parent;
|
|
885
|
+
}
|
|
886
|
+
// What actually occupies this node's place in its parent's child list. See `ISymbioteNode.wrapper`:
|
|
887
|
+
// a wrapped owner is what the adapter names and the wrapper is what the tree holds, so every
|
|
888
|
+
// structural op takes the owner and moves the wrapper.
|
|
889
|
+
function placedNode(node) {
|
|
890
|
+
return node.wrapper ?? node;
|
|
352
891
|
}
|
|
353
|
-
|
|
892
|
+
// Make `child` the owner's parent, in place. Returns false when this is not a wrap claim, so the
|
|
893
|
+
// two inserts fall through to the ordinary path on one call.
|
|
894
|
+
//
|
|
895
|
+
// 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
|
+
// `owner.parent` is undefined and the swap below is skipped. The later `appendChild(root, owner)`
|
|
898
|
+
// then inserts the wrapper instead, because `placedNode` says so.
|
|
899
|
+
function wrapsOwner(owner, child) {
|
|
900
|
+
if (owner.childHost === undefined)
|
|
901
|
+
return false;
|
|
902
|
+
if (claimModeFor(owner, child.component) !== 'wrap')
|
|
903
|
+
return false;
|
|
904
|
+
if (hasHostBehaviors())
|
|
905
|
+
reattachHostBehaviors(child);
|
|
906
|
+
if (hasAnimatedBindings())
|
|
907
|
+
reattachAnimatedProps(child);
|
|
354
908
|
detach(child);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
909
|
+
const outerParent = owner.parent;
|
|
910
|
+
if (outerParent !== undefined) {
|
|
911
|
+
markStructureDirty(outerParent);
|
|
912
|
+
outerParent.children[outerParent.children.indexOf(owner)] = child;
|
|
913
|
+
child.parent = outerParent;
|
|
914
|
+
}
|
|
915
|
+
owner.wrapper = child;
|
|
916
|
+
owner.parent = child;
|
|
917
|
+
markStructureDirty(child);
|
|
918
|
+
child.children.push(owner);
|
|
919
|
+
notifyWrapChange(owner, child);
|
|
920
|
+
return true;
|
|
359
921
|
}
|
|
360
|
-
|
|
361
|
-
|
|
922
|
+
// Put the owner back where its wrapper stood — the mirror of `wrapsOwner`. It must leave the owner
|
|
923
|
+
// ATTACHED: the framework is removing the RefreshControl, not the ScrollView.
|
|
924
|
+
function unwrapsOwner(owner, child) {
|
|
925
|
+
if (owner.wrapper !== child)
|
|
926
|
+
return false;
|
|
927
|
+
const outerParent = child.parent;
|
|
928
|
+
owner.wrapper = undefined;
|
|
929
|
+
if (outerParent !== undefined) {
|
|
930
|
+
markStructureDirty(outerParent);
|
|
931
|
+
outerParent.children[outerParent.children.indexOf(child)] = owner;
|
|
932
|
+
}
|
|
933
|
+
owner.parent = outerParent;
|
|
934
|
+
child.parent = undefined;
|
|
935
|
+
child.children.length = 0;
|
|
936
|
+
notifyWrapChange(owner, undefined);
|
|
937
|
+
return true;
|
|
938
|
+
}
|
|
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
|
+
export function appendChild(requestedParent, child) {
|
|
959
|
+
if (wrapsOwner(requestedParent, child))
|
|
960
|
+
return;
|
|
961
|
+
const parent = hostFor(requestedParent, child);
|
|
962
|
+
// A node the sweep tore down can be put back — Svelte parks live subtrees offscreen across
|
|
963
|
+
// commits. A WeakSet miss for anything freshly built, so the create path pays nothing.
|
|
964
|
+
if (hasHostBehaviors())
|
|
965
|
+
reattachHostBehaviors(child);
|
|
966
|
+
if (hasAnimatedBindings())
|
|
967
|
+
reattachAnimatedProps(child);
|
|
968
|
+
const placed = placedNode(child);
|
|
969
|
+
detach(placed);
|
|
970
|
+
markStructureDirty(parent);
|
|
971
|
+
placed.parent = parent;
|
|
972
|
+
if (parent.childHost !== undefined) {
|
|
973
|
+
parent.children.splice(indexFor(parent, undefined), 0, placed);
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
parent.children.push(placed);
|
|
977
|
+
}
|
|
978
|
+
if (hasHostBehaviors())
|
|
979
|
+
notifyChildInserted(parent, placed);
|
|
980
|
+
}
|
|
981
|
+
// `beforeChild` is genuinely nullable and the signature used to say otherwise: Solid's renderer
|
|
982
|
+
// spells "append" as `insertBefore(parent, child, null)`, which worked by accident because
|
|
983
|
+
// `indexOf(null)` is -1 and the old fallback appended. Reading a field off it is what made the lie
|
|
984
|
+
// fatal, so the type now says what the callers do.
|
|
985
|
+
export function insertBefore(requestedParent, child, beforeChild) {
|
|
986
|
+
if (wrapsOwner(requestedParent, child))
|
|
987
|
+
return;
|
|
988
|
+
const parent = hostFor(requestedParent, child);
|
|
989
|
+
if (hasHostBehaviors())
|
|
990
|
+
reattachHostBehaviors(child);
|
|
991
|
+
if (hasAnimatedBindings())
|
|
992
|
+
reattachAnimatedProps(child);
|
|
993
|
+
const placed = placedNode(child);
|
|
994
|
+
detach(placed);
|
|
995
|
+
markStructureDirty(parent);
|
|
996
|
+
placed.parent = parent;
|
|
997
|
+
parent.children.splice(indexFor(parent, beforeChild === null ? null : placedNode(beforeChild)), 0, placed);
|
|
998
|
+
if (hasHostBehaviors())
|
|
999
|
+
notifyChildInserted(parent, placed);
|
|
1000
|
+
}
|
|
1001
|
+
// Removal only NOMINATES a behavior for teardown; the commit sweep decides. A framework may spell
|
|
1002
|
+
// a move as remove-then-reinsert (Solid does), so tearing down here kills the machine of a node
|
|
1003
|
+
// that comes back alive in the same batch — see host-behavior.ts's markDetachCandidate.
|
|
1004
|
+
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
|
+
// A wrap claim leaving: the owner takes its own place back and stays in the tree. Nominated for
|
|
1011
|
+
// teardown like any other removed node, because the wrapper IS leaving.
|
|
1012
|
+
if (unwrapsOwner(requestedParent, child)) {
|
|
1013
|
+
if (hasHostBehaviors() || hasAnimatedBindings())
|
|
1014
|
+
markDetachCandidate(child);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
// A slot that IS the child being removed stops being one. Only a behavior that adopts an APP
|
|
1018
|
+
// child as its slot can reach this (`onChildInserted`); a `buildStructure` slot is internal and
|
|
1019
|
+
// no framework removes it. Without the clear, `hostFor` below redirects the removal INTO the very
|
|
1020
|
+
// node being removed, `indexOf` misses, the splice no-ops, and the child stays committed under a
|
|
1021
|
+
// parent the framework believes it left — and the NEXT child appended nests inside the orphan.
|
|
1022
|
+
if (requestedParent.childHost === child)
|
|
1023
|
+
requestedParent.childHost = undefined;
|
|
1024
|
+
const parent = hostFor(requestedParent, child);
|
|
1025
|
+
if (hasHostBehaviors() || hasAnimatedBindings())
|
|
1026
|
+
markDetachCandidate(child);
|
|
1027
|
+
markStructureDirty(parent);
|
|
1028
|
+
const placed = placedNode(child);
|
|
1029
|
+
const index = parent.children.indexOf(placed);
|
|
362
1030
|
if (index >= 0)
|
|
363
1031
|
parent.children.splice(index, 1);
|
|
364
1032
|
child.parent = undefined;
|
|
365
|
-
markDirty(parent);
|
|
366
1033
|
}
|
|
367
1034
|
export function censusRetainedTree(roots) {
|
|
368
1035
|
const census = {
|