@symbiote-native/engine 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/node.js CHANGED
@@ -1,1128 +1,9 @@
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`).
4
- import { recordAppendChild, recordCreateAnchor, recordCreateVoid, noteHostSideChange, recordCreateElement, recordCreateRawText, recordInsertBefore, recordRemoveChild, recordSetComponent, recordSetOwnedListener, recordSetUnderlayShown, recordSetProp, recordSetText, } from './mutation-buffer.js';
5
- import { isEventFor } from './view-config.js';
6
- import { canonicalClassName, EMPTY_STYLE, isClassNameValue, resolveActiveClassName, resolveClassName, } from './style-registry/index.js';
7
- import { dlog, isDebug } from './debug.js';
8
- import { appListenerFor, attachHostBehavior, claimModeFor, hasAttachedBehaviors, hasHostBehaviors, markDetachCandidate, notifyChildInserted, notifyOwnedListenerChange, noteCommitHookNodeChanged, ownsListener, reattachHostBehaviors, derivedNodesOf, slotDerivesFrom, slotPropNameFor, slotTakesChildren, stashAppListener, } from './host-behavior.js';
9
- import { configPayloadFold } from './registry.js';
10
- import { resolveStructuredStyle } from './structured-style.js';
11
- import { IMAGE_SOURCE_PROPS, IMAGE_LOAD_EVENT_NAMES, anyImageLoadEventListenerWired, resolveImageSourceProp, } from './image-source-write.js';
12
- import { Platform } from './platform';
13
- // A cycle, deliberately: `imperative.ts` imports this module; the prototype methods below call
14
- // back into it. Neither touches the other at module-evaluation time, only inside a function body,
15
- // so every loader resolves it (CLAUDE.md's load-time side-effect rule).
16
- import { measure as engineMeasure, measureInWindow as engineMeasureInWindow, measureLayout as engineMeasureLayout, setNativeProps as engineSetNativeProps, dispatchViewCommand, } from './imperative.js';
17
- // The same deliberate cycle, for the same reason: `tree-host.ts` imports `takePropStats` from here
18
- // and `censusRetainedTree` below asks it for the census. Function bodies only, on both sides.
19
- import { EMPTY_CENSUS, flushOps, treeHost, } from './tree-host.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';
25
- const BRAND = Symbol('symbiote.node');
26
- // A node carries the Fabric view name directly, so adding a primitive is just a new string from
27
- // the adapter, no core change. The only name resolved at commit time is text: a <Text> nested
28
- // inside another <Text> becomes a virtual span; `isText` marks a container so descendants pick it.
29
- export const RAW_TEXT_COMPONENT = 'RCTRawText';
30
- export const TEXT_COMPONENT = 'RCTText';
31
- export const VIRTUAL_TEXT_COMPONENT = 'RCTVirtualText';
32
- // Runtime guard narrowing `unknown` to ISymbioteEvent (no `as` cast). Lives with the interface
33
- // it tests, so every adapter checking for a Symbiote event shares one guard instead of writing
34
- // its own copy.
35
- export function isSymbioteEvent(value) {
36
- if (typeof value !== 'object' || value === null)
37
- return false;
38
- const nativeEvent = Reflect.get(value, 'nativeEvent');
39
- return typeof nativeEvent === 'object' && nativeEvent !== null;
40
- }
41
- const FOCUS_COMMAND = 'focus';
42
- const BLUR_COMMAND = 'blur';
43
- // Names and arg order mirror RN's ScrollViewCommands.
44
- const SCROLL_TO_COMMAND = 'scrollTo';
45
- const SCROLL_TO_END_COMMAND = 'scrollToEnd';
46
- const FLASH_SCROLL_INDICATORS_COMMAND = 'flashScrollIndicators';
47
- // The one shape every retained node has: a class, so the imperative methods share a prototype
48
- // instead of being allocated per node, and both factories below mint the same hidden class. Fields
49
- // are `declare`d and assigned in the constructor, the shape V8/Hermes handle best.
50
- class SymbioteNode {
51
- constructor(component, isText) {
52
- // Every field below is assigned here, not lazily: present from the constructor, they all keep
53
- // ONE hidden class for every node. `attachHostBehavior` raises several of them later for the
54
- // rare node whose behavior declares that capability.
55
- this[BRAND] = true;
56
- this.component = component;
57
- this.isText = isText;
58
- this.listeners = undefined;
59
- this.hasCommitHook = false;
60
- this.resolvesImageSources = false;
61
- this.nativeIdWinsOverId = false;
62
- this.styleParts = undefined;
63
- this.payloadFold = undefined;
64
- this.hostBehavior = undefined;
65
- this.childHost = undefined;
66
- this.wrapper = undefined;
67
- this.mayHaveChildren = false;
68
- this.isTornDown = false;
69
- // `slotOf` reads this pair on EVERY handle operand of every op, so both must be stable slots.
70
- // `slotBatch` starts at a value no real batch carries, so an untouched node reads as "not in
71
- // this batch" with no separate flag.
72
- this.slot = 0;
73
- this.slotBatch = 0;
74
- }
75
- measure(callback) {
76
- engineMeasure(this, callback);
77
- }
78
- measureInWindow(callback) {
79
- engineMeasureInWindow(this, callback);
80
- }
81
- measureLayout(relativeToNativeNode, onSuccess, onFail) {
82
- if (!isSymbioteNode(relativeToNativeNode)) {
83
- dlog('measureLayout: relative target must be a host ref');
84
- return;
85
- }
86
- engineMeasureLayout(this, relativeToNativeNode, onSuccess, onFail);
87
- }
88
- setNativeProps(nativeProps) {
89
- engineSetNativeProps(this, nativeProps);
90
- }
91
- focus() {
92
- dispatchViewCommand(this, FOCUS_COMMAND, []);
93
- }
94
- blur() {
95
- dispatchViewCommand(this, BLUR_COMMAND, []);
96
- }
97
- // The defaults live HERE and nowhere else. `buildScrollViewHandle`
98
- // (`@symbiote-native/components`) delegates here, so a built handle and a node cannot drift on
99
- // what `scrollTo()` with no argument means.
100
- scrollTo(options) {
101
- const x = options?.x ?? 0;
102
- const y = options?.y ?? 0;
103
- const animated = options?.animated ?? true;
104
- dlog(`ScrollView.scrollTo x=${x} y=${y} animated=${animated}`);
105
- dispatchViewCommand(this, SCROLL_TO_COMMAND, [x, y, animated]);
106
- }
107
- scrollToEnd(options) {
108
- const animated = options?.animated ?? true;
109
- dlog(`ScrollView.scrollToEnd animated=${animated}`);
110
- dispatchViewCommand(this, SCROLL_TO_END_COMMAND, [animated]);
111
- }
112
- flashScrollIndicators() {
113
- dlog('ScrollView.flashScrollIndicators');
114
- dispatchViewCommand(this, FLASH_SCROLL_INDICATORS_COMMAND, []);
115
- }
116
- }
117
- // The committed record — handle, tag, rootTag — is the host's; `committedRecordOf` (tree-host.ts)
118
- // answers for it, keyed on the handle OBJECT. A Vue Proxy around a host element misses that key,
119
- // so a wrapped node's calls degrade to "not committed" — hold host nodes with `shallowRef`.
120
- // Mint an element and record its creation. The node object IS the handle: what the ops address,
121
- // what the host attaches its native node to, what Fabric hands back as an event target.
122
- export function createElement(component, isText = false,
123
- // The intrinsic tag this node came from, when it differs from the Fabric view name above. The
124
- // behavior registry is keyed by tag, so an adapter creating `<pressable>` must hand it over here
125
- // or the registration cannot fire. Nothing is stored — the lookup happens once, right below.
126
- tag = component) {
127
- const node = new SymbioteNode(component, isText);
128
- // A primitive that commits NO VIEW resolves to the anchor component through `descriptorFor`
129
- // (touchable-without-feedback, touchable-native-feedback), reaching this rather than
130
- // `createAnchor` because the caller only knows it has a descriptor.
131
- if (component === ANCHOR_COMPONENT)
132
- recordCreateAnchor(node);
133
- // A primitive whose ENTIRE subtree must vanish on this platform (input-accessory-view on
134
- // Android) resolves to the void component the same way. An anchor hoists its children into
135
- // Fabric in its place; a void node contributes neither itself nor them.
136
- else if (component === VOID_COMPONENT)
137
- recordCreateVoid(node);
138
- // `instanceHandle` is the node itself: it round-trips through Fabric unchanged and comes back
139
- // as the event target, and the BRAND below confirms it is one of ours.
140
- else
141
- recordCreateElement(node, component, isText, node);
142
- // Gated on the boolean, not on the Map: this runs ~9 000 times per benchmark create, and an app
143
- // that registers nothing must pay one boolean read rather than a hash lookup per node.
144
- if (hasHostBehaviors())
145
- attachHostBehavior(node, tag);
146
- // A third-party view's own ViewConfig processors, as a fold, AFTER the behavior's — the behavior
147
- // rewrites wrapper-body props, and `validAttributes[*].process` then converts what it produced.
148
- // Costs one `Set.has` per node for a built-in, where `resolve` bails.
149
- const configFold = configPayloadFold(component);
150
- if (configFold !== undefined) {
151
- const behaviorFold = node.payloadFold;
152
- node.payloadFold =
153
- behaviorFold === undefined
154
- ? configFold
155
- : props => configFold(behaviorFold(props));
156
- }
157
- return node;
158
- }
159
- // `tag` mirrors `createElement`'s: the behavior registry is keyed by tag, and a raw text's CONTENT
160
- // can still be a function of the platform (Button renders its title uppercased on Android), even
161
- // with no props an app can write. Defaulted to the raw-text component for existing callers.
162
- export function createRawText(text, tag = RAW_TEXT_COMPONENT) {
163
- const node = new SymbioteNode(RAW_TEXT_COMPONENT, false);
164
- recordCreateRawText(node, text);
165
- // TAG check first, not `hasHostBehaviors()`: almost no raw text is tagged (thousands are leaves
166
- // under a `<Text>`), so an untagged one pays a pointer-equality compare against the default,
167
- // not a registry lookup.
168
- if (tag !== RAW_TEXT_COMPONENT && hasHostBehaviors())
169
- attachHostBehavior(node, tag);
170
- return node;
171
- }
172
- // `instanceHandle` round-trips through Fabric unchanged: the object we pass to
173
- // createNode comes back as the event target. We brand our nodes so the event
174
- // handler can confirm a target is one of ours before dispatching.
175
- export function isSymbioteNode(value) {
176
- return typeof value === 'object' && value !== null && BRAND in value;
177
- }
178
- // A WeakMap can't be logged, so this gives every node a small human-readable id, assigned lazily
179
- // on first call — lets a dlog at ref-attach time and one at commit/dispatch time be compared to
180
- // prove whether they're the SAME node object. Kept behind DEBUG per <keep_logs_gate_behind_DEBUG>.
181
- const debugIds = new WeakMap();
182
- let nextDebugId = 1;
183
- export function debugNodeId(node) {
184
- let id = debugIds.get(node);
185
- if (id === undefined) {
186
- id = nextDebugId++;
187
- debugIds.set(node, id);
188
- }
189
- return id;
190
- }
191
- // Vue's runtime-core needs comment/anchor nodes (fragments, v-if, v-for) to track sibling order;
192
- // Fabric has no such concept. An anchor is a real retained node so insert/nextSibling/parentNode
193
- // ordering stays correct, but the commit walk SKIPS it — no native view is ever created.
194
- export const ANCHOR_COMPONENT = '#anchor';
195
- export function createAnchor() {
196
- const node = new SymbioteNode(ANCHOR_COMPONENT, false);
197
- recordCreateAnchor(node);
198
- return node;
199
- }
200
- // The sentinel a primitive resolves to when its ENTIRE subtree must vanish from Fabric on this
201
- // platform (input-accessory-view on Android). Unlike ANCHOR_COMPONENT, whose node hoists children
202
- // up, a void node's children never reach Fabric either — the commit walk stops there, recursively.
203
- export const VOID_COMPONENT = '#void';
204
- export function createVoid() {
205
- const node = new SymbioteNode(VOID_COMPONENT, false);
206
- recordCreateVoid(node);
207
- return node;
208
- }
209
- // The sentinel a SURFACE's own root node carries, so `parentOf` can stop there. A top-level node
210
- // must answer `undefined` for its parent — Angular reads `null` as "defer, ng-content will place
211
- // this", while Vue/Solid spell `?? surface` and need the exact object to compare against.
212
- // JS-side name only: what goes over the wire is `RCTView`, because this node is real.
213
- export const SURFACE_COMPONENT = '#surface';
214
- // One persistent root view per surface, mirroring RN's own AppContainer: `renderApplication` wraps
215
- // the app in `<View style={{flex:1}} pointerEvents="box-none">`. Not decoration — without flex:1 a
216
- // non-flex root collapses to content height, and without box-none an outside touch has no escape.
217
- // Commits as ONE node rather than hoisting children into the child set, so the host materializes
218
- // the node OP_COMMIT names instead of walking its children (an anchor there still hoists).
219
- export function createSurfaceRoot() {
220
- const node = new SymbioteNode(SURFACE_COMPONENT, false);
221
- recordCreateElement(node, 'RCTView', false, node);
222
- // Recorded straight, not through `routeProp`: these are literal Fabric props, not props an app
223
- // authored, so they want none of the class merging or event routing that path exists for.
224
- recordSetProp(node, 'style', { flex: 1 });
225
- recordSetProp(node, 'pointerEvents', 'box-none');
226
- return node;
227
- }
228
- export function isAnchor(node) {
229
- return node.component === ANCHOR_COMPONENT;
230
- }
231
- // A raw text with no characters must not reach Fabric — AttributedString::appendFragment drops the
232
- // fragment while the text walk has already flagged "last child was raw text", so the next raw
233
- // sibling merges into an empty `fragments.back()` and aborts. Enforced by the host, not here.
234
- // No dirty-marking here: an op names the node it changed, so the host marks that node and its
235
- // ancestors. `node.listeners` never reaches Fabric, except `layout`, which raises `onLayout`
236
- // through `setProp` below.
237
- // Change which Fabric view a node commits as, keeping the node's identity. Policy stays out of the
238
- // engine — which prop decides, and between which views, lives in HOST_PRIMITIVES; the engine only
239
- // swaps the name. A no-op when unchanged, so a renderer may call it on every update.
240
- // Both the JS field and the op move: `node.component` is what the aria fold and fabricProps key
241
- // on, the op is what makes the host re-create the node, since no prop write moves it between views.
242
- export function setNodeComponent(node, component) {
243
- if (node.component === component)
244
- return;
245
- node.component = component;
246
- recordSetComponent(node, component);
247
- }
248
- // Tree-staleness marking (which ancestor went stale, presence flips, anchor climbs) lives entirely
249
- // on the host now, derived from the ops themselves — nothing here has to answer those questions.
250
- // How many prop writes an adapter pushed at the engine. Read-and-zeroed through
251
- // readCommitProfile() (tree-host.ts), which prices the layer ABOVE the host. No `noops` count: the
252
- // `Object.is` guard lives in the host, which would need the previous value back over the wire.
253
- // Not gated behind isDebug(): an integer increment is noise next to the prop write it counts, and
254
- // the figure is only meaningful from a release build. No per-call dlog either — a log line per
255
- // write would measure the logging rather than the code.
256
- const propStats = { writes: 0 };
257
- export function takePropStats() {
258
- const snapshot = { writes: propStats.writes };
259
- propStats.writes = 0;
260
- return snapshot;
261
- }
262
- // `<component>.<key>` -> write count, gated behind `isDebug()`: a Map lookup per write is real
263
- // cost on the hottest path, so it only runs when someone asked. Names exactly which (view, key)
264
- // pair an adapter comparison's aggregate delta is hiding.
265
- let propKeyTally;
266
- export function takePropKeyTally() {
267
- const snapshot = propKeyTally ?? new Map();
268
- propKeyTally = undefined;
269
- return snapshot;
270
- }
271
- // A pure prop set: no event inference. `onTintColor` is a Switch prop and reaches Fabric like any
272
- // other; the event-vs-prop decision is made by routeProp, never by the key's name.
273
- // `undefined` DELETES the key (`NO_VALUE` on the wire, mutation-buffer.ts). `null` is NOT the same:
274
- // it's a legitimate Fabric value meaning "reset to default", and a merge-on-clone host must tell a
275
- // removed key from one that was never there.
276
- // No `Object.is` DEDUPE HERE: it needs the value the node already holds, which JS doesn't. The
277
- // guard lives in the host's `OP_SET_PROP` instead, where the previous value is a local field.
278
- export function setProp(node, key, value) {
279
- // A composed primitive's slot — and its wrapper, where it has one — can carry a value DERIVED
280
- // from an owner prop, so `markPropsDirty` bubbles up or neither ever learns. Here, not in
281
- // `routeProp`: this is the one choke point every writer passes. See `IHostBehavior.slotDerived`.
282
- if (node.childHost !== undefined && slotDerivesFrom(node, key)) {
283
- markPropsDirty(node.childHost);
284
- // Past the slot: a `buildStructure` that builds a CHAIN registers the deeper nodes here, and
285
- // each keeps its own pure fold reading the owner. See `addDerivedNode`.
286
- const derived = derivedNodesOf(node);
287
- if (derived !== undefined)
288
- for (const each of derived)
289
- markPropsDirty(each);
290
- if (node.wrapper !== undefined)
291
- markPropsDirty(node.wrapper);
292
- }
293
- propStats.writes += 1;
294
- if (isDebug()) {
295
- propKeyTally ??= new Map();
296
- const tallyKey = `${node.component}.${key}`;
297
- propKeyTally.set(tallyKey, (propKeyTally.get(tallyKey) ?? 0) + 1);
298
- }
299
- writeProp(node, key, value);
300
- }
301
- // Function props that never left JS, keyed by node. A function CANNOT cross this wire:
302
- // `jsi::dynamicFromValue` THROWS on a callable, killing the whole batch.
303
- // Two paths reach here past `routeProp`'s registered-`on*` diversion: an unregistered `on*` that's
304
- // an ordinary prop (`onValueChange`), and `setNativeProps`, which bypasses `routeProp` entirely —
305
- // e.g. `Animated.View` spreading `panResponder.panHandlers` copies every key it holds.
306
- // Live here like listeners do; `propOf` looks here first, keeping `onValueChange` readable.
307
- // `fabricProps` drops function props on both hosts, so the payload is byte-identical either way.
308
- const functionProps = new WeakMap();
309
- // The one place a prop reaches the wire, and the only place that can keep a function off it.
310
- // `setNativeProps` calls this rather than `recordSetProp` for that reason — the path with no
311
- // `routeProp` in front of it.
312
- export function writeProp(node, key, value) {
313
- // The same strip `routeProp` does, repeated because THIS is the path with no `routeProp` in
314
- // front of it: `AnimatedProps` re-sends its whole raw prop bag every frame, so on a JSX adapter
315
- // `__self` rides straight past the declarative-path filter into the host.
316
- if (REACT_JSX_DEV_PROPS.has(key))
317
- return;
318
- // Arms the node's recurring post-commit hook, for the rare node that has one. HERE, not in
319
- // `setProp`: this is where both the declarative and `setNativeProps` paths meet, so a hook armed
320
- // only by the former misses the imperative write entirely.
321
- if (node.hasCommitHook)
322
- noteCommitHookNodeChanged(node);
323
- // `boxShadow`/`filter`/`transform` and Image's source props resolve on the way IN, at this same
324
- // choke point: the C++ payload builder has no JS to do it headless. `structured-style.ts` hands
325
- // back the same object when nothing needed resolving, keeping the host's identity guard intact.
326
- let written = value;
327
- if (key === 'style' || key === 'activeStyle') {
328
- written = resolveStructuredStyle(value);
329
- }
330
- else if (node.resolvesImageSources && IMAGE_SOURCE_PROPS.has(key)) {
331
- written = resolveImageSourceProp(value, key === 'source' && Platform.OS === 'android');
332
- }
333
- if (typeof written === 'function') {
334
- let bag = functionProps.get(node);
335
- if (bag === undefined) {
336
- bag = new Map();
337
- functionProps.set(node, bag);
338
- }
339
- bag.set(key, value);
340
- // The host must not be left holding whatever stood under this key before — a stale value read
341
- // back through `propOf` would beat the function this write just stashed.
342
- recordSetProp(node, key, undefined);
343
- return;
344
- }
345
- // Written over with a non-function: the stash must let go, or it keeps answering.
346
- const bag = functionProps.get(node);
347
- if (bag !== undefined)
348
- bag.delete(key);
349
- recordSetProp(node, key, written);
350
- }
351
- /** What `propOf` consults before asking the host. `undefined` when nothing was stashed. */
352
- export function functionPropOf(node, key) {
353
- return functionProps.get(node)?.get(key);
354
- }
355
- // The same stash, whole — what `propsOf` layers over the host's answer. `undefined` rather than an
356
- // empty Map for a node that stashed nothing (nearly every node), so the caller hands back the
357
- // host's own object instead of copying it.
358
- export function functionPropsOf(node) {
359
- return functionProps.get(node);
360
- }
361
- // "Rebuild this node's payload — the fold reads state I just changed." A behavior whose payload is
362
- // DERIVED has no prop to write (the sticky header's debounced translateY lives in its own
363
- // runtime), so this is the one route that dirties it directly. Pair with requestCommitFor.
364
- export function markPropsDirty(node) {
365
- flushOps();
366
- // Announced to the buffer even though it writes no op: this is the one route that dirties a node
367
- // without one, and a commit that cannot see it would skip itself as idle.
368
- noteHostSideChange();
369
- // The other way a node's payload is rebuilt, and the beat's population must cover both or a
370
- // behavior whose payload is DERIVED — the sticky header's debounced translateY has no prop to
371
- // write — would be armed by nothing.
372
- if (node.hasCommitHook)
373
- noteCommitHookNodeChanged(node);
374
- treeHost()?.markPropsDirty(node);
375
- }
376
- // Fabric gates a handful of events behind a BOOLEAN prop: unlike scroll/touch/change, these fire
377
- // only when the shadow node carries the flag. We drop function props from the payload, so a gated
378
- // handler attaches on our side and the native event silently never arrives without this map.
379
- // Exhaustive as of react-native 0.86: every `bool on*` field in Fabric's C++ props
380
- // (ReactCommon/react/renderer/components/**), keyed by the post-`listenerName` event name.
381
- // `magicTap` maps to `onMagicTap`, not the C++ member name `onAccessibilityMagicTap` — RN's own
382
- // view config (BaseViewConfig.ios.js) disagrees with its C++ prop name, and matching stock is the
383
- // only defensible choice until RN resolves it.
384
- const GATED_EVENT_PROPS = new Map([
385
- ['layout', 'onLayout'],
386
- ['textLayout', 'onTextLayout'],
387
- ['accessibilityTap', 'onAccessibilityTap'],
388
- ['magicTap', 'onMagicTap'],
389
- ['accessibilityEscape', 'onAccessibilityEscape'],
390
- ['accessibilityAction', 'onAccessibilityAction'],
391
- ]);
392
- // The explicit event channel. Structural adapters (Svelte addEventListener, Angular
393
- // Renderer2.listen) call this directly with an already-known event name; flat-bag
394
- // adapters reach it through routeProp. A non-function value clears the listener.
395
- // Install a listener the BEHAVIOR owns, bypassing the ownership check: `setEventListener` diverts
396
- // an owned name into the stash, which would be circular for the behavior's own dispatcher.
397
- // `undefined` removes it, gate flag included — ScrollView takes the owner's `layout` only while
398
- // something wants it, and a one-way installer would leave `onLayout: true` stuck in the payload.
399
- export function setBehaviorListener(node, name, listener) {
400
- if (listener === undefined)
401
- node.listeners?.delete(name);
402
- else
403
- (node.listeners ??= new Map()).set(name, listener);
404
- const flagProp = GATED_EVENT_PROPS.get(name);
405
- if (flagProp !== undefined)
406
- setProp(node, flagProp, listener === undefined ? undefined : true);
407
- }
408
- // The unowned names whose presence a platform rule reads. See `setEventListener`.
409
- const PRESSABILITY_NAMES = new Set([
410
- 'press',
411
- 'longPress',
412
- 'startShouldSetResponder',
413
- ]);
414
- export function setEventListener(node, name, value) {
415
- const isHandler = typeof value === 'function';
416
- // A name a host behavior OWNS never reaches `node.listeners`: the behavior's dispatcher holds
417
- // that slot, and `node.listeners` is single-slot, so without this the app's callback would evict
418
- // it with no diagnostic — on the keys a gesture STARTS on, silently pressless.
419
- if (hasHostBehaviors() && ownsListener(node, name)) {
420
- // The PRESENCE only, never the identity: a fresh closure nearly every render must not notify.
421
- const wasWired = appListenerFor(node, name) !== undefined;
422
- stashAppListener(node, name, isHandler ? value : undefined);
423
- if (wasWired !== isHandler) {
424
- // The BIT, on the flip only, so a platform rule (`focusable` on a touchable) can resolve a
425
- // key depending on whether the app wired anything, without the closure leaving JS.
426
- recordSetOwnedListener(node, name, isHandler);
427
- notifyOwnedListenerChange(node, name, isHandler);
428
- }
429
- const flagged = GATED_EVENT_PROPS.get(name);
430
- if (flagged !== undefined)
431
- setProp(node, flagged, isHandler ? true : undefined);
432
- return;
433
- }
434
- // A plain `<text>` presses through the engine's own synthesis, with no behavior owning the
435
- // names, yet its payload depends on whether it is pressable (Android `accessible`, the `link`
436
- // role). Same bit as the owned path, on the flip only.
437
- if (PRESSABILITY_NAMES.has(name)) {
438
- const wasWired = node.listeners?.has(name) === true;
439
- if (wasWired !== isHandler)
440
- recordSetOwnedListener(node, name, isHandler);
441
- }
442
- if (isHandler) {
443
- const handler = value;
444
- const listeners = (node.listeners ??= new Map());
445
- listeners.set(name, (event) => handler(event));
446
- }
447
- else {
448
- node.listeners?.delete(name);
449
- }
450
- const flagProp = GATED_EVENT_PROPS.get(name);
451
- if (flagProp !== undefined)
452
- setProp(node, flagProp, isHandler ? true : undefined);
453
- // `onLoad`/`onLoadStart`/`onLoadEnd`/`onError` are real Fabric events on `RCTImageView`, so they
454
- // land here, not in `writeProp` — see image-source-write.ts for why Android's
455
- // `shouldNotifyLoadEvents` is synthesized from the listener map, not a stashed function value.
456
- if (node.resolvesImageSources && IMAGE_LOAD_EVENT_NAMES.has(name)) {
457
- setProp(node, 'shouldNotifyLoadEvents', anyImageLoadEventListenerWired(node.listeners) ? true : undefined);
458
- }
459
- }
460
- // `/^on[A-Z]/` spelled out: this runs on EVERY prop write, and a regex costs measurably more than
461
- // the character reads (mutation-api-fill-cost.itest.ts). The boundary is pinned by node.test.ts.
462
- // 111 is 'o', 110 is 'n', 65-90 is A-Z. `charCodeAt` past the end answers NaN, which fails every
463
- // comparison, so a two-character `on` needs no length check.
464
- function isOnEventName(key) {
465
- if (key.charCodeAt(0) !== 111 || key.charCodeAt(1) !== 110)
466
- return false;
467
- const third = key.charCodeAt(2);
468
- return third >= 65 && third <= 90;
469
- }
470
- // onChange -> change
471
- function listenerName(propName) {
472
- return propName.charAt(2).toLowerCase() + propName.slice(3);
473
- }
474
- // The responder-negotiation events (PanResponder's panHandlers): a JS-side protocol synthesized
475
- // from raw touches, not Fabric ViewConfig events, so `isEventFor` never reports them. Treated as
476
- // listeners on any node so the handlers attach instead of reaching Fabric as dead props.
477
- const RESPONDER_EVENTS = new Set([
478
- 'startShouldSetResponder',
479
- 'startShouldSetResponderCapture',
480
- 'moveShouldSetResponder',
481
- 'moveShouldSetResponderCapture',
482
- 'responderGrant',
483
- 'responderReject',
484
- 'responderStart',
485
- 'responderMove',
486
- 'responderEnd',
487
- 'responderRelease',
488
- 'responderTerminate',
489
- 'responderTerminationRequest',
490
- ]);
491
- // React's JSX dev transform annotates every element with __self (the component instance) and
492
- // __source. React's own Fabric host config consumes both and never forwards them; a JSX-based
493
- // adapter (Vue JSX, Solid JSX) instead carries them as ordinary props, reaching Fabric.
494
- // Both platforms reject it, not just Android: iOS's `jsi::dynamicFromValue` keeps no visited set,
495
- // and `__self` is a cyclic module `this` — an endless walk inside `applyOps` that allocates until
496
- // RAM is exhausted, worse than Android's loud `folly::dynamic` rejection.
497
- // SFC/template authoring never produces them. Strip them here, once, mirroring React's host config.
498
- const REACT_JSX_DEV_PROPS = new Set([
499
- '__self',
500
- '__source',
501
- ]);
502
- // All slots are present from the start rather than added as they are written: one hidden class
503
- // for every styled node, instead of a shape transition per slot.
504
- // Narrowed rather than cast: `routeProp` takes `unknown`, and a bare `typeof v === 'function'`
505
- // leaves TS with `Function`, callable with anything. This states the shape the contract promises.
506
- function isStyleCallback(value) {
507
- return typeof value === 'function';
508
- }
509
- function stylePartsOf(node) {
510
- return (node.styleParts ??= {
511
- classStyle: undefined,
512
- explicitStyle: undefined,
513
- hiddenStyle: undefined,
514
- className: undefined,
515
- isPressed: false,
516
- activeStyle: undefined,
517
- activeStyleFromCallback: false,
518
- published: undefined,
519
- });
520
- }
521
- // The pressed variant is a complete REPLACEMENT, not an overlay: `resolveActiveClassName` resolves
522
- // the element's tokens PLUS `:active` through the same matcher, so the result already contains
523
- // everything the base class gave. No extra slot needed; the published array's shape stays put.
524
- // Resolved LAZILY, at press time, never beside `classStyle` — eager would double the resolutions
525
- // on every class WRITE to serve a state almost no node is ever in. A press is one event on one
526
- // node, so the extra lookup there is invisible.
527
- // `:active` applies only to a class reaching the engine as a STRING — true for nearly every
528
- // producer: Vue/Angular/React all normalize `class` to a string before it reaches here, except
529
- // Svelte's `normalizeSvelteClass`, the one live producer of a non-string class below.
530
- // An OBJECT here is not a class map — `IClassNameValue` types it as an IResolvedStyle, the channel
531
- // ScrollView/VirtualizedList/FlatList/ImageBackground use to hand a style through the class prop.
532
- // Canonicalising it into tokens would silently break their styling — do not "simplify" it away.
533
- // An ARRAY of plain strings: no adapter produces this today, and it reduces fresh every call, so
534
- // it gets neither a pressed variant nor `isAlreadyPublished`.
535
- // The registry memoises a class STRING to the same object; `isAlreadyPublished` compares slot 0
536
- // with Object.is. A variant resolving fresh each call could never be turned away by that guard, so
537
- // unpressed rows would republish and re-dirty forever — the storm the guard exists to stop.
538
- // Slot 1's twin of `baseStyleOf`: the variant replaces slot 1, not slot 0, so it beats the class
539
- // cascade the way the authored style does, while a `:active` rule can still win slot 0 underneath.
540
- function explicitStyleOf(parts) {
541
- return parts.isPressed && parts.activeStyle !== undefined
542
- ? parts.activeStyle
543
- : parts.explicitStyle;
544
- }
545
- function baseStyleOf(parts) {
546
- return parts.isPressed && typeof parts.className === 'string'
547
- ? resolveActiveClassName(parts.className)
548
- : parts.classStyle;
549
- }
550
- // Republish the merged style after one half changed. Halves are written IN PLACE by the callers
551
- // below — no patch object, no spread — because this is the hottest function in the mutation API.
552
- // The fresh ARRAY allocation stays, DELIBERATELY: `setNativeProps` bypasses these parts, so an app
553
- // handing over a hoisted style constant would get skipped by the Object.is guard and never
554
- // restore the declarative style an animation overwrote. The re-push IS the restore path.
555
- // Sound because `pushClassStyle` is the ONLY writer of `parts.published` — so a node that has
556
- // published nothing holds `undefined`, and the first write can never be swallowed as "unchanged".
557
- // What a node publishes when NOTHING resolves. Length 0 is the marker, needing no second field:
558
- // `pushClassStyle` never otherwise publishes an empty array, and it's distinct from `undefined`
559
- // ("nothing published yet", which must never be turned away).
560
- const PUBLISHED_NOTHING = Object.freeze([]);
561
- // ── ONE ARRAY PER DISTINCT PAIR, SHARED ACROSS NODES ─────────────────────────────────────────────
562
- // `mutation-buffer.ts` interns values BY IDENTITY, so distinct-but-equal arrays across a list
563
- // styled the same way cost distinct entries and separate JS -> `folly::dynamic` conversions.
564
- // `WeakMap`, both levels, so nothing grows unbounded: a fresh style object per render gets a fresh
565
- // cache entry that dies with the object, and correctly gets no sharing — two structurally equal
566
- // objects are two values to whoever reads them.
567
- // The three-slot (hidden) form is deliberately NOT cached: `display: 'none'` is rare by
568
- // construction, so a third map would be paid for on every write to serve it.
569
- const sharedPairByExplicit = new WeakMap();
570
- const sharedPairByBase = new WeakMap();
571
- // The published array for this pair — same object every time the same two parts are handed in.
572
- // `undefined` when the pair can't be keyed (a primitive half, or the hidden form), and the caller
573
- // then builds its own array; every reader compares slots by identity, never the array itself.
574
- function sharedStylePair(base, explicit) {
575
- const baseIsKeyable = typeof base === 'object' && base !== null;
576
- const explicitIsKeyable = typeof explicit === 'object' && explicit !== null;
577
- if (base === undefined && explicitIsKeyable) {
578
- const cached = sharedPairByExplicit.get(explicit);
579
- if (cached !== undefined)
580
- return cached;
581
- const made = [base, explicit];
582
- sharedPairByExplicit.set(explicit, made);
583
- return made;
584
- }
585
- if (!baseIsKeyable)
586
- return undefined;
587
- if (explicit === undefined) {
588
- const cached = sharedPairByBase.get(base);
589
- if (Array.isArray(cached))
590
- return cached;
591
- if (cached === undefined) {
592
- const made = [base, explicit];
593
- sharedPairByBase.set(base, made);
594
- return made;
595
- }
596
- // A base that has already been seen WITH an explicit half holds the second-level map here, and
597
- // the base-only array has nowhere to live beside it. Rare enough not to earn a third map.
598
- return undefined;
599
- }
600
- if (!explicitIsKeyable)
601
- return undefined;
602
- const existing = sharedPairByBase.get(base);
603
- const byExplicit = existing instanceof WeakMap ? existing : new WeakMap();
604
- if (existing === undefined)
605
- sharedPairByBase.set(base, byExplicit);
606
- // Same clash as above, the other way round: this base is holding its base-only array. Leave it.
607
- if (Array.isArray(existing))
608
- return undefined;
609
- const cached = byExplicit.get(explicit);
610
- if (cached !== undefined)
611
- return cached;
612
- const made = [base, explicit];
613
- byExplicit.set(explicit, made);
614
- return made;
615
- }
616
- // A slot that contributes no keys to the payload: absent, or the registry's shared "this class
617
- // styles nothing" object. An IDENTITY compare, not a key count — `Object.keys(x).length` allocates
618
- // an array, and this runs on every class and style write.
619
- // A plain style bag — not an array of styles, not a callback, not null.
620
- function isStyleRecord(value) {
621
- return typeof value === 'object' && value !== null && !Array.isArray(value);
622
- }
623
- // Is this rebuilt style the same style, key for key? A component body writing its style inline
624
- // hands over a fresh object every render, equal to the one already standing, which Object.is
625
- // cannot see — without this the write crosses into the host and is only found unchanged there.
626
- // Shallow and conservative, deliberately: a nested value (transform list, shadow, style array)
627
- // reports "not the same" rather than being deep-compared, so being wrong here is slow, never
628
- // incorrect — the host's own diffProps still refuses those exactly as before.
629
- // `undefined` on either side also reports "not the same", which lets the key COUNT stand in for a
630
- // key-set comparison: equal counts plus every key of `next` matching a defined value in `standing`
631
- // cannot leave a key unaccounted for.
632
- export function isSameShallowStyle(next, standing) {
633
- // THE SAME OBJECT IS THE SAME STYLE, checked first: without it, a re-push of a hoisted constant
634
- // (what Solid does on every signal change, having no diff) allocates two key arrays and walks
635
- // them to reach the same answer the identity check gives for free.
636
- // Changes nothing OBSERVABLE — break-tested, not assumed: inverting this line (identical object
637
- // reporting "changed") leaves the full test suite green, because `pushClassStyle`'s own
638
- // `isAlreadyPublished` catches the republish downstream via `sharedStylePair`'s memoization.
639
- if (next === standing)
640
- return isStyleRecord(next);
641
- if (!isStyleRecord(next) || !isStyleRecord(standing))
642
- return false;
643
- const keys = Object.keys(next);
644
- if (keys.length !== Object.keys(standing).length)
645
- return false;
646
- for (const key of keys) {
647
- const value = next[key];
648
- if (value === undefined || isStyleRecord(value) || Array.isArray(value)) {
649
- return false;
650
- }
651
- if (!Object.is(value, standing[key]))
652
- return false;
653
- }
654
- return true;
655
- }
656
- function contributesNothing(slot) {
657
- return slot === undefined || slot === EMPTY_STYLE;
658
- }
659
- // Does this node have a style at all? Read through the same two resolvers as the publication, for
660
- // the reason the guard below states: guard and publication disagreeing is a silent wrong screen.
661
- function hasNothingToPublish(parts) {
662
- return (parts.hiddenStyle === undefined &&
663
- contributesNothing(baseStyleOf(parts)) &&
664
- contributesNothing(explicitStyleOf(parts)));
665
- }
666
- function isAlreadyPublished(parts) {
667
- const published = parts.published;
668
- if (published === undefined)
669
- return false;
670
- // The delete is already standing. Asked before the slot comparisons because an empty array would
671
- // otherwise pass both of them on `undefined` and then fail the length check, republishing a
672
- // delete the host already performed.
673
- if (published.length === 0)
674
- return hasNothingToPublish(parts);
675
- // `baseStyleOf`, not `parts.classStyle` — the guard and the publication must read slot 0 the
676
- // same way or a press is turned away as already-published and silently does nothing on device
677
- // while the behavior fires correctly and nothing goes red.
678
- if (!Object.is(published[0], baseStyleOf(parts)))
679
- return false;
680
- // Through the resolver for the same reason as slot 0 above: guard and publication must agree, or
681
- // a press is turned away as already-published and does nothing on device with nothing red.
682
- if (!Object.is(published[1], explicitStyleOf(parts)))
683
- return false;
684
- return parts.hiddenStyle === undefined
685
- ? published.length === 2
686
- : published.length === 3 && Object.is(published[2], parts.hiddenStyle);
687
- }
688
- function pushClassStyle(node, parts) {
689
- // An unchanged class still reaches this write — Solid has no diff, so a list-wide signal
690
- // re-pushes every row's class and dirties the whole tree. Guard keys off `published`, which
691
- // setNativeProps clears, so an imperative restore still re-publishes correctly.
692
- if (isAlreadyPublished(parts))
693
- return;
694
- // Emits NO_VALUE rather than `[undefined, undefined]` — the host skips a real array with one
695
- // pointer check instead of building and diffing a `folly::dynamic`. Same published-marker guard
696
- // as above keeps the restore path working after setNativeProps clears it.
697
- if (hasNothingToPublish(parts)) {
698
- parts.published = PUBLISHED_NOTHING;
699
- setProp(node, 'style', undefined);
700
- return;
701
- }
702
- // Third slot only appended while hidden — a permanent 3-element array would add an allocation
703
- // to every style write for a state most nodes never enter.
704
- const base = baseStyleOf(parts);
705
- const explicit = explicitStyleOf(parts);
706
- const published = parts.hiddenStyle === undefined
707
- ? (sharedStylePair(base, explicit) ?? [base, explicit])
708
- : [base, explicit, parts.hiddenStyle];
709
- parts.published = published;
710
- setProp(node, 'style', published);
711
- }
712
- // `display: 'none'` is a real RN style value (Yoga's DisplayNone), so a hidden node keeps its
713
- // place in the tree, its state and its children — it just stops laying out and painting.
714
- const HIDDEN_STYLE = { display: 'none' };
715
- // Stop a node painting without unmounting it, or let it paint again — the seam React's
716
- // Activity/Suspense reach for through hideInstance/unhideInstance. Lives in the engine, not an
717
- // adapter, since restoring the author's style byte belongs to whoever owns the style merge.
718
- export function setNodeHidden(node, hidden) {
719
- const parts = stylePartsOf(node);
720
- parts.hiddenStyle = hidden ? HIDDEN_STYLE : undefined;
721
- pushClassStyle(node, parts);
722
- }
723
- // Put a node into (or out of) its pressed state, so `:active` rules apply. The engine-owned half:
724
- // press state resolves below the framework, which is what lets a pressable stay an intrinsic tag
725
- // instead of a component — a component is forced only when the template must read the state.
726
- // Costs nothing when no `:active` rule is registered: resolveActiveClassName hands back the same
727
- // object the unpressed path returns, so isAlreadyPublished turns the re-push away.
728
- export function setNodePressed(node, pressed) {
729
- const parts = stylePartsOf(node);
730
- parts.isPressed = pressed;
731
- pushClassStyle(node, parts);
732
- }
733
- // Tell the host a behavior's FEEDBACK is showing — TouchableHighlight's underlay, and only that.
734
- // Deliberately not the same bit as setNodePressed: this drives a rule living in C++
735
- // (foldTouchableHighlightUnderlay), crossing as one op, since `shown` lags `pressed` by a timer.
736
- // No style computed here at all: the two props the rule reads (underlayColor, activeOpacity) are
737
- // ones the engine already strips from the payload, so their defaults live in one place.
738
- export function setNodeUnderlayShown(node, shown) {
739
- recordSetUnderlayShown(node, shown);
740
- }
741
- // Forget what was last published, so the next pushClassStyle cannot be turned away. The one
742
- // caller is setNativeProps, which writes the style slot past this file. A no-op for a node nobody
743
- // has styled — that's why this isn't `stylePartsOf(node).published = undefined`.
744
- export function clearPublishedStyle(node) {
745
- if (node.styleParts !== undefined)
746
- node.styleParts.published = undefined;
747
- }
748
- // The explicit (non-class) style half — an adapter that builds style key-by-key (Angular's
749
- // ɵɵstyleProp/setStyle) merges onto this, not node.props.style directly, which may hold the
750
- // [classStyle, explicitStyle] pair pushClassStyle publishes.
751
- export function getExplicitStyle(node) {
752
- return node.styleParts?.explicitStyle;
753
- }
754
- // The `[classStyle, explicitStyle]` pair the node currently publishes, same order pushClassStyle
755
- // writes, so flattenStyle collapses it the way Fabric will.
756
- // For a caller that wants the merged answer without a host: the pair reaches the payload as an op,
757
- // and only a host holds ops — core/css-parser reads it here instead of reaching into styleParts.
758
- export function getPublishedStyle(node) {
759
- const parts = node.styleParts;
760
- if (parts === undefined)
761
- return [];
762
- return [parts.classStyle, parts.explicitStyle];
763
- }
764
- const CLASS_PROP_KEYS = new Set(['class', 'className']);
765
- // Flat-bag split (React/Vue/Solid): `onX` becomes a listener only when the component's ViewConfig
766
- // declares `x` as an event — otherwise it's a plain prop, so `onTintColor` on a Switch (whose only
767
- // event is `change`) routes to setProp and reaches Fabric untouched.
768
- // `id` is RN's alias for `nativeID` and wins when both are set (View.js: `nativeID = id`). No
769
- // ViewConfig declares raw `id`, so Fabric drops it — a half-working rename loses nativeID with
770
- // nothing red anywhere.
771
- const ID_ALIAS_FROM = 'id';
772
- const ID_ALIAS_TO = 'nativeID';
773
- // The ONE place this rename happens: HERE because every adapter's prop write ends at routeProp,
774
- // whatever shape it starts in — a bag fold can't serve the per-key renderers and a per-key fold
775
- // can't serve the bag ones, but the seam they share can serve both.
776
- // Precedence needs state because upstream decides `id ?? nativeID` in one expression; a per-key
777
- // writer never sees both, so unmemoized precedence would fall out of write order instead. The
778
- // authored nativeID is remembered, so clearing `id` hands the slot back rather than latching.
779
- // Precedence is per-component: View.js's `id ?? nativeID` is the default, but
780
- // TouchableWithoutFeedback's clone unconditionally overwrites nativeID with the authored value
781
- // when set. `node.nativeIdWinsOverId`, set for the one behavior that declares it, flips the winner.
782
- const idAliased = new WeakMap();
783
- function routeIdAlias(node, key, value) {
784
- const state = idAliased.get(node) ?? {
785
- idValue: undefined,
786
- nativeIdValue: undefined,
787
- };
788
- if (key === ID_ALIAS_FROM)
789
- state.idValue = value;
790
- else
791
- state.nativeIdValue = value;
792
- idAliased.set(node, state);
793
- const published = node.nativeIdWinsOverId
794
- ? (state.nativeIdValue ?? state.idValue)
795
- : (state.idValue ?? state.nativeIdValue);
796
- setProp(node, ID_ALIAS_TO, published);
797
- }
798
- export function routeProp(node, key, value) {
799
- if (REACT_JSX_DEV_PROPS.has(key))
800
- return;
801
- // Prop twin of the child redirect in `appendChild` — a composed primitive's owner receives props
802
- // that belong to its internal slot (`contentContainerStyle` on ScrollView styles the content
803
- // view), same reason the owner is named for a child: that's where the app wrote it.
804
- // Gated on the field, so a node with no slot pays one load+branch and never touches the registry.
805
- // The redirect recurses into the slot's own routeProp, single-hop by construction — a slot has no
806
- // slot of its own (`childHost` is documented single-hop).
807
- if (node.childHost !== undefined) {
808
- const slotKey = slotPropNameFor(node, key);
809
- if (slotKey !== undefined) {
810
- // A class name is a legal spelling of `contentContainerStyle`, so a string must land on the
811
- // slot as `class`, not `style` — renamed verbatim it would publish a style holding a string,
812
- // dropped with nothing red. React's wrapper resolves the name itself; this only matters here.
813
- const slotValueFor = node.hostBehavior?.slotValueFor;
814
- routeProp(node.childHost, slotKey === 'style' && typeof value === 'string' ? 'class' : slotKey, slotValueFor === undefined ? value : slotValueFor(slotKey, value));
815
- return;
816
- }
817
- }
818
- // After the slot redirect on purpose: a composed primitive forwards most of its bag to an
819
- // internal node (ImageBackground spreads everything but `style` onto its image), so an `id` on
820
- // the owner belongs there. Resolved earlier, nativeID would land on the wrapper unseen.
821
- if (key === ID_ALIAS_FROM || key === ID_ALIAS_TO) {
822
- routeIdAlias(node, key, value);
823
- return;
824
- }
825
- // An AnimatedNode in a prop (`style={{opacity: value}}`) resolves here to the value to publish,
826
- // the engine holding the subscription. Returns its input by identity when nothing is animated,
827
- // so every branch below is unchanged (animated/host-binding.ts).
828
- // After the slot redirect, so an animated `contentContainerStyle` binds on the node that
829
- // actually carries the style.
830
- const resolved = hasAnimatedNodes()
831
- ? bindAnimatedValue(node, key, value)
832
- : value;
833
- if (CLASS_PROP_KEYS.has(key)) {
834
- const parts = stylePartsOf(node);
835
- // Canonicalised HERE so the stored value is what everything downstream keys on: an all-string
836
- // array becomes one string, and then the pressed variant and isAlreadyPublished work on it
837
- // exactly as on an authored string. One `typeof` for the common case.
838
- parts.className = canonicalClassName(isClassNameValue(resolved) ? resolved : undefined);
839
- parts.classStyle = resolveClassName(parts.className);
840
- pushClassStyle(node, parts);
841
- return;
842
- }
843
- if (key === 'style') {
844
- const parts = stylePartsOf(node);
845
- // A function `style` (`style={({pressed}) => …}`) arrives here intact and is resolved at both
846
- // states — writing `style` + `activeStyle` as an explicit pair, cheaper by one call per
847
- // recompute than the app doing it by hand.
848
- // Without this the failure is silent: a function isn't an `on*` name, misses setEventListener,
849
- // lands in setProp as a function value, and fabricProps drops function props — the node commits
850
- // with no style at all.
851
- // The callback must be pure in `pressed` — read once per state, here and under every
852
- // transform's emission (core/components/src/state-style.ts carries the same contract).
853
- if (isStyleCallback(resolved)) {
854
- parts.explicitStyle = resolved({ pressed: false });
855
- parts.activeStyle = resolved({ pressed: true });
856
- parts.activeStyleFromCallback = true;
857
- }
858
- else {
859
- // A rebuilt literal equal to what is standing is not a change — see `isSameShallowStyle`.
860
- // Gated on something being published, which keeps the restore path intact: a setNativeProps
861
- // write clears `parts.published`, and after that this must never turn a write away — the
862
- // re-push IS the restore, same mechanism isAlreadyPublished relies on.
863
- // Gated on the previous write not coming from a callback, since that one owns
864
- // `parts.activeStyle` and this branch must clear it — returning early would leave the old
865
- // pressed look standing under a plain style.
866
- if (parts.published !== undefined &&
867
- !parts.activeStyleFromCallback &&
868
- isSameShallowStyle(resolved, parts.explicitStyle)) {
869
- return;
870
- }
871
- parts.explicitStyle = resolved;
872
- // Only a variant WE derived is stale now. `style` switching from a callback to a plain value
873
- // must not leave the old pressed look standing, and an AUTHORED `activeStyle` must survive a
874
- // `style` write, because the two arrive as independent props in an unspecified order.
875
- if (parts.activeStyleFromCallback) {
876
- parts.activeStyle = undefined;
877
- parts.activeStyleFromCallback = false;
878
- }
879
- }
880
- pushClassStyle(node, parts);
881
- return;
882
- }
883
- // Ours, never Fabric's — it is consumed here and must not reach the payload, or every pressable
884
- // in the app carries an unknown key to native.
885
- if (key === 'activeStyle') {
886
- const parts = stylePartsOf(node);
887
- parts.activeStyle = resolved;
888
- // Slot 1 is no longer ours — whatever a callback derived has just been replaced. Without this
889
- // the flag outlives its value: this branch overwrites the slot silently and a later plain
890
- // `style` clears a variant the engine never derived, a sequence a flat-bag adapter can deliver.
891
- parts.activeStyleFromCallback = false;
892
- pushClassStyle(node, parts);
893
- return;
894
- }
895
- // RN's snapshot affordance (Pressable.js seeds usePressState with it): render the control
896
- // pressed with no gesture. Selects activeStyle and any `:active` class — exactly what isPressed
897
- // already decides, so it belongs beside activeStyle rather than in a behavior.
898
- // Here rather than in attachAfterCommit: a behavior hook reading this prop costs a post-commit
899
- // crossing per pressable node (budgeted in crossing-and-payload-census.probe.test.tsx). This
900
- // branch is one string compare on the first commit — no crossing at all.
901
- // TouchableHighlight's half of the same prop paints an underlay, so it's a separate rule in
902
- // SymbioteFabricProps.cpp — a side effect AND a passthrough. Returning early here left that rule
903
- // blind; keeping it out of the payload is kPressableMachineKeys's job, done separately.
904
- // Same shape GATED_EVENT_PROPS uses above: act, then let the write continue.
905
- if (key === 'testOnly_pressed')
906
- setNodePressed(node, resolved === true);
907
- if (isOnEventName(key)) {
908
- // A native-driven `Animated.event` needs the native module as well as the listener map, and
909
- // registers under the PROP name — see `bindAnimatedEvent`, which no-ops for anything else.
910
- if (hasAnimatedNodes())
911
- bindAnimatedEvent(node, key, resolved);
912
- const name = listenerName(key);
913
- const isRegisteredEvent = RESPONDER_EVENTS.has(name) || isEventFor(node.component, name);
914
- // RNS* views derive events from react-native-screens' own codegen ViewConfig, so an
915
- // unregistered event falls through to setProp as a dead prop Fabric ignores — indistinguishable
916
- // from "the button did nothing" at the UI. Scoped to RNS* to avoid noise; gated behind DEBUG.
917
- if (node.component.startsWith('RNS')) {
918
- dlog(`routeProp: ${node.component} "${key}" -> listener "${name}" ` +
919
- `registered=${isRegisteredEvent} at t=${Date.now()}`);
920
- }
921
- if (isRegisteredEvent) {
922
- setEventListener(node, name, resolved);
923
- return;
924
- }
925
- }
926
- setProp(node, key, resolved);
927
- }
928
- // Counted in propStats — a text write IS a prop write, reaching Fabric as RCTRawText's only prop.
929
- // Unguarded, same reason setProp is: comparing against the standing text means reading it back
930
- // from the host, which holds it locally and dedupes there — including empty-string child-list
931
- // transitions and reparenting under `<Text>` (RCTVirtualText vs RCTText).
932
- export function setText(node, text) {
933
- propStats.writes += 1;
934
- recordSetText(node, text);
935
- }
936
- // Structural ops: each is one op and nothing else — the host detaches a child from whatever
937
- // parent it currently has before linking it, true even when an adapter names a stale one (a MOVE
938
- // spells as remove-then-insert). JS doesn't track the old parent and doesn't need to.
939
- // What JS still decides is which node an op names — two redirects, a composed primitive's slot and
940
- // a wrap claim, both read off a field so a plain node pays one load and one branch per op.
941
- // The host's raw answer, surface INCLUDED — unlike `parentOf` (host-access.ts), which reports a
942
- // top-level node as parentless by design. The two swaps below have to NAME the holder in an op, and
943
- // for a wrapped node sitting directly under a surface that holder is the surface node.
944
- function holderOf(node) {
945
- flushOps();
946
- const parent = treeHost()?.parentOf(node);
947
- return isSymbioteNode(parent) ? parent : undefined;
948
- }
949
- // Which node a child actually lands on. See `ISymbioteNode.childHost`: the adapter always names the
950
- // OWNER, and a node whose behavior built an internal subtree redirects the app's children into it —
951
- // unless the behavior CLAIMS this particular child, which keeps it on the owner (`claimedChildren`).
952
- // SINGLE HOP, not a loop, and the field's own comment says why — a chain would put a walk on the
953
- // engine's hottest path to express a depth no primitive has. A behavior needing depth points
954
- // `childHost` at the innermost node itself.
955
- // Reads a field undefined on every node with no composed primitive — one load, one branch.
956
- // Deliberately not behind hasHostBehaviors() (a second read for nothing); the claim check sits
957
- // behind that branch, so only a slot-bearing node pays the registry probe.
958
- function hostFor(parent, child) {
959
- const slot = parent.childHost;
960
- if (slot === undefined)
961
- return parent;
962
- // A slot that is a built SIBLING rather than a container — ImageBackground's absolutely-filled
963
- // image — keeps the app's children on the owner. See `IHostBehavior.slotTakesNoChildren`.
964
- if (!slotTakesChildren(parent))
965
- return parent;
966
- return claimModeFor(parent, child.component) === undefined ? slot : parent;
967
- }
968
- // The node a child must be inserted before, or `undefined` for an ordinary append.
969
- // A host that still has a slot is an owner taking a claimed child, which goes before the slot
970
- // whatever the framework asked — RN renders `{refreshControl}{content}` in that order, and
971
- // `beforeChild` lives inside the slot anyway.
972
- // A sibling slot is the opposite: RN paints the background image first and children over it
973
- // (ImageBackground.js), so they append past it rather than in front — what `undefined` leaves
974
- // alone.
975
- function slotAnchorOf(host) {
976
- const slot = host.childHost;
977
- if (slot === undefined || !slotTakesChildren(host))
978
- return undefined;
979
- return slot;
980
- }
981
- // ── the two structural recorders, and why nothing here calls the raw ones ───────────────────────
982
- // `mayHaveChildren` is only sound if every op that gives a node a child raises it — raised here
983
- // rather than at each of the five call sites, since a forgotten one would make childrenOf answer
984
- // "empty" for a node that has children: a wrong answer, not a slow one.
985
- // Arm a parent's recurring post-commit hook for a STRUCTURAL change, not just a prop write: the
986
- // ScrollView sticky-header machine drops a wrapper when the framework takes the wrapped child
987
- // away, writing no prop on the wrapper's owner at all — narrowing the beat to props left it stuck.
988
- function armCommitHookForChildChange(parent) {
989
- if (parent.hasCommitHook)
990
- noteCommitHookNodeChanged(parent);
991
- }
992
- function recordAppendInto(parent, child) {
993
- parent.mayHaveChildren = true;
994
- armCommitHookForChildChange(parent);
995
- recordAppendChild(parent, child);
996
- }
997
- function recordInsertInto(parent, child, beforeChild) {
998
- parent.mayHaveChildren = true;
999
- armCommitHookForChildChange(parent);
1000
- recordInsertBefore(parent, child, beforeChild);
1001
- }
1002
- // What actually occupies this node's place in its parent's child list. See `ISymbioteNode.wrapper`:
1003
- // a wrapped owner is what the adapter names and the wrapper is what the tree holds, so every
1004
- // structural op takes the owner and moves the wrapper.
1005
- function placedNode(node) {
1006
- return node.wrapper ?? node;
1007
- }
1008
- // Make `child` the owner's parent, in place. Returns false when this is not a wrap claim, so the
1009
- // two inserts fall through to the ordinary path on one call.
1010
- // The owner being unattached is the normal case: every adapter fills a node's children before
1011
- // appending it to its own parent, so the wrap usually happens with no holder yet and only the
1012
- // second op runs — the later appendChild(root, owner) inserts the wrapper via placedNode.
1013
- function wrapsOwner(owner, child) {
1014
- if (owner.childHost === undefined)
1015
- return false;
1016
- if (claimModeFor(owner, child.component) !== 'wrap')
1017
- return false;
1018
- if (hasHostBehaviors())
1019
- reattachHostBehaviors(child);
1020
- if (hasAnimatedBindings())
1021
- reattachAnimatedProps(child);
1022
- const holder = holderOf(owner);
1023
- // Wrapper takes the owner's place first, then the owner moves under it — the host's own detach
1024
- // on link is what unlinks the owner from `holder`, so no removal op is needed.
1025
- if (holder !== undefined)
1026
- recordInsertInto(holder, child, owner);
1027
- owner.wrapper = child;
1028
- recordAppendInto(child, owner);
1029
- return true;
1030
- }
1031
- // Put the owner back where its wrapper stood — the mirror of `wrapsOwner`. It must leave the owner
1032
- // ATTACHED: the framework is removing the RefreshControl, not the ScrollView.
1033
- function unwrapsOwner(owner, child) {
1034
- if (owner.wrapper !== child)
1035
- return false;
1036
- owner.wrapper = undefined;
1037
- const holder = holderOf(child);
1038
- if (holder === undefined) {
1039
- // The wrapper never reached a parent, so there is no place to take back — the owner simply
1040
- // stops hanging off it.
1041
- recordRemoveChild(child, owner);
1042
- }
1043
- else {
1044
- recordInsertInto(holder, owner, child);
1045
- recordRemoveChild(holder, child);
1046
- }
1047
- return true;
1048
- }
1049
- export function appendChild(requestedParent, child) {
1050
- if (wrapsOwner(requestedParent, child))
1051
- return;
1052
- const parent = hostFor(requestedParent, child);
1053
- // A node the sweep tore down can be put back — Svelte parks live subtrees offscreen across
1054
- // commits. A WeakSet miss for anything freshly built, so the create path pays nothing.
1055
- if (hasHostBehaviors())
1056
- reattachHostBehaviors(child);
1057
- if (hasAnimatedBindings())
1058
- reattachAnimatedProps(child);
1059
- const placed = placedNode(child);
1060
- const anchor = slotAnchorOf(parent);
1061
- if (anchor === undefined)
1062
- recordAppendInto(parent, placed);
1063
- else
1064
- recordInsertInto(parent, placed, anchor);
1065
- if (hasHostBehaviors())
1066
- notifyChildInserted(parent, placed);
1067
- }
1068
- // No anchor means append, and it's a real case, not a defensive guard: solid-js/universal spells
1069
- // "insert at end" as `insertNode(parent, node, null)`, and Vue passes `anchor` through as `null`.
1070
- // A slot has to name a node, so an unanchored insert IS an append and is recorded as one.
1071
- export function insertBefore(requestedParent, child, beforeChild) {
1072
- if (wrapsOwner(requestedParent, child))
1073
- return;
1074
- const parent = hostFor(requestedParent, child);
1075
- if (hasHostBehaviors())
1076
- reattachHostBehaviors(child);
1077
- if (hasAnimatedBindings())
1078
- reattachAnimatedProps(child);
1079
- const placed = placedNode(child);
1080
- const anchor = slotAnchorOf(parent) ??
1081
- (beforeChild === null || beforeChild === undefined
1082
- ? undefined
1083
- : placedNode(beforeChild));
1084
- if (anchor === undefined)
1085
- recordAppendInto(parent, placed);
1086
- else
1087
- recordInsertInto(parent, placed, anchor);
1088
- if (hasHostBehaviors())
1089
- notifyChildInserted(parent, placed);
1090
- }
1091
- // Removal only NOMINATES a behavior for teardown; the commit sweep decides. A framework may spell
1092
- // a move as remove-then-reinsert (Solid does), so tearing down here kills the machine of a node
1093
- // that comes back alive in the same batch — see host-behavior.ts's markDetachCandidate.
1094
- export function removeChild(requestedParent, child) {
1095
- // A wrap claim leaving: the owner takes its own place back and stays in the tree. Nominated for
1096
- // teardown like any other removed node, because the wrapper IS leaving.
1097
- if (unwrapsOwner(requestedParent, child)) {
1098
- if (hasAttachedBehaviors() || hasAnimatedBindings())
1099
- markDetachCandidate(child);
1100
- return;
1101
- }
1102
- // A slot that IS the child being removed stops being one. Only a behavior that adopts an app
1103
- // child as its slot reaches this; without the clear, hostFor below redirects the removal into
1104
- // the node being removed, and the next child appended nests inside the orphan.
1105
- if (requestedParent.childHost === child)
1106
- requestedParent.childHost = undefined;
1107
- // Redirected for the same reason the two inserts are: the adapter removes from the node it
1108
- // appended to, which is the OWNER, while the child actually lives in the slot.
1109
- const parent = hostFor(requestedParent, child);
1110
- // hasAttachedBehaviors, not hasHostBehaviors: the latter is on from module load in every app just
1111
- // from registering Pressable as a type. Nominating a candidate crosses every removed node into JS
1112
- // on the commit sweep, which can't matter before a behavior has actually attached to anything.
1113
- if (hasAttachedBehaviors() || hasAnimatedBindings())
1114
- markDetachCandidate(child);
1115
- // BOTH, and the owner is the one that matters: a composed primitive's behavior lives on the node
1116
- // the adapter named, while `hostFor` redirects the mutation into its internal slot. Arming only
1117
- // the slot arms a node that has no behavior at all.
1118
- armCommitHookForChildChange(requestedParent);
1119
- armCommitHookForChildChange(parent);
1120
- recordRemoveChild(parent, placedNode(child));
1121
- }
1122
- // A structural census of the tree the HOST holds — see ITreeCensus (tree-host.ts). Walks nothing
1123
- // here: the walk needs props.text and a child list, which JS has neither. `undefined` from
1124
- // treeHost() means nothing installed, so the empty census can't be mistaken for a real zero.
1125
- export function censusRetainedTree(roots) {
1126
- flushOps();
1127
- return treeHost()?.census(roots) ?? EMPTY_CENSUS;
1128
- }
1
+ // The mutation API, as a barrel so `from './node'` keeps naming the whole of it. Adapters call it
2
+ // and every call appends an OPCODE to `mutation-buffer.ts`, т.к. the tree is the HOST's
3
+ export { ANCHOR_COMPONENT, RAW_TEXT_COMPONENT, SURFACE_COMPONENT, TEXT_COMPONENT, VIRTUAL_TEXT_COMPONENT, VOID_COMPONENT, isAnchor, isSymbioteEvent, isSymbioteNode, } from './node-types.js';
4
+ export { createAnchor, createElement, createRawText, createSurfaceRoot, createVoid, debugNodeId, setNodeComponent, } from './node-instance.js';
5
+ export { functionPropOf, functionPropsOf, markPropsDirty, setProp, setText, takePropKeyTally, takePropStats, writeProp, } from './node-props.js';
6
+ export { clearPublishedStyle, getExplicitStyle, getPublishedStyle, isSameShallowStyle, setNodeHidden, setNodePressed, setNodeUnderlayShown, } from './node-style.js';
7
+ export { hasListenerFor, listenerFor, setBehaviorListener, setEventListener, setNodeDispatch, } from './node-events.js';
8
+ export { routeProp } from './node-route.js';
9
+ export { appendChild, censusRetainedTree, insertBefore, removeChild, } from './node-tree.js';