@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.
@@ -1,3 +1,4 @@
1
+ export declare const ARIA_ALIAS_KEYS: readonly string[];
1
2
  /**
2
3
  * Whether one key is an alias this fold consumes. `startsWith` rather than a Set lookup: this runs
3
4
  * on `setProp`, the hottest write path in the engine (32 001 writes on one benchmark create), and
@@ -55,7 +55,11 @@ const ROLE_TO_ACCESSIBILITY_ROLE = {
55
55
  timer: 'timer',
56
56
  toolbar: 'toolbar',
57
57
  };
58
- const ARIA_KEYS = [
58
+ // Exported so a behavior that folds a DIFFERENT node's bag can name them without restating the
59
+ // list. `slotDerived` (host-behavior.ts) takes prop NAMES, so a primitive whose payload derives
60
+ // from an owner's aria props has to enumerate them — and a second hand-written copy is exactly what
61
+ // `.claude/rules/adapter-parity-audit.md` records going stale one member at a time.
62
+ export const ARIA_ALIAS_KEYS = [
59
63
  'role',
60
64
  'aria-label',
61
65
  'aria-labelledby',
@@ -75,8 +79,8 @@ const ARIA_KEYS = [
75
79
  // An indexed loop rather than `.some(key => …)`: the callback captures `props`, so a closure is
76
80
  // allocated per call, and this is the gate on a path that runs once per node.
77
81
  function hasAnyAriaKey(props) {
78
- for (let index = 0; index < ARIA_KEYS.length; index += 1) {
79
- if (props[ARIA_KEYS[index]] !== undefined)
82
+ for (let index = 0; index < ARIA_ALIAS_KEYS.length; index += 1) {
83
+ if (props[ARIA_ALIAS_KEYS[index]] !== undefined)
80
84
  return true;
81
85
  }
82
86
  return false;
@@ -127,8 +131,8 @@ export function foldAriaProps(props) {
127
131
  const ariaValueMin = bag['aria-valuemin'];
128
132
  const ariaValueNow = bag['aria-valuenow'];
129
133
  const ariaValueText = bag['aria-valuetext'];
130
- for (let index = 0; index < ARIA_KEYS.length; index += 1) {
131
- bag[ARIA_KEYS[index]] = undefined;
134
+ for (let index = 0; index < ARIA_ALIAS_KEYS.length; index += 1) {
135
+ bag[ARIA_ALIAS_KEYS[index]] = undefined;
132
136
  }
133
137
  // RULE ONE, for every scalar: the explicit prop WINS, the alias only fills a hole.
134
138
  if (typeof ariaLabelledBy === 'string' &&
@@ -3,12 +3,14 @@ import { type IInterpolationConfig } from './interpolation';
3
3
  export type IValueListener = (state: {
4
4
  value: number | string;
5
5
  }) => void;
6
+ export declare function hasAnimatedNodes(): boolean;
6
7
  export declare class AnimatedNode {
7
8
  private readonly listeners;
8
9
  private suspendCallbacks;
9
10
  protected isNative: boolean;
10
11
  private nativeTag;
11
12
  private platformConfig;
13
+ constructor();
12
14
  __attach(): void;
13
15
  __detach(): void;
14
16
  __isNative(): boolean;
@@ -20,6 +20,17 @@ let nextListenerId = 1;
20
20
  // channel. RN tolerates the per-channel flushes and relies on a downstream commit-
21
21
  // coalescing layer; symbiote has none here, so it coalesces at the source.
22
22
  let flushSuspendDepth = 0;
23
+ // Has this process ever constructed an Animated node at all?
24
+ //
25
+ // `routeProp` has to answer "does this prop hold an animated value" on EVERY prop write - 32 001
26
+ // of them on one benchmark create - and for an app that animates nothing the only cheap answer is
27
+ // that no AnimatedNode exists to find. One boolean read, the discipline `hasHostBehaviors` and
28
+ // `isDebug` already set. Set in the base constructor so every node type raises it, including the
29
+ // operators and interpolations an app never names directly.
30
+ let anyAnimatedNode = false;
31
+ export function hasAnimatedNodes() {
32
+ return anyAnimatedNode;
33
+ }
23
34
  export class AnimatedNode {
24
35
  listeners = new Map();
25
36
  // While > 0, this node's own __callListeners is a no-op. A composite setter
@@ -39,6 +50,9 @@ export class AnimatedNode {
39
50
  // native config at creation (__getNativeTag). Optional: undefined when no caller
40
51
  // supplied one, matching today's behavior.
41
52
  platformConfig;
53
+ constructor() {
54
+ anyAnimatedNode = true;
55
+ }
42
56
  __attach() { }
43
57
  __detach() {
44
58
  this.removeAllListeners();
@@ -0,0 +1,39 @@
1
+ import { type ISymbioteNode } from '../node';
2
+ export declare function hasAnimatedBindings(): boolean;
3
+ /**
4
+ * Give a node a behavior-owned animated style layer, or drop it by passing `undefined`.
5
+ *
6
+ * The seam a lowered `TouchableOpacity` needs: RN runs its press fade from an `Animated.View` whose
7
+ * style is `[props.style, {opacity: anim}]` (TouchableOpacity.js:302), and a tag has no such
8
+ * wrapper. The layer is bound to the leaf, never folded into `node.props.style` — so the behavior
9
+ * can still read the AUTHOR's resting opacity back without seeing its own fade.
10
+ */
11
+ export declare function setAnimatedBehaviorStyle(node: ISymbioteNode, style: unknown): void;
12
+ /**
13
+ * Resolve an animated value in a prop, returning what should be published for it.
14
+ *
15
+ * Returns its input by IDENTITY when the value holds nothing animated, so `routeProp` can call it
16
+ * unconditionally and every downstream branch — class, style, activeStyle, `on*`, `setProp` —
17
+ * keeps seeing a plain value and needs no change.
18
+ */
19
+ export declare function bindAnimatedValue(node: ISymbioteNode, key: string, value: unknown): unknown;
20
+ /**
21
+ * Bind a native-driven `Animated.event` handler written as an `on*` prop. A no-op for every other
22
+ * handler, so `routeProp` calls it for any `on*` name behind the same one-boolean gate.
23
+ */
24
+ export declare function bindAnimatedEvent(node: ISymbioteNode, propName: string, handler: unknown): void;
25
+ /**
26
+ * Release a node's animated subscription. Called per node from the commit sweep, which is where a
27
+ * genuine removal is first distinguishable from a framework spelling a move as remove-then-insert
28
+ * (host-behavior.ts, `markDetachCandidate`).
29
+ */
30
+ export declare function detachAnimatedProps(node: ISymbioteNode): void;
31
+ /**
32
+ * Re-arm a subtree the sweep tore down and the framework put back — Svelte parks LIVE nodes
33
+ * offscreen across commits and returns them with their props unwritten, so without this the
34
+ * animation stops with nothing red anywhere.
35
+ *
36
+ * A WeakSet miss and no walk at all for every node in a freshly built tree, which is the path that
37
+ * runs ~9 000 times per benchmark create.
38
+ */
39
+ export declare function reattachAnimatedProps(node: ISymbioteNode): void;
@@ -0,0 +1,263 @@
1
+ // An AnimatedNode written straight into a prop of a HOST NODE — the engine half of what
2
+ // `createAnimatedComponent` used to broker.
3
+ //
4
+ // WHY IT EXISTS. `Animated.View` is `createAnimatedComponent(View)`: a wrapper whose whole job is
5
+ // to keep an `AnimatedProps` leaf alive beside a base COMPONENT. Once a primitive is a bare
6
+ // intrinsic tag there is no component to wrap, so every `Animated.*` built that way loses its
7
+ // base. The two halves the wrapper actually brokered — the value graph and the node's Fabric view
8
+ // tag — are both already engine-side (`animated/props.ts`, `commit.ts`'s `getNativeTag`), so the
9
+ // wrapper was standing between two things that live in the same room. An app writes
10
+ //
11
+ // <view style={{ opacity: someAnimatedValue }} />
12
+ //
13
+ // and `routeProp` resolves it here: publish the current value so the first paint is concrete,
14
+ // subscribe the leaf so every frame lands as one targeted `setNativeProps` commit, and tear the
15
+ // subscription down when the node leaves the tree for good.
16
+ //
17
+ // THE PRECEDENT IT FOLLOWS is `routeProp`'s own `isStyleCallback` branch: a `style` function is
18
+ // already a value the engine INTERPRETS rather than forwards, resolved at both values of
19
+ // `pressed`. An AnimatedNode is the same shape of problem one step further — the resolution is
20
+ // continuous rather than two-valued, so it needs a subscription instead of a second call.
21
+ //
22
+ // THE CYCLE IS DELIBERATE, and it is the one `node.ts` already carries with `commit.ts`: node.ts
23
+ // imports this module for `routeProp`, and this module reaches back into node.ts for `setProp`.
24
+ // Neither side touches the other at module-evaluation time — only inside a function body — so
25
+ // every loader resolves it. The alternative, a
26
+ // `registerAnimatedResolver` installed from elsewhere, is exactly the load-time-side-effect shape
27
+ // Metro's `inlineRequires` drops in release builds (CLAUDE.md; and see `graph.ts`'s note on why
28
+ // `AnimatedInterpolation` lives next to its base class).
29
+ import { AnimatedNode } from './graph.js';
30
+ import { AnimatedStyle } from './style.js';
31
+ import { attachNativeEventHandler } from './event.js';
32
+ import { createAnimatedLeafLifecycle, } from './leaf-lifecycle.js';
33
+ import { setProp } from '../node.js';
34
+ const bindings = new WeakMap();
35
+ // Nodes the commit sweep tore down, whether or not they carried a binding — the twin of
36
+ // host-behavior.ts's `tornDown`, and marked for every node for the same reason: the node a
37
+ // framework re-inserts is usually a plain container whose DESCENDANT holds the subscription.
38
+ const parked = new WeakSet();
39
+ // The gate, matching `hasHostBehaviors`. `removeChild` and the two inserts read it on every call,
40
+ // so an app that animates nothing must pay one boolean read rather than a WeakMap probe.
41
+ let anyBinding = false;
42
+ export function hasAnimatedBindings() {
43
+ return anyBinding;
44
+ }
45
+ // Only what `AnimatedProps` can actually bind: a prop that IS a node, or a `style` holding one.
46
+ // Deliberately NOT a general deep walk — an arbitrary prop bag can hold an app object with a
47
+ // cycle in it, and a walk that never terminates would be a hang on the engine's hottest path.
48
+ function styleHoldsAnimated(style) {
49
+ if (Array.isArray(style))
50
+ return style.some(styleHoldsAnimated);
51
+ if (typeof style !== 'object' || style === null)
52
+ return false;
53
+ for (const key of Object.keys(style)) {
54
+ const entry = Reflect.get(style, key);
55
+ if (entry instanceof AnimatedNode)
56
+ return true;
57
+ // `transform: [{ translateX: node }]` — the one nesting `AnimatedTransform` reads.
58
+ if (key !== 'transform' || !Array.isArray(entry))
59
+ continue;
60
+ for (const item of entry) {
61
+ if (typeof item !== 'object' || item === null)
62
+ continue;
63
+ for (const inner of Object.keys(item)) {
64
+ if (Reflect.get(item, inner) instanceof AnimatedNode)
65
+ return true;
66
+ }
67
+ }
68
+ }
69
+ return false;
70
+ }
71
+ // The value to PUBLISH now, or undefined when there is nothing animated after all. A false
72
+ // positive from the scan above lands here and is turned away, so the raw value still reaches
73
+ // `setProp` unchanged and nothing new can break.
74
+ function rasterize(key, value) {
75
+ if (value instanceof AnimatedNode)
76
+ return value.__getValue();
77
+ if (key !== 'style')
78
+ return undefined;
79
+ return AnimatedStyle.from(value)?.__getValue();
80
+ }
81
+ function reconcile(node, binding) {
82
+ parked.delete(node);
83
+ if (Object.keys(binding.raw).length > 0) {
84
+ // `wantsNative: false` — nothing here forces the native driver. A leaf becomes native by
85
+ // CASCADE, from the value it is a child of (`AnimatedWithChildren.__addChild` /
86
+ // `__connectNativeChildren`), which is what makes `useNativeDriver` on the animation the only
87
+ // thing that decides. The wrapper's own `wantsNative` was keyed on
88
+ // `passthroughAnimatedPropExplicitValues`, a wrapper-ism with no meaning on a bare tag.
89
+ //
90
+ // And NO `scheduleNativeBind`, though a prop write always precedes the node's first commit:
91
+ // nothing the bind does needs a Fabric tag on the spot. `setNativeView` only stores the
92
+ // target, `connectToView` defers itself through `pendingViewConnects` + the post-commit hook
93
+ // (`props.ts`), and `attachNativeEventHandler` wraps its own `whenCommitted`. A deferral here
94
+ // would be unfalsifiable code, so it is not here.
95
+ binding.lifecycle.reconcile(binding.raw, node, false);
96
+ return;
97
+ }
98
+ binding.lifecycle.teardown();
99
+ bindings.delete(node);
100
+ // Fabric may flatten a view again once nothing animates it.
101
+ setProp(node, 'collapsable', undefined);
102
+ }
103
+ // A style layer a HOST BEHAVIOR owns on its own node, composed OVER the app's style.
104
+ //
105
+ // WHY IT IS NOT AN ORDINARY PROP. TouchableOpacity's press fade is an AnimatedValue that has to
106
+ // beat whatever `opacity` the caller's own style asks for, and `fabricProps` hoists the style slot
107
+ // AFTER every plain prop — so a top-level `opacity` loses to `style={{opacity: 0.6}}`, silently and
108
+ // only for the styled call sites. The layer has to sit INSIDE the style, above the author's.
109
+ //
110
+ // AND IT CANNOT SHARE `raw.style` WITH THE APP. The leaf keys its bound props by name, so the app's
111
+ // next plain `style` write would find `raw.style` holding nothing animated and tear the behavior's
112
+ // binding down. Composing here means that write still sees an animated style — ours — and
113
+ // re-registers the pair instead.
114
+ const behaviorStyles = new WeakMap();
115
+ // The gate, matching `anyBinding`: one boolean read on the style branch of every prop write in an
116
+ // app whose behaviors own no animated layer, which is nearly all of them.
117
+ let anyBehaviorStyle = false;
118
+ function withBehaviorStyle(node, style) {
119
+ if (!anyBehaviorStyle)
120
+ return style;
121
+ const layer = behaviorStyles.get(node);
122
+ return layer === undefined ? style : [style, layer];
123
+ }
124
+ /**
125
+ * Give a node a behavior-owned animated style layer, or drop it by passing `undefined`.
126
+ *
127
+ * The seam a lowered `TouchableOpacity` needs: RN runs its press fade from an `Animated.View` whose
128
+ * style is `[props.style, {opacity: anim}]` (TouchableOpacity.js:302), and a tag has no such
129
+ * wrapper. The layer is bound to the leaf, never folded into `node.props.style` — so the behavior
130
+ * can still read the AUTHOR's resting opacity back without seeing its own fade.
131
+ */
132
+ export function setAnimatedBehaviorStyle(node, style) {
133
+ if (style === undefined)
134
+ behaviorStyles.delete(node);
135
+ else {
136
+ behaviorStyles.set(node, style);
137
+ anyBehaviorStyle = true;
138
+ }
139
+ // Re-register against the style standing right now, so the pair the leaf holds is always
140
+ // (author, layer) whichever of the two moved last.
141
+ bindAnimatedValue(node, 'style', node.props.style);
142
+ }
143
+ /**
144
+ * Resolve an animated value in a prop, returning what should be published for it.
145
+ *
146
+ * Returns its input by IDENTITY when the value holds nothing animated, so `routeProp` can call it
147
+ * unconditionally and every downstream branch — class, style, activeStyle, `on*`, `setProp` —
148
+ * keeps seeing a plain value and needs no change.
149
+ */
150
+ export function bindAnimatedValue(node, key, value) {
151
+ const existing = bindings.get(node);
152
+ const bound = key === 'style' ? withBehaviorStyle(node, value) : value;
153
+ const isAnimated = bound instanceof AnimatedNode ||
154
+ (key === 'style' && styleHoldsAnimated(bound));
155
+ if (!isAnimated) {
156
+ // A prop that USED to be animated and no longer is: drop it, or the leaf keeps writing a
157
+ // stale value over whatever the app just wrote.
158
+ if (existing !== undefined && Object.hasOwn(existing.raw, key)) {
159
+ delete existing.raw[key];
160
+ reconcile(node, existing);
161
+ }
162
+ return value;
163
+ }
164
+ const binding = existing ?? {
165
+ raw: {},
166
+ lifecycle: createAnimatedLeafLifecycle('host'),
167
+ };
168
+ if (existing === undefined) {
169
+ bindings.set(node, binding);
170
+ anyBinding = true;
171
+ }
172
+ binding.raw[key] = bound;
173
+ // Fabric flattens a view whose props do not require one, and a flattened view has no tag for
174
+ // the native driver to bind to. RN forces the same flag from `reduceAnimatedProps`.
175
+ setProp(node, 'collapsable', false);
176
+ reconcile(node, binding);
177
+ // The AUTHOR's value, rasterized — never the composed one. A behavior's layer reaches Fabric
178
+ // through the per-frame `setNativeProps` merge, and folding it in here would publish the fade's
179
+ // own output as the node's declarative style, which the behavior then reads back as resting.
180
+ const own = rasterize(key, value);
181
+ return own === undefined ? value : own;
182
+ }
183
+ const eventBindings = new WeakMap();
184
+ /**
185
+ * Bind a native-driven `Animated.event` handler written as an `on*` prop. A no-op for every other
186
+ * handler, so `routeProp` calls it for any `on*` name behind the same one-boolean gate.
187
+ */
188
+ export function bindAnimatedEvent(node, propName, handler) {
189
+ const bound = eventBindings.get(node);
190
+ if (bound !== undefined) {
191
+ const current = bound.get(propName);
192
+ if (current !== undefined) {
193
+ // A framework hands a fresh closure most renders; only a different handler is worth a
194
+ // detach/attach round trip through the native module.
195
+ if (current.handler === handler)
196
+ return;
197
+ current.attachment.detach();
198
+ bound.delete(propName);
199
+ }
200
+ }
201
+ // Returns undefined unless this really is a native-driven AnimatedEvent, and defers itself
202
+ // through `whenCommitted` until the node has a Fabric tag — no second deferral invented here.
203
+ const attachment = attachNativeEventHandler(node, propName, handler);
204
+ if (attachment === undefined)
205
+ return;
206
+ const map = bound ?? new Map();
207
+ if (bound === undefined)
208
+ eventBindings.set(node, map);
209
+ map.set(propName, { handler, attachment });
210
+ // The same gate the props half raises: teardown and re-arm are both behind it.
211
+ anyBinding = true;
212
+ }
213
+ // Re-attach against the tag the node has NOW. The old handles are spent — each detached against
214
+ // the tag it attached with, which is the point — so re-arming is a fresh attach, not a resume, and
215
+ // it goes back through `bindAnimatedEvent` rather than growing a second attach path. Only a node
216
+ // the sweep parked reaches here, and parking always detaches, so clearing without detaching first
217
+ // drops nothing live.
218
+ function reattachAnimatedEvents(node) {
219
+ const bound = eventBindings.get(node);
220
+ if (bound === undefined)
221
+ return;
222
+ const spent = [...bound];
223
+ bound.clear();
224
+ for (const [propName, binding] of spent)
225
+ bindAnimatedEvent(node, propName, binding.handler);
226
+ }
227
+ /**
228
+ * Release a node's animated subscription. Called per node from the commit sweep, which is where a
229
+ * genuine removal is first distinguishable from a framework spelling a move as remove-then-insert
230
+ * (host-behavior.ts, `markDetachCandidate`).
231
+ */
232
+ export function detachAnimatedProps(node) {
233
+ // The sweep runs for host behaviors too, so an app that animates nothing must not pay a WeakSet
234
+ // insert per removed node to learn it has nothing to release.
235
+ if (!anyBinding)
236
+ return;
237
+ parked.add(node);
238
+ bindings.get(node)?.lifecycle.teardown();
239
+ const bound = eventBindings.get(node);
240
+ if (bound === undefined)
241
+ return;
242
+ for (const binding of bound.values())
243
+ binding.attachment.detach();
244
+ }
245
+ /**
246
+ * Re-arm a subtree the sweep tore down and the framework put back — Svelte parks LIVE nodes
247
+ * offscreen across commits and returns them with their props unwritten, so without this the
248
+ * animation stops with nothing red anywhere.
249
+ *
250
+ * A WeakSet miss and no walk at all for every node in a freshly built tree, which is the path that
251
+ * runs ~9 000 times per benchmark create.
252
+ */
253
+ export function reattachAnimatedProps(node) {
254
+ if (!parked.has(node))
255
+ return;
256
+ parked.delete(node);
257
+ const binding = bindings.get(node);
258
+ if (binding !== undefined)
259
+ reconcile(node, binding);
260
+ reattachAnimatedEvents(node);
261
+ for (const child of node.children)
262
+ reattachAnimatedProps(child);
263
+ }
@@ -1,6 +1,6 @@
1
1
  // The AnimatedProps leaf lifecycle every Animated.* wrapper needs: build a leaf from the current
2
- // props, swap it into the value graph, bind it to the committed node, go native when asked, and
3
- // rebind native event props. Framework-agnostic on purpose - it knows nothing about hooks,
2
+ // props, swap it into the value graph, bind it to the committed node, and go native when asked.
3
+ // Framework-agnostic on purpose - it knows nothing about hooks,
4
4
  // effects, change detection or reactivity, only the engine's own Animated primitives.
5
5
  //
6
6
  // WHY THIS LIVES IN THE ENGINE. It used to live four times, once per adapter (React's
@@ -15,7 +15,6 @@
15
15
  // What stays in the adapter: WHEN to call reconcile (an effect, onUpdated, ngOnChanges, a $effect)
16
16
  // and HOW to find the host node (a ref, a ViewChild, a shim). Everything below is the same for all.
17
17
  import { AnimatedProps } from './props.js';
18
- import { attachNativeEventHandler } from './event.js';
19
18
  import { dlog } from '../debug.js';
20
19
  // Diagnostic-only, DEBUG-gated: a process-wide sequence number so every reconcile call across
21
20
  // every Animated.* instance in a log dump is individually identifiable and orderable.
@@ -66,7 +65,6 @@ function describeTransform(value) {
66
65
  }
67
66
  export function createAnimatedLeafLifecycle(label) {
68
67
  let attached = null;
69
- let eventDetachers = [];
70
68
  // Diagnostic-only: the last node identity, plus a reentrancy flag to catch reconcile() being
71
69
  // called AGAIN from inside its own call stack - the smoking-gun shape for a synchronous
72
70
  // same-flush loop, as opposed to merely "called often".
@@ -77,11 +75,6 @@ export function createAnimatedLeafLifecycle(label) {
77
75
  let lastWantsNative = false;
78
76
  // Canceller for a native bind the caller deferred and that has not run yet.
79
77
  let cancelPendingBind;
80
- function detachEvents() {
81
- for (const detach of eventDetachers)
82
- detach();
83
- eventDetachers = [];
84
- }
85
78
  return {
86
79
  reconcile(props, node, wantsNative, scheduleNativeBind) {
87
80
  const seq = ++globalReconcileSeq;
@@ -142,23 +135,19 @@ export function createAnimatedLeafLifecycle(label) {
142
135
  if (attached !== null && attached !== newLeaf)
143
136
  attached.__detach();
144
137
  attached = newLeaf;
145
- // The native half, which a caller may defer. Rebinds events each reconcile so a new inline
146
- // event re-attaches, detaching first so the prior binding does not leak;
147
- // attachNativeEventHandler no-ops unless the prop really is a native event handler on a
148
- // committed node, so the JS path stays the fallback.
138
+ // The native half, which a caller may defer.
139
+ //
140
+ // It used to native-attach every `Animated.event` prop here as well. It must not any more:
141
+ // a wrapper hands those same props DOWN to a host element, so they reach `routeProp`,
142
+ // which binds them itself (`host-binding.ts`, `bindAnimatedEvent`) — that is what makes a
143
+ // bare `<scroll-view onScroll={…} />` work with no wrapper at all. Doing it here too
144
+ // registered the SAME mapping twice on one view tag, caught by the Vue and Svelte
145
+ // wrapper tests the day the engine half landed.
149
146
  const bindNative = () => {
150
147
  if (node !== null)
151
148
  newLeaf.setNativeView(node);
152
149
  if (wantsNative)
153
150
  newLeaf.__makeNative();
154
- detachEvents();
155
- if (node === null)
156
- return;
157
- for (const key of Object.keys(props)) {
158
- const attachment = attachNativeEventHandler(node, key, props[key]);
159
- if (attachment !== undefined)
160
- eventDetachers.push(attachment.detach);
161
- }
162
151
  };
163
152
  cancelPendingBind?.();
164
153
  cancelPendingBind = undefined;
@@ -175,7 +164,6 @@ export function createAnimatedLeafLifecycle(label) {
175
164
  teardown() {
176
165
  cancelPendingBind?.();
177
166
  cancelPendingBind = undefined;
178
- detachEvents();
179
167
  if (attached !== null) {
180
168
  attached.__detach();
181
169
  attached = null;
package/build/commit.js CHANGED
@@ -21,7 +21,8 @@ import { registerPostCommit, runPostCommitHooks } from './post-commit.js';
21
21
  import { fabricProps } from './fabric-props.js';
22
22
  import { isRecord } from './type-guards.js';
23
23
  import { isAriaAliasKey } from './accessibility-props.js';
24
- import { runDeferredAttaches, sweepDetachedBehaviors } from './host-behavior.js';
24
+ import { runCommittedHooks, runDeferredAttaches, sweepDetachedBehaviors, teardownSubtree, } from './host-behavior.js';
25
+ import { detachAnimatedProps } from './animated/host-binding.js';
25
26
  // Re-exported from ./platform-color so callers don't need to change their import path.
26
27
  export { processColor, setColorProcessor } from './platform-color/index.js';
27
28
  // Per-commit work counters, surfaced via dlog so a device run can prove the
@@ -186,6 +187,15 @@ function jsonEqual(a, b) {
186
187
  return false;
187
188
  return keys.every(key => key in b && jsonEqual(a[key], b[key]));
188
189
  }
190
+ // The committed-state record (IMirror) and its guarded accessor (committedOf) live on the node
191
+ // itself, in node.ts - see the `committed` field there for why the side table was collapsed into a
192
+ // field, and committedOf's doc comment for the node-identity check that replaced the WeakMap miss.
193
+ // Everything below reads it exclusively through committedOf and writes it as `node.committed`.
194
+ // The predicate both behavior drains take. Passed in rather than imported by `host-behavior.ts`,
195
+ // keeping that dependency one-directional — this module already imports from it, and a cycle is a
196
+ // live hazard under Metro's `inlineRequires`. Hoisted to module scope because two call sites need
197
+ // the identical question.
198
+ const isNodeCommitted = (node) => committedOf(node) !== undefined;
189
199
  function isSkippedAtCommit(node) {
190
200
  return isAnchor(node) || isEmptyRawText(node);
191
201
  }
@@ -515,8 +525,16 @@ export function disposeRoot(rootTag) {
515
525
  // Drop any setNativeProps writes still queued for this surface: their flush is a microtask away
516
526
  // and would otherwise commit into a container that no longer exists, re-creating it from scratch.
517
527
  pendingByRoot.delete(rootTag);
518
- if (rootContainers.delete(rootTag))
519
- dlog(`root container disposed root=${rootTag}`);
528
+ const container = rootContainers.get(rootTag);
529
+ if (container === undefined)
530
+ return;
531
+ // BEFORE the delete, which is the only reason the subtree is still reachable. An unmount removes
532
+ // no child, so `sweepDetachedBehaviors` never hears about these nodes — without this every one of
533
+ // them keeps its `afterCommit` registration and its timers, and a restarted surface's commits
534
+ // drain the dead one's hooks forever (`.claude/rules/unmount-does-not-sweep-host-behaviors.md`).
535
+ teardownSubtree(container, detachAnimatedProps);
536
+ rootContainers.delete(rootTag);
537
+ dlog(`root container disposed root=${rootTag}`);
520
538
  }
521
539
  export function commitChildren(rootTag, children) {
522
540
  // The wrapper holds the surface's top-level children; reconcile walks from it so the
@@ -546,7 +564,7 @@ function commitContainer(rootTag) {
546
564
  // that `removeChild` unlinked is now either back under a parent (a framework spelling a move as
547
565
  // remove-then-reinsert) or gone for good. Costs one Set-size read until an app registers its
548
566
  // first host behavior. See host-behavior.ts for why removal cannot answer this itself.
549
- sweepDetachedBehaviors(container.children);
567
+ sweepDetachedBehaviors(container.children, detachAnimatedProps);
550
568
  stats.created = 0;
551
569
  stats.cloneProps = 0;
552
570
  stats.cloneChildren = 0;
@@ -568,20 +586,23 @@ function commitContainer(rootTag) {
568
586
  // The container's identity is stable, so its un-cloned flag is the no-op signal:
569
587
  // an over-scheduled commit that touched nothing makes zero native calls.
570
588
  //
571
- // TRAP FOR BEHAVIOR AUTHORS, and it cost two iterations to find: this return is ALSO the gate on
572
- // `runDeferredAttaches` and `runPostCommitHooks` below. A host behavior that calls
573
- // `requestCommitFor(node)` WITHOUT writing a prop therefore never reaches its `afterCommit` /
574
- // `attachAfterCommit` half — the commit it asked for is a no-op, and a no-op returns here.
589
+ // TRAP FOR BEHAVIOR AUTHORS, and it cost two iterations to find: this return is the gate on
590
+ // `runPostCommitHooks` and `runDeferredAttaches` below. Both exist to retry once FRESH FABRIC TAGS
591
+ // are assigned, and a commit that made zero native calls assigned none — so gating them here is
592
+ // correct, and hoisting them above this line would run every deferred hook on every
593
+ // over-scheduled commit, which is the common case.
594
+ //
595
+ // `afterCommit` is NOT in that group and was gated with them by accident until 2026-09-10. It asks
596
+ // only "props were published", which a no-op commit satisfies, and gating it made it unreachable
597
+ // for exactly the props a behavior OWNS: a fold that strips a prop (TouchableOpacity's `disabled`,
598
+ // Button's `title`/`color`) makes its own commit byte-identical, so the hook that must react to
599
+ // the flip is the one the flip cannot wake. It is drained on both paths now.
575
600
  //
576
- // That is correct for what the hooks are FOR: they exist to retry once fresh Fabric tags are
577
- // assigned, and a commit that made zero native calls assigned none. So the fix is not to hoist
578
- // them above this line — that would run every deferred hook on every over-scheduled commit, which
579
- // is the common case. A behavior needing a turn of the loop with nothing to write should schedule
580
- // its own (`queueMicrotask`, as Switch's snap-back and Angular's `snapBackIfNeeded` both do) and
581
- // keep `afterCommit` registered for the case a microtask cannot reach: a prop change with no
582
- // preceding native event.
601
+ // A behavior needing a turn of the loop with nothing to write at all may still schedule its own
602
+ // (`queueMicrotask`, as Switch's snap-back and Angular's `snapBackIfNeeded` both do).
583
603
  if (!result.changed) {
584
604
  dlog(`commit root=${rootTag} no-op (skipped completeRoot)`);
605
+ runCommittedHooks(isNodeCommitted);
585
606
  return;
586
607
  }
587
608
  const childSet = slot.createChildSet(rootTag);
@@ -594,10 +615,11 @@ function commitContainer(rootTag) {
594
615
  runPostCommitHooks();
595
616
  // The same moment, for the half of a host behavior that could not run at `attach`. A behavior
596
617
  // whose setup needs a Fabric tag (a view command, a native Animated binding, an event attach)
597
- // declares `attachAfterCommit` and is drained here. `committedOf` is passed as the predicate
598
- // rather than imported by `host-behavior.ts`, keeping that dependency one-directional — this
599
- // module already imports from it, and a cycle is a live hazard under Metro's `inlineRequires`.
600
- runDeferredAttaches(node => committedOf(node) !== undefined);
618
+ // declares `attachAfterCommit` and is drained here.
619
+ runDeferredAttaches(isNodeCommitted);
620
+ // After the setup half, never before it: a node carrying both hooks has `attachAfterCommit` seed
621
+ // the mirrors `afterCommit` then compares against. See `runCommittedHooks`.
622
+ runCommittedHooks(isNodeCommitted);
601
623
  if (isDebug()) {
602
624
  const mode = stats.created > 0 && stats.reused === 0 ? 'full' : 'incremental';
603
625
  dlog(`commit root=${rootTag} ${mode} ` +
@@ -811,6 +833,12 @@ function commitTargeted(nodes) {
811
833
  profile.commits += 1;
812
834
  profile.nodesVisited += cloned.size;
813
835
  runPostCommitHooks();
836
+ // Same reasoning as the container path's drain, reached from the other end: this commit
837
+ // published props, which is all `afterCommit` asks. Missing here it was unreachable for a
838
+ // behavior whose only write is its own bookkeeping — no framework prop changes, so no container
839
+ // commit follows to drain it. `runDeferredAttaches` deliberately stays out: a targeted commit
840
+ // only happens after a container commit has already assigned tags and drained them.
841
+ runCommittedHooks(isNodeCommitted);
814
842
  dlog(`commit targeted root=${rootTag} leaves=${writes.length} ` +
815
843
  `union=${branches.size}`);
816
844
  return true;