Skip to content

fix(Carousel): under snap paging, the edge buttons follow the held row - #10359

Merged
cixzhang merged 2 commits into
meta:mainfrom
vjeux:uikit/c08-carousel-snap-edges
Nov 7, 2026
Merged

cixzhang merged 2 commits into
meta:mainfrom
vjeux:uikit/c08-carousel-snap-edges

Conversation

@vjeux

@vjeux vjeux commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

User impact

Under snap paging (hasSnap={useSnapPaging()}), a flick from a Carousel's start left the start button hidden about half a second after the row had visibly moved. That is 29 frames in Chromium, 21–22 in WebKit, and 33–34 frames (557 ms) in Safari on the iOS 26.2 Simulator. The edge fades lagged too, and the end button did on a flick back from the end.

Problem and solution fit

The buttons and fades read the row's scroll offset (useScrollOverflow). While snap paging holds the row, the offset stays where the hold began and the items move by a translate. So the edges changed only when the settle handed the row back. Carousel's native modes follow the row on every scroll event, as a UIKit control fed by scrollViewDidScroll follows its content.

Expected behavior and authority

component:Carousel FR1: "An edge scroll button whose direction has run out is hidden." Under snap paging, a direction runs out or comes back with the row the person sees. FR8 still holds: Carousel imports no pager code, and calls only attach and detach on the value, plus its move of a held row. FR2's focus handoff still fires once per edge transition.

Smallest restoration

  • attachSnapPaging takes an optional listener, passed through attach.
  • The track reports where the held row is shown on each frame of the hold, from the lead child's own animator. The direction and the lead child are read at the takeover, so a frame reads no style or layout.
  • An internal sibling of useScrollOverflow measures the edges from those reports and sets state only when one flips. After the hand-back it keeps them one frame, until the scroll offset is current. useScrollOverflow's return is unchanged.

Evidence

Main (top): the start button appears after the row arrives. This change (bottom): as the row leaves the start.

  • Reproduction before the change: CarouselSnapPagingEdges.interaction.browser.spec.ts measures the start button coming back 29 frames after the row leaves the start in Chromium, and 21–22 in WebKit. The end button comes back 29–30 and 23–24 frames after the row leaves the end.
  • Result after the change: 1 frame in every run, while the paging still holds the row.
  • Representative unchanged path: the existing Carousel and SnapPaging cases pass.
  • Regression tests: the new spec, three SnapPaging.test.tsx cases that fail on main, and three useScrollOverflow.test.tsx cases for the reported position.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

Review notes

  • component:Carousel's verification map does not name this proof yet. Its FR1 row lists unit and accessibility-tree tests at rest. A records follow-up adds the new interaction spec and the held-row state to that row, so the owners' stamp on the record does not hold this fix.

While snap paging holds the row, its scroll offset stays where the hold
began and its items move. The edge buttons and fades read the scroll
offset, so they changed only at the hand-back, about half a second after
the row had visibly left an edge.

attachSnapPaging takes an optional listener, still through attach. The
track reports where the held row is shown on each frame of the hold,
from the lead child's own animator, with the direction read at the
takeover, so a frame reads no style or layout. Carousel turns each
report into its edges and sets state only when an edge flips. After the
hand-back it keeps the held edges for one frame, until the scroll
measure reads the new offset, so no button flickers.
@vercel

vercel Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 6, 2026 8:56pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 6, 2026
@astracat-bot

astracat-bot Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 7, 2026, 12:28 AM UTC

@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · dbd183d

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
TabList 40.4 kB (+125 B) · 6.4 kB CSS (0) — — · — · —
Carousel · Storybook 59.8 kB (+120 B) · 10.3 kB CSS (0) 599 (0) ✓ · ✓ · ✓

Package size: @astryxdesign/core 6.80 MB packed (+1.7 kB); no new dependencies.

API changes: core +0 · ~1 · −0

Committed API report behind this build: cli 19 rows, core 25 rows; run pnpm api:report.

1 API change (advisory)
Package Where Signature Usage
~ core . · Hooks · hook useScrollOverflow hook useScrollOverflow: () => { overflowStart: boolean; overflowEnd: boolean; hasOverflow: boolean; scrollRef: (el: HTMLElement | null) => void } → hook useScrollOverflow: () => { scrollRef: (el: HTMLElement | null) => void; overflowStart: boolean; overflowEnd: boolean; hasOverflow: boolean } import {useScrollOverflow} from '@astryxdesign/core'; useScrollOverflow(…)

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot 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.

Approval needs an engineering owner's decision first, and two fixes are needed after it.

Decision needed: under snap paging, the edge fades and handleRef's canScrollPrev() / canScrollNext() now follow the held row the way the edge buttons do, but the Carousel record lists edge fades as uncontracted and does not cover the imperative handle. An engineering owner records approval by commenting /approve-spec <full head SHA> on this pull request (the author can do this as an engineering owner), or asks for them to keep reading the scroll offset.

Required fixes:

  • Carousel.tsx:477-484 copies useScrollOverflow's edge tolerance and keeps its own heldEdges state beside the shared hook, so extend useScrollOverflow to take the held position and delete the copy.
  • Unmounting the Carousel or dropping hasSnap mid-flick leaves the release animation frame running, because the cleanup at Carousel.tsx:486-490 cancels it before detach() schedules a new one; cancel it after detach().

The fix itself holds up: the start button now returns one frame after the row leaves the start, and the new unit tests fail without the change. CI has not run on this draft yet.

[Automated review]

Comment thread packages/core/src/Carousel/Carousel.tsx Resolved
Comment thread packages/core/src/Carousel/Carousel.tsx Resolved
Comment on lines +158 to +159
// Reports where the held row is shown, once a frame, from the lead child's
// own animator.

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.

Non-blocking: this comment narrates follow(). A name such as reportHeldRow says the same thing, and the no-layout reason already sits at lines 126-127.

Carousel kept its own copy of the scroll measure's tolerance and its own
held-edge state beside useScrollOverflow. The hook now does both: an
internal useShownScrollOverflow takes the position snap paging reports
and measures the edges from it, and null measures the scroll offset
again from the next frame. That frame ends with the container or the
hook, so a detach mid-flick leaves none running. useScrollOverflow's
own return is unchanged.

@astracat-bot astracat-bot Bot 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.

Approval waits on one engineering-owner decision; the two fixes from the last review are in.

Decision needed: under snap paging, the edge fades and handleRef's canScrollPrev() / canScrollNext() now follow the held row the way the edge buttons do, but the Carousel record lists edge fades as uncontracted and does not cover the imperative handle. An engineering owner records approval by commenting /approve-spec <full head SHA> on this pull request (the author may do this as an engineering owner), or asks for them to keep reading the scroll offset until the hand-back.

The edge buttons now come back one frame after the row leaves an edge in both writing directions, and the new tests fail without the change. The held edges now live in the shared scroll-overflow hook, and its frame is released on detach and unmount. Two comments could be tightened (inline, non-blocking). CI has not run on this draft yet.

[Automated review]

shownRef.current = null;
return;
}
// The reported edges stand until then, so no edge flickers.

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.

Non-blocking (comment style): "then" has no referent at this line. Suggested wording:

// The reported edges stand until the next frame, when the handed-back
// offset is current. No edge flickers.

@cixzhang

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec dbd183d

@astracat-bot astracat-bot Bot 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.

Approved pending CI: CI has not run on this head yet, and it runs once the pull request leaves draft and a maintainer adds the ci:run label.

Under snap paging, the edge buttons and fades now come back one frame after the row leaves an edge in both writing directions, and the new tests fail without the change. An engineering owner approved the fades and canScrollPrev() / canScrollNext() following the held row for this head. Two comments could still be tightened (inline, non-blocking).

[Automated review]

@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 7, 2026
@vjeux
vjeux marked this pull request as ready for review November 7, 2026 05:12
@cixzhang
cixzhang merged commit 72a3558 into meta:main Nov 7, 2026
125 checks passed
@vjeux
vjeux deleted the uikit/c08-carousel-snap-edges branch November 7, 2026 05:58

This branch was successfully deployed

1 active deployment
Preview — dbd183d1 Deployed Nov 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants