Skip to content

feat(🎨): add Canvas frame presentation callback#3935

Open
PradyumnaShome wants to merge 2 commits into
Shopify:mainfrom
PradyumnaShome:feat/canvas-frame-presented
Open

feat(🎨): add Canvas frame presentation callback#3935
PradyumnaShome wants to merge 2 commits into
Shopify:mainfrom
PradyumnaShome:feat/canvas-frame-presented

Conversation

@PradyumnaShome

@PradyumnaShome PradyumnaShome commented Jul 11, 2026

Copy link
Copy Markdown

Summary

  • add onFramePresented?: () => void to <Canvas> and marshal it safely back to the JavaScript thread
  • propagate one notifier per successfully rendered frame through Metal, OpenGL, and Graphite presentation paths
  • cancel stale native notifiers when the callback changes or the view is destroyed, then document exact backend guarantees and lifecycle behavior

Why

React commit and Skia picture recording only show that a frame was prepared. They do not show that the platform presentation path has accepted—or, where the platform exposes it, actually displayed—the frame. Applications that remove placeholders, take measurements, or start captures at the earlier boundary can race the pixels they are waiting for and observe blank or misleading intermediate state.

This callback exposes the strongest presentation boundary available on each backend:

  • iOS/tvOS Metal: MTLDrawable.addPresentedHandler, and only when presentedTime > 0 confirms on-screen display
  • Android OpenGL: after successful eglSwapBuffers (handed to the compositor, not guaranteed physical scanout)
  • Graphite: after WebGPU Surface::Present() returns (presentation submission)
  • macOS/Mac Catalyst: after the Metal presentation command buffer completes because drawable presentation handlers are unavailable
  • Web: not emitted

Callbacks are delivered asynchronously on the JS thread. Failed renders do not emit. Replacing or clearing the prop invalidates native notifications that have not started, while work already queued to JS may still run once.

Attribution

The original idea was authored by Szymon Kapała (@Szymon20000).

Validation

  • yarn workspace @shopify/react-native-skia test CanvasPresentation --runInBand
  • yarn workspace @shopify/react-native-skia lint
  • yarn workspace @shopify/react-native-skia tsc
  • yarn workspace @shopify/react-native-skia build
  • C++/Objective-C++ sources formatted with Xcode clang-format
  • native iOS/Android example builds (Skia submodules and native headers are not initialized in this isolated checkout)

No React Native codegen output changes are required because the callback uses the existing JSI view-property path rather than adding a codegen native prop.

The docs workspace's standalone tsc was also attempted, but it currently fails in existing ExpoSnackApi.ts code because its TypeScript configuration does not include DOM globals (document/window); this change does not touch that file.

Expose the native presentation boundary so applications can avoid racing UI handoffs against frames that have only been recorded or submitted.
@PradyumnaShome

Copy link
Copy Markdown
Author

I have signed the CLA!

@PradyumnaShome PradyumnaShome marked this pull request as ready for review July 11, 2026 08:27
@wcandillon

wcandillon commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Thank You for this. This looks intriguing. Can you give me a sense of the paint points/use-case this solves? This never came up while working with graphic APIs.

The only thing I can think of is that in RN Skia Ganesh due to the current architecture, it would be nice indeed to know if the job is "done" on the UI thread. But this will be nicely solved in Graphite.

I would really love to understand the use-case better.

@PradyumnaShome

Copy link
Copy Markdown
Author

Hey William, thanks for taking a look. The concrete use case is coordinating the handoff between a placeholder and Skia-rendered content.

For example, we keep a cached low-resolution image visible while a high-resolution image is decoded or composited in the background. Decode completion or even the React state update is too early to hide the placeholder: the updated Canvas still needs to render and present its next frame. Hiding it immediately can expose a blank or stale Canvas for one frame, causing a visible flash.

The callback lets us keep the placeholder visible until Skia reaches the presentation point for the updated frame, and only then complete the transition.

Let me know if there is a better way of doing this, but this has definitely helped us in our app.

@wcandillon

Copy link
Copy Markdown
Contributor

@PradyumnaShome Thank You for the use-case description, do you have a reproducible example that I can test?
if you want you can contribute it as a separate PR in apps/example

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.

2 participants