Skip to content

fix(svg): Android crash, UI stalls, iOS stale frames, hsl() colours and <style> rules - #164

Merged
triniwiz merged 8 commits into
masterfrom
fix/svg-android-document-pointer
Sep 29, 2026
Merged

triniwiz merged 8 commits into
masterfrom
fix/svg-android-document-pointer

Conversation

@triniwiz

@triniwiz triniwiz commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Fixes for @nativescript/canvas-svg: an Android crash, UI stalls on Android and iOS, an iOS redraw bug, colours that draw black on every platform, and <style> rules that Skia ignores.

Crash. SVGDocumentImpl::NativePointer returned the SvgDocument* as a double. arm64 Android tags heap pointers in the top byte (0xb4…), which is past Long.MAX_VALUE and past a double's 53 bits. NSCSVG.renderDocument(Long) received a clamped 0x7fff…, and the first bitmap render segfaulted (fault address 0x800000000000004f).

UI stalls. Opening a page of Svg views froze the UI thread before any animation played, for about 425 ms on a Galaxy A53. Three causes:

  • Detaching a threaded view blocked until the shared render thread reached its removal. The removal was queued behind a full GPU context build for every view that had just appeared.
  • Every view's surface was built before any view was presented.
  • Every SvgDocument created its own FontMgr. Since Skia m122, FontMgr::new() scans all system fonts on every call (rust-skia#976). Each view makes two documents, so the SVG demo page ran about 14 font scans on the UI thread.

iOS stale frame. With the threaded renderer (the default), NSCSVG.draw(_:) kept drawing the first raster frame, the one rendered before the Metal host had a size, underneath the transparent Metal layer. When src changed, the old and new content appeared drawn over each other; in the starter's gauge, "64%" stayed visible under the new value. This predates this PR: the alpha.20 framework shows the same thing.

Black colours. Skia's SVG parser reads hex, named colours, rgb(r,g,b) and rgba(r,g,b,a) only. hsl(), hsla(), hwb() and rgb(r g b / a) fail to parse, and the paint draws black. Canvas 2D accepts all of them through csscolorparser, so the same colour drew differently in an svg than on a canvas.

<style> rules ignored. Skia's SVG module reads presentation attributes and the style attribute, but not <style> rules. Class-styled SVGs, which is what design tools export (.cls-1 { fill: … }), drew with default paint: black fills and no strokes.

Changes

  • Document pointer: nativePointer() returns a decimal string, like the __getPointer bindings in canvas. Android passes it on as long(ptr); iOS converts it with Number(), since iOS pointers fit in a double. The Node-API module returns the same string.
  • Non-blocking teardown (crates/canvas-svg-c/src/gpu/thread.rs): new canvas_native_svg_render_thread_release(render, release). The view hands its window to the render thread, which tears the surface down and then calls release, so the UI thread doesn't wait.
    • Android releases the ANativeWindow.
    • iOS releases the retained host view on the main queue, since UIKit views must not be deallocated on other threads.
    • canvas_native_svg_render_thread_destroy keeps its blocking behaviour.
  • Render thread scheduling:
    • Removals are processed first, and again between surface builds.
    • A view detached before its surface was built never gets one.
    • Each surface is presented as soon as it exists.
    • A detached view is never presented to or rebuilt against its abandoned window.
  • Font manager (crates/canvas-svg): one FontMgr per thread, reused by every document. It's thread-local because skia-safe doesn't implement Send/Sync for FontMgr.
  • iOS redraw (NSCSVG.swift): draw(_:) skips the raster image while either GPU path is active, not only the single-threaded one, and the view redraws when a GPU context comes up so the old layer contents are cleared. Android already does both.
  • Colours (crates/canvas-svg/src/css_color.rs): those functions are rewritten to rgb()/rgba() with csscolorparser, the parser Canvas 2D uses.
    • When a document is parsed: attribute values and <style> contents, before SMIL/CSS animation extraction, so animated colours interpolate. Text content is left alone.
    • In setAttribute and addStylesheet.
    • SMIL frames and other internal writes use a new set_normalized_attribute, which skips the rewrite, so the rewrite never runs per frame. On a Mac, normalize costs 6–57 ns for values it leaves alone and about 300 ns for an hsl() it rewrites.
  • Stylesheets (crates/canvas-svg/src/stylesheet.rs): when a document is parsed, each element's matching rule declarations are folded into its style attribute in cascade order.
    • Precedence: selector rules over presentation attributes, inline style over rules, !important over both, then specificity and source order.
    • The style attribute is written last, because Skia applies attributes in order.
    • Selectors: type, *, #id, .class, attribute selectors, :root, :first-child, :last-child, :only-child, and the descendant, >, + and ~ combinators. A rule using anything else is dropped rather than risk matching too much.
    • Reuses the rule parser in smil/css.rs. Rule values go through the colour rewrite too.
    • Parse time only: changing class or id at runtime, adding elements, or rules passed to addStylesheet (still @keyframes only) don't restyle anything.
  • Artifacts: rebuilds canvassvg-release.aar (4 ABIs) and CanvasSVG.xcframework (iOS, visionOS, tvOS). Windows is not rebuilt.

Testing

Android, Galaxy A53 (Android 16). The starter app opens its SVG demo (6 animated tiles and a gauge) from a home page with 8 SVG icons. Profiled with simpleperf:

Before After
Longest UI-thread block while opening 426 ms 16–27 ms
UI-thread time creating documents ~227 ms ~2 ms
GPU contexts built per open 15 7
Steady-state janky frames (A/B, same session) 5.1% 1.1%
  • 25 rapid open/back cycles: no crashes and no "context lost" rebuilds.
  • An intermediate version released windows asynchronously but could still rebuild a context against a window that was already gone. It logged 10 such rebuilds over 15 cycles, which is why detached views are now never rebuilt.
  • The pointer fix was also checked on an arm64 emulator (API 36), which crashed on launch before this change.

iOS, iPhone 17 Pro Max simulator:

  • The starter app ran 15 scripted open/back cycles of the SVG demo with no crash.

  • Cycle times matched the scheduled waits to within about 13 ms, so the main thread wasn't blocked.

  • The demo renders and animates afterwards.

  • The gauge from the starter stepped through 34 → 71 → 12 → 88 → 53 from script. Before: overlapping digits over the initial "64%", same result with the alpha.20 framework. After: a clean "53%" with the matching arc length.

  • Colours, both platforms: the starter's gauge, whose gradient stops are hsl(), drew a black arc on Android and iOS. It now draws its gradient, and the hue follows the value (Android A53, iOS simulator).

  • Stylesheets, both platforms: a test SVG with .bg, .dot, g > .dot:first-child and #ring rules rendered every rule correctly on the A53 and the iOS simulator. tools/demo/canvas/assets/svg/trinidadAndTobagoHigh.svg, whose paths are styled by a .land class, went from solid black to its intended grey fill with white borders.

Unit tests: cargo test -p canvas-svg-c --features metal gpu::thread passes 4 tests, including a new one for non-blocking release. cargo test -p canvas-svg passes 136 tests, including tests/stylesheets.rs, which renders rule precedence, specificity, inheritance, a CDATA export and keyframes on a styled element, as well as the colour rewrite's unit tests and tests/css_colors.rs, which renders hsl() fills, style, gradient stops, runtime setAttribute, SMIL interpolation and @keyframes (in-document and addStylesheet) and checks the pixels. The Node-API test suite passes when run against a local macOS build, but that is only a smoke check, since macOS isn't a supported Node-API target.

Not tested: Windows, visionOS and tvOS were not run. visionOS and tvOS were built only.

nativePointer() returned the SvgDocument* as a double. arm64 Android tags
heap pointers in the top byte (0xb4...), which is past Long.MAX_VALUE and
past a double's 53 bits, so renderDocument(Long) received a clamped
0x7fff... and the first bitmap render segfaulted.

Return it as a decimal string, like the canvas __getPointer bindings, and
hand it to Kotlin via long(ptr). iOS pointers still fit a double, so iOS
converts with Number().
Opening a page of Svg views froze Android's UI thread for ~425ms (A53)
before any animation played:

- Detaching a threaded view waited for the shared render thread to get
  to its removal, which was queued behind a whole GPU context build for
  every view that had just appeared. The view now hands its window to
  the render thread, which releases it after tearing the surface down
  (canvas_native_svg_render_thread_release); the blocking destroy stays
  for iOS. Removals are taken first and between builds, a view detached
  before its surface was built never gets one, and a removed view is
  never presented to or rebuilt against its abandoned window.
- Each surface is presented as soon as it is built, instead of after
  every queued build.
- Every SvgDocument made a new FontMgr. Since m122 that is a fresh
  system font scan per call (rust-skia#976); two per view cost ~225ms
  of UI thread on this page. It is now made once per thread.

Longest UI-thread stall opening the SVG demo: 426ms -> ~20ms.
iOS had the same blocking detach as Android: renderThreadDestroy waited
for the shared render thread before releasing the host view. It now
uses canvas_native_svg_render_thread_release, and the render thread
hands the retained view back through a callback that releases it on the
main queue, since UIKit views must not be deallocated elsewhere.

The Node-API module's nativePointer returns the same decimal string as
the V8 bindings.

Rebuilds CanvasSVG.xcframework (iOS, visionOS, tvOS).
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 5ad0b2a9-e41c-47c3-ada0-bf2ce3fe7e85

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

draw(_:) only skipped the raster image when the single-threaded GPU
context was active. With the threaded renderer (the default) the frame
rendered before the Metal host had a size stayed in the layer and
showed through the transparent Metal layer under every GPU frame, so a
changed src looked like its old and new content drawn over each other.
It now skips while either GPU path is active, and asks for a redraw
when one comes up so the old contents are cleared, as Android does.

Rebuilds CanvasSVG.xcframework.
Skia's SVG parser reads hex, named colours, rgb(r,g,b) and rgba(r,g,b,a)
only; anything else fails to parse and the paint draws black. Canvas 2D
accepts the rest through csscolorparser, so the same markup drew
differently in an svg than on a canvas.

Those functions are now rewritten to rgb()/rgba() with csscolorparser:
in attribute values and <style> contents when a document is parsed
(before SMIL/CSS animation extraction, so animated values interpolate),
in setAttribute, and in addStylesheet. Text content is left alone.

SMIL frames and other internal writes use set_normalized_attribute, so
the rewrite stays off the per-frame path.

Rebuilds canvassvg-release.aar and CanvasSVG.xcframework.
@triniwiz triniwiz changed the title fix(svg): Android crash on first render, and UI stalls opening pages of Svg views fix(svg): Android crash, UI stalls, iOS stale frames and black hsl() colours Sep 29, 2026
Skia's SVG module reads presentation attributes and the style attribute
but not <style> rules, so class-styled SVGs (what design tools export)
drew with default paint: black fills, no strokes.

When a document is parsed, each element's matching rule declarations
are folded into its style attribute in cascade order: selector rules
over presentation attributes, inline style over rules, !important over
both, then specificity and source order. The style attribute is written
last, since Skia applies attributes in order.

Supported selectors: type, *, #id, .class, attribute selectors,
:root, :first-child, :last-child, :only-child, and the descendant, >,
+ and ~ combinators. Rules with anything else are dropped rather than
matching too much. Rules apply at parse time only.

Rebuilds canvassvg-release.aar and CanvasSVG.xcframework.
@triniwiz triniwiz changed the title fix(svg): Android crash, UI stalls, iOS stale frames and black hsl() colours fix(svg): Android crash, UI stalls, iOS stale frames, hsl() colours and <style> rules Sep 29, 2026
@triniwiz
triniwiz merged commit 94ee40f into master Sep 29, 2026
8 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant