@symbiote-native/engine 0.1.7 → 0.2.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.
Files changed (40) hide show
  1. package/build/alert/index.ios.js +10 -0
  2. package/build/animated/color.d.ts +2 -8
  3. package/build/animated/color.js +4 -45
  4. package/build/animated/easing.js +0 -8
  5. package/build/animated/graph.d.ts +13 -5
  6. package/build/animated/graph.js +85 -11
  7. package/build/animated/index.d.ts +2 -2
  8. package/build/animated/index.js +2 -2
  9. package/build/animated/interpolation.js +1 -1
  10. package/build/animated/leaf-lifecycle.d.ts +7 -0
  11. package/build/animated/leaf-lifecycle.js +185 -0
  12. package/build/animated/native/native-animated.js +1 -0
  13. package/build/animated/rgba.d.ts +8 -0
  14. package/build/animated/rgba.js +55 -0
  15. package/build/animated/value.js +12 -7
  16. package/build/commit.js +1 -5
  17. package/build/debug.d.ts +1 -1
  18. package/build/debug.js +10 -3
  19. package/build/events/index.js +0 -2
  20. package/build/index.d.ts +4 -2
  21. package/build/index.js +2 -1
  22. package/build/layout-animation/index.js +0 -1
  23. package/build/process-font-variant.js +0 -1
  24. package/build/process-transform/index.js +13 -19
  25. package/build/report-error.d.ts +5 -0
  26. package/build/report-error.js +60 -0
  27. package/build/status-bar/index.android.js +5 -6
  28. package/build/style-registry/index.js +95 -16
  29. package/build/style-sheet/index.js +4 -2
  30. package/build/surface.js +2 -1
  31. package/build/vibration/index.android.js +2 -3
  32. package/package.json +1 -1
  33. package/build/animated/interpolation-node.d.ts +0 -15
  34. package/build/animated/interpolation-node.js +0 -58
  35. package/build/style-registry/scope/index.d.ts +0 -3
  36. package/build/style-registry/scope/index.js +0 -41
  37. package/build/touch-history/index.d.ts +0 -23
  38. package/build/touch-history/index.js +0 -150
  39. package/build/type-guards/index.d.ts +0 -4
  40. package/build/type-guards/index.js +0 -15
@@ -43,6 +43,16 @@ function prompt(title, message, callbackOrButtons, type = 'plain-text', defaultV
43
43
  if (btn.isPreferred) {
44
44
  preferredButtonKey = String(index);
45
45
  }
46
+ // UPSTREAM-BUG(react-native): Libraries/Alert/Alert.js:173 - a TRAILING button with no text
47
+ // is left out of the native buttons array while its onPress still occupies callbacks[index],
48
+ // so that handler can never fire: native has no id to return for a button it was never told
49
+ // about. Only the last button, only when textless, which reads more like leftover
50
+ // length - 1 bookkeeping than an intended "omit a blank trailing button" affordance.
51
+ // Ported for parity; do NOT fix without recording a deliberate divergence.
52
+ //
53
+ // The `!== undefined` here IS a deliberate divergence from upstream's truthy `btn.text`:
54
+ // an explicit `text: ''` is a caller asking for a blank label, not an omission, and this
55
+ // codebase treats falsy-vs-absent as distinct on principle. Upstream drops that button.
46
56
  if (btn.text !== undefined || index < callbackOrButtons.length - 1) {
47
57
  buttons.push({ [index]: btn.text ?? '' });
48
58
  }
@@ -1,12 +1,8 @@
1
1
  import { AnimatedWithChildren } from './graph';
2
2
  import { AnimatedValue } from './value';
3
3
  import type { INativeNodeConfig, IPlatformConfig } from './native/native-animated';
4
- export interface IRgbaValue {
5
- r: number;
6
- g: number;
7
- b: number;
8
- a: number;
9
- }
4
+ import { normalizeColor, type IRgbaValue } from './rgba';
5
+ export { normalizeColor, type IRgbaValue };
10
6
  type IChannel = number | AnimatedValue;
11
7
  interface IRgbaInput {
12
8
  r: IChannel;
@@ -15,7 +11,6 @@ interface IRgbaInput {
15
11
  a: IChannel;
16
12
  }
17
13
  export type IColorInput = IRgbaInput | IRgbaValue | string | number;
18
- export declare function normalizeColor(color: string | number): IRgbaValue | undefined;
19
14
  export declare class AnimatedColor extends AnimatedWithChildren {
20
15
  readonly r: AnimatedValue;
21
16
  readonly g: AnimatedValue;
@@ -34,4 +29,3 @@ export declare class AnimatedColor extends AnimatedWithChildren {
34
29
  __makeNative(platformConfig?: IPlatformConfig): void;
35
30
  __getNativeConfig(): INativeNodeConfig;
36
31
  }
37
- export {};
@@ -6,57 +6,16 @@
6
6
  // normalizeColor / processColorObject, which live outside shared).
7
7
  import { AnimatedWithChildren, flushValue } from './graph.js';
8
8
  import { AnimatedValue } from './value.js';
9
- const DEFAULT_COLOR = { r: 0, g: 0, b: 0, a: 1 };
9
+ import { DEFAULT_COLOR, normalizeColor } from './rgba.js';
10
+ // Re-exported so every existing `from './color'` import keeps working; the definitions now
11
+ // live in the dependency-free ./rgba leaf (see its header for the cycle it breaks).
12
+ export { normalizeColor };
10
13
  function isRgbaInput(value) {
11
14
  return typeof value === 'object' && value !== null && 'r' in value && 'g' in value;
12
15
  }
13
16
  function toChannel(value) {
14
17
  return value instanceof AnimatedValue ? value : new AnimatedValue(value);
15
18
  }
16
- // Decompose a #hex (3/4/6/8), rgb()/rgba(), or 0xRRGGBBAA number into channels.
17
- // undefined when unparseable (a named/platform color), so the caller falls back to
18
- // the default rather than throwing inside a render. Exported so interpolation's
19
- // color path parses through the same RGBA decoder (DRY) rather than duplicating it.
20
- export function normalizeColor(color) {
21
- if (typeof color === 'number') {
22
- const c = color >>> 0;
23
- return { r: (c >>> 24) & 255, g: (c >>> 16) & 255, b: (c >>> 8) & 255, a: (c & 255) / 255 };
24
- }
25
- const trimmed = color.trim();
26
- if (trimmed.startsWith('#'))
27
- return parseHex(trimmed);
28
- if (/^rgba?\(/i.test(trimmed))
29
- return parseRgb(trimmed);
30
- return undefined;
31
- }
32
- function parseHex(hex) {
33
- let body = hex.slice(1);
34
- if (body.length === 3 || body.length === 4) {
35
- body = body
36
- .split('')
37
- .map(c => c + c)
38
- .join('');
39
- }
40
- if (body.length !== 6 && body.length !== 8)
41
- return undefined;
42
- const int = Number.parseInt(body, 16);
43
- if (Number.isNaN(int))
44
- return undefined;
45
- if (body.length === 6) {
46
- return { r: (int >>> 16) & 255, g: (int >>> 8) & 255, b: int & 255, a: 1 };
47
- }
48
- const c = int >>> 0;
49
- return { r: (c >>> 24) & 255, g: (c >>> 16) & 255, b: (c >>> 8) & 255, a: (c & 255) / 255 };
50
- }
51
- function parseRgb(str) {
52
- const match = /^rgba?\(([^)]+)\)$/i.exec(str);
53
- if (match === null)
54
- return undefined;
55
- const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()));
56
- if (parts.length < 3 || parts.some(n => Number.isNaN(n)))
57
- return undefined;
58
- return { r: parts[0], g: parts[1], b: parts[2], a: parts.length >= 4 ? parts[3] : 1 };
59
- }
60
19
  // Resolve any input form to four concrete channel values (numbers or pre-built
61
20
  // AnimatedValues), so the constructor can wrap each in an AnimatedValue.
62
21
  function resolveInput(value) {
@@ -4,15 +4,12 @@
4
4
  import { bezier } from './bezier.js';
5
5
  let cachedEase;
6
6
  export const Easing = {
7
- // A stepping function: 1 for any positive n, else 0.
8
7
  step0(n) {
9
8
  return n > 0 ? 1 : 0;
10
9
  },
11
- // A stepping function: 1 once n >= 1, else 0.
12
10
  step1(n) {
13
11
  return n >= 1 ? 1 : 0;
14
12
  },
15
- // Linear, f(t) = t.
16
13
  linear(t) {
17
14
  return t;
18
15
  },
@@ -23,11 +20,9 @@ export const Easing = {
23
20
  }
24
21
  return cachedEase(t);
25
22
  },
26
- // Quadratic, f(t) = t * t.
27
23
  quad(t) {
28
24
  return t * t;
29
25
  },
30
- // Cubic, f(t) = t * t * t.
31
26
  cubic(t) {
32
27
  return t * t * t;
33
28
  },
@@ -35,15 +30,12 @@ export const Easing = {
35
30
  poly(n) {
36
31
  return t => Math.pow(t, n);
37
32
  },
38
- // Sinusoidal.
39
33
  sin(t) {
40
34
  return 1 - Math.cos((t * Math.PI) / 2);
41
35
  },
42
- // Circular.
43
36
  circle(t) {
44
37
  return 1 - Math.sqrt(1 - t * t);
45
38
  },
46
- // Exponential.
47
39
  exp(t) {
48
40
  return Math.pow(2, 10 * (t - 1));
49
41
  },
@@ -1,11 +1,8 @@
1
1
  import { type INativeNodeConfig, type IPlatformConfig } from './native/native-animated';
2
- import type { IInterpolationConfig } from './interpolation';
3
- import type { AnimatedInterpolation } from './interpolation-node';
2
+ import { type IInterpolationConfig } from './interpolation';
4
3
  export type IValueListener = (state: {
5
4
  value: number | string;
6
5
  }) => void;
7
- type IInterpolationFactory = (parent: AnimatedNode, config: IInterpolationConfig) => AnimatedInterpolation;
8
- export declare function registerInterpolationFactory(factory: IInterpolationFactory): void;
9
6
  export declare class AnimatedNode {
10
7
  private readonly listeners;
11
8
  private suspendCallbacks;
@@ -41,4 +38,15 @@ export declare class AnimatedWithChildren extends AnimatedNode {
41
38
  __callListeners(value: number | string): void;
42
39
  }
43
40
  export declare function flushValue(rootNode: AnimatedNode): void;
44
- export {};
41
+ export declare class AnimatedInterpolation extends AnimatedWithChildren {
42
+ private readonly parent;
43
+ private readonly config;
44
+ private interpolation;
45
+ constructor(parent: AnimatedNode, config: IInterpolationConfig);
46
+ private getInterpolation;
47
+ __getValue(): number | string;
48
+ __attach(): void;
49
+ __detach(): void;
50
+ __makeNative(platformConfig?: IPlatformConfig): void;
51
+ __getNativeConfig(): INativeNodeConfig;
52
+ }
@@ -10,11 +10,9 @@
10
10
  // B) bottom-up: each flagged leaf re-pulls its whole subtree via __getValue()
11
11
  // to rebuild composite props (e.g. transform from many parents).
12
12
  import { generateNativeNodeTag, nativeAnimated, } from './native/native-animated.js';
13
+ import { checkValidRanges, createInterpolation } from './interpolation.js';
14
+ import { dlog } from '../debug.js';
13
15
  let nextListenerId = 1;
14
- let createInterpolationNode;
15
- export function registerInterpolationFactory(factory) {
16
- createInterpolationNode = factory;
17
- }
18
16
  // Depth of the currently-active withSuspendedCallbacks blocks. While > 0, a
19
17
  // composite setter (AnimatedColor.setValue) is driving several channels in a row;
20
18
  // each channel's own flushValue is suppressed so the bound leaf commits ONCE (at
@@ -104,13 +102,10 @@ export class AnimatedNode {
104
102
  }
105
103
  // Map this node's value before it reaches a prop, e.g. 0..1 -> 0..10. Defined once
106
104
  // here (Information Expert: every node subclass wants the identical one-liner)
107
- // instead of duplicated per subclass; see registerInterpolationFactory above for
108
- // why the concrete constructor is injected rather than imported directly.
105
+ // instead of duplicated per subclass. AnimatedInterpolation lives at the bottom of
106
+ // THIS file rather than its own module on purpose — see the comment there.
109
107
  interpolate(config) {
110
- if (createInterpolationNode === undefined) {
111
- throw new Error('AnimatedNode.interpolate: interpolation factory not registered');
112
- }
113
- return createInterpolationNode(this, config);
108
+ return new AnimatedInterpolation(this, config);
114
109
  }
115
110
  __addChild(_child) { }
116
111
  __removeChild(_child) { }
@@ -199,8 +194,18 @@ export class AnimatedWithChildren extends AnimatedNode {
199
194
  super.__callListeners(value);
200
195
  // A native-driven node's children are updated natively; don't also walk them
201
196
  // here (their values aren't tracked in JS while native owns the animation).
202
- if (this.isNative)
197
+ //
198
+ // Diagnostic seam, permanent: this early return is what silences a JS listener sitting on a
199
+ // CHILD of a value that was made native (a sticky header's interpolation under a natively
200
+ // attached scroll value). A consumer whose only driver is that listener then goes dead with no
201
+ // other symptom - see the sticky-header bootstrap notes. Logged only when children are
202
+ // actually being skipped, so a childless native value stays quiet.
203
+ if (this.isNative) {
204
+ if (this.children.length > 0) {
205
+ dlog(`STICKY[cascade] native value silences ${this.children.length} child JS listener(s) value=${String(value)}`);
206
+ }
203
207
  return;
208
+ }
204
209
  for (const child of this.children) {
205
210
  child.__callListeners(numericValueOf(child));
206
211
  }
@@ -238,3 +243,72 @@ export function flushValue(rootNode) {
238
243
  collect(rootNode);
239
244
  leaves.forEach(update => update());
240
245
  }
246
+ // AnimatedInterpolation: a graph node that maps its parent's numeric value through an
247
+ // interpolation. Ported from RN's AnimatedInterpolation.js (numeric, string-with-units and
248
+ // color output ranges; platform colors stay out of scope, color.ts defers them).
249
+ //
250
+ // IT LIVES HERE, NOT IN ITS OWN MODULE, AND MUST STAY HERE. It used to sit in
251
+ // `interpolation-node.ts` and hand its constructor back to `AnimatedNode.interpolate()` through
252
+ // a `registerInterpolationFactory` call executed at module load — the usual trick for breaking
253
+ // an import cycle (this module owns the base classes that module extended). That trick is
254
+ // UNSOUND under Metro's production bundle: `inlineRequires` (production-only) defers a module's
255
+ // `require()` to the first use of its binding, nothing in the codebase ever names
256
+ // `AnimatedInterpolation` as a value, and a barrel's `export { X } from './x'` compiles to a
257
+ // lazy getter — so the module never evaluated, the factory was never registered, and the first
258
+ // `.interpolate()` on a device threw `interpolation factory not registered`, blanking the screen
259
+ // in RELEASE builds only (dev was fine, every headless test was fine, and the code was even
260
+ // present in the bundle — it just never ran). Adding a bare `import './interpolation-node'` to
261
+ // the barrel does NOT fix it either: Babel merges that import with the adjacent re-export of the
262
+ // same specifier, and the merged dependency stays lazy. Both were tried on device 2026-08-14.
263
+ //
264
+ // Keeping the class next to its base class removes the cycle, the factory, and the entire class
265
+ // of load-order bug with it: `interpolate()` constructs it directly and there is nothing left to
266
+ // register. Do not "tidy" it back into its own file.
267
+ export class AnimatedInterpolation extends AnimatedWithChildren {
268
+ parent;
269
+ config;
270
+ interpolation;
271
+ constructor(parent, config) {
272
+ super();
273
+ this.parent = parent;
274
+ this.config = config;
275
+ // Validate eagerly so a bad range fails at construction, not first frame.
276
+ checkValidRanges(config.inputRange, config.outputRange);
277
+ }
278
+ getInterpolation() {
279
+ if (this.interpolation === undefined) {
280
+ this.interpolation = createInterpolation(this.config);
281
+ }
282
+ return this.interpolation;
283
+ }
284
+ __getValue() {
285
+ const parentValue = this.parent.__getValue();
286
+ if (typeof parentValue !== 'number') {
287
+ throw new Error('Cannot interpolate an input which is not a number');
288
+ }
289
+ return this.getInterpolation()(parentValue);
290
+ }
291
+ __attach() {
292
+ this.parent.__addChild(this);
293
+ super.__attach();
294
+ }
295
+ __detach() {
296
+ this.parent.__removeChild(this);
297
+ super.__detach();
298
+ }
299
+ // Make the upstream value native first, so the parent->interpolation edge can be
300
+ // wired when this node is reached from a leaf rather than from the value.
301
+ __makeNative(platformConfig) {
302
+ this.parent.__makeNative(platformConfig);
303
+ super.__makeNative(platformConfig);
304
+ }
305
+ __getNativeConfig() {
306
+ return {
307
+ type: 'interpolation',
308
+ inputRange: this.config.inputRange,
309
+ outputRange: this.config.outputRange,
310
+ extrapolateLeft: this.config.extrapolateLeft ?? this.config.extrapolate ?? 'extend',
311
+ extrapolateRight: this.config.extrapolateRight ?? this.config.extrapolate ?? 'extend',
312
+ };
313
+ }
314
+ }
@@ -1,7 +1,6 @@
1
- export { AnimatedNode, AnimatedWithChildren, flushValue, type IValueListener } from './graph';
1
+ export { AnimatedNode, AnimatedWithChildren, AnimatedInterpolation, flushValue, type IValueListener, } from './graph';
2
2
  export { AnimatedValue } from './value';
3
3
  export { AnimatedValueXY, type IValueXY } from './value-xy';
4
- export { AnimatedInterpolation } from './interpolation-node';
5
4
  export { AnimatedAddition, AnimatedSubtraction, AnimatedMultiplication, AnimatedDivision, AnimatedModulo, AnimatedDiffClamp, add, subtract, multiply, divide, modulo, diffClamp, } from './operators';
6
5
  export { AnimatedEvent, event, forkEvent, unforkEvent, attachNativeEvent, attachNativeEventHandler, type IEventConfig, type IEventListener, type IAnimatedEventHandler, type INativeEventAttachment, } from './event';
7
6
  export { AnimatedColor, type IRgbaValue, type IColorInput } from './color';
@@ -15,6 +14,7 @@ export { AnimatedTracking } from './animations/tracking';
15
14
  export { timing, spring, decay, parallel, sequence, stagger, loop, delay, type ICompositeAnimation, type ITimingConfig, type ISpringConfig, type IDecayConfig, type IParallelConfig, type ILoopAnimationConfig, } from './animations/composition';
16
15
  export { nativeAnimated, isNativeAnimatedAvailable, type INativeNodeConfig, type INativeAnimationConfig, type INativeEventMapping, type IPlatformConfig, } from './native/native-animated';
17
16
  export { AnimatedProps } from './props';
17
+ export { createAnimatedLeafLifecycle, type IAnimatedLeafLifecycle } from './leaf-lifecycle';
18
18
  export { AnimatedStyle, AnimatedTransform } from './style';
19
19
  export { AnimatedMock } from './mock';
20
20
  export { reduceProps, isAnimatedNode, readPassthroughStyle, resolveHostNode } from './shared';
@@ -1,10 +1,9 @@
1
1
  // @symbiote-native/engine/animated: the framework-agnostic, JS-driven Animated engine.
2
2
  // The value graph, easing, interpolation and drivers are pure JS with no React
3
3
  // and no native dependency; every adapter re-exports them.
4
- export { AnimatedNode, AnimatedWithChildren, flushValue } from './graph.js';
4
+ export { AnimatedNode, AnimatedWithChildren, AnimatedInterpolation, flushValue, } from './graph.js';
5
5
  export { AnimatedValue } from './value.js';
6
6
  export { AnimatedValueXY } from './value-xy.js';
7
- export { AnimatedInterpolation } from './interpolation-node.js';
8
7
  export { AnimatedAddition, AnimatedSubtraction, AnimatedMultiplication, AnimatedDivision, AnimatedModulo, AnimatedDiffClamp, add, subtract, multiply, divide, modulo, diffClamp, } from './operators.js';
9
8
  export { AnimatedEvent, event, forkEvent, unforkEvent, attachNativeEvent, attachNativeEventHandler, } from './event.js';
10
9
  export { AnimatedColor } from './color.js';
@@ -22,6 +21,7 @@ export { nativeAnimated, isNativeAnimatedAvailable, } from './native/native-anim
22
21
  // no React/Vue). They live here with the rest of the graph; every adapter's
23
22
  // createAnimatedComponent + Animated namespace re-exports them.
24
23
  export { AnimatedProps } from './props.js';
24
+ export { createAnimatedLeafLifecycle } from './leaf-lifecycle.js';
25
25
  export { AnimatedStyle, AnimatedTransform } from './style.js';
26
26
  export { AnimatedMock } from './mock.js';
27
27
  // Framework-agnostic createAnimatedComponent helpers. Both adapters import them.
@@ -4,7 +4,7 @@
4
4
  // extrapolation. A numeric output range stays number -> number; a string output
5
5
  // range (values-with-units like '0deg' or colors like '#000') is interpolated
6
6
  // per numeric token and reassembled. See createStringInterpolation below.
7
- import { normalizeColor } from './color.js';
7
+ import { normalizeColor } from './rgba.js';
8
8
  import { Easing } from './easing.js';
9
9
  function interpolateSegment(input, inputMin, inputMax, outputMin, outputMax, easing, extrapolateLeft, extrapolateRight) {
10
10
  let result = input;
@@ -0,0 +1,7 @@
1
+ import type { ISymbioteNode } from '../node';
2
+ export type IScheduleNativeBind = (bind: () => void) => (() => void) | void;
3
+ export type IAnimatedLeafLifecycle = {
4
+ reconcile(props: Record<string, unknown>, node: ISymbioteNode | null, wantsNative: boolean, scheduleNativeBind?: IScheduleNativeBind): void;
5
+ teardown(): void;
6
+ };
7
+ export declare function createAnimatedLeafLifecycle(label: string): IAnimatedLeafLifecycle;
@@ -0,0 +1,185 @@
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,
4
+ // effects, change detection or reactivity, only the engine's own Animated primitives.
5
+ //
6
+ // WHY THIS LIVES IN THE ENGINE. It used to live four times, once per adapter (React's
7
+ // create-animated-component.tsx, Vue's create-animated-component.ts, Svelte's
8
+ // animated-props-runtime.ts, Angular's animated-leaf-binder.ts). Each re-derived the same policy,
9
+ // and they drifted: only Svelte ever grew the rebuild guard below, after a day of device
10
+ // debugging in 2026-08. React's `useMemo(..., [rest])` LOOKS like the same guard but is not - its
11
+ // dependency is a fresh rest-destructured object every render - and Vue's `pendingLeaf === null`
12
+ // answers "has render run yet", not "did anything change". A fix landing in one copy is exactly
13
+ // what <adapters_reach_full_feature_parity> forbids: parity has to be structural.
14
+ //
15
+ // What stays in the adapter: WHEN to call reconcile (an effect, onUpdated, ngOnChanges, a $effect)
16
+ // and HOW to find the host node (a ref, a ViewChild, a shim). Everything below is the same for all.
17
+ import { AnimatedProps } from './props.js';
18
+ import { attachNativeEventHandler } from './event.js';
19
+ import { dlog } from '../debug.js';
20
+ // Diagnostic-only, DEBUG-gated: a process-wide sequence number so every reconcile call across
21
+ // every Animated.* instance in a log dump is individually identifiable and orderable.
22
+ let globalReconcileSeq = 0;
23
+ // Per-key identity comparison, NOT deep equality. A style/handler/props object that is memoized
24
+ // upstream stays `===` across ticks that did not touch it, which is what distinguishes "same
25
+ // content" from a real change. Deep-comparing instead would walk live AnimatedNode graphs, which
26
+ // are circular.
27
+ //
28
+ // The caller MUST pass a stored SNAPSHOT as `a` - see `lastProps` below. Handing this the same
29
+ // live object on both sides compares it with itself and is unconditionally true.
30
+ function shallowEqualProps(a, b) {
31
+ const aKeys = Object.keys(a);
32
+ const bKeys = Object.keys(b);
33
+ if (aKeys.length !== bKeys.length)
34
+ return false;
35
+ for (const key of aKeys) {
36
+ if (a[key] !== b[key])
37
+ return false;
38
+ }
39
+ return true;
40
+ }
41
+ // Diagnostic-only. `style` here is a raw prop value, so it can be anything the caller wrote -
42
+ // an object, an array of them, a class name, undefined. Read the field, do not assume a shape.
43
+ function readTransform(style) {
44
+ if (typeof style !== 'object' || style === null)
45
+ return undefined;
46
+ return Reflect.get(style, 'transform');
47
+ }
48
+ // Diagnostic-only: a SHALLOW, non-serializing description of a style.transform array. Props here
49
+ // carry RAW values - an animated entry is a live AnimatedNode in a circular parent<->children
50
+ // graph - so JSON.stringify throws "Converting circular structure to JSON". Describe by
51
+ // constructor name instead of walking it.
52
+ function describeTransform(value) {
53
+ if (!Array.isArray(value))
54
+ return String(value);
55
+ const entries = value.map(entry => {
56
+ if (typeof entry !== 'object' || entry === null)
57
+ return String(entry);
58
+ const [key] = Object.keys(entry);
59
+ const inner = key === undefined ? undefined : Reflect.get(entry, key);
60
+ const innerDesc = typeof inner === 'number' || typeof inner === 'string'
61
+ ? String(inner)
62
+ : `<${inner !== null && typeof inner === 'object' ? inner.constructor.name : typeof inner}>`;
63
+ return `${key}:${innerDesc}`;
64
+ });
65
+ return `[${entries.join(',')}]`;
66
+ }
67
+ export function createAnimatedLeafLifecycle(label) {
68
+ let attached = null;
69
+ let eventDetachers = [];
70
+ // Diagnostic-only: the last node identity, plus a reentrancy flag to catch reconcile() being
71
+ // called AGAIN from inside its own call stack - the smoking-gun shape for a synchronous
72
+ // same-flush loop, as opposed to merely "called often".
73
+ let lastNode = null;
74
+ let inReconcile = false;
75
+ // Last-seen props/wantsNative for the skip below - a real content check, not diagnostics.
76
+ let lastProps = null;
77
+ let lastWantsNative = false;
78
+ // Canceller for a native bind the caller deferred and that has not run yet.
79
+ let cancelPendingBind;
80
+ function detachEvents() {
81
+ for (const detach of eventDetachers)
82
+ detach();
83
+ eventDetachers = [];
84
+ }
85
+ return {
86
+ reconcile(props, node, wantsNative, scheduleNativeBind) {
87
+ const seq = ++globalReconcileSeq;
88
+ const reentrant = inReconcile;
89
+ const nodeChanged = node !== lastNode;
90
+ const wantsNativeChanged = wantsNative !== lastWantsNative;
91
+ const propsChanged = lastProps === null || !shallowEqualProps(lastProps, props);
92
+ lastNode = node;
93
+ lastWantsNative = wantsNative;
94
+ // A SNAPSHOT, never the reference. A caller can legitimately hand back the SAME object on
95
+ // every update, mutating what its keys resolve to rather than allocating (Svelte's rest
96
+ // proxy does exactly that, device-confirmed 2026-08-13); storing it would make the next
97
+ // call run shallowEqualProps on that object against ITSELF - reading the same current values
98
+ // through both sides - which is unconditionally true. `propsChanged` could then never be
99
+ // true again and reconcile would be skipped forever, so every rebuilt AnimatedInterpolation
100
+ // never reaches the native graph: the view stays wired to the FIRST interpolation, built
101
+ // before measurement with range [-1,0]->[0,0]. One pixel of travel, which on device reads as
102
+ // "the sticky header ignores scrolling entirely".
103
+ lastProps = { ...props };
104
+ // Thunk, not a string: reconcile runs on EVERY update of every Animated.* wrapper in the
105
+ // app (Angular re-reads it on each change-detection pass), and describeTransform allocates.
106
+ // Built eagerly it would cost its full price with logging off.
107
+ dlog(() => `AnimatedProps[${label}] reconcile#${seq} reentrant=${reentrant} wantsNative=${wantsNative} ` +
108
+ `hasNode=${node !== null} nodeChanged=${nodeChanged} propsChanged=${propsChanged} ` +
109
+ `transform=${describeTransform(readTransform(props.style))}`);
110
+ if (reentrant) {
111
+ dlog(`AnimatedProps[${label}] reconcile#${seq} *** RE-ENTRANT CALL DETECTED - see reconcile above ***`);
112
+ }
113
+ // Nothing meaningfully changed since the last call AND we are already natively connected
114
+ // (steady state) - skip tearing down and rebuilding the native AnimatedProps graph.
115
+ //
116
+ // Deliberately scoped to `attached.__isNative()` ONLY: before the first native connection,
117
+ // reconcile must run unconditionally on every tick - that cadence is what wires a rebuilt
118
+ // interpolation node into the shared value's children (AnimatedInterpolation.__attach ->
119
+ // parent.__addChild, cascaded from AnimatedProps.__attach); skipping there can leave a
120
+ // freshly rebuilt interpolation never attached, so its listener never fires. Once native and
121
+ // staying native, that bootstrap concern is moot - and rebuilding there was the
122
+ // collision-disappearance bug: every scroll-driven passthrough tick reconnected a brand new
123
+ // native node (restoreDefaultValues + connect) even though nothing animated-relevant had
124
+ // changed, and a reconnect landing exactly when scrolling stopped left the view frozen at
125
+ // its post-reset default until the next tick.
126
+ if (attached !== null &&
127
+ attached.__isNative() &&
128
+ !nodeChanged &&
129
+ !wantsNativeChanged &&
130
+ !propsChanged) {
131
+ dlog(`AnimatedProps[${label}] reconcile#${seq} skipped (no-op: already native, props/node unchanged)`);
132
+ return;
133
+ }
134
+ inReconcile = true;
135
+ try {
136
+ // Attach the NEW leaf BEFORE detaching the OLD one: a shared Value self-detaches (dropping
137
+ // its native node) the instant its child count hits zero, so detaching first would kill a
138
+ // running native animation on any unrelated update. Mirrors RN's
139
+ // AnimatedComponent._attachProps.
140
+ const newLeaf = new AnimatedProps(props);
141
+ newLeaf.__attach();
142
+ if (attached !== null && attached !== newLeaf)
143
+ attached.__detach();
144
+ 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.
149
+ const bindNative = () => {
150
+ if (node !== null)
151
+ newLeaf.setNativeView(node);
152
+ if (wantsNative)
153
+ 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
+ };
163
+ cancelPendingBind?.();
164
+ cancelPendingBind = undefined;
165
+ if (scheduleNativeBind === undefined || node === null) {
166
+ bindNative();
167
+ return;
168
+ }
169
+ cancelPendingBind = scheduleNativeBind(bindNative) ?? undefined;
170
+ }
171
+ finally {
172
+ inReconcile = false;
173
+ }
174
+ },
175
+ teardown() {
176
+ cancelPendingBind?.();
177
+ cancelPendingBind = undefined;
178
+ detachEvents();
179
+ if (attached !== null) {
180
+ attached.__detach();
181
+ attached = null;
182
+ }
183
+ },
184
+ };
185
+ }
@@ -101,6 +101,7 @@ export const nativeAnimated = {
101
101
  module()?.stopAnimation(animationId);
102
102
  },
103
103
  setAnimatedNodeValue(nodeTag, value) {
104
+ dlog(`native: setAnimatedNodeValue node=${nodeTag} value=${value}`);
104
105
  module()?.setAnimatedNodeValue(nodeTag, value);
105
106
  },
106
107
  setAnimatedNodeOffset(nodeTag, offset) {
@@ -0,0 +1,8 @@
1
+ export interface IRgbaValue {
2
+ r: number;
3
+ g: number;
4
+ b: number;
5
+ a: number;
6
+ }
7
+ export declare const DEFAULT_COLOR: IRgbaValue;
8
+ export declare function normalizeColor(color: string | number): IRgbaValue | undefined;
@@ -0,0 +1,55 @@
1
+ // Pure RGBA parsing — no graph node, no AnimatedValue, no imports from this package at all.
2
+ //
3
+ // It is split out of color.ts DELIBERATELY, and must stay dependency-free. `interpolation.ts`
4
+ // needs `normalizeColor` for its color output ranges, and `graph.ts` needs `interpolation.ts`
5
+ // (AnimatedInterpolation lives there). color.ts also defines `AnimatedColor extends
6
+ // AnimatedWithChildren`, so leaving the parser there closes a cycle
7
+ // graph -> interpolation -> color -> graph
8
+ // which surfaces at load time as `TypeError: Class extends value undefined is not a
9
+ // constructor` — AnimatedWithChildren is still uninitialized when color.ts evaluates. Keeping
10
+ // the pure half in its own leaf module breaks it structurally rather than by import ordering.
11
+ export const DEFAULT_COLOR = { r: 0, g: 0, b: 0, a: 1 };
12
+ // Decompose a #hex (3/4/6/8), rgb()/rgba(), or 0xRRGGBBAA number into channels.
13
+ // undefined when unparseable (a named/platform color), so the caller falls back to
14
+ // the default rather than throwing inside a render. Exported so interpolation's
15
+ // color path parses through the same RGBA decoder (DRY) rather than duplicating it.
16
+ export function normalizeColor(color) {
17
+ if (typeof color === 'number') {
18
+ const c = color >>> 0;
19
+ return { r: (c >>> 24) & 255, g: (c >>> 16) & 255, b: (c >>> 8) & 255, a: (c & 255) / 255 };
20
+ }
21
+ const trimmed = color.trim();
22
+ if (trimmed.startsWith('#'))
23
+ return parseHex(trimmed);
24
+ if (/^rgba?\(/i.test(trimmed))
25
+ return parseRgb(trimmed);
26
+ return undefined;
27
+ }
28
+ function parseHex(hex) {
29
+ let body = hex.slice(1);
30
+ if (body.length === 3 || body.length === 4) {
31
+ body = body
32
+ .split('')
33
+ .map(c => c + c)
34
+ .join('');
35
+ }
36
+ if (body.length !== 6 && body.length !== 8)
37
+ return undefined;
38
+ const int = Number.parseInt(body, 16);
39
+ if (Number.isNaN(int))
40
+ return undefined;
41
+ if (body.length === 6) {
42
+ return { r: (int >>> 16) & 255, g: (int >>> 8) & 255, b: int & 255, a: 1 };
43
+ }
44
+ const c = int >>> 0;
45
+ return { r: (c >>> 24) & 255, g: (c >>> 16) & 255, b: (c >>> 8) & 255, a: (c & 255) / 255 };
46
+ }
47
+ function parseRgb(str) {
48
+ const match = /^rgba?\(([^)]+)\)$/i.exec(str);
49
+ if (match === null)
50
+ return undefined;
51
+ const parts = match[1].split(',').map(part => Number.parseFloat(part.trim()));
52
+ if (parts.length < 3 || parts.some(n => Number.isNaN(n)))
53
+ return undefined;
54
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length >= 4 ? parts[3] : 1 };
55
+ }