Repository navigation
fix(⚛️): size the canvas context from its layout, and build the ref handle once - #525
Closed
YevheniiKotyrlo wants to merge 3 commits into
Closed
YevheniiKotyrlo wants to merge 3 commits into
YevheniiKotyrlo wants to merge 3 commits into
Conversation
…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.
Contributor
Author
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.
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.






Problem
Canvas.getContext()measures the canvas withgetBoundingClientRect(), which includes the transforms of the view and its ancestors, and creates the context at that size:That size seeds
canvas.width/canvas.heightandcanvas.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 undertransform: [{ 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, andgetNativeSurface()reports it.Canvasalso passesuseImperativeHandleno 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 throwsMaximum update depth exceeded, and a stable callback ref is called withnulland 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'smeasureLayout()answers from a timeout, so on the web the polyfill takes the size from its<canvas>element's ownclientWidth/clientHeightinstead, for the context and forgetNativeSurface().The handle reads only
contextIdandviewRef, which are fixed for the canvas's lifetime, socontextIdis 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 underscale: 0.5: the context's canvas reports 64 × 32 forwidth/heightandclientWidth/clientHeight, and re-renders of the screen keep the same handle. Onmainboth fail: 32 × 16, and a new handle. The screen'srerender()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. Onmain: 100 × 50, and 50 × 25. The spec also holds what the sizing must keep: a secondMakeWebGPUCanvasContext()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()insetup.tsreplaces 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 withCI=true) on a Galaxy S23, Android 14: 43 suites and 218 tests passed, wheremainpasses 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 forgetBoundingClientRect,clientWidth,useImperativeHandleandmeasureLayout.