@symbiote-native/engine 0.4.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.
@@ -8,7 +8,7 @@
8
8
  import { dlog } from '../debug.js';
9
9
  import { runWrapped } from '../dispatch.js';
10
10
  import { getSlot } from '../fabric.js';
11
- import { isAnchor, isSymbioteNode, } from '../node.js';
11
+ import { committedOf, isAnchor, isSymbioteNode, } from '../node.js';
12
12
  import { registeredNativeEvent } from '../registry.js';
13
13
  import { attachTouchHistory, recordTouchTrack, resetTouchHistory, touchHistory, } from '../touch-history.js';
14
14
  import { isRecord } from '../type-guards.js';
@@ -248,6 +248,34 @@ function findWantsResponder(path, captureName, bubbleName, nativeEvent, skip) {
248
248
  }
249
249
  return undefined;
250
250
  }
251
+ // Tell native which node owns the gesture, RN's `injectGlobalResponderHandler`
252
+ // (ReactFabric-dev.js:18862) — the OLD owner first, then the new one, both in one call so no
253
+ // site can do half of it.
254
+ //
255
+ // WITHOUT THIS A JS RESPONDER LOSES TO ANY SCROLL VIEW ABOVE IT, and it is invisible from JS:
256
+ // `onStartShouldSetResponder` returns true, the native UIScrollView never learns the gesture was
257
+ // claimed, and every subsequent move arrives as `topScroll` instead of `topTouchMove` — so the
258
+ // negotiation never even gets a move to grant on. Device-diagnosed 2026-09-08 on a PanResponder
259
+ // drag box inside the canary's ScrollView: `startShouldSet -> true` followed by
260
+ // `topScrollBeginDrag` and twenty `topScroll`, with no grant and no move.
261
+ //
262
+ // `blockNativeResponder` is the taker's own `onResponderGrant` return, exactly as RN reads it.
263
+ function handOverNativeResponder(from, to, blockNativeResponder) {
264
+ const slot = getSlot();
265
+ const fromHandle = from === undefined ? undefined : committedOf(from)?.handle;
266
+ const toHandle = to === undefined ? undefined : committedOf(to)?.handle;
267
+ dlog(`setIsJSResponder from=${from === undefined ? 'none' : fromHandle === undefined ? 'UNCOMMITTED' : 'yes'} ` +
268
+ `to=${to === undefined ? 'none' : toHandle === undefined ? 'UNCOMMITTED' : 'yes'} block=${blockNativeResponder}`);
269
+ if (fromHandle !== undefined)
270
+ slot.setIsJSResponder(fromHandle, false, blockNativeResponder);
271
+ if (toHandle !== undefined)
272
+ slot.setIsJSResponder(toHandle, true, blockNativeResponder);
273
+ }
274
+ // Whether the taker asked native to stand down. RN reads this off the grant dispatch's return;
275
+ // an absent listener means no claim, which is RN's `blockNativeResponder || false`.
276
+ function blocksNative(result) {
277
+ return result === true;
278
+ }
251
279
  // Negotiate (or re-negotiate) the responder for a touch start/move. If nobody holds
252
280
  // it, the winner is granted. If someone does, the incumbent is asked to relinquish
253
281
  // via onResponderTerminationRequest (absent listener = implicit yes); on yes it is
@@ -268,12 +296,21 @@ function negotiateResponder(target, phase, nativeEvent) {
268
296
  const wants = phase === 'start'
269
297
  ? findWantsResponder(path, START_SHOULD_SET_CAPTURE, START_SHOULD_SET, nativeEvent, skip)
270
298
  : findWantsResponder(path, MOVE_SHOULD_SET_CAPTURE, MOVE_SHOULD_SET, nativeEvent, skip);
271
- if (!wants || wants === currentResponder)
299
+ // Every exit is logged: a negotiation that declines is indistinguishable from one that never
300
+ // ran, and the two have opposite causes.
301
+ if (!wants) {
302
+ dlog(`responder ${phase}: nobody wants it (path=${path.length}${skip === undefined ? '' : ', one skipped'})`);
303
+ return;
304
+ }
305
+ if (wants === currentResponder) {
306
+ dlog(`responder ${phase}: ${wants.component} already holds it`);
272
307
  return;
308
+ }
273
309
  if (currentResponder === undefined) {
274
310
  currentResponder = wants;
275
311
  dlog(`responder granted to ${wants.component}`);
276
- callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
312
+ const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
313
+ handOverNativeResponder(undefined, wants, blocksNative(granted));
277
314
  return;
278
315
  }
279
316
  const incumbent = currentResponder;
@@ -292,9 +329,10 @@ function negotiateResponder(target, phase, nativeEvent) {
292
329
  // before terminate on the consent path (matching RN's grant<terminate ordering) and
293
330
  // omit it on reject; the consent OUTCOME is unchanged either way.
294
331
  dlog(`responder transferred ${incumbent.component} -> ${wants.component}`);
295
- callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
332
+ const granted = callOwnListener(wants, RESPONDER_GRANT, nativeEvent);
296
333
  callOwnListener(incumbent, RESPONDER_TERMINATE, nativeEvent);
297
334
  currentResponder = wants;
335
+ handOverNativeResponder(incumbent, wants, blocksNative(granted));
298
336
  }
299
337
  else {
300
338
  dlog(`responder takeover of ${incumbent.component} rejected`);
@@ -309,7 +347,7 @@ export function installEventHandler() {
309
347
  if (!isSymbioteNode(instanceHandle))
310
348
  return;
311
349
  if (topLevelType === TOUCH_START) {
312
- dlog(`event ${TOUCH_START}`);
350
+ dlog(`event ${TOUCH_START} on ${isSymbioteNode(instanceHandle) ? instanceHandle.component : 'NON-NODE'}`);
313
351
  // Update the touch bank, then attach it so responder handlers (PanResponder)
314
352
  // read each touch's own previous->current delta; RN records before dispatch.
315
353
  recordTouchTrack('start', nativeEvent);
@@ -432,8 +470,10 @@ export function installEventHandler() {
432
470
  // release) only when the last responder touch lifted.
433
471
  if (responder) {
434
472
  callOwnListener(responder, RESPONDER_END, nativeEvent);
435
- if (releases)
473
+ if (releases) {
436
474
  callOwnListener(responder, RESPONDER_RELEASE, nativeEvent);
475
+ handOverNativeResponder(responder, undefined, false);
476
+ }
437
477
  else
438
478
  dlog('responderEnd without release (touches remain inside responder)');
439
479
  }
@@ -468,8 +508,10 @@ export function installEventHandler() {
468
508
  // Like touch-end, every finger leaving emits responderEnd. Termination is final only
469
509
  // when no touch remains inside the responder.
470
510
  callOwnListener(responder, RESPONDER_END, nativeEvent);
471
- if (terminatesResponder)
511
+ if (terminatesResponder) {
472
512
  callOwnListener(responder, RESPONDER_TERMINATE, nativeEvent);
513
+ handOverNativeResponder(responder, undefined, false);
514
+ }
473
515
  }
474
516
  });
475
517
  if (touchHistory.numberActiveTouches === 0)
@@ -291,7 +291,22 @@ function foldTextInputValue(props) {
291
291
  }
292
292
  export function fabricProps(node) {
293
293
  if (node.component === RAW_TEXT_COMPONENT) {
294
- return { text: node.props.text };
294
+ // A raw-text node gets its behavior's fold too — it TRANSFORMS the text that is already there
295
+ // (Button uppercases its label on Android) and may not SUPPLY one, which is narrower than this
296
+ // comment claimed when it landed. `isEmptyRawText` (node.ts) decides whether the node commits
297
+ // at all from `node.props.text`, before any fold runs, so a text that exists only as a fold
298
+ // result is dropped by `renderableChildren` and the fold never executes. Reported by the hook's
299
+ // first consumer, within the hour.
300
+ //
301
+ // Which is why the skip is NOT the thing to change: it runs for every raw-text node in every
302
+ // app, and consulting a fold there would put one on that walk. Get the value into `props.text`
303
+ // instead — Button routes the owner's `title` onto this node with `slotProps: {title: 'text'}`,
304
+ // so the skip and the fold read the same source and an empty title still commits nothing.
305
+ return {
306
+ text: node.payloadFold !== undefined
307
+ ? node.payloadFold(node.props).text
308
+ : node.props.text,
309
+ };
295
310
  }
296
311
  // This runs once per node per commit - 9 000 times on one benchmark press - so the two loops
297
312
  // below iterate with Object.keys rather than Object.entries: entries allocates a fresh
package/build/fabric.d.ts CHANGED
@@ -23,6 +23,7 @@ export interface IFabricSlot {
23
23
  registerEventHandler(handler: IFabricEventHandler): void;
24
24
  dispatchCommand(node: IFabricNode, commandName: string, args: readonly unknown[]): void;
25
25
  sendAccessibilityEvent(node: IFabricNode, eventType: string): void;
26
+ setIsJSResponder(node: IFabricNode, isResponder: boolean, blockNativeResponder: boolean): void;
26
27
  measure(node: IFabricNode, callback: IMeasureOnSuccess): void;
27
28
  measureInWindow(node: IFabricNode, callback: IMeasureInWindowOnSuccess): void;
28
29
  measureLayout(node: IFabricNode, relativeToNode: IFabricNode, onFail: () => void, onSuccess: IMeasureLayoutOnSuccess): void;
package/build/fabric.js CHANGED
@@ -29,6 +29,7 @@ export function getSlot() {
29
29
  // Optional on some hosts: read it off the live binding and feature-detect below so an
30
30
  // older slot without it degrades to a logged no-op instead of throwing.
31
31
  const { sendAccessibilityEvent } = host;
32
+ const { setIsJSResponder } = host;
32
33
  const { measure } = host;
33
34
  const { measureInWindow } = host;
34
35
  const { measureLayout } = host;
@@ -59,6 +60,13 @@ export function getSlot() {
59
60
  }
60
61
  sendAccessibilityEvent(node, eventType);
61
62
  },
63
+ setIsJSResponder: (node, isResponder, blockNativeResponder) => {
64
+ if (typeof setIsJSResponder !== 'function') {
65
+ dlog('setIsJSResponder -> host lacks the method (no-op)');
66
+ return;
67
+ }
68
+ setIsJSResponder(node, isResponder, blockNativeResponder);
69
+ },
62
70
  measure: (node, callback) => measure(node, callback),
63
71
  measureInWindow: (node, callback) => measureInWindow(node, callback),
64
72
  measureLayout: (node, relativeToNode, onFail, onSuccess) => measureLayout(node, relativeToNode, onFail, onSuccess),
@@ -17,10 +17,20 @@ import type { ISymbioteNode } from './node';
17
17
  * this one return their input by identity when there is nothing to do.
18
18
  */
19
19
  export type IPayloadFold = (props: Readonly<Record<string, unknown>>) => Record<string, unknown>;
20
+ export type IClaimMode = 'beside' | 'wrap';
20
21
  export interface IHostBehavior {
21
22
  readonly ownedListeners?: readonly string[];
23
+ readonly slotProps?: Readonly<Record<string, string>>;
24
+ readonly slotPropsExcept?: readonly string[];
25
+ readonly slotTakesNoChildren?: boolean;
26
+ readonly slotDerived?: readonly string[];
27
+ readonly claimedChildren?: Readonly<Record<string, IClaimMode>>;
28
+ onChildInserted?(node: ISymbioteNode, child: ISymbioteNode): void;
29
+ buildStructure?(node: ISymbioteNode): ISymbioteNode | undefined;
22
30
  attach(node: ISymbioteNode): void;
23
31
  attachAfterCommit?(node: ISymbioteNode): void;
32
+ onWrapChange?(owner: ISymbioteNode, wrapper: ISymbioteNode | undefined): void;
33
+ onOwnedListenerChange?(node: ISymbioteNode, name: string, wired: boolean): void;
24
34
  afterCommit?(node: ISymbioteNode): void;
25
35
  detach(node: ISymbioteNode): void;
26
36
  readonly foldPayload?: IPayloadFold;
@@ -28,6 +38,15 @@ export interface IHostBehavior {
28
38
  export declare function registerHostBehavior(component: string, behavior: IHostBehavior): void;
29
39
  export declare function hostBehaviorFor(tag: string): IHostBehavior | undefined;
30
40
  export declare function hasHostBehaviors(): boolean;
41
+ export declare function slotPropNameFor(node: ISymbioteNode, key: string): string | undefined;
42
+ export declare function slotTakesChildren(node: ISymbioteNode): boolean;
43
+ export declare function addDerivedNode(owner: ISymbioteNode, node: ISymbioteNode): void;
44
+ export declare function derivedNodesOf(owner: ISymbioteNode): readonly ISymbioteNode[] | undefined;
45
+ export declare function notifyOwnedListenerChange(node: ISymbioteNode, name: string, wired: boolean): void;
46
+ export declare function notifyChildInserted(node: ISymbioteNode, child: ISymbioteNode): void;
47
+ export declare function notifyWrapChange(owner: ISymbioteNode, wrapper: ISymbioteNode | undefined): void;
48
+ export declare function claimModeFor(node: ISymbioteNode, component: string): IClaimMode | undefined;
49
+ export declare function slotDerivesFrom(node: ISymbioteNode, key: string): boolean;
31
50
  export declare function ownsListener(node: ISymbioteNode, name: string): boolean;
32
51
  export declare function stashAppListener(node: ISymbioteNode, name: string, listener: unknown): void;
33
52
  export declare function appListenerFor(node: ISymbioteNode, name: string): unknown;
@@ -41,7 +60,25 @@ export declare function attachHostBehavior(node: ISymbioteNode, tag: string): vo
41
60
  * for the commit that lands it, and `detachSubtree` drops it if that commit never comes.
42
61
  */
43
62
  export declare function runDeferredAttaches(isCommitted: (node: ISymbioteNode) => boolean): void;
63
+ /**
64
+ * The recurring beat. Split from `runDeferredAttaches` because the two answer different questions:
65
+ * `attachAfterCommit` needs a FRESH FABRIC TAG, so it belongs below `completeRoot` and must not run
66
+ * on a commit that made no native call; `afterCommit` needs only "props were published", which a
67
+ * no-op commit satisfies just as well.
68
+ *
69
+ * Keeping them together made `afterCommit` unreachable for exactly the props a behavior owns: a fold
70
+ * that STRIPS a prop makes its own commit byte-identical, `commitContainer` returns above the drain,
71
+ * and the hook never sees the flip. TouchableOpacity's re-settle on `disabled` is the case
72
+ * (`disabled` is a MACHINE_ONLY key), Button's `title`/`color` the other.
73
+ *
74
+ * SETUP STILL RUNS BEFORE THE BEAT on the first commit, and the order is load-bearing: a node
75
+ * carrying both hooks has `attachAfterCommit` seed the mirrors `afterCommit` compares against. The
76
+ * caller preserves it by calling this AFTER `runDeferredAttaches` on the changed path — the no-op
77
+ * path has no setup to run, since a node with no Fabric tag has not committed at all.
78
+ */
79
+ export declare function runCommittedHooks(isCommitted: (node: ISymbioteNode) => boolean): void;
44
80
  export declare function markDetachCandidate(node: ISymbioteNode): void;
45
- export declare function sweepDetachedBehaviors(topLevel: readonly ISymbioteNode[]): void;
81
+ export declare function sweepDetachedBehaviors(topLevel: readonly ISymbioteNode[], onDetached: (node: ISymbioteNode) => void): void;
82
+ export declare function teardownSubtree(node: ISymbioteNode, onDetached: (node: ISymbioteNode) => void): void;
46
83
  export declare function reattachHostBehaviors(node: ISymbioteNode): void;
47
84
  export declare function clearHostBehaviors(): void;
@@ -39,7 +39,7 @@ const tornDown = new WeakSet();
39
39
  //
40
40
  // THE REGISTRY IS KEYED BY INTRINSIC TAG AND THE NODE IS NOT. `node.component` is the FABRIC view
41
41
  // name: every adapter resolves the tag through `descriptorFor` before calling `createElement`, so
42
- // `symbiote-view` arrives as `RCTView`. Keying the registry by Fabric name instead is not an
42
+ // `view` arrives as `RCTView`. Keying the registry by Fabric name instead is not an
43
43
  // option — a pressable resolves to `RCTView` like any other view, so the press machine would
44
44
  // attach to every plain `View` in the app. So the tag alphabet is used EXACTLY ONCE, at
45
45
  // `attachHostBehavior`, where the caller still holds it; every later lookup reads this map.
@@ -70,6 +70,78 @@ export function hostBehaviorFor(tag) {
70
70
  export function hasHostBehaviors() {
71
71
  return hasBehaviors;
72
72
  }
73
+ // What an owner prop is called on the slot, or undefined when it belongs to the owner after all.
74
+ //
75
+ // Called from `routeProp` only for a node that HAS a slot (`node.childHost !== undefined`), which
76
+ // is what keeps a WeakMap probe off the hot path: every other node is turned away by one field
77
+ // read, the same gate `payloadFold` uses one layer down.
78
+ export function slotPropNameFor(node, key) {
79
+ const behavior = attached.get(node);
80
+ if (behavior === undefined)
81
+ return undefined;
82
+ const named = behavior.slotProps?.[key];
83
+ if (named !== undefined)
84
+ return named;
85
+ const except = behavior.slotPropsExcept;
86
+ if (except !== undefined && !except.includes(key))
87
+ return key;
88
+ return undefined;
89
+ }
90
+ // Does this owner's slot host the app's children, or is it a built sibling they land beside? See
91
+ // `slotTakesNoChildren`. Same `node.childHost` gate as every other probe here: the two callers ask
92
+ // only after the field said there is a slot at all.
93
+ export function slotTakesChildren(node) {
94
+ return attached.get(node)?.slotTakesNoChildren !== true;
95
+ }
96
+ // Nodes a behavior built that are NOT the slot, and whose payloads derive from the owner's props.
97
+ //
98
+ // `slotDerived` marks `node.childHost` and nothing else, which is one hop — enough for ScrollView,
99
+ // whose only derived node IS the slot, and not enough for a primitive whose `buildStructure` builds
100
+ // a chain. Button builds view > text > raw text and folds two of them from the same three owner
101
+ // props; without this the deeper nodes freeze at their mount values, and the workaround is to write
102
+ // them from inside a fold whose contract says it MUST be pure.
103
+ //
104
+ // A WeakMap rather than a field, for the reason `stashed` is one: this exists only for the handful
105
+ // of nodes a composed behavior built, and a field costs a shape transition on every node in every
106
+ // app. It is read only inside the `slotDerived` branch, which has already paid a WeakMap probe.
107
+ const derived = new WeakMap();
108
+ // Called from `buildStructure` for each node past the slot. Not idempotent-checked: structure is
109
+ // built exactly once (`attachHostBehavior`, never `reattachSubtree`), so a second call would be a
110
+ // bug worth seeing rather than one worth absorbing.
111
+ export function addDerivedNode(owner, node) {
112
+ const existing = derived.get(owner);
113
+ if (existing === undefined)
114
+ derived.set(owner, [node]);
115
+ else
116
+ existing.push(node);
117
+ }
118
+ export function derivedNodesOf(owner) {
119
+ return derived.get(owner);
120
+ }
121
+ // Called from `setEventListener` on a PRESENCE flip of an owned name, and only there — the caller
122
+ // has already established that this node owns the name, so the WeakMap probe is one it just paid.
123
+ export function notifyOwnedListenerChange(node, name, wired) {
124
+ attached.get(node)?.onOwnedListenerChange?.(node, name, wired);
125
+ }
126
+ // Called from the two inserts once the child is in place. See `onChildInserted`.
127
+ export function notifyChildInserted(node, child) {
128
+ attached.get(node)?.onChildInserted?.(node, child);
129
+ }
130
+ // Called from the two structural entry points when a wrap claim lands or leaves. See
131
+ // `onWrapChange`.
132
+ export function notifyWrapChange(owner, wrapper) {
133
+ attached.get(owner)?.onWrapChange?.(owner, wrapper);
134
+ }
135
+ // What this owner does with a child of that Fabric component, or undefined when it does not claim
136
+ // it at all. See `claimedChildren`.
137
+ export function claimModeFor(node, component) {
138
+ return attached.get(node)?.claimedChildren?.[component];
139
+ }
140
+ // Does this owner key feed the slot's payload? See `slotDerived`. Same `node.childHost` gate as
141
+ // above keeps the WeakMap probe off every node that has no slot.
142
+ export function slotDerivesFrom(node, key) {
143
+ return attached.get(node)?.slotDerived?.includes(key) === true;
144
+ }
73
145
  // The app's listeners for names a behavior owns, per node. Not on the node: this exists only for
74
146
  // nodes carrying a behavior, and adding a field for it would pay a shape transition on every node
75
147
  // in every app for a feature almost none of them use.
@@ -107,6 +179,12 @@ export function attachHostBehavior(node, tag) {
107
179
  // A field rather than a lookup at payload-build time: `fabricProps` runs per node per commit and
108
180
  // must not pay a Map probe to discover that almost nothing has a fold.
109
181
  node.payloadFold = behavior.foldPayload;
182
+ // Shape before runtime: `attach` may want to read `node.childHost`, and nothing in `attach`'s
183
+ // contract depends on the node being childless. Deliberately NOT repeated in `reattachSubtree` —
184
+ // see `buildStructure`.
185
+ if (behavior.buildStructure !== undefined) {
186
+ node.childHost = behavior.buildStructure(node);
187
+ }
110
188
  behavior.attach(node);
111
189
  if (behavior.attachAfterCommit !== undefined)
112
190
  awaitingCommit.add(node);
@@ -132,20 +210,36 @@ const awaitingCommit = new Set();
132
210
  * for the commit that lands it, and `detachSubtree` drops it if that commit never comes.
133
211
  */
134
212
  export function runDeferredAttaches(isCommitted) {
135
- // The gate: an app registering no behavior pays two Set-size reads per commit, matching the
213
+ // The gate: an app registering no behavior pays one Set-size read per commit, matching the
136
214
  // discipline `hasBehaviors` sets for `createElement`.
137
- if (awaitingCommit.size === 0 && committedEachTime.size === 0)
215
+ if (awaitingCommit.size === 0)
138
216
  return;
139
- // SETUP BEFORE THE RECURRING BEAT, and the order is load-bearing on the FIRST commit, where a
140
- // node carrying both hooks is drained by both. `attachAfterCommit` is where a behavior seeds the
141
- // mirrors that `afterCommit` then compares against; run them the other way round and the first
142
- // beat compares against nothing and commands a redundant write down to native.
143
217
  for (const node of awaitingCommit) {
144
218
  if (!isCommitted(node))
145
219
  continue;
146
220
  awaitingCommit.delete(node);
147
221
  attached.get(node)?.attachAfterCommit?.(node);
148
222
  }
223
+ }
224
+ /**
225
+ * The recurring beat. Split from `runDeferredAttaches` because the two answer different questions:
226
+ * `attachAfterCommit` needs a FRESH FABRIC TAG, so it belongs below `completeRoot` and must not run
227
+ * on a commit that made no native call; `afterCommit` needs only "props were published", which a
228
+ * no-op commit satisfies just as well.
229
+ *
230
+ * Keeping them together made `afterCommit` unreachable for exactly the props a behavior owns: a fold
231
+ * that STRIPS a prop makes its own commit byte-identical, `commitContainer` returns above the drain,
232
+ * and the hook never sees the flip. TouchableOpacity's re-settle on `disabled` is the case
233
+ * (`disabled` is a MACHINE_ONLY key), Button's `title`/`color` the other.
234
+ *
235
+ * SETUP STILL RUNS BEFORE THE BEAT on the first commit, and the order is load-bearing: a node
236
+ * carrying both hooks has `attachAfterCommit` seed the mirrors `afterCommit` compares against. The
237
+ * caller preserves it by calling this AFTER `runDeferredAttaches` on the changed path — the no-op
238
+ * path has no setup to run, since a node with no Fabric tag has not committed at all.
239
+ */
240
+ export function runCommittedHooks(isCommitted) {
241
+ if (committedEachTime.size === 0)
242
+ return;
149
243
  for (const node of committedEachTime) {
150
244
  if (!isCommitted(node))
151
245
  continue;
@@ -182,7 +276,13 @@ export function markDetachCandidate(node) {
182
276
  //
183
277
  // The subtree walk lives here rather than at removal, and is cheaper for it: only the nodes that
184
278
  // actually left are walked.
185
- export function sweepDetachedBehaviors(topLevel) {
279
+ //
280
+ // `onDetached` runs for EVERY node of a genuinely-removed subtree, whether or not it carries a
281
+ // behavior — it is how the engine's other per-node lifetime state (an Animated subscription, see
282
+ // `animated/host-binding.ts`) gets the same "did it really leave" answer this sweep exists to
283
+ // compute. Passed in for the no-cycle reason `runDeferredAttaches`' predicate is: this module must
284
+ // keep pointing one way, and Metro's `inlineRequires` makes that a live hazard rather than taste.
285
+ export function sweepDetachedBehaviors(topLevel, onDetached) {
186
286
  if (detachCandidates.size === 0)
187
287
  return;
188
288
  // A surface's top-level nodes carry `parent === undefined` by design (surface.ts), and
@@ -192,16 +292,31 @@ export function sweepDetachedBehaviors(topLevel) {
192
292
  for (const node of detachCandidates) {
193
293
  if (node.parent !== undefined || topLevel.includes(node))
194
294
  continue;
195
- detachSubtree(node, seen);
295
+ detachSubtree(node, seen, onDetached);
196
296
  }
197
297
  detachCandidates.clear();
198
298
  }
299
+ // Tear a subtree down unconditionally — the SURFACE teardown path, where there is nothing to
300
+ // decide: `disposeRoot` drops the root container, so every node under it has left for good whatever
301
+ // any framework intended.
302
+ //
303
+ // It exists because the sweep above cannot answer this. The sweep only sees nodes a `removeChild`
304
+ // NOMINATED, and an unmount removes nothing — the adapter drops the whole surface. So before this,
305
+ // `disposeRoot` touched no node at all: `committedOf` reads `node.committed`, a field on the node,
306
+ // so every node of a dead surface still answered `isCommitted` and stayed in `committedEachTime`,
307
+ // drained on every later commit anywhere in the process, with its timers still armed.
308
+ export function teardownSubtree(node, onDetached) {
309
+ detachSubtree(node, new Set(), onDetached);
310
+ }
199
311
  // `seen` guards the one overlap the candidate set can contain: a removed parent and a removed
200
- // descendant of it are both nominated, and without it the descendant is detached twice.
201
- function detachSubtree(node, seen) {
202
- if (seen.has(node))
312
+ // descendant of it are both nominated, and without it the descendant is detached twice. `tornDown`
313
+ // guards the same overlap ACROSS calls — a node the sweep already released and that `disposeRoot`
314
+ // then walks again, which is the ordinary shape of an unmount after the framework emptied the tree.
315
+ function detachSubtree(node, seen, onDetached) {
316
+ if (seen.has(node) || tornDown.has(node))
203
317
  return;
204
318
  seen.add(node);
319
+ onDetached(node);
205
320
  // Marked whether or not THIS node carries a behavior: the mark is what tells a later insert to
206
321
  // walk, and the node re-inserted is usually a plain container whose DESCENDANT holds the
207
322
  // machine. Gating the mark on `behaviors.has` made the row wrapper unmarked and the whole walk
@@ -217,14 +332,11 @@ function detachSubtree(node, seen) {
217
332
  // The recurring hook stops with the node, and unlike the deferral above this one has a visible
218
333
  // consequence if forgotten: a torn-down node would keep being asked to reconcile props against a
219
334
  // subtree that has left the tree, on every commit, forever.
220
- // The recurring hook stops with the node, and unlike the deferral above this one has a visible
221
- // consequence if forgotten: a torn-down node would keep being asked to reconcile props against a
222
- // subtree that has left the tree, on every commit, forever.
223
335
  committedEachTime.delete(node);
224
336
  // The map, not the registry: by here only the Fabric name is left on the node.
225
337
  attached.get(node)?.detach(node);
226
338
  for (const child of node.children)
227
- detachSubtree(child, seen);
339
+ detachSubtree(child, seen, onDetached);
228
340
  }
229
341
  // Re-arms a node the sweep tore down but that the framework put back. Called from appendChild and
230
342
  // insertBefore, and it is a WeakSet miss — no walk at all — for every node in a freshly built
package/build/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, censusRetainedTree, getExplicitStyle, setNodeHidden, setNodeComponent, setNodePressed, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node';
1
+ export { createElement, createRawText, createAnchor, ANCHOR_COMPONENT, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, censusRetainedTree, getExplicitStyle, setNodeHidden, setNodeComponent, setNodePressed, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node';
2
+ export { setAnimatedBehaviorStyle } from './animated/host-binding';
2
3
  export { isEventFor } from './view-config';
3
4
  export { registerComponent, setNativeViewConfigSource } from './registry';
4
5
  export { isRecord } from './type-guards';
@@ -13,7 +14,7 @@ export { setEventDispatcher } from './dispatch';
13
14
  export { setColorProcessor, processColor, dispatchViewCommand, sendAccessibilityEvent, setNativeProps, getNativeTag, getNativeNode, whenCommitted, measure, measureInWindow, measureLayout, disposeRoot, readCommitProfile, } from './commit';
14
15
  export type { ICommitProfile } from './commit';
15
16
  export { registerPostCommit, unregisterPostCommit } from './post-commit';
16
- export { foldAriaProps } from './accessibility-props';
17
+ export { ARIA_ALIAS_KEYS, foldAriaProps } from './accessibility-props';
17
18
  export { toPublicInstance } from './host-instance';
18
19
  export type { IHostInstance } from './host-instance';
19
20
  export { PlatformColor, DynamicColorIOS, isOpaqueColorValue, } from './platform-color';
@@ -90,7 +91,7 @@ export type { IAccessibilityChangeEvent, IAccessibilityChangeEventName, IAccessi
90
91
  export { applyStatusBarProps, statusBarImperative, statusBarCurrentHeight, } from './status-bar';
91
92
  export { hideTransition, STATUS_BAR_MANAGER, ANIMATED_HIDE_TRANSITION, STATIC_HIDE_TRANSITION, } from './status-bar/shared';
92
93
  export type { IStatusBarProps, IStatusBarStyle, IStatusBarAnimation, IStatusBarImperative, } from './status-bar/shared';
93
- export { registerHostBehavior, hasHostBehaviors, hostBehaviorFor, clearHostBehaviors, appListenerFor, } from './host-behavior';
94
- export type { IHostBehavior } from './host-behavior';
94
+ export { registerHostBehavior, hasHostBehaviors, hostBehaviorFor, clearHostBehaviors, appListenerFor, addDerivedNode, } from './host-behavior';
95
+ export type { IClaimMode, IHostBehavior, IPayloadFold } from './host-behavior';
95
96
  export { requestCommitFor } from './commit';
96
- export { setBehaviorListener } from './node';
97
+ export { setBehaviorListener, markPropsDirty } from './node';
package/build/index.js CHANGED
@@ -2,7 +2,15 @@
2
2
  // Every framework adapter drives this tiny mutation API; all Fabric-specific
3
3
  // logic (tag allocation, view-name resolution, clone-on-write, event
4
4
  // normalization) lives behind it, in one place.
5
- export { createElement, createRawText, createAnchor, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, censusRetainedTree, getExplicitStyle, setNodeHidden, setNodeComponent, setNodePressed, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node.js';
5
+ export { createElement, createRawText, createAnchor,
6
+ // The component name of a node the commit walk skips and whose children flatten into its parent.
7
+ // Exported so a PRIMITIVE that renders no view of its own can be born with it — RN's
8
+ // TouchableNativeFeedback clones onto its single child and commits nothing
9
+ // (TouchableNativeFeedback.js:339) — rather than being converted after the fact.
10
+ ANCHOR_COMPONENT, isAnchor, appendChild, insertBefore, removeChild, setProp, setEventListener, routeProp, censusRetainedTree, getExplicitStyle, setNodeHidden, setNodeComponent, setNodePressed, setText, isSymbioteNode, isSymbioteEvent, RAW_TEXT_COMPONENT, debugNodeId, } from './node.js';
11
+ // For a HOST BEHAVIOR that owns an animated style layer on its own node — TouchableOpacity's press
12
+ // fade, which RN drives from an `Animated.View` the tag replaces.
13
+ export { setAnimatedBehaviorStyle } from './animated/host-binding.js';
6
14
  export { isEventFor } from './view-config.js';
7
15
  export { registerComponent, setNativeViewConfigSource } from './registry.js';
8
16
  // Real cross-package consumer: core/components' KeyboardAvoidingView render narrows
@@ -27,7 +35,7 @@ export { registerPostCommit, unregisterPostCommit } from './post-commit.js';
27
35
  // The aria/role -> accessibility* fold. Lives here rather than in a component wrapper because a
28
36
  // LOWERED element has no wrapper: `fabricProps` runs it on the way to the payload, so every path
29
37
  // gets it. `core/components`' typed `resolveAccessibilityProps` delegates to this one.
30
- export { foldAriaProps } from './accessibility-props.js';
38
+ export { ARIA_ALIAS_KEYS, foldAriaProps } from './accessibility-props.js';
31
39
  // The public instance every host node already is (React's getPublicInstance, the Vue renderer's
32
40
  // createElement): the imperative measure/setNativeProps/focus API, on the shared node prototype.
33
41
  // toPublicInstance is the identity that names the seam — see ./host-instance.
@@ -91,6 +99,10 @@ export { AccessibilityInfo } from './accessibility-info';
91
99
  // .ios re-export would otherwise duplicate-export the type symbols).
92
100
  export { applyStatusBarProps, statusBarImperative, statusBarCurrentHeight, } from './status-bar';
93
101
  export { hideTransition, STATUS_BAR_MANAGER, ANIMATED_HIDE_TRANSITION, STATIC_HIDE_TRANSITION, } from './status-bar/shared.js';
94
- export { registerHostBehavior, hasHostBehaviors, hostBehaviorFor, clearHostBehaviors, appListenerFor, } from './host-behavior.js';
102
+ export { registerHostBehavior, hasHostBehaviors, hostBehaviorFor, clearHostBehaviors, appListenerFor, addDerivedNode, } from './host-behavior.js';
95
103
  export { requestCommitFor } from './commit.js';
96
- export { setBehaviorListener } from './node.js';
104
+ // `markPropsDirty` is a behavior's only way to say "the fold reads state I just changed". Every
105
+ // other dirtying route goes through a prop write, and a behavior whose payload is DERIVED — the
106
+ // sticky header's debounced translateY lives in its own runtime, not in `node.props` — has no
107
+ // prop to write. Pair it with `requestCommitFor`: dirtying is not publishing.
108
+ export { setBehaviorListener, markPropsDirty } from './node.js';
package/build/node.d.ts CHANGED
@@ -29,12 +29,23 @@ export interface ISymbioteNode {
29
29
  structureDirty: boolean;
30
30
  committed: IMirror | undefined;
31
31
  styleParts: IClassStyleParts | undefined;
32
+ childHost: ISymbioteNode | undefined;
33
+ wrapper: ISymbioteNode | undefined;
32
34
  measure(callback: IMeasureOnSuccess): void;
33
35
  measureInWindow(callback: IMeasureInWindowOnSuccess): void;
34
36
  measureLayout(relativeToNativeNode: ISymbioteNode | number, onSuccess: IMeasureLayoutOnSuccess, onFail?: () => void): void;
35
37
  setNativeProps(nativeProps: Record<string, unknown>): void;
36
38
  focus(): void;
37
39
  blur(): void;
40
+ scrollTo(options?: {
41
+ x?: number;
42
+ y?: number;
43
+ animated?: boolean;
44
+ }): void;
45
+ scrollToEnd(options?: {
46
+ animated?: boolean;
47
+ }): void;
48
+ flashScrollIndicators(): void;
38
49
  }
39
50
  export interface IMirror {
40
51
  handle: IFabricNode;
@@ -104,8 +115,13 @@ export declare function setProp(node: ISymbioteNode, key: string, value: unknown
104
115
  * `setEventListener` diverts an owned name into the stash, which is right for an app listener and
105
116
  * circular for the behavior's own dispatcher — it would stash itself and never occupy the slot it
106
117
  * exists to hold. This is the one writer allowed past that gate.
118
+ *
119
+ * `undefined` removes it, gate flag included. A behavior whose dispatcher is conditional needs
120
+ * that as much as it needs the install: ScrollView takes the owner's `layout` only while the app
121
+ * or an inverted sticky header wants it, and a one-way installer leaves `onLayout: true` standing
122
+ * in the payload of a ScrollView that no longer reads the event.
107
123
  */
108
- export declare function setBehaviorListener(node: ISymbioteNode, name: string, listener: IListener): void;
124
+ export declare function setBehaviorListener(node: ISymbioteNode, name: string, listener: IListener | undefined): void;
109
125
  export declare function setEventListener(node: ISymbioteNode, name: string, value: unknown): void;
110
126
  export interface IClassStyleParts {
111
127
  classStyle: unknown;
@@ -141,9 +157,9 @@ export declare function setNodePressed(node: ISymbioteNode, pressed: boolean): v
141
157
  export declare function getExplicitStyle(node: ISymbioteNode): unknown;
142
158
  export declare function routeProp(node: ISymbioteNode, key: string, value: unknown): void;
143
159
  export declare function setText(node: ISymbioteNode, text: string): void;
144
- export declare function appendChild(parent: ISymbioteNode, child: ISymbioteNode): void;
145
- export declare function insertBefore(parent: ISymbioteNode, child: ISymbioteNode, beforeChild: ISymbioteNode): void;
146
- export declare function removeChild(parent: ISymbioteNode, child: ISymbioteNode): void;
160
+ export declare function appendChild(requestedParent: ISymbioteNode, child: ISymbioteNode): void;
161
+ export declare function insertBefore(requestedParent: ISymbioteNode, child: ISymbioteNode, beforeChild: ISymbioteNode | null): void;
162
+ export declare function removeChild(requestedParent: ISymbioteNode, child: ISymbioteNode): void;
147
163
  export interface ITreeCensus {
148
164
  nodes: number;
149
165
  anchors: number;