@symbiote-native/engine 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/build/accessibility-props.d.ts +20 -0
- package/build/accessibility-props.js +213 -0
- package/build/animated/graph.d.ts +2 -0
- package/build/animated/graph.js +14 -0
- package/build/animated/host-binding.d.ts +39 -0
- package/build/animated/host-binding.js +263 -0
- package/build/animated/leaf-lifecycle.js +10 -22
- package/build/app-registry/index.d.ts +1 -0
- package/build/app-registry/index.js +15 -5
- package/build/commit.d.ts +22 -0
- package/build/commit.js +509 -78
- package/build/debug.js +10 -4
- package/build/events/index.js +172 -87
- package/build/fabric-props.js +179 -10
- package/build/fabric.d.ts +11 -3
- package/build/fabric.js +19 -2
- package/build/host-behavior.d.ts +84 -0
- package/build/host-behavior.js +374 -0
- package/build/host-instance/index.d.ts +2 -10
- package/build/host-instance/index.js +13 -46
- package/build/index.d.ts +7 -1
- package/build/index.js +23 -4
- package/build/node.d.ts +115 -5
- package/build/node.js +737 -70
- package/build/pan-responder/index.d.ts +2 -2
- package/build/pan-responder/index.js +10 -4
- package/build/style-registry/index.d.ts +2 -0
- package/build/style-registry/index.js +79 -0
- package/build/styles.d.ts +5 -1
- package/build/surface.js +21 -4
- package/build/view-config.js +6 -0
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -97,8 +97,14 @@ before a tag is guaranteed to exist.
|
|
|
97
97
|
- **Runtime modules**, framework-agnostic, re-exported by every adapter: `Platform`,
|
|
98
98
|
`StyleSheet` (+ `computeHairlineWidth`), `Dimensions`, `PixelRatio`, `Appearance`, `AppState`,
|
|
99
99
|
`Keyboard`, `AccessibilityInfo`, `BackHandler`, `PermissionsAndroid`, `LayoutAnimation`,
|
|
100
|
-
`InteractionManager`, `PanResponder`, and the imperative modules `Alert`, `Share`,
|
|
100
|
+
`InteractionManager`, `PanResponder`, `StatusBar`, and the imperative modules `Alert`, `Share`,
|
|
101
101
|
`ActionSheetIOS`, `Linking`, `Vibration`, `ToastAndroid`, `Settings`, `I18nManager`.
|
|
102
|
+
- **Host behaviors** (`registerHostBehavior` / `IHostBehavior` / `hasHostBehaviors` /
|
|
103
|
+
`clearHostBehaviors` / `appListenerFor` / `setBehaviorListener` / `requestCommitFor`) — the
|
|
104
|
+
registry that lets a primitive's state machine live directly on the engine node instead of
|
|
105
|
+
inside a framework component, so a `Pressable`/`Switch`/`TextInput`/`Image` can compile to a
|
|
106
|
+
bare intrinsic tag ("host-primitive lowering"). `@symbiote-native/components` registers its
|
|
107
|
+
behaviors against this seam; the engine never imports them back.
|
|
102
108
|
- **`Animated`** — both the JS and native driver (`timing` / `spring` / `decay` / `loop` /
|
|
103
109
|
`ValueXY` / tracking / `diffClamp` / `Easing`), including the native-event attachment path
|
|
104
110
|
(`attachNativeEvent`, `AnimatedEvent`).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const ARIA_ALIAS_KEYS: readonly string[];
|
|
2
|
+
/**
|
|
3
|
+
* Whether one key is an alias this fold consumes. `startsWith` rather than a Set lookup: this runs
|
|
4
|
+
* on `setProp`, the hottest write path in the engine (32 001 writes on one benchmark create), and
|
|
5
|
+
* it is guarded by the node's sticky flag so it is reached at most once per node per key. The
|
|
6
|
+
* `role` comparison comes first because it is the one alias with no prefix.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isAriaAliasKey(key: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Fold the web-alias `aria-*` / `role` props into RN's canonical `accessibility*` props.
|
|
11
|
+
*
|
|
12
|
+
* Returns the input BY IDENTITY when no alias is present — the fast path that keeps this off the
|
|
13
|
+
* hot path for the ~99% of nodes carrying none, and the property idempotence rests on: pass 1
|
|
14
|
+
* blanks every alias, so a second pass finds nothing and returns by identity again.
|
|
15
|
+
*
|
|
16
|
+
* The alias keys are blanked to `undefined` rather than deleted. That is not laziness: `setProp`
|
|
17
|
+
* treats an `undefined` write as a delete and `fabricProps` skips undefined, so a blanked alias
|
|
18
|
+
* cannot reach Fabric, while a `delete` would deoptimise the object's shape on every folded node.
|
|
19
|
+
*/
|
|
20
|
+
export declare function foldAriaProps(props: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// RN's `aria-*` / `role` -> `accessibility*` fold, at the layer every path goes through.
|
|
2
|
+
//
|
|
3
|
+
// WHY IT IS HERE AND NOT IN A WRAPPER. It used to run inside each primitive's COMPONENT, which is
|
|
4
|
+
// exactly the layer host-primitive lowering removes. A per-attribute element path cannot do it —
|
|
5
|
+
// `aria-checked` has to be folded against a sibling `accessibilityState` — so the four lowering
|
|
6
|
+
// transforms REFUSED any element carrying `role` or an `aria-*` attribute
|
|
7
|
+
// (`REFUSAL_CATEGORIES.bagFold`). Accessibility props are ordinary in real code, so that refusal
|
|
8
|
+
// cost lowering coverage on every primitive, including the three already lowered. Moving the fold
|
|
9
|
+
// down deletes the refusal instead of teaching four transforms a bag operation they cannot express.
|
|
10
|
+
//
|
|
11
|
+
// IT IS A MOVE, NOT A REWRITE, AND THAT IS DELIBERATE. The function carries TWO CONTRADICTORY
|
|
12
|
+
// PRECEDENCE RULES: for the scalars an explicit `accessibility*` WINS and the alias only fills a
|
|
13
|
+
// hole (`if (next.X === undefined)`), while INSIDE the `accessibilityState` / `accessibilityValue`
|
|
14
|
+
// composites the ALIAS wins per field (`ariaBusy ?? existing?.busy`). Both mirror RN's View.js.
|
|
15
|
+
// Anyone "cleaning this up" collapses them into one rule, and every component test stays green
|
|
16
|
+
// while real accessibility silently changes. `core/components/src/accessibility-props.test.ts`
|
|
17
|
+
// pins both directions; read it before touching the branches below.
|
|
18
|
+
//
|
|
19
|
+
// Record-level rather than typed, because the engine's caller has a raw `node.props` bag and an
|
|
20
|
+
// interface is not assignable to `Record<string, unknown>` (no index signature). The typed
|
|
21
|
+
// `resolveAccessibilityProps<T>` in `core/components` stays where adapters already import it and
|
|
22
|
+
// delegates here, keeping its own typed gate so the fast path allocates nothing.
|
|
23
|
+
import { dlog } from './debug.js';
|
|
24
|
+
// Copied line for line from the wrapper this replaces. The first copy silently dropped five
|
|
25
|
+
// entries (`button`, `grid`, `link`, `list`, `listitem`) — a role that falls through simply passes
|
|
26
|
+
// unmapped, so `role="listitem"` would have reached Fabric as `listitem` instead of `list` with
|
|
27
|
+
// nothing red anywhere. Diff this against RN's View.js rather than reading it for plausibility.
|
|
28
|
+
const ROLE_TO_ACCESSIBILITY_ROLE = {
|
|
29
|
+
alert: 'alert',
|
|
30
|
+
button: 'button',
|
|
31
|
+
checkbox: 'checkbox',
|
|
32
|
+
combobox: 'combobox',
|
|
33
|
+
grid: 'grid',
|
|
34
|
+
heading: 'header',
|
|
35
|
+
img: 'image',
|
|
36
|
+
link: 'link',
|
|
37
|
+
list: 'list',
|
|
38
|
+
listitem: 'list',
|
|
39
|
+
menu: 'menu',
|
|
40
|
+
menubar: 'menubar',
|
|
41
|
+
menuitem: 'menuitem',
|
|
42
|
+
none: 'none',
|
|
43
|
+
presentation: 'none',
|
|
44
|
+
progressbar: 'progressbar',
|
|
45
|
+
radio: 'radio',
|
|
46
|
+
radiogroup: 'radiogroup',
|
|
47
|
+
scrollbar: 'scrollbar',
|
|
48
|
+
searchbox: 'search',
|
|
49
|
+
slider: 'adjustable',
|
|
50
|
+
spinbutton: 'spinbutton',
|
|
51
|
+
summary: 'summary',
|
|
52
|
+
switch: 'switch',
|
|
53
|
+
tab: 'tab',
|
|
54
|
+
tablist: 'tablist',
|
|
55
|
+
timer: 'timer',
|
|
56
|
+
toolbar: 'toolbar',
|
|
57
|
+
};
|
|
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 = [
|
|
63
|
+
'role',
|
|
64
|
+
'aria-label',
|
|
65
|
+
'aria-labelledby',
|
|
66
|
+
'aria-live',
|
|
67
|
+
'aria-hidden',
|
|
68
|
+
'aria-busy',
|
|
69
|
+
'aria-checked',
|
|
70
|
+
'aria-disabled',
|
|
71
|
+
'aria-expanded',
|
|
72
|
+
'aria-selected',
|
|
73
|
+
'aria-modal',
|
|
74
|
+
'aria-valuemax',
|
|
75
|
+
'aria-valuemin',
|
|
76
|
+
'aria-valuenow',
|
|
77
|
+
'aria-valuetext',
|
|
78
|
+
];
|
|
79
|
+
// An indexed loop rather than `.some(key => …)`: the callback captures `props`, so a closure is
|
|
80
|
+
// allocated per call, and this is the gate on a path that runs once per node.
|
|
81
|
+
function hasAnyAriaKey(props) {
|
|
82
|
+
for (let index = 0; index < ARIA_ALIAS_KEYS.length; index += 1) {
|
|
83
|
+
if (props[ARIA_ALIAS_KEYS[index]] !== undefined)
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether one key is an alias this fold consumes. `startsWith` rather than a Set lookup: this runs
|
|
90
|
+
* on `setProp`, the hottest write path in the engine (32 001 writes on one benchmark create), and
|
|
91
|
+
* it is guarded by the node's sticky flag so it is reached at most once per node per key. The
|
|
92
|
+
* `role` comparison comes first because it is the one alias with no prefix.
|
|
93
|
+
*/
|
|
94
|
+
export function isAriaAliasKey(key) {
|
|
95
|
+
return key === 'role' || key.startsWith('aria-');
|
|
96
|
+
}
|
|
97
|
+
function isRecord(value) {
|
|
98
|
+
return typeof value === 'object' && value !== null;
|
|
99
|
+
}
|
|
100
|
+
function fieldOf(source, field) {
|
|
101
|
+
return isRecord(source) ? source[field] : undefined;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Fold the web-alias `aria-*` / `role` props into RN's canonical `accessibility*` props.
|
|
105
|
+
*
|
|
106
|
+
* Returns the input BY IDENTITY when no alias is present — the fast path that keeps this off the
|
|
107
|
+
* hot path for the ~99% of nodes carrying none, and the property idempotence rests on: pass 1
|
|
108
|
+
* blanks every alias, so a second pass finds nothing and returns by identity again.
|
|
109
|
+
*
|
|
110
|
+
* The alias keys are blanked to `undefined` rather than deleted. That is not laziness: `setProp`
|
|
111
|
+
* treats an `undefined` write as a delete and `fabricProps` skips undefined, so a blanked alias
|
|
112
|
+
* cannot reach Fabric, while a `delete` would deoptimise the object's shape on every folded node.
|
|
113
|
+
*/
|
|
114
|
+
export function foldAriaProps(props) {
|
|
115
|
+
if (!hasAnyAriaKey(props))
|
|
116
|
+
return props;
|
|
117
|
+
const bag = { ...props };
|
|
118
|
+
dlog('foldAriaProps: folding aria/role aliases into accessibility* props');
|
|
119
|
+
const role = bag.role;
|
|
120
|
+
const ariaLabel = bag['aria-label'];
|
|
121
|
+
const ariaLabelledBy = bag['aria-labelledby'];
|
|
122
|
+
const ariaLive = bag['aria-live'];
|
|
123
|
+
const ariaHidden = bag['aria-hidden'];
|
|
124
|
+
const ariaBusy = bag['aria-busy'];
|
|
125
|
+
const ariaChecked = bag['aria-checked'];
|
|
126
|
+
const ariaDisabled = bag['aria-disabled'];
|
|
127
|
+
const ariaExpanded = bag['aria-expanded'];
|
|
128
|
+
const ariaSelected = bag['aria-selected'];
|
|
129
|
+
const ariaModal = bag['aria-modal'];
|
|
130
|
+
const ariaValueMax = bag['aria-valuemax'];
|
|
131
|
+
const ariaValueMin = bag['aria-valuemin'];
|
|
132
|
+
const ariaValueNow = bag['aria-valuenow'];
|
|
133
|
+
const ariaValueText = bag['aria-valuetext'];
|
|
134
|
+
for (let index = 0; index < ARIA_ALIAS_KEYS.length; index += 1) {
|
|
135
|
+
bag[ARIA_ALIAS_KEYS[index]] = undefined;
|
|
136
|
+
}
|
|
137
|
+
// RULE ONE, for every scalar: the explicit prop WINS, the alias only fills a hole.
|
|
138
|
+
if (typeof ariaLabelledBy === 'string' &&
|
|
139
|
+
bag.accessibilityLabelledBy === undefined) {
|
|
140
|
+
bag.accessibilityLabelledBy = ariaLabelledBy.split(/\s*,\s*/g);
|
|
141
|
+
}
|
|
142
|
+
if (ariaLabel !== undefined && bag.accessibilityLabel === undefined) {
|
|
143
|
+
bag.accessibilityLabel = ariaLabel;
|
|
144
|
+
}
|
|
145
|
+
if (ariaLive !== undefined && bag.accessibilityLiveRegion === undefined) {
|
|
146
|
+
bag.accessibilityLiveRegion = ariaLive === 'off' ? 'none' : ariaLive;
|
|
147
|
+
}
|
|
148
|
+
// One input, TWO outputs, and the second is conditional on the VALUE rather than on presence.
|
|
149
|
+
if (ariaHidden !== undefined) {
|
|
150
|
+
if (bag.accessibilityElementsHidden === undefined) {
|
|
151
|
+
bag.accessibilityElementsHidden = ariaHidden;
|
|
152
|
+
}
|
|
153
|
+
if (ariaHidden === true && bag.importantForAccessibility === undefined) {
|
|
154
|
+
bag.importantForAccessibility = 'no-hide-descendants';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (ariaModal !== undefined && bag.accessibilityViewIsModal === undefined) {
|
|
158
|
+
bag.accessibilityViewIsModal = ariaModal;
|
|
159
|
+
}
|
|
160
|
+
if (typeof role === 'string' && bag.accessibilityRole === undefined) {
|
|
161
|
+
bag.accessibilityRole = ROLE_TO_ACCESSIBILITY_ROLE[role] ?? role;
|
|
162
|
+
}
|
|
163
|
+
// RULE TWO, INSIDE the composites: the polarity INVERTS and the ALIAS wins per field. Read from
|
|
164
|
+
// the ORIGINAL props, not from `bag` — the loop above has already blanked the aliases there.
|
|
165
|
+
//
|
|
166
|
+
// UPSTREAM-BUG(react-native): View.js:96 is `checked: ariaChecked ?? accessibilityState?.checked`
|
|
167
|
+
// — NO type coercion. A template writes `aria-checked="true"` as a STRING in every framework we
|
|
168
|
+
// support, so `accessibilityState.checked` reaches native as `'true'` where the native side
|
|
169
|
+
// declares `boolean | 'mixed'`. Ported verbatim for parity; do NOT add a cast without recording
|
|
170
|
+
// a deliberate divergence. Found by the Svelte session 2026-08-31 and pinned as an ASSERTION in
|
|
171
|
+
// `adapters/svelte/src/aria-fold-parity.test.ts`, so a future decision to coerce shows up as a
|
|
172
|
+
// failing test at the point the decision was made rather than as a silent behaviour change.
|
|
173
|
+
//
|
|
174
|
+
// A SECOND, SMALLER DIVERGENCE, and this one is ours rather than upstream's — it predates the
|
|
175
|
+
// move down and is kept only because changing it here would be an undeclared behaviour change:
|
|
176
|
+
// upstream gates the composite on `!= null` (View.js:87-92) and this gates on `!== undefined`.
|
|
177
|
+
// So an explicit `aria-busy={null}` builds an all-undefined `accessibilityState` here and builds
|
|
178
|
+
// nothing upstream. The VALUES agree either way — `??` treats null and undefined alike — so only
|
|
179
|
+
// the composite's existence differs.
|
|
180
|
+
//
|
|
181
|
+
// The composite is REPLACED by a fresh literal listing exactly the known fields, so an unknown
|
|
182
|
+
// field riding on the incoming object is dropped. Faithful to RN and pinned by a test; it is the
|
|
183
|
+
// shape of bug that only shows for whoever passes a field RN adds later.
|
|
184
|
+
const existingState = fieldOf(props, 'accessibilityState');
|
|
185
|
+
if (existingState !== undefined ||
|
|
186
|
+
ariaBusy !== undefined ||
|
|
187
|
+
ariaChecked !== undefined ||
|
|
188
|
+
ariaDisabled !== undefined ||
|
|
189
|
+
ariaExpanded !== undefined ||
|
|
190
|
+
ariaSelected !== undefined) {
|
|
191
|
+
bag.accessibilityState = {
|
|
192
|
+
busy: ariaBusy ?? fieldOf(existingState, 'busy'),
|
|
193
|
+
checked: ariaChecked ?? fieldOf(existingState, 'checked'),
|
|
194
|
+
disabled: ariaDisabled ?? fieldOf(existingState, 'disabled'),
|
|
195
|
+
expanded: ariaExpanded ?? fieldOf(existingState, 'expanded'),
|
|
196
|
+
selected: ariaSelected ?? fieldOf(existingState, 'selected'),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const existingValue = fieldOf(props, 'accessibilityValue');
|
|
200
|
+
if (existingValue !== undefined ||
|
|
201
|
+
ariaValueMax !== undefined ||
|
|
202
|
+
ariaValueMin !== undefined ||
|
|
203
|
+
ariaValueNow !== undefined ||
|
|
204
|
+
ariaValueText !== undefined) {
|
|
205
|
+
bag.accessibilityValue = {
|
|
206
|
+
max: ariaValueMax ?? fieldOf(existingValue, 'max'),
|
|
207
|
+
min: ariaValueMin ?? fieldOf(existingValue, 'min'),
|
|
208
|
+
now: ariaValueNow ?? fieldOf(existingValue, 'now'),
|
|
209
|
+
text: ariaValueText ?? fieldOf(existingValue, 'text'),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return bag;
|
|
213
|
+
}
|
|
@@ -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
|
+
}
|