Skip to content

[pull] main from Shopify:main - #221

Merged
pull[bot] merged 7 commits into
code:mainfrom
Shopify:main
Aug 23, 2026
Merged

[pull] main from Shopify:main#221
pull[bot] merged 7 commits into
code:mainfrom
Shopify:main

Conversation

@pull

@pull pull Bot commented Aug 23, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

dennytosp and others added 7 commits August 23, 2026 09:58
#4015 fixed processTransform3d but missed the C++ mirror of it in the
native recorder, which builds the same matrix for transform props with
the same two axes swapped. The SkM44 constructor reads its arguments
row-major, so skewX has to put tan(angle) in row 0 - exactly like the
TypeScript version it shadows.

Since the snapshots were regenerated for the corrected behaviour, native
now disagrees with them and Transforms.spec fails on main for both
test-android and build-test-ios-graphite.

Co-authored-by: William Candillon <wcandillon@gmail.com>
…ty (#4023)

* fix(🐛): derive the inner shadow from the shape outline, not its opacity

An inner shadow is generated outside the shape, offset, blurred, and clipped
back into it. "Outside" is produced with `SrcOut` against the source graphic,
which yields `shadowColor x (1 - dst.alpha)` - the complement of the source's
*alpha*, not of its silhouette.

Inside a translucent shape `1 - alpha` is non-zero, so the shadow is generated
across the whole interior and then clipped onto it, tinting the shape towards
the shadow colour. The extent of the effect is decoupled from the blur radius
and the offset: a shadow with `blur={0} dx={0} dy={0}`, which cannot move
anything from outside the shape to inside it, still tints a translucent shape
uniformly.

Saturating alpha before the `SrcOut` makes the complement geometric again: the
shadow is generated only outside the outline, and its reach is bounded by the
blur and the offset, for any fill opacity. Opaque shapes are unaffected, which
is why the existing inner shadow baselines do not move.

The same graph is built twice - once in the TypeScript player used on web and
in the JS recorder, once in `cpp/api/recorder/ImageFilters.h` for iOS, Android
and macOS - so both are updated.

---------

Co-authored-by: William Candillon <wcandillon@gmail.com>
* fix(🐛): scale SkMatrix uniformly when y is omitted

SkMatrix.scale() and postScale() take an optional y. The CanvasKit
backend falls back to x for a uniform scale, but the JSI bindings fell
back to 1, so matrix.scale(0.5) halved only the x axis on iOS and
Android while halving both on Web.

The renderer's own "Scale with origin using a matrix" test pins the
uniform result ([0.5, 0, 192, 0, 0.5, 192, 0, 0, 1]), but it runs on
CanvasKit only, so the native path was never covered.

* test(🧪): run the SkMatrix scale spec against native too

Move it to renderer/__tests__/e2e and go through surface.eval, so the
JSI bindings this fixes are actually exercised. As a plain unit test it
only ever ran against CanvasKit, which was already correct.

---------

Co-authored-by: William Candillon <wcandillon@gmail.com>
* fix(🐛): don't emit a CTM restore for a CTM that never saved

processCTM() records a SaveCTM command whenever any CTM prop is defined, and
visitNode() then pairs it with an unconditional restoreCTM(). saveCTM() is
narrower: it only saves the canvas when there is a transform/matrix, a clip or
a layer. When a group carries a CTM prop that falls outside that set, the
restore has no matching save and pops a save belonging to an ancestor, so every
later sibling of that group is drawn without the ancestor's transform or clip.

Three ways to hit it, all of them ordinary code:

  - `origin` with no `transform` or `matrix` (an identity transform, so
    processTransformProps2 returns null and nothing is saved);
  - `clip={cond && path}` where `cond` is false, which passes `clip={false}`;
  - `invertClip` on a group that has no clip.

Emit the CTM command only when it will actually save. This is the shared
visitor, so the fix covers the native recorder, the CanvasKit player and the
static container at once, and it leaves the two save conditions expressed in
one place instead of two that can drift.

Adds a regression test covering each of the three props plus a control and a
CTM that does save; the three inert cases fail without this change.

* test(🧪): assert CTM balance with checkImage

Move the spec to renderer/__tests__/e2e so it runs against native as
well as CanvasKit, and compare rendered images instead of reading back
individual pixels. Every inert CTM case now has to match the reference
scene, where the group under test has no props at all.

---------

Co-authored-by: William Candillon <wcandillon@gmail.com>
…4022)

* fix(🐛): apply the mask srcIn composite once per group, not per draw

`<Mask>` finishes by drawing its children onto the mask with `srcIn`, using
`<Group blendMode="srcIn">`. `blendMode` on a `<Group>` is a paint property, so
it is applied once per draw call inside the group rather than once to the group
as a whole - `Group.tsx` only requests a layer when a `layer` prop is given.

`srcIn` replaces the destination inside the coverage of the draw it is attached
to. With a single child that coincides with compositing the children once, which
is why the existing mask baselines are unaffected. With more than one child,
every child after the first is composited against the previous child's result
instead of against the mask, so overlapping translucent children lose their
mutual blending and their alpha is multiplied a second time.

Requesting a layer instead composites the children together first and applies
`srcIn` once, to the result.

---------

Co-authored-by: William Candillon <wcandillon@gmail.com>
Every SavePaint command must push exactly one frame onto the paint and
opacity stacks, because RestorePaint and RestorePaintDeclaration each pop
exactly one. Both backends break that invariant, in opposite directions
and on opposite branches.

The native recorder pushes twice for a standalone paint:

    ctx->savePaint();
    if (standalone) {
      SkPaint freshPaint;
      ctx->pushPaint(freshPaint);   // second push
    }

while the TypeScript player resets the frame savePaint() already pushed.
A <Paint> declaration is always closed by RestorePaintDeclaration, which
pops one frame, so each declaration leaves one stale frame behind. The
enclosing group's RestorePaint then pops that stale frame instead of its
own, and the group's opacity stays on the stack and is applied to every
sibling drawn afterwards — including siblings that are not in the group
at all. That is issue #3355: it only shows up with a <Mask> (or any
<Paint> child) inside the group, because that is what emits a standalone
paint.

The TypeScript player has the mirror defect on the other branch: the
`paint` prop pushes onto `paints` without pushing an opacity, so
restorePaint() underflows the opacity stack. getOpacity() then returns
undefined and setAlphaf(alpha * undefined) yields NaN, so everything
drawn after the group disappears entirely.

Fix both so a SavePaint always moves each stack by exactly one frame.

Fixes #3355

Co-authored-by: William Candillon <wcandillon@gmail.com>
* fix(🐛): trim a path before stroking it

drawPath applied the stroke prop before start/end, so trimming walked
the perimeter of the generated outline rather than the path the caller
drew. <Path path="M 20 128 L 236 128" stroke={{ width: 20 }} end={0.5} />
painted the right half of the line instead of the left.

The native recorder already trims first (PathCmd in
cpp/api/recorder/Drawings.h), so this only affected the JS player - Web
and the static container.

* test(🧪): compare rendered images for the path trim spec

Move it to renderer/__tests__/e2e so it also runs against native, draw
through surface.draw and compare with checkImage instead of sampling
alpha by hand. An untrimmed baseline sits next to the trimmed one so
the two halves are easy to tell apart.

---------

Co-authored-by: William Candillon <wcandillon@gmail.com>
@pull pull Bot locked and limited conversation to collaborators Aug 23, 2026
@pull pull Bot added the ⤵️ pull label Aug 23, 2026
@pull
pull Bot merged commit cb178e4 into code:main Aug 23, 2026
7 of 8 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants