@pixodesk/svg-animator-rn 1.0.21 → 1.0.22

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.
@@ -6,17 +6,21 @@
6
6
  import {
7
7
  generateNewIds,
8
8
  getAnimatorConfig,
9
+ getDefs,
9
10
  materialiseAllInTree,
10
11
  validateNodeEffects,
11
12
  PxAnimatorEngine,
12
13
  type FillMode,
14
+ type OutAction,
13
15
  type PlaybackDirection,
14
16
  type PxAnimatedSvgDocument,
15
17
  type PxNode,
16
18
  } from '@pixodesk/svg-animator-core';
17
- import React, { useEffect, useImperativeHandle, useMemo, useRef, type ComponentType, type ReactElement, type ReactNode } from 'react';
19
+ import React, { createElement, useEffect, useImperativeHandle, useMemo, useRef, useState, type ComponentType, type ReactElement, type ReactNode } from 'react';
20
+ import { Dimensions, Platform, Pressable, View } from 'react-native';
18
21
  import Animated, {
19
22
  cancelAnimation,
23
+ useAnimatedReaction,
20
24
  Easing,
21
25
  runOnJS,
22
26
  useAnimatedProps,
@@ -27,7 +31,9 @@ import Animated, {
27
31
  type SharedValue,
28
32
  } from 'react-native-reanimated';
29
33
  import { compileTracks, sampleProps, type PxCompiledTracks, type PxElementTracks } from './PxRnTracks';
30
- import { renderRnNode } from './PxRnRender';
34
+ import { renderRnNode, type RenderRnNodeOptions } from './PxRnRender';
35
+ import { PxRnErrorBoundary } from './PxRnErrorBoundary';
36
+ import { openClosedTextPathTargets } from './PxRnSafety';
31
37
 
32
38
 
33
39
  // -- Public types -----------------------------------------------------------
@@ -83,6 +89,15 @@ export interface PixodeskSvgAnimatorProps {
83
89
  /** Playback direction. */
84
90
  direction?: PlaybackDirection;
85
91
 
92
+ /** Snap back to the start state after a natural finish. */
93
+ resetOnFinish?: boolean;
94
+
95
+ /**
96
+ * What a second tap does when `startOn: 'click'` is active.
97
+ * Defaults to the document's `trigger.outAction`, else `'pause'`.
98
+ */
99
+ outAction?: OutAction;
100
+
86
101
  // -- Declarative control --------------------------------------------------
87
102
 
88
103
  /** When true, honours the document trigger (`startOn: 'load'` plays on mount). */
@@ -114,11 +129,41 @@ export interface PixodeskSvgAnimatorProps {
114
129
  onPause?: () => void;
115
130
  onCancel?: () => void;
116
131
  onFinish?: () => void;
132
+
133
+
134
+ // -- Failure handling -----------------------------------------------------
135
+
136
+ /**
137
+ * Called when a document cannot be compiled or rendered. The component
138
+ * renders {@link fallback} instead of throwing, so a single broken
139
+ * animation never takes down the screen around it.
140
+ *
141
+ * Only JavaScript failures reach this — a crash inside react-native-svg's
142
+ * native renderer bypasses JavaScript entirely.
143
+ */
144
+ onError?: (error: Error, componentStack?: string) => void;
145
+
146
+ /** Rendered in place of the animation after a failure. Default: nothing. */
147
+ fallback?: (error: Error) => ReactElement | null;
117
148
  }
118
149
 
119
150
 
120
151
  // -- Animated element wrapper ------------------------------------------------
121
152
 
153
+ /**
154
+ * Whether react-native-svg is backed by NATIVE views here, rather than by the
155
+ * DOM through react-native-web.
156
+ *
157
+ * The two want different things from an animated `transform`: a native view
158
+ * declares a `matrix` prop taking six numbers, while the DOM wants a
159
+ * `transform` attribute holding an SVG string. Getting it wrong is silent —
160
+ * the value is dropped and the element simply never moves — so this single
161
+ * constant decides it once and feeds both the track compiler (value form) and
162
+ * the sampler (prop name). Everything else in the package defaults to the
163
+ * DOM-compatible form.
164
+ */
165
+ const NATIVE_SVG_VIEWS = Platform.OS !== 'web';
166
+
122
167
  const animatedComponentCache = new Map<ComponentType<any>, ComponentType<any>>();
123
168
 
124
169
  function getAnimatedComponent(Component: ComponentType<any>): ComponentType<any> {
@@ -147,7 +192,10 @@ function AnimatedPxElement({
147
192
  // Runs on the UI thread every frame; `sampleProps` is a trivial indexed
148
193
  // lookup into the precompiled tracks — no interpolation logic on the hot path.
149
194
  const animatedProps = useAnimatedProps(() => {
150
- return sampleProps(tracks, progress.value, stepMs, sampleCount);
195
+ // On iOS/Android these values bypass react-native-svg's JS prop layer
196
+ // and land on the native view, which declares `matrix`, not
197
+ // `transform`. The web build keeps the DOM-facing `transform` name.
198
+ return sampleProps(tracks, progress.value, stepMs, sampleCount, NATIVE_SVG_VIEWS);
151
199
  }, [tracks, stepMs, sampleCount]);
152
200
 
153
201
  return (
@@ -158,6 +206,153 @@ function AnimatedPxElement({
158
206
  }
159
207
 
160
208
 
209
+ /**
210
+ * react-native-svg elements whose `render()` returns `null`. They carry data for
211
+ * their PARENT (a `<Stop>` is read by the gradient that owns it) rather than
212
+ * producing a native view, so reanimated has nothing to attach to — wrapping one
213
+ * in `Animated.createAnimatedComponent` throws
214
+ * "Cannot find host instance for this component".
215
+ */
216
+ const NON_HOST_TAGS = new Set(['stop', 'feMergeNode']);
217
+
218
+ /**
219
+ * Definition elements — they describe paint/geometry for something else rather
220
+ * than drawing themselves. Their animated attributes (a gradient's `y1`, a
221
+ * stop's `stop-color`) do not reliably flow through reanimated's animated-props
222
+ * path, so an animated def is rendered by re-sampling from JS instead. There
223
+ * are only ever a handful per document and they change slowly, so the cost is
224
+ * negligible — visual elements still animate entirely on the UI thread.
225
+ */
226
+ const SAMPLED_DEF_TAGS = new Set(['linearGradient', 'radialGradient', ...NON_HOST_TAGS]);
227
+
228
+ /** True when this subtree must be driven from JS rather than the UI thread:
229
+ * either the node itself is an animated def, or it owns an animated non-host
230
+ * child (an animated `<Stop>` only re-renders via its parent gradient). */
231
+ function needsJsSampling(node: PxNode, trackById: Map<string, PxElementTracks>): boolean {
232
+ const id = (node as any).id;
233
+ if (SAMPLED_DEF_TAGS.has(String(node.type)) && id && trackById.has(id)) return true;
234
+ return (node.children ?? []).some(c => needsJsSampling(c, trackById));
235
+ }
236
+
237
+ /**
238
+ * Renders a subtree whose animation cannot run on the UI thread (see
239
+ * {@link NON_HOST_TAGS}) by re-rendering it from JS with sampled values.
240
+ *
241
+ * Changing a `<Stop>`'s props does not re-render its parent gradient, so the
242
+ * whole subtree is rebuilt — which is why this wraps the gradient rather than
243
+ * the stop. The reaction itself runs on the UI thread and only crosses to JS
244
+ * when the QUANTISED sample index changes, capping these few elements at
245
+ * ~30fps instead of a JS call every frame.
246
+ */
247
+ function SampledSubtree({
248
+ node, trackById, progress, stepMs, sampleCount, renderOpts,
249
+ }: {
250
+ node: PxNode;
251
+ trackById: Map<string, PxElementTracks>;
252
+ progress: SharedValue<number>;
253
+ stepMs: number;
254
+ sampleCount: number;
255
+ renderOpts: RenderRnNodeOptions;
256
+ }) {
257
+ const [idx, setIdx] = useState(0);
258
+
259
+ useAnimatedReaction(
260
+ () => Math.floor(Math.round(progress.value / stepMs) / 2) * 2, // half-rate
261
+ (next, prev) => {
262
+ if (next !== prev) runOnJS(setIdx)(next);
263
+ },
264
+ [stepMs]
265
+ );
266
+
267
+ const tMs = Math.min(Math.max(idx, 0), sampleCount - 1) * stepMs;
268
+
269
+ return renderRnNode(node, {
270
+ ...renderOpts,
271
+ // Sampled values are baked in as PLAIN props — nothing reanimated-driven.
272
+ decorate: (n, Component, staticProps, children, key) => {
273
+ const id = (n as any).id;
274
+ const tracks = id ? trackById.get(id) : undefined;
275
+ if (!tracks) return undefined;
276
+ // Wire prop names, NOT the native ones: these go back through
277
+ // react-native-svg's JS prop layer, which does the renaming itself.
278
+ const sampled = sampleProps(tracks, tMs, stepMs, sampleCount);
279
+ return createElement(Component, { ...staticProps, ...sampled, key }, children);
280
+ },
281
+ });
282
+ }
283
+
284
+
285
+
286
+ /** Overrides that shadow the document's own `animator` config. */
287
+ interface ConfigOverrides {
288
+ duration?: number;
289
+ delay?: number;
290
+ iterations?: number | 'infinite';
291
+ fill?: FillMode;
292
+ direction?: PlaybackDirection;
293
+ resetOnFinish?: boolean;
294
+ }
295
+
296
+ interface Compiled {
297
+ /** Materialised document, or null when compilation failed. */
298
+ doc: PxAnimatedSvgDocument | null;
299
+ tracks: PxCompiledTracks;
300
+ error: Error | null;
301
+ }
302
+
303
+ /** Playable-but-empty tracks, so a failed compile still satisfies every hook
304
+ * below it — React forbids skipping hooks on an error path. */
305
+ const EMPTY_TRACKS: PxCompiledTracks = {
306
+ duration: 1, iterations: 1, direction: 'normal', delay: 0,
307
+ fill: 'forwards', resetOnFinish: false, stepMs: 1, sampleCount: 2, elements: [],
308
+ };
309
+
310
+ /**
311
+ * Materialises + compiles a document. Extracted from the component so the
312
+ * whole thing sits behind one try/catch, and so it can be tested directly.
313
+ */
314
+ function compileDocument(doc: PxAnimatedSvgDocument, overrides: ConfigOverrides): Compiled {
315
+ const { duration, delay, iterations, fill, direction, resetOnFinish } = overrides;
316
+ const warnings = validateNodeEffects(doc as PxNode);
317
+ for (const w of warnings) console.warn('[PixodeskSvgAnimator] effects shape warning:', w);
318
+
319
+ // `webapi` = the FULLY-FLATTENED materialisation: effects + loops +
320
+ // sampled motion paths + animated `<use>` inlined into real `<g>`
321
+ // clones + orphaned defs pruned. That last part is why RN must not use
322
+ // the `frames` flavour: frames keeps `<use href="#animatedTarget">`
323
+ // live references, which only work because the DOM propagates
324
+ // attribute writes through `<use>` shadow trees. react-native-svg has
325
+ // no such live propagation, so an animated `<use>` would render frozen.
326
+ let prepared = materialiseAllInTree(doc, PxAnimatorEngine.webapi);
327
+
328
+ // Sidestep a react-native-svg NATIVE crash (see PxRnSafety). Guarded on
329
+ // the platform because the DOM renders this case correctly and the web
330
+ // document must stay exactly as the core pipeline produced it.
331
+ if (NATIVE_SVG_VIEWS) {
332
+ prepared = openClosedTextPathTargets(prepared as PxNode) as PxAnimatedSvgDocument;
333
+ }
334
+
335
+ // Apply prop overrides onto the animator config (mirrors the react wrapper).
336
+ const animator = getAnimatorConfig(prepared) || {};
337
+ prepared = {
338
+ ...prepared,
339
+ animator: {
340
+ ...animator,
341
+ duration: duration !== undefined ? duration : animator.duration,
342
+ delay: delay !== undefined ? delay : animator.delay,
343
+ iterations: iterations !== undefined ? iterations : animator.iterations,
344
+ fill: fill !== undefined ? fill : animator.fill,
345
+ direction: direction !== undefined ? direction : animator.direction,
346
+ resetOnFinish: resetOnFinish !== undefined ? resetOnFinish : animator.resetOnFinish,
347
+ },
348
+ };
349
+
350
+ prepared = generateNewIds(prepared);
351
+ const tracks = compileTracks(prepared, { native: NATIVE_SVG_VIEWS });
352
+ return { doc: prepared, tracks, error: null };
353
+ }
354
+
355
+
161
356
  // -- Main component ----------------------------------------------------------
162
357
 
163
358
  /**
@@ -171,44 +366,26 @@ function AnimatedPxElement({
171
366
  * indexing the precompiled tracks. No JS-thread frame loop.
172
367
  */
173
368
  export function PixodeskSvgAnimator({
174
- doc, duration, delay, iterations, fill, direction,
369
+ doc, duration, delay, iterations, fill, direction, resetOnFinish, outAction: outActionProp,
175
370
  autoplay, play, pause, apiRef, time, timeMs,
176
- onPlay, onStop, onPause, onCancel, onFinish,
371
+ onPlay, onStop, onPause, onCancel, onFinish, onError, fallback,
177
372
  }: PixodeskSvgAnimatorProps): ReactElement | null {
178
373
 
179
374
  // -- Compile the document (once per doc/override change) ------------------
180
375
 
181
- const compiled = useMemo(() => {
182
- const warnings = validateNodeEffects(doc as PxNode);
183
- for (const w of warnings) console.warn('[PixodeskSvgAnimator] effects shape warning:', w);
184
-
185
- // `webapi` = the FULLY-FLATTENED materialisation: effects + loops +
186
- // sampled motion paths + animated `<use>` inlined into real `<g>`
187
- // clones + orphaned defs pruned. That last part is why RN must not use
188
- // the `frames` flavour: frames keeps `<use href="#animatedTarget">`
189
- // live references, which only work because the DOM propagates
190
- // attribute writes through `<use>` shadow trees. react-native-svg has
191
- // no such live propagation, so an animated `<use>` would render frozen.
192
- let prepared = materialiseAllInTree(doc, PxAnimatorEngine.webapi);
193
-
194
- // Apply prop overrides onto the animator config (mirrors the react wrapper).
195
- const animator = getAnimatorConfig(prepared) || {};
196
- prepared = {
197
- ...prepared,
198
- animator: {
199
- ...animator,
200
- duration: duration !== undefined ? duration : animator.duration,
201
- delay: delay !== undefined ? delay : animator.delay,
202
- iterations: iterations !== undefined ? iterations : animator.iterations,
203
- fill: fill !== undefined ? fill : animator.fill,
204
- direction: direction !== undefined ? direction : animator.direction,
205
- },
206
- };
207
-
208
- prepared = generateNewIds(prepared);
209
- const tracks = compileTracks(prepared);
210
- return { doc: prepared, tracks };
211
- }, [doc, duration, delay, iterations, fill, direction]);
376
+ const compiled = useMemo((): Compiled => {
377
+ try {
378
+ return compileDocument(
379
+ doc,
380
+ { duration, delay, iterations, fill, direction, resetOnFinish }
381
+ );
382
+ } catch (e) {
383
+ // A malformed document must not take the host screen down with it.
384
+ const error = e instanceof Error ? e : new Error(String(e));
385
+ console.warn('[PixodeskSvgAnimator] could not compile the document:', error.message);
386
+ return { doc: null, tracks: EMPTY_TRACKS, error };
387
+ }
388
+ }, [doc, duration, delay, iterations, fill, direction, resetOnFinish]);
212
389
 
213
390
  const tracks: PxCompiledTracks = compiled.tracks;
214
391
  const totalDuration = tracks.duration * (tracks.iterations === Infinity ? 1 : tracks.iterations);
@@ -221,9 +398,20 @@ export function PixodeskSvgAnimator({
221
398
  const playingRef = useRef(false);
222
399
  const rateRef = useRef(1);
223
400
 
401
+ /** True when the current rate plays the timeline backwards. */
402
+ const reversePlayback = () => rateRef.current < 0;
403
+
404
+ /** Where the playhead rests once playback ends. `resetOnFinish` snaps back
405
+ * to the start; otherwise `fill` decides whether the final frame is held. */
406
+ const restingPosition = () => {
407
+ if (tracks.resetOnFinish) return 0;
408
+ if (tracks.fill === 'none' || tracks.fill === 'backwards') return 0;
409
+ return reversePlayback() ? 0 : tracks.duration;
410
+ };
411
+
224
412
  const notifyFinish = () => {
225
413
  playingRef.current = false;
226
- if (tracks.fill === 'none' || tracks.fill === 'backwards') progress.value = 0;
414
+ progress.value = restingPosition();
227
415
  onFinish?.();
228
416
  onStop?.();
229
417
  };
@@ -231,16 +419,21 @@ export function PixodeskSvgAnimator({
231
419
  const startFrom = (fromMs: number) => {
232
420
  const dur = tracks.duration;
233
421
  const rate = rateRef.current || 1;
234
- const reversedStart = tracks.direction === 'reverse' || tracks.direction === 'alternate-reverse';
422
+ const backwards = rate < 0;
423
+ const speed = Math.abs(rate);
424
+ // `direction` decides which end a leg runs toward; a negative playback
425
+ // rate flips it again (the two compose, as in the Web Animations API).
426
+ const directionReversed = tracks.direction === 'reverse' || tracks.direction === 'alternate-reverse';
427
+ const reversedStart = backwards ? !directionReversed : directionReversed;
235
428
  const alternates = tracks.direction === 'alternate' || tracks.direction === 'alternate-reverse';
236
429
  const from = Math.max(0, Math.min(fromMs, dur));
237
430
 
238
431
  const legTarget = reversedStart ? 0 : dur;
239
- const legRemaining = Math.abs(legTarget - from) / rate;
432
+ const legRemaining = Math.abs(legTarget - from) / speed;
240
433
  const repeats = tracks.iterations === Infinity ? -1 : tracks.iterations;
241
434
 
242
435
  cancelAnimation(progress);
243
- progress.value = reversedStart ? (from === 0 ? dur : from) : from;
436
+ progress.value = reversedStart ? (from <= 0 ? dur : from) : (from >= dur ? 0 : from);
244
437
 
245
438
  const animation = repeats === 1
246
439
  ? withTiming(legTarget, { duration: legRemaining, easing: Easing.linear }, (finished) => {
@@ -257,7 +450,7 @@ export function PixodeskSvgAnimator({
257
450
  );
258
451
 
259
452
  progress.value = tracks.delay > 0 && from === 0
260
- ? withDelay(tracks.delay / rate, animation)
453
+ ? withDelay(tracks.delay / speed, animation)
261
454
  : animation;
262
455
 
263
456
  playingRef.current = true;
@@ -266,8 +459,10 @@ export function PixodeskSvgAnimator({
266
459
  const api: RnAnimatorApi = {
267
460
  isPlaying: () => playingRef.current,
268
461
  play: () => {
269
- const from = playingRef.current ? progress.value : progress.value >= tracks.duration ? 0 : progress.value;
270
- startFrom(from);
462
+ // `startFrom` rewinds to the opposite end when the playhead is
463
+ // already resting at a boundary (mirrors WAAPI, where play() on a
464
+ // finished animation auto-rewinds).
465
+ startFrom(progress.value);
271
466
  onPlay?.();
272
467
  },
273
468
  pause: () => {
@@ -285,14 +480,14 @@ export function PixodeskSvgAnimator({
285
480
  },
286
481
  finish: () => {
287
482
  cancelAnimation(progress);
288
- progress.value = tracks.fill === 'none' || tracks.fill === 'backwards' ? 0 : tracks.duration;
289
483
  playingRef.current = false;
484
+ progress.value = restingPosition();
290
485
  onFinish?.();
291
486
  onStop?.();
292
487
  },
293
488
  setPlaybackRate: (rate: number) => {
294
- if (!isFinite(rate) || rate <= 0) {
295
- console.warn('setPlaybackRate: only finite positive rates are supported in the RN player (reverse is on the feature-gap list)');
489
+ if (!isFinite(rate) || rate === 0) {
490
+ console.warn('setPlaybackRate: rate must be finite and non-zero');
296
491
  return;
297
492
  }
298
493
  rateRef.current = rate;
@@ -300,10 +495,17 @@ export function PixodeskSvgAnimator({
300
495
  },
301
496
  getCurrentTime: () => progress.value,
302
497
  setCurrentTime: (t: number) => {
498
+ const wasPlaying = playingRef.current;
303
499
  cancelAnimation(progress);
304
500
  playingRef.current = false;
305
501
  const clamped = Math.max(0, Math.min(t, totalDuration));
306
- progress.value = tracks.duration > 0 ? clamped % tracks.duration || (clamped === 0 ? 0 : tracks.duration) : 0;
502
+ const withinIteration = tracks.duration > 0
503
+ ? (clamped % tracks.duration) || (clamped === 0 ? 0 : tracks.duration)
504
+ : 0;
505
+ progress.value = withinIteration;
506
+ // Seeking mid-playback continues from the new position rather than
507
+ // silently pausing.
508
+ if (wasPlaying) startFrom(withinIteration);
307
509
  },
308
510
  };
309
511
 
@@ -311,7 +513,9 @@ export function PixodeskSvgAnimator({
311
513
 
312
514
  // -- Declarative control --------------------------------------------------
313
515
 
314
- const startOn = getAnimatorConfig(compiled.doc)?.trigger?.startOn ?? 'load';
516
+ const trigger = compiled.doc ? getAnimatorConfig(compiled.doc)?.trigger : undefined;
517
+ const startOn = trigger?.startOn ?? 'load';
518
+ const outAction = outActionProp ?? trigger?.outAction ?? 'pause';
315
519
 
316
520
  useEffect(() => {
317
521
  if (time !== undefined || timeMs !== undefined) {
@@ -326,12 +530,50 @@ export function PixodeskSvgAnimator({
326
530
  else api.play();
327
531
  return;
328
532
  }
533
+ // 'click' and 'scrollIntoView' start from their own handlers below.
329
534
  if (autoplay && startOn === 'load') {
330
535
  api.play();
331
536
  }
332
537
  // eslint-disable-next-line react-hooks/exhaustive-deps
333
538
  }, [compiled, autoplay, play, pause, time, timeMs]);
334
539
 
540
+ // `startOn: 'scrollIntoView'` — react-native has no IntersectionObserver, so
541
+ // visibility is sampled by measuring the view against the window box. The
542
+ // poll is cheap (a native measure every 200ms) and only runs while this
543
+ // trigger is active; `outAction` decides what leaving the viewport does.
544
+ const scrollRef = useRef<View | null>(null);
545
+ const inViewRef = useRef(false);
546
+ useEffect(() => {
547
+ if (!autoplay || startOn !== 'scrollIntoView') return;
548
+ const threshold = trigger?.scrollIntoViewThreshold ?? 0;
549
+ inViewRef.current = false;
550
+
551
+ const check = () => {
552
+ const node = scrollRef.current;
553
+ if (!node) return;
554
+ node.measureInWindow((_x, y, _w, h) => {
555
+ if (!h) return;
556
+ const screen = Dimensions.get('window').height;
557
+ const visible = Math.max(0, Math.min(y + h, screen) - Math.max(y, 0));
558
+ const ratio = visible / h;
559
+ const isIn = ratio > 0 && ratio >= threshold;
560
+ if (isIn === inViewRef.current) return;
561
+ inViewRef.current = isIn;
562
+ if (isIn) {
563
+ if (rateRef.current < 0) api.setPlaybackRate(Math.abs(rateRef.current));
564
+ api.play();
565
+ } else if (outAction === 'reset') api.cancel();
566
+ else if (outAction === 'reverse') { api.setPlaybackRate(-Math.abs(rateRef.current || 1)); api.play(); }
567
+ else if (outAction !== 'continue') api.pause();
568
+ });
569
+ };
570
+
571
+ check();
572
+ const id = setInterval(check, 200);
573
+ return () => clearInterval(id);
574
+ // eslint-disable-next-line react-hooks/exhaustive-deps
575
+ }, [compiled, autoplay, startOn, outAction]);
576
+
335
577
  // Stop cleanly on unmount / doc swap.
336
578
  useEffect(() => {
337
579
  return () => {
@@ -350,29 +592,63 @@ export function PixodeskSvgAnimator({
350
592
  }, [tracks]);
351
593
 
352
594
  const warningsRef = useRef<Array<string>>([]);
595
+ const renderErrorRef = useRef<Error | null>(null);
353
596
  const root = useMemo(() => {
354
597
  warningsRef.current = [];
355
- return renderRnNode(compiled.doc as PxNode, {
598
+ renderErrorRef.current = null;
599
+ if (!compiled.doc) return null;
600
+
601
+ const renderOpts: RenderRnNodeOptions = {
356
602
  warnings: warningsRef.current,
357
- decorate: (node, Component, staticProps, children) => {
358
- const id = (node as any).id;
359
- const elTracks = id ? trackById.get(id) : undefined;
360
- if (!elTracks) return undefined;
361
- return (
362
- <AnimatedPxElement
363
- key={staticProps.key}
364
- Component={Component}
365
- staticProps={staticProps}
366
- tracks={elTracks}
367
- progress={progress}
368
- stepMs={tracks.stepMs}
369
- sampleCount={tracks.sampleCount}
370
- >
371
- {children}
372
- </AnimatedPxElement>
373
- );
374
- },
375
- });
603
+ defs: getDefs(compiled.doc),
604
+ };
605
+ try {
606
+ return renderRnNode(compiled.doc as PxNode, {
607
+ ...renderOpts,
608
+ decorate: (node, Component, staticProps, children, key) => {
609
+ // An animated definition subtree (gradient / its stops) cannot be
610
+ // driven on the UI thread — hand the WHOLE subtree to the
611
+ // JS-sampled renderer and stop descending here.
612
+ if (needsJsSampling(node, trackById)) {
613
+ return (
614
+ <SampledSubtree
615
+ key={key}
616
+ node={node}
617
+ trackById={trackById}
618
+ progress={progress}
619
+ stepMs={tracks.stepMs}
620
+ sampleCount={tracks.sampleCount}
621
+ renderOpts={renderOpts}
622
+ />
623
+ );
624
+ }
625
+
626
+ const id = (node as any).id;
627
+ const elTracks = id ? trackById.get(id) : undefined;
628
+ if (!elTracks) return undefined;
629
+ return (
630
+ <AnimatedPxElement
631
+ key={key}
632
+ Component={Component}
633
+ staticProps={staticProps}
634
+ tracks={elTracks}
635
+ progress={progress}
636
+ stepMs={tracks.stepMs}
637
+ sampleCount={tracks.sampleCount}
638
+ >
639
+ {children}
640
+ </AnimatedPxElement>
641
+ );
642
+ },
643
+ });
644
+ } catch (e) {
645
+ // Building the element tree threw — report it and render nothing
646
+ // rather than propagating and unmounting the host screen.
647
+ const error = e instanceof Error ? e : new Error(String(e));
648
+ renderErrorRef.current = error;
649
+ console.warn('[PixodeskSvgAnimator] could not render the document:', error.message);
650
+ return null;
651
+ }
376
652
  // eslint-disable-next-line react-hooks/exhaustive-deps
377
653
  }, [compiled, trackById]);
378
654
 
@@ -380,7 +656,51 @@ export function PixodeskSvgAnimator({
380
656
  for (const w of warningsRef.current) console.warn('[PixodeskSvgAnimator]', w);
381
657
  }, [root]);
382
658
 
383
- return root;
659
+ // Surface compile/render failures to the host exactly once per occurrence.
660
+ const failure = compiled.error ?? renderErrorRef.current;
661
+ useEffect(() => {
662
+ if (failure) onError?.(failure);
663
+ // eslint-disable-next-line react-hooks/exhaustive-deps
664
+ }, [failure]);
665
+
666
+ if (failure) return fallback ? fallback(failure) : null;
667
+
668
+ // `startOn: 'click'` — the touch analogue of the web player's click trigger:
669
+ // tap to start, tap again to apply `outAction`. Hover (`mouseOver`) has no
670
+ // touch equivalent and `scrollIntoView` needs the surrounding scroll view,
671
+ // so both are left to the host app.
672
+ let content: ReactElement | null = root;
673
+
674
+ if (autoplay && startOn === 'scrollIntoView' && root) {
675
+ // `collapsable={false}` keeps the view in the native tree so it can be measured.
676
+ content = <View ref={scrollRef} collapsable={false}>{root}</View>;
677
+ } else if (autoplay && startOn === 'click' && root) {
678
+ content = (
679
+ <Pressable
680
+ onPress={() => {
681
+ if (playingRef.current) {
682
+ if (outAction === 'reset') api.cancel();
683
+ else if (outAction === 'reverse') { api.setPlaybackRate(-Math.abs(rateRef.current || 1)); api.play(); }
684
+ else if (outAction !== 'continue') api.pause();
685
+ } else {
686
+ if (rateRef.current < 0) api.setPlaybackRate(Math.abs(rateRef.current));
687
+ api.play();
688
+ }
689
+ }}
690
+ >
691
+ {root}
692
+ </Pressable>
693
+ );
694
+ }
695
+
696
+ // Catches what the try/catch above cannot: throws during React's own render
697
+ // and commit of the tree — react-native-svg internals, reanimated failing
698
+ // to attach to a component that turns out not to be a host view, and so on.
699
+ return (
700
+ <PxRnErrorBoundary onError={onError} fallback={fallback}>
701
+ {content}
702
+ </PxRnErrorBoundary>
703
+ );
384
704
  }
385
705
 
386
706
  export default PixodeskSvgAnimator;
@@ -0,0 +1,57 @@
1
+ /*---------------------------------------------------------------------------------------
2
+ * Copyright (c) Pixodesk LTD.
3
+ * Licensed under the MIT License. See the LICENSE file in the project root for details.
4
+ *---------------------------------------------------------------------------------------*/
5
+
6
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
7
+
8
+ export interface PxRnErrorBoundaryProps {
9
+ children: ReactNode;
10
+ /** Rendered instead of the children once something has thrown. */
11
+ fallback?: (error: Error) => ReactNode;
12
+ onError?: (error: Error, info?: string) => void;
13
+ }
14
+
15
+ interface State {
16
+ error: Error | null;
17
+ }
18
+
19
+ /**
20
+ * Keeps one bad animation from taking down the screen around it.
21
+ *
22
+ * A throw anywhere in the rendered SVG tree — an unsupported prop shape, a
23
+ * react-native-svg internal, a reanimated attachment failure — otherwise
24
+ * unmounts the whole React tree above it. Here it is contained to this one
25
+ * animation, reported through `onError`, and replaced by `fallback`.
26
+ *
27
+ * NOTE the limit: this catches JavaScript errors only. A crash INSIDE the
28
+ * native renderer (see `openClosedTextPathTargets` for a real example) never
29
+ * reaches JavaScript and cannot be caught here — those have to be avoided
30
+ * rather than handled.
31
+ */
32
+ export class PxRnErrorBoundary extends Component<PxRnErrorBoundaryProps, State> {
33
+ override state: State = { error: null };
34
+
35
+ static getDerivedStateFromError(error: Error): State {
36
+ return { error };
37
+ }
38
+
39
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
40
+ this.props.onError?.(error, info?.componentStack ?? undefined);
41
+ console.warn('[PixodeskSvgAnimator] render failed:', error?.message ?? error);
42
+ }
43
+
44
+ override componentDidUpdate(prev: PxRnErrorBoundaryProps): void {
45
+ // A new document deserves a fresh attempt — otherwise the boundary
46
+ // would stay latched on the failure for the rest of its life.
47
+ if (this.state.error && prev.children !== this.props.children) {
48
+ this.setState({ error: null });
49
+ }
50
+ }
51
+
52
+ override render(): ReactNode {
53
+ const { error } = this.state;
54
+ if (error) return this.props.fallback ? this.props.fallback(error) : null;
55
+ return this.props.children;
56
+ }
57
+ }