Skip to content

perf(mobile): cache initial SkPath in usePathTransition#777

Draft
AndreiCalazans wants to merge 1 commit into
coinbase:masterfrom
AndreiCalazans:perf/path-transition-cache-initial-skpath
Draft

perf(mobile): cache initial SkPath in usePathTransition#777
AndreiCalazans wants to merge 1 commit into
coinbase:masterfrom
AndreiCalazans:perf/path-transition-cache-initial-skpath

Conversation

@AndreiCalazans

Copy link
Copy Markdown
Member

What changed? Why?

usePathTransition (packages/mobile/src/visualizations/chart/utils/transition.ts) was calling Skia.Path.MakeFromSVGString(initialPath ?? currentPath) directly in the function body on every render. The result is only consumed as the initial value of four useSharedValue calls immediately below it. Because useSharedValue ignores its argument on every render after the first, every later parse was discarded.

This PR moves the parse into a useState lazy initializer so it runs exactly once per hook instance.

Why useState (not useMemo)

  • useMemo is documented as a performance hint that React may discard. useState's lazy initializer is the only documented "called exactly once per component instance" primitive.
  • The natural useMemo dependency array ([initialPath, currentPath]) would re-parse on every currentPath change while the shared-value initializers continued to ignore the new result — re-introducing the bug in a slightly different form.
  • An empty useMemo dependency array ([]) would lint-fail under react-hooks/exhaustive-deps, and is precisely the anti-pattern that "use useState for one-time init" docs warn against.

Impact

In an Android prod CPU profile (1:03 capture, Hermes) on a chart-heavy screen:

Caller chain Calls Self time
AnimatedPath > usePathTransition (render body) 22 276.7 ms
DottedArea > usePathTransition (render body) 9 74.8 ms
Path > useDerivedValue > initialUpdaterRun (static branch) 19 192.3 ms
commitHookEffectListMount (data-change useEffect) 1 2.7 ms
Total MakeFromSVGString 51 546.5 ms

The render-body bucket (rows 1 and 2) is the one this PR eliminates: ~31 calls / ~351 ms, ≈64% of all MakeFromSVGString self-time. After the fix, only 1 of those calls per hook instance remains (the legitimate first-render parse).

Root cause (required for bugfixes)

N/A — perf-only refactor; no behaviour change.

UI changes

No UI change. useSharedValue already ignores all subsequent values, so the visible animation is identical — only the parse cost is removed.

Testing

How has it been tested?

  • Unit tests — packages/mobile/src/visualizations/chart/utils/__tests__/transition.test.ts passes; full nx test mobile suite (170 suites / 1845 tests) passes.
  • Lint — nx lint mobile clean on transition.ts.
  • Typecheck — nx typecheck mobile clean.
  • Interaction tests
  • Pseudo State tests
  • Manual - Web (N/A — file is mobile-only)
  • Manual - Android (Emulator / Device)
  • Manual - iOS (Emulator / Device)

Testing instructions

Render any chart that uses Path / AnimatedPath / DottedArea (e.g. LineChart, AreaChart, sparkline) and confirm that:

  1. Initial enter animation still plays from initialPath (or currentPath when no initialPath is provided).
  2. Subsequent data updates still animate via interpolatePath exactly as before.
  3. Setting transitions.update: null still produces an instant transition.

Optional perf verification: capture a Hermes CPU profile while scrolling a chart-heavy screen and confirm [HostFunction] MakeFromSVGString self-time is reduced and that the render-body bucket disappears from the sandwich view of usePathTransition.

Change management

type=routine
risk=low
impact=sev5

automerge=false

`usePathTransition` was calling `Skia.Path.MakeFromSVGString` on every
render to compute `initialSkiaPath`, but the result is only ever consumed
as the initial value of the four `useSharedValue` calls below it. Since
`useSharedValue` ignores its argument on every render after the first,
every later parse was discarded.

Switch to a `useState` lazy initializer so the parse runs exactly once per
hook instance.

In an Android prod CPU trace (1:03 capture), this hot path accounted for
~64% of all `MakeFromSVGString` self-time (~350ms over the trace) across
`AnimatedPath` and `DottedArea` chart renders. `useState` is used
rather than `useMemo` because `useMemo` is documented as a perf hint that
React may discard, and any realistic dependency array would either fail the
exhaustive-deps lint rule (`[]`) or re-parse on every `currentPath` change
while the shared-value initializers continue to ignore the new result.
@cb-heimdall

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 1
Global minimum 0
Max 1
1
1 if commit is unverified 1
Sum 2
CODEOWNERS 🟡 See below

🟡 CODEOWNERS

Code Owner Status Calculation
ui-systems-eng-team 🟡 0/1
Denominator calculation
Additional CODEOWNERS Requirement
Show calculation
Sum 0
0
From CODEOWNERS 1
Sum 1

@AndreiCalazans AndreiCalazans marked this pull request as draft June 26, 2026 19:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

2 participants