Skip to content

fix(🐛): trim a path before stroking it - #4018

Merged
wcandillon merged 8 commits into
Shopify:mainfrom
dennytosp:fix/path-trim-before-stroke
Aug 23, 2026
Merged

fix(🐛): trim a path before stroking it#4018
wcandillon merged 8 commits into
Shopify:mainfrom
dennytosp:fix/path-trim-before-stroke

Conversation

@dennytosp

Copy link
Copy Markdown
Contributor

drawPath applies the stroke prop before start/end:

// packages/skia/src/sksg/Recorder/commands/Drawing.ts
if (hasFillType) {  }
if (hasStrokeOptions) { path = ctx.Skia.Path.Stroke(path, stroke); }   // 1st
if (hasStartOffset || hasEndOffset) { path = ctx.Skia.Path.Trim(path, start, end, false); }  // 2nd

Path.Stroke replaces the path with the outline of the stroke — a closed loop around it. Trimming afterwards walks that loop's perimeter, so start/end no longer refer to anything the caller drew.

<Path path="M 20 128 L 236 128" color="red" stroke={{ width: 20 }} start={0} end={0.5} />
x expected on main
60 (first half) opaque transparent
200 (second half) transparent opaque

end={0.5} paints the right half of the line — the halves are inverted, because half the outline's perimeter happens to land there.

The native recorder already gets this right — PathCmd::draw in cpp/api/recorder/Drawings.h runs trim → fill type → stroke:

if (hasStartOffset || hasEndOffset) { /* SkTrimPathEffect */ }
if (props.fillType.has_value()) { p->setFillType(...); }
if (hasStrokeOptions) { /* strokePaint.getFillPath */ }

So this is a JS-player divergence: Web and the static container were affected, iOS and Android were not.

Fix

Reorder the JS player to match: trim → fill type → stroke.

Tests

packages/skia/src/sksg/__tests__/PathTrim.spec.tsx renders the case above and samples both halves. It fails on main (Expected: 255, Received: 0) and passes with the fix.

No baselines move — nothing in the suite combined stroke with start/end, which is why this went unnoticed. Full suite: 747 passed, tsc --noEmit and eslint --max-warnings 0 clean.

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.

@wcandillon wcandillon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice catch :)
please use checkImage(img...) instead of expect(alphaAt(...

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.
@dennytosp

Copy link
Copy Markdown
Contributor Author

@wcandillon appreciate it! alphaAt is gone — the spec draws via surface.draw and compares with checkImage, and I added an untrimmed baseline next to the trimmed one so the two halves are easy to tell apart. Over to you whenever you have a moment ✌️

@dennytosp
dennytosp requested a review from wcandillon August 22, 2026 12:30
@wcandillon

wcandillon commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

the native implementation side disappeared somehow? I added it back

@wcandillon
wcandillon merged commit cb178e4 into Shopify:main Aug 23, 2026
10 of 11 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.11.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants