@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.
- package/build/accessibility-props.d.ts +1 -0
- package/build/accessibility-props.js +9 -5
- package/build/animated/graph.d.ts +2 -0
- package/build/animated/graph.js +14 -0
- package/build/animated/host-binding.d.ts +39 -0
- package/build/animated/host-binding.js +263 -0
- package/build/animated/leaf-lifecycle.js +10 -22
- package/build/commit.js +47 -19
- package/build/events/index.js +49 -7
- package/build/fabric-props.js +16 -1
- package/build/fabric.d.ts +1 -0
- package/build/fabric.js +8 -0
- package/build/host-behavior.d.ts +38 -1
- package/build/host-behavior.js +128 -16
- package/build/index.d.ts +6 -5
- package/build/index.js +16 -4
- package/build/node.d.ts +20 -4
- package/build/node.js +274 -23
- package/build/pan-responder/index.d.ts +2 -2
- package/build/pan-responder/index.js +10 -4
- package/build/styles.d.ts +5 -1
- package/build/surface.js +14 -0
- package/package.json +1 -1
|
@@ -55,7 +55,11 @@ const ROLE_TO_ACCESSIBILITY_ROLE = {
|
|
|
55
55
|
timer: 'timer',
|
|
56
56
|
toolbar: 'toolbar',
|
|
57
57
|
};
|
|
58
|
-
|
|
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 <
|
|
79
|
-
if (props[
|
|
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 <
|
|
131
|
-
bag[
|
|
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;
|
package/build/animated/graph.js
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
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.
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
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
|
-
|
|
519
|
-
|
|
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
|
|
572
|
-
// `
|
|
573
|
-
//
|
|
574
|
-
//
|
|
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
|
-
//
|
|
577
|
-
//
|
|
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.
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
|
|
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;
|