Skip to content

fix(⚛️): size the canvas context from its layout, and build the ref handle once - #525

Closed
YevheniiKotyrlo wants to merge 3 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/canvas-context-layout-size
Closed

YevheniiKotyrlo wants to merge 3 commits into
wcandillon:mainfrom
YevheniiKotyrlo:fix/canvas-context-layout-size

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Canvas.getContext() measures the canvas with getBoundingClientRect(), which includes the transforms of the view and its ancestors, and creates the context at that size:

const size =
  "getBoundingClientRect" in view
    ? view.getBoundingClientRect()
    : view.unstable_getBoundingClientRect();
return RNWebGPU.MakeWebGPUCanvasContext(contextId, size.width, size.height);

That size seeds canvas.width / canvas.height and canvas.clientWidth / canvas.clientHeight, which the docs describe as the layout size and size the drawing buffer from (canvas.width = canvas.clientWidth * PixelRatio.get()). So a canvas set up while it is drawn under transform: [{ scale: 0.5 }], as in a zoom-in transition, keeps a drawing buffer of half its layout once it reaches full size: 360 × 360 for a 240 × 240 pt canvas at 3× on a Galaxy S23. On the web the polyfill sizes the <canvas> element from the same box, and getNativeSurface() reports it.

Canvas also passes useImperativeHandle no dependency list, so it builds a new handle on every render and React re-attaches the ref each time: <Canvas ref={setCanvas} /> renders until React throws Maximum update depth exceeded, and a stable callback ref is called with null and a new handle on every render. The Jest mock does the same.

Solution

measureLayout() relative to the view itself answers its layout size without transforms, and calls back synchronously on the new architecture. react-native-web's measureLayout() answers from a timeout, so on the web the polyfill takes the size from its <canvas> element's own clientWidth / clientHeight instead, for the context and for getNativeSurface().

The handle reads only contextId and viewRef, which are fixed for the canvas's lifetime, so contextId is its dependency; the mock's handle depends on nothing.

These are wcandillon/react-native-skia#4216 and wcandillon/react-native-skia#4215 for this repo.

Tests

  • Canvas.spec.ts (e2e), with a canvas on the Tests screen laid out at 64 × 32 and drawn at half that under scale: 0.5: the context's canvas reports 64 × 32 for width / height and clientWidth / clientHeight, and re-renders of the screen keep the same handle. On main both fail: 32 × 16, and a new handle. The screen's rerender() resolves each request once a commit has rendered it, so requests made together all resolve.
  • WebPolyfill.spec.ts: a canvas laid out at 100 × 50 whose bounding box is 50 × 25 gets a 200 × 100 drawing buffer at a device pixel ratio of 2, and a 100 × 50 native surface. On main: 100 × 50, and 50 × 25. The spec also holds what the sizing must keep: a second MakeWebGPUCanvasContext() at an unchanged size writes no attribute, since a write clears the drawing buffer; getNativeSurface() writes none, and binds its DOM methods to the element.
  • isNativeApp() in setup.ts replaces the native-app check the specs spelled four ways.

Verification

yarn lint · yarn tsc · yarn turbo run build --filter='./packages/*' · the Expo template's web build · yarn test:plugin · yarn check-dawn · yarn build:docs. CI's Android e2e step (debug build, Metro with CI=true) on a Galaxy S23, Android 14: 43 suites and 218 tests passed, where main passes its 42 suites and 211 tests on the same device. The before/after pairs from that device are in this comment. Not run here: the iOS e2e job, which needs a simulator, and the semantic-release dry run, which needs the repository's token. No issue tracks this; I searched the tracker for getBoundingClientRect, clientWidth, useImperativeHandle and measureLayout.

…andle once

getContext() measured the canvas with getBoundingClientRect(), which includes
the transforms of the view and its ancestors, so a context made while the
canvas was drawn under transform: [{ scale: 0.5 }] got half its layout size
for canvas.width/height and clientWidth/clientHeight. measureLayout() relative
to the view itself answers its layout size without transforms, synchronously
on the new architecture. react-native-web's measureLayout() answers from a
timeout, so on the web the polyfill sizes the context from its <canvas>
element's clientWidth/clientHeight, which getNativeSurface() now reports too.

Canvas passed useImperativeHandle no dependency list, so every render built a
new handle and React re-attached the ref. The handle depends only on contextId
and viewRef, fixed for the canvas's lifetime; the Jest mock builds its handle
once as well.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — on main, a canvas set up with the docs' code while it is drawn at half its size keeps a 360 × 360 drawing buffer for its 240 × 240 pt layout at 3×, so once it reaches full size its checkerboard of 3-pixel cells is drawn at half resolution and stretched; and <Canvas ref={setCanvas} /> throws Maximum update depth exceeded.

FIXED — with this PR, the drawing buffer is 720 × 720 and the cells are sharp; the state-setter ref renders its owner twice.

before — main after — with this PR

Galaxy S23 (Android 14), debug build of the example app. First row: a 240 × 240 pt canvas whose effect runs the docs' setup (canvas.width = canvas.clientWidth * PixelRatio.get()) while the canvas is drawn under scale: 0.5, then sets the scale to 1 and draws a checkerboard of 3-pixel cells from the fragment position every frame; the text is the drawing buffer the setup chose. Second row: the centre of that canvas, 90 × 90 screen pixels enlarged four times. Third row: a screen whose only content is <Canvas ref={setCanvas} />. The two builds differ only in this PR's change.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 14, 2026 17:57
…heck

The Tests screen's rerender() kept one pending resolver, so a request made
before the previous one committed left that promise unresolved until the eval
timed out. useRerender() numbers the requests, and the effect after a commit
resolves every request that commit rendered. The Canvas spec requests two
re-renders together and a third after them.

isNativeApp() in setup.ts, total over TestOS, replaces the native-app check
the specs spelled four ways.
…lace each

getNativeSurface() and the context each looked up the canvas element and read
its clientWidth/clientHeight; one helper each now holds that. WebPolyfill.spec
derives its polyfill type from RNWebGPU's and covers the rest of the module:
a second context at an unchanged size writes no attribute, since a write
clears the drawing buffer; getNativeSurface() writes none and binds its DOM
methods to the element; present() is a callable no-op. Canvas drops its
unused state setter binding.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Closing: #531 and #532 brought both fixes to main. The tests that still apply, and the Jest mock's handle fix, are in #529.

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