Skip to content

feat(lab): RefreshControl, pull to refresh as UIRefreshControl - #8175

Draft
vjeux wants to merge 1910 commits into
meta:mainfrom
vjeux:uikit/c28-refresh-control
Draft

vjeux wants to merge 1910 commits into
meta:mainfrom
vjeux:uikit/c28-refresh-control

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8171. Carries #8562 (Core's internal entries) as a dependency; if the entries land differently, this follows her decision. Review only this PR's change: vjeux/astryx@338dc33...16f8df7

Why

Astryx has no pull to refresh: on a phone, a pull at the top of a list reloads the whole page instead of refreshing the list.

What led to it

Part of the touch-presentations program (spec:AST-094). #8086 measured UIRefreshControl, and #8170 and #8171 built the pull; this is the component that renders it.

Options and tradeoffs

  • A capability on ScrollableArea or List: fewer exports, but it ties every scroller to the pull. Proposed instead: a lab component around any scroll container or the page.
  • A callback or a controlled flag: both, onRefresh (a promise keeps it refreshing) and a controlled isRefreshing.
  • Replace the browser's pull to refresh everywhere: only near the top, and only while mounted.
  • Draw UIKit's measured spinner now: a stand-in here; feat(lab): RefreshControl draws UIRefreshControl's measured indicator #8251 draws the measured one.

Why this choice

These keep the pull opt-in per list and leave everything else to the browser. It works under a finger, on iOS and Android alike (#8688 and #8697 add the mouse, pen, trackpad and wheel). The docs require a visible refresh button, since a pull has no keyboard or screen-reader equivalent. Under Reduce Motion a release and the end only fade, within 200 ms. These four choices are proposed for owner review. The new lab exports are canary only: RefreshControl and useRefreshControl.

How to check it

On a phone or the iOS simulator:

  1. In the story "In a scroll area", pull down from the top past the trigger: the spinner turns, the content rests under it while refreshing, then the spinner shrinks away as the content slides back.
  2. Pull a little and let go before the trigger: it springs back without refreshing.
  3. In "On the page", pull at the top: the list refreshes and the page does not reload.
  4. Scroll up from mid-list in one motion: when the content reaches the top, the same touch becomes the pull.

Evidence

With #8251's spinner, iOS 26.2 simulator at 60 fps: pull · cancel · end · short list · page, no reload · push back · pull again · catch · Reduce Motion. Android 15 emulator: scroll area · page, no reload.

iOS

Stack (merge in this order): #8360 gesture foundation (carries #8076) → #8086 refresh values → #8170 pull and state machine → #8171 useRefreshControl → this PR. It also needs #8098 (useScrollLock v2, on #8360).

@vercel

vercel Bot commented Oct 24, 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 Oct 30, 2026 1:51am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 30, 2026, 3:00 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

New Components

RefreshControl (@astryxdesign/lab) · View in Storybook
Metric Value
Entry File (ESM) 430B
Lines of Code 1188
Source Files 8
Complexity Low (3)
Exports RefreshControl, useRefreshControl
Props Count N/A
Has Tests Yes
Has Stories Yes

Component Size

Component Import before Import after Delta CSS before CSS after Delta
core/AlertDialog 56.9KB 58.8KB +1.9KB 12.4KB 12.4KB 0B
core/AppShell 57.9KB 58.6KB +780B 11.6KB 11.6KB 0B
core/AvatarGroupOverflow 39.9KB 40.2KB +320B 6.4KB 6.4KB 0B
core/Banner 47.8KB 48.1KB +305B 9.9KB 9.9KB 0B
core/BaseTypeahead 54.3KB 54.6KB +264B 9.9KB 9.9KB 0B
core/BottomSheet 34.0KB 35.6KB +1.6KB 7.9KB 7.9KB 0B
core/BottomSheetSwitcher 14.8KB 16.5KB +1.7KB 3.3KB 3.3KB 0B
core/BreadcrumbItem 66.4KB 66.7KB +316B 11.0KB 11.0KB 0B
core/Button 43.3KB 43.6KB +287B 9.2KB 9.2KB 0B
core/Calendar 54.9KB 55.2KB +306B 10.0KB 10.0KB 0B
core/Carousel 50.2KB 50.5KB +340B 10.2KB 10.2KB 0B
core/ChatComposer 60.6KB 60.9KB +286B 10.8KB 10.8KB 0B
core/ChatComposerInput 55.7KB 56.0KB +279B 10.1KB 10.1KB 0B
core/ChatComposerTokenElement 55.7KB 56.0KB +279B 10.1KB 10.1KB 0B
core/ChatDictationButton 46.5KB 46.8KB +281B 9.5KB 9.5KB 0B
core/ChatLayout 48.8KB 49.1KB +289B 9.9KB 9.9KB 0B
core/ChatLayoutScrollButton 46.4KB 46.7KB +271B 9.6KB 9.6KB 0B
core/ChatSendButton 45.8KB 46.1KB +280B 9.2KB 9.2KB 0B
core/CheckboxInput 49.9KB 50.2KB +283B 9.3KB 9.3KB 0B
core/CheckboxListItem 54.0KB 54.3KB +253B 10.5KB 10.5KB 0B
core/ClickableCard 12.8KB 13.1KB +289B 6.9KB 6.9KB 0B
core/CodeBlock 60.5KB 60.8KB +292B 10.7KB 10.7KB 0B
core/Collapsible 11.7KB 12.0KB +291B 4.2KB 4.2KB 0B
core/CommandPalette 50.9KB 52.7KB +1.8KB 11.8KB 11.8KB 0B
core/CommandPaletteItem 8.4KB 8.7KB +280B 3.5KB 3.5KB 0B
core/ComplexSelector 55.8KB 56.1KB +330B 10.6KB 10.6KB 0B
core/ContextMenu 77.7KB 79.5KB +1.8KB 14.6KB 14.6KB 0B
core/ContextMenuItem 16.6KB 16.8KB +290B 5.2KB 5.2KB 0B
core/DateInput 99.9KB 101.7KB +1.8KB 14.8KB 14.8KB 0B
core/DateRangeInput 69.4KB 71.2KB +1.8KB 11.2KB 11.2KB 0B
core/DateTimeInput 110.1KB 111.9KB +1.8KB 15.2KB 15.2KB 0B
core/Dialog 16.2KB 17.9KB +1.7KB 7.1KB 7.1KB 0B
core/DialogHeader 48.8KB 49.1KB +294B 9.8KB 9.8KB 0B
core/Drawer 31.6KB 33.3KB +1.6KB 8.0KB 8.0KB 0B
core/DrawerHeader 48.9KB 49.2KB +265B 11.2KB 11.2KB 0B
core/DropdownMenu 72.7KB 73.0KB +301B 14.7KB 14.7KB 0B
core/DropdownMenuCheckboxItem 16.1KB 16.4KB +284B 5.6KB 5.6KB 0B
core/DropdownMenuItem 15.4KB 15.7KB +287B 5.2KB 5.2KB 0B
core/DropdownMenuRadioItem 16.2KB 16.5KB +275B 5.6KB 5.6KB 0B
core/DropdownMenuSubMenu 58.1KB 58.4KB +285B 9.5KB 9.5KB 0B
core/FileInput 54.1KB 54.4KB +269B 10.3KB 10.3KB 0B
core/IconButton 43.3KB 43.6KB +287B 9.2KB 9.2KB 0B
core/Item 11.4KB 11.6KB +273B 4.7KB 4.7KB 0B
core/LayerProvider 63.3KB 63.6KB +302B 10.1KB 10.1KB 0B
core/Lightbox 51.2KB 53.0KB +1.8KB 9.8KB 9.8KB 0B
core/Link 43.5KB 43.8KB +303B 7.6KB 7.6KB 0B
core/ListItem 53.5KB 53.7KB +274B 10.5KB 10.5KB 0B
core/Markdown 144.5KB 146.2KB +1.7KB 17.4KB 17.4KB 0B
core/MobileNav 51.0KB 51.8KB +802B 10.1KB 10.1KB 0B
core/MobileNavToggle 46.2KB 46.5KB +275B 9.4KB 9.4KB 0B
core/MoreMenu 72.9KB 73.2KB +297B 14.7KB 14.7KB 0B
core/MultiSelector 76.2KB 77.9KB +1.8KB 14.6KB 14.6KB 0B
core/NumberInput 55.7KB 56.0KB +282B 10.7KB 10.7KB 0B
core/Outline 28.6KB 28.9KB +306B 4.0KB 4.0KB 0B
core/Pagination 87.7KB 89.4KB +1.7KB 15.3KB 15.3KB 0B
core/Popover 49.8KB 50.1KB +260B 12.1KB 12.1KB 0B
core/PowerSearch 155.1KB 156.9KB +1.8KB 18.6KB 18.6KB 0B
core/RadioListItem 52.1KB 52.4KB +294B 9.7KB 9.7KB 0B
core/SegmentedControlItem 8.6KB 8.9KB +285B 3.9KB 3.9KB 0B
core/SelectableCard 12.8KB 13.1KB +294B 7.1KB 7.1KB 0B
core/Selector 79.1KB 80.9KB +1.7KB 15.0KB 15.0KB 0B
core/SelectorOption 14.5KB 14.8KB +251B 5.1KB 5.1KB 0B
core/SideNav 58.0KB 58.8KB +815B 10.6KB 10.6KB 0B
core/SideNavCollapseButton 46.6KB 46.9KB +258B 9.5KB 9.5KB 0B
core/SideNavHeading 54.7KB 55.0KB +264B 10.1KB 10.1KB 0B
core/SideNavItem 54.5KB 54.8KB +280B 10.1KB 10.1KB 0B
core/Step 32.4KB 32.7KB +296B 5.3KB 5.3KB 0B
core/Stepper 47.4KB 47.7KB +281B 9.6KB 9.6KB 0B
core/Switch 48.4KB 48.7KB +298B 9.3KB 9.3KB 0B
core/Tab 9.7KB 10.0KB +294B 4.1KB 4.1KB 0B
core/TabMenu 51.6KB 51.9KB +291B 10.0KB 10.0KB 0B
core/Table 84.0KB 85.7KB +1.7KB 15.1KB 15.1KB 0B
core/TableCell 78.8KB 80.5KB +1.7KB 14.8KB 14.8KB 0B
core/TableHeaderCell 78.6KB 80.3KB +1.7KB 14.8KB 14.8KB 0B
core/TableSelectionToolbar 52.4KB 52.6KB +291B 11.2KB 11.2KB 0B
core/TextInput 53.5KB 53.8KB +288B 10.5KB 10.5KB 0B
core/Thumbnail 50.7KB 50.9KB +290B 11.0KB 11.0KB 0B
core/TimeInput 86.8KB 88.7KB +1.9KB 13.8KB 13.8KB 0B
core/Timestamp 27.5KB 27.5KB +16B 9.5KB 9.5KB 0B
core/Toast 59.8KB 60.1KB +273B 9.7KB 9.7KB 0B
core/ToggleButton 44.1KB 44.4KB +257B 9.3KB 9.3KB 0B
core/Token 29.6KB 29.9KB +305B 4.5KB 4.5KB 0B
core/Tokenizer 64.8KB 65.1KB +322B 11.7KB 11.7KB 0B
core/TopNav 51.9KB 52.6KB +802B 10.4KB 10.4KB 0B
core/TopNavHeading 54.2KB 54.4KB +244B 9.9KB 9.9KB 0B
core/TopNavItem 9.0KB 9.3KB +283B 3.8KB 3.8KB 0B
core/TopNavMegaMenu 54.2KB 54.5KB +283B 10.8KB 10.8KB 0B
core/TopNavMegaMenuItem 9.1KB 9.4KB +281B 4.1KB 4.1KB 0B
core/TopNavMenu 53.6KB 53.9KB +274B 10.1KB 10.1KB 0B
core/TreeList 32.5KB 32.8KB +290B 4.7KB 4.7KB 0B
core/Typeahead 61.5KB 61.7KB +283B 11.3KB 11.3KB 0B
lab/RefreshControl N/A 54.5KB N/A N/A 621B N/A

Sizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.

Bundle Size Summary

Gzip figures measure each package's entry file, not the code an import pulls in.

Package Entry (ESM) Entry gzip before Entry gzip after Delta
@astryxdesign/core 4.9KB 1.2KB 1.2KB 0B
@astryxdesign/lab 4.8KB 1.9KB 2.0KB +76B
Package stylesheet Gzip before Gzip after Delta
@astryxdesign/core 34.6KB 34.6KB 0B
@astryxdesign/lab 11.1KB 11.3KB +154B

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

55 of 502 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 753,933
Selector Indicator Space Bottom Sheet Wide Start neutral light 739,916
RichTextEditor Markdown parity: side by side probe dark 558,840
RichTextEditor Markdown parity: side by side probe light 540,756
Chat Full AI Chat probe light 230,823
Chat Full AI Chat probe dark 229,805
AppShell With Mobile Nav probe light 217,010
AppShell With Mobile Nav probe dark 216,960
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
AppShell Playground neutral dark 65,198
AppShell Playground neutral light 63,788
Chat Default neutral dark 36,304
Chat Default neutral light 35,767
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232

and 35 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval of this pull request waits on an engineering owner's spec decision: the draft spec:AST-094 requirements (FR1–FR23, DEC-1, DEC-2), RefreshControl's UIKit-parity behaviour in Lab, and the two new experimental core subpaths (@astryxdesign/core/motion/experimental, @astryxdesign/core/gestures/experimental, which spec:AST-017 FR21 does not cover) have no current spec authority. An engineering owner records that decision by commenting /approve-spec 2adb6f3b1f5994902204dfdcb8727820dffea32c on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, please land the stacked dependencies first so this diff carries only the refresh-control commits, move the adaptive-presentation breakpoint change (744/500 px instead of 768 px) into its own pull request, and fix the three refresh-control defects:

  • A controlled isRefreshing that stays false never ends a pulled refresh (useRefreshControl.ts:455-460, :724-731).
  • Every pull leaves an inline translate on the content, so position: fixed children stop being fixed (useRefreshControl.ts:276-278, :530).
  • The pulled list gets a blocking touchmove listener although the pull never claims a touch (GestureHost.ts:148-153).

[Automated review]

const callHandler = useCallback(() => {
const token = ++refreshToken.current;
const result = latest.current.onRefresh?.();
if (latest.current.isControlled) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In controlled mode a triggered pull returns here and can end only when isRefreshing changes (the Effect at line 724). A parent that keeps false, or sets true then false in one handler, leaves the list refreshing behind the 60 px inset with aria-busy="true". Render controlled state from the prop, or end on the first render after onRefresh in which the prop is still false.

}
}, []);
const demote = useCallback(() => {
contentElement.current?.style.removeProperty('will-change');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only will-change is removed at rest; the content keeps the translate: 0px written by animator.set({y: 0}), which makes it a containing block for position: fixed children for the rest of the session (a fixed bar inside the page story moves from 0 to −284 px after one pull). Remove the inline translate here as well.

};
}, [applyEdge, isPullEnabled, releaseEdge, resolveOwner]);

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This Effect sets state with no comment naming what it synchronizes (architecture:react-component-runtime INV1/INV3), and a lock already held at mount first renders the pull enabled. useSyncExternalStore(subscribeScrollLock, isScrollLockActive, () => false) reads it during render.

Comment on lines +148 to +153
function claimsWhileMoving(recognizer: GestureRecognizer): boolean {
return (
recognizer.acceptsTouchInput &&
!(recognizer instanceof TapGestureRecognizer)
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This ignores claimsTouches, so RefreshPullRecognizer (which never claims) still gives the whole refreshable list a {passive: false} touchmove listener; the README's Known limits say scrolls that start on such an element wait for the current task in Mobile Safari. Requiring recognizer.claimsTouches here removes it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval of this pull request waits on an engineering owner's spec decision: the iOS pull-to-refresh direction for the Lab RefreshControl, the draft spec:AST-094 requirements (whose FR4 and DEC-1 keep touch motion off the theme while this branch makes it themable), and the two new experimental core subpaths, none of which has current spec authority. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these still need changes:

  • Land the stacked dependencies first so this diff carries only the refresh-control commit; it is over 33,000 authored lines today.
  • Move the theme motion roles and the platform adaptation condition (which contradict spec:AST-012 FR2 and architecture:theme-tokens INV1) and the 744/500 px adaptive breakpoint into their own pull requests.
  • Make a controlled isRefreshing that stays false end a pulled refresh, apply the pull's overscroll-behavior-y: none only while the scroll owner overflows (spec:AST-025 FR14), give non-claiming recognizers no blocking touchmove listener, and replace the two state-setting Effects in useRefreshControl.ts.
  • Add the ## Writing rubric results section that spec approval reads.

[Automated review]

const callHandler = useCallback(() => {
const token = ++refreshToken.current;
const result = latest.current.onRefresh?.();
if (latest.current.isControlled) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In controlled mode a triggered pull returns here, and only the Effect at line 750 can end it, which runs only when isRefreshing changes. A parent that keeps false, or sets true then false in one handler, leaves the list refreshing behind the 60 px inset with aria-busy="true". Render controlled state from the prop, or end on the first render after onRefresh in which the prop is still false.

};
}, [applyEdge, isPullEnabled, releaseEdge, resolveOwner]);

useEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This Effect sets state with no comment naming what it synchronizes (architecture:react-component-runtime INV1/INV3), and a lock already held at mount first renders the pull enabled. useSyncExternalStore(subscribeScrollLock, isScrollLockActive, () => false) reads it during render.

Comment on lines +402 to +404
const near =
isPullEnabledRef.current &&
scrollTopOf(current) < Math.max(heightOf(current), REFRESH_HEIGHT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

near never checks that the owner overflows, so a short list keeps overscroll-behavior-y: none on an area that does not scroll; spec:AST-025 FR14 allows a containment policy only on axes that currently overflow.

@vjeux vjeux Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept on purpose. This is the refresh pull's own containment, not ScrollableArea's, and it follows UIKit: a UIScrollView with a UIRefreshControl always bounces vertically (alwaysBounceVertical), so a list shorter than its scroll view still pulls and refreshes. That is one of this stack's interaction proofs ("a list shorter than the screen still pulls and refreshes").

Without the claim, a pull on a scroll owner that doesn't overflow chains to the page: the page rubber-bands under the pull, and on iOS Safari the page's own reload can fire. The claim is held only near the top, only while a control is mounted and the pull is enabled (iOS only), and it is dropped while a scroll lock holds the page.

If AST-025 FR14 should govern the refresh pull too, that needs an owner decision on the spec, and pulls on short lists would stop working.

recognizer.acceptsTouchInput &&
!(recognizer instanceof TapGestureRecognizer)
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This ignores claimsTouches, so RefreshPullRecognizer (which never claims) still gives the whole refreshable list a {passive: false} touchmove listener; the note at the top of this file says a scroll that begins on such an element waits for a busy main thread in iOS Safari. Requiring recognizer.claimsTouches here removes it.

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

platform is a fifth condition field; spec:AST-012 FR2 keeps the when vocabulary closed to width, pointer, contrast and motion until that record adds one, so this needs a pull request that amends FR2.

}
const variable = motionRoleVariable(id);
try {
tokens[variable] = formatMotionRoleValue(kind, value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These --motion-* names come from table paths and are never declared in tokens.stylex.ts, which architecture:theme-tokens INV1 names as the origin of every core token name; the draft spec:AST-094 FR4 in this same pull request also says touch motion is not themable.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval of this pull request waits on an engineering owner's spec decision: the iOS pull-to-refresh direction for the Lab RefreshControl, the draft spec:AST-094 requirements (whose FR4 and DEC-1 keep touch motion off the theme while this branch makes it themable), and the two new experimental core subpaths have no current spec authority. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these still need changes:

  • Land the stacked dependencies first so this diff carries only the refresh-control commit; it is over 33,000 authored lines today.
  • Make the adaptive compact test follow spec:AST-012 FR9 (width below the effective md, 768 px by default, or height below 500) instead of 744 px, and move the theme motion roles and the platform condition, which contradict spec:AST-012 FR2 and architecture:theme-tokens INV1, into their own pull request.
  • Make a controlled isRefreshing that stays false end a pulled refresh, start the end slide from end() so a refresh that begins and ends in one render still slides back, apply the pull's overscroll-behavior-y: none only while the scroll owner overflows (spec:AST-025 FR14), give non-claiming recognizers no blocking touchmove listener, and replace the two state-setting Effects in useRefreshControl.ts.
  • Add the ## Writing rubric results section that spec approval reads.

[Automated review]

Comment on lines +529 to +531
if (latest.current.isControlled) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In controlled mode a triggered pull returns here, and only the Effect at line 848 can end it, which runs only when isRefreshing changes. A parent that keeps false, or sets true then false in one handler, leaves the list refreshing behind the 60 px inset with aria-busy="true". End a triggered refresh on the first commit after onRefresh returns in which the prop is still false, or render controlled state from the prop.

Comment on lines +681 to +693
useEffect(() => {
if (isRefreshing || pendingEnd.current == null) {
return;
}
endFrame.current = requestAnimationFrame(() => {
endFrame.current = requestAnimationFrame(() => {
endFrame.current = null;
const play = pendingEnd.current;
pendingEnd.current = null;
play?.();
});
});
}, [isRefreshing]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The end slide now waits for this Effect, which runs only when isRefreshing changes. When the begin and the end land in one render (a refresh() from a timer or socket handler with an onRefresh that settles at once), it never runs: aria-busy clears but the content stays at translate: 0px 60px with the spinner spinning in the gap. Scheduling the two frames from end() itself avoids depending on a state change rendering.

Comment on lines +434 to +436
const near =
isPullEnabledRef.current &&
scrollTopOf(current) < Math.max(heightOf(current), REFRESH_HEIGHT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

near never checks that the owner overflows, so an overflow: auto container whose content fits still gets overscroll-behavior-y: none; spec:AST-025 FR14 allows a containment policy only on axes that currently overflow.

Comment on lines +517 to +520
useEffect(() => {
setIsLocked(isScrollLockActive());
return subscribeScrollLock(setIsLocked);
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This Effect sets state with no comment naming what it synchronizes (architecture:react-component-runtime INV1/INV3), and a lock already held at mount first renders the pull enabled. useSyncExternalStore(subscribeScrollLock, isScrollLockActive, () => false) reads it during render.

Comment on lines +152 to +157
function claimsWhileMoving(recognizer: GestureRecognizer): boolean {
return (
recognizer.acceptsTouchInput &&
!(recognizer instanceof TapGestureRecognizer)
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This ignores claimsTouches, so RefreshPullRecognizer (which never claims) still gives the whole refreshable list a {passive: false} touchmove listener; the note at the top of this file says a scroll that begins on such an element waits for a busy main thread in iOS Safari. Requiring recognizer.claimsTouches here removes it.

Comment on lines +75 to +79
export const COMPACT_QUERY = '(width < 744px), (height < 500px)';

/** Both: presentations such as a sheet in place of a popover. */
export const COMPACT_TOUCH_QUERY =
'(width < 744px) and (pointer: coarse), (height < 500px) and (pointer: coarse)';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current spec:AST-012 FR9 defines compact as width below the effective md point (768 px by default) or height below 500, exported once from the theme, and says no resolver keeps size numbers of its own. The 744 px edge sends a 744–767 px touch screen to the popover where FR9 promises the sheet.

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

platform is a new condition field; spec:AST-012 FR2 keeps the when vocabulary closed to width, height, pointer, contrast and motion until that record adds one, so this needs a pull request that amends FR2.

Comment on lines +146 to +148
const variable = motionRoleVariable(id);
try {
tokens[variable] = formatMotionRoleValue(kind, value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These --motion-* names come from table paths and are never declared in tokens.stylex.ts, which architecture:theme-tokens INV1 names as the origin of every core token name; the draft spec:AST-094 FR4 in this same pull request also says touch motion is not themable.

@vjeux
vjeux force-pushed the uikit/c28-refresh-control branch from 4e4c16b to 8bfbe7c Compare October 24, 2026 23:45
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
…, on the split touch layer

Restacked on meta#8175's split hook: the drawing's layer animators,
showRevealLayer, attachLayer and the two layer refs live in
refreshTouchController.ts, the touch module that useRefreshControl
imports once, and the indicator names come from refreshIndicator.ts. The
gate is a finger on any touch device (useTouchPresentation), not iOS.

The trigger's cross-fade from the revealed spokes to the stepping ones
passes no reduced-motion option, so under Reduce Motion the animator
fades it, opacity only, in 200 ms or less on a touch screen
(design:reduced-motion DR2). The Chromium test that pinned the drawing
unchanged under reduced motion now records the animations the trigger
starts and checks that (it fails on an animator that plays the opacity
spring as is). The drawing's browser tests find the glyph inside the
indicator band, after the content.

The drawing, its unit tests and its interaction tests are as before
(the measured spokes, the 0.8 s stepping gradient, plus-lighter's sum at
the trigger, the stepping kept through the end's exit).
@vjeux
vjeux force-pushed the uikit/c28-refresh-control branch from 8bfbe7c to 435e3f7 Compare October 25, 2026 00:51
vjeux added 15 commits October 29, 2026 10:13
…nt lets go

The host's touch-action rules sit in an adopted sheet or a style element
per document or shadow root, and were never removed, so a page whose
last recognizer detached kept them, against spec:AST-094 FR24 (nothing
touch-related remains after the last instance unmounts). Fails until a
root's rules go with its last marked element.
…th its last marked element

The touch-action writer records which elements it marked in each
document or shadow root, and when the last one is unmarked (its last
recognizer detached) it removes that root's adopted sheet or style
element, so nothing touch-related remains after the last instance
unmounts (spec:AST-094 FR24). A later mark writes the rules again; an
element marked in another root first lets go of the old one.
… the inline style it reads

- The motion registry, the host's engine gate and the README name the
  presented hit test as an engine workaround (spec:AST-013 FR6) with its
  review condition (re-measure on each iOS release; remove it once iOS
  WebKit hit-tests a compositor-moved element where it is drawn) and its
  regression fixture.
- While a curve runs the animator holds the value it starts from inline,
  so presentation.ts and the README no longer say the inline style holds
  the end.
…core/internal/touch

Core ships the internal entry @astryxdesign/core/internal/touch, which
re-exports the gesture primitives for Astryx's own packages, and the
Lab-import guard fails a Lab import of core outside core's exports
(architecture:react-component-runtime INV13). The README and the barrel's
header named internal workspace imports and said nothing here is
exported; they now name that entry.
…ill needs the element, and the listeners that cancel

The release note, the stream's header, the host's comment and the README
said the stream is one passive listener per event type and that a
document-capture stopPropagation no longer starves the recognizers. Such a
stop never hid a listener on the same node, and some subscriptions cancel.
They now say what changed: an app's window-capture stopPropagation, or a
document-capture stopImmediatePropagation registered before core, no
longer hides a touch; a recognizer that must claim still needs the touch to
reach its element; the gesture host's touchend, click and contextmenu
cancel while a claimer is attached and the page hold's touchmove while it
holds; and the stream stays ahead of an app's window-capture listener only
until a type's listener is re-added. The README's list of blocking
touchmove listeners gains the claimed touch's target and the document
while a claiming element moves.
…default it moves

The {stylexOptions} form of astryxStylex now passes StyleX
treeshakeCompensation: true unless the caller sets it. spec:AST-017 FR3
asks the release note to name the old and new default and the option
that restores the old one; it now does.
…er and for the two stops the input stream fixes

- A still finger with sub-pixel jitter over a scroll view keeps its tap
  and scrolls nothing (spec:AST-094 FR18–FR22).
- A tap is still recognized under an app's window-capture
  stopPropagation, and under a document-capture
  stopImmediatePropagation registered before core's: the recognizers hear
  touches on the window's capture phase.
Chromium and WebKit pass.
…s once

Where nothing that claims is attached, the host's touchend listener is
passive and cannot cancel the engine's own click after a retargeted tap
clicks what is drawn. The shared click guard swallows that late click
whatever is attached; this case pins it, as asked on meta#8293.
…esture host's subscriptions

The gesture host subscribes no click: the one cancelling click subscriber
is the click guard's, added at a window's first arm and kept. The release
note, the stream's header and the gestures README listed click with the
host's touchend and contextmenu; they now name the guard's own.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

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 to provide or identify the governing spec for the Lab RefreshControl (the four choices the description proposes, including the UIKit-table motion it plays, which current spec:AST-094 leaves to the component's own record) and to settle three conflicts with current spec: the refresh starting with the finger still down (spec:AST-094 FR36/FR37), a pull that works under a coarse pointer only (spec:AST-094 FR19/FR43), and overscroll-behavior-y: none on a scroll container that does not overflow, which needs spec:AST-025 FR14 updated if it stays. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these still need changes:

  • Land the stacked predecessors first: this pull request carries 35,029 authored lines, about 5,300 of them for RefreshControl itself.
  • Make scripts/touch-support-bundle.test.mjs pass again and let the gesture host attach for every pointer (architecture:react-component-runtime INV13, spec:AST-094 FR43).
  • Remove the per-move scroll-offset read (spec:AST-094 FR9), keep the title's status element mounted (spec:AST-086 FR3), route the animator through internal/touch (INV13), and stop the page jump when the refresh button runs on short content.
  • Add fine-pointer before-and-after evidence for the scroll-lock change (spec:AST-017 FR59) and real-device evidence for the iOS Safari and Android Chrome claims.

[Automated review]

{...stylex.props(styles.spokes)}>
{spokes}
</span>
{title != null ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The role="status" span mounts only once title is set, so a title that first appears after mount lands in a newly mounted live region already holding its text, which spec:AST-086 FR3 forbids. Render the status span always and leave it empty without a title.

Comment on lines +758 to +759
const scrolledBack =
scrollTopOf(owner.current) - geometry.scrollTop0 > 0.5;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This reads scrollTop (or window.scrollY) on every move after the pull starts; spec:AST-094 FR9 allows no layout read after the gesture begins. Flag the scroll-back from the owner's scroll event, which the controller already listens for (:490-505), instead of reading the offset per move.

Comment on lines +874 to +875
place(REFRESH_HEIGHT);
scrollOwnerBy(current, REFRESH_HEIGHT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When the owner cannot scroll by the whole inset, the 60 px translate shows: in the Controlled story with three rows, pressing Refresh scrolled the page 32 px and dropped the list 28 px in one frame. Settle the inset in, or skip it, when the owner cannot absorb it, and do not scroll the page for a scroller that does not overflow.

*
* ONLY WHERE THE PRIMARY POINTER IS COARSE
*
* - Recognizers attach, and the host registers listeners, only while the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current architecture:react-component-runtime INV13 says the host "attaches for every pointer, with no touch-only query", and spec:AST-094 FR19 and FR43 require recognizers to attach for every pointer; this gate keeps the host coarse-only.


import {useEffect} from 'react';
import {claimRootScrollStyle, reportScrollLock} from './rootScrollStyles';
import {holdPageScroll} from '../gestures/pageHold';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Importing the page hold here brings gestures/pageHold.ts and gestures/hostRegistry.ts into Core's root and hooks entries, and scripts/touch-support-bundle.test.mjs fails its two entry tests at this head (they pass on the base).

…he page presented

Two proofs failed on a loaded machine with no defect in the control:
- the busy-main-thread settle counted the screencast's delivered frames
  against its cadence. It now hands the compositor's own frame record to
  busyWindowProof, whose verdict alone judges the settle; a settle paused
  before the block fails it.
- the catch read the settle's position before the touch, which lands later
  under load. A capture listener now records where the settle stood as the
  finger landed; a catch that restarts from zero travel fails it.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:32
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 29, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

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 to provide or identify the governing spec for the Lab RefreshControl (the four choices the description proposes, including the UIKit-table motion it plays, which current spec:AST-094 leaves to the component's own record) and to settle three intended departures from current spec: the UIKit release (the refresh starts with the finger still down, and any later lift settles onto the inset from rest, whatever its velocity, against spec:AST-094 FR36–FR38), a pull that works under a coarse pointer only (spec:AST-094 FR19/FR43), and overscroll-behavior-y: none on a scroll container that does not overflow, which needs spec:AST-025 FR14 updated if it stays. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, these still need changes:

  • Land the stacked predecessors first: this pull request carries 35,051 authored lines, about 5,300 of them for RefreshControl itself.
  • Make scripts/touch-support-bundle.test.mjs pass again and let the gesture host attach for every pointer (architecture:react-component-runtime INV13, spec:AST-094 FR43).
  • Remove the per-move scroll-offset reads (spec:AST-094 FR9), keep the title's status element mounted (spec:AST-086 FR3), route the animator through internal/touch (INV13), stop the page jump when the refresh button runs on short content, attach the pull's recognizer only under a touch presentation, and move the @astryxdesign/build default change to its own pull request.
  • Add fine-pointer before-and-after evidence for the scroll-lock change (spec:AST-017 FR59) and real-device evidence for the iOS Safari and Android Chrome claims.

[Automated review]

…dy part

The proof failed on a loaded machine with one long task: it judged long
tasks over the whole trace, the pull's trigger and the release's render
included, while Layout and Paint were judged only in the steady part. Under a
4x CPU throttle the release's last commit frame also landed past the fixed
50 ms start. The steady part now starts once the page has rendered three
frames after the settle began, counted in frames, and long tasks are judged
there too. It passes 4 of 4 under the throttle; a paint on every frame of the
settle, or an 80 ms block inside it, still fails it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

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 to provide or identify the governing spec for the Lab RefreshControl (the four choices the description proposes, including the UIKit-table motion it plays, which current spec:AST-094 leaves to the component's own record) and to settle three intended departures from current spec: the UIKit release (the refresh starts with the finger still down, and any later lift settles onto the inset from rest, whatever its velocity, against spec:AST-094 FR36–FR38), a pull that works under a coarse pointer only (spec:AST-094 FR19/FR43), and overscroll-behavior-y: none on a scroll container that does not overflow, which needs spec:AST-025 FR14 updated if it stays. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. The latest commit's narrower settle trace is sound, but whatever the decision, these still need changes:

  • Land the stacked predecessors first: this pull request carries 35,091 authored lines, about 5,400 of them for RefreshControl itself.
  • Make scripts/touch-support-bundle.test.mjs pass again and let the gesture host attach for every pointer (architecture:react-component-runtime INV13, spec:AST-094 FR43).
  • Remove the per-move scroll-offset reads (spec:AST-094 FR9), keep the title's status element mounted (spec:AST-086 FR3), route the animator through internal/touch (INV13), stop the page jump when the refresh button runs on short content, attach the pull's recognizer only under a touch presentation, and move the @astryxdesign/build default change to its own pull request.
  • Add fine-pointer before-and-after evidence for the scroll-lock change (spec:AST-017 FR59) and real-device evidence for the iOS Safari and Android Chrome claims.

[Automated review]

cixzhang pushed a commit that referenced this pull request Oct 31, 2026
…oined (#9070)

## Exact decision needed

Whether `spec:AST-086` FR9's shared channel joins a component's superseded message about its current state with its next one, and the optional public `useAnnounce` option that asks for replacement. Today every message written to a region before the previous one is exposed is joined, so a component that states its current state on every input, such as a search's result count, has all its stale states spoken together when input outpaces a frame (a barcode scanner, dictation, automation): `1 result Create "Urg" Create "Urge" Create "Urgen" Create "Urgent"`.

## Existing authority searched

- `spec:AST-086` FR9 (the join), FR6 (an identical message repeated in a frame is one message), FR8 (a busy start its outcome overtakes is dropped), FR13 and FR14 (own-content messages and modal containers).
- `architecture:interaction-modality` INV8: superseded work must not change the current status or announcement, with supersession left to component contracts.
- `spec:AST-075` DEC-2 and FR7: Astryx's comboboxes announce their result count with `useAnnounce`, the same hook products use. Tokenizer's record: its sheet's count and empty-state text go through `useAnnounce`. MultiSelector's record (draft): its result count and create row label go through the polite channel.
- `spec:AST-002` FR1, FR2, FR5, FR7, FR16 and FR20 for the new public option; `has*` booleans such as `hasClear` and `hasPaging` for its name.
- Open pull requests that mention AST-086 (#7871, #7902, #7910, #8175, #8211, #8583, #8001): none edits FR9. #8640 implements the replacement for MultiSelector and waits for this decision.

## Decision

A `useAnnounce` instance created with `hasReplace` states one current state of its owner, such as a search's result count: each message it writes withdraws that instance's previous message while it is not yet exposed, whatever their urgencies, and is spoken in the region of its own urgency; a message already exposed has been heard and stays. Messages from other instances, and from instances without `hasReplace`, still join. `useAnnounce` takes an optional options object with one field, `hasReplace` (default `false`), and the `announce(message, politeness)` it returns is unchanged. FR14's modal containers withdraw the same way, and a picker's own empty-panel message comes from its search's `hasReplace` instance (FR13). Astryx's search counts and MultiSelector's create row use a separate `useAnnounce({hasReplace: true})` instance (`spec:AST-075` FR7, Tokenizer's and MultiSelector's records); selection messages keep their own plain instance. DEC-4 records it; its decider is the approving engineering owner, with INV8 and FR8 as its reasons.

Public option (`spec:AST-002` FR5, FR20): two otherwise identical messages, `"7 results"` written twice in one frame, need different outcomes. As a search's count it replaces the earlier one; as two distinct notices both are spoken. Only the caller knows which it sends, and the channel cannot derive it. Before: `const announce = useAnnounce(); announce(t('resultCount', {count}));`. After: `const announceCount = useAnnounce({hasReplace: true}); announceCount(t('resultCount', {count}));`. Existing calls are unchanged.

## Rejected alternatives

- Each owner debouncing its own messages: every announcement waits, and every owner repeats the rule.
- An exception for every `useAnnounce` message: Astryx's comboboxes and Tokenizer announce through `useAnnounce`, so their counts would keep joining.
- A string key on each message (`announce(message, politeness, {currentState})`): a third argument whose strings every call must keep consistent, where one instance already states one state.

## Claim scope and non-goals

FR9's replacement within one `hasReplace` instance, the option that asks for it, and the AST-075, Tokenizer and MultiSelector sentences that describe their counts' announcement. Which messages a component sends through a `hasReplace` instance stays with its own record.

## Evidence and implementation relationship

- On main, the MultiSelector CreateRow accessibility spec, which types into the create row at about four keystrokes per frame, fails 9 of 10 runs alone and 26 of 48 under load, its polite region reading the joined statuses.
- #8640 implements the replacement for MultiSelector. With it, the spec passes 10 of 10 alone and 48 of 48 under load. It lands after this record changes, adopting `useAnnounce({hasReplace: true})`.

## Writing rubric results

- R1 narration: none, in AST-086, AST-075, Tokenizer's or MultiSelector's record.
- R2 research in the record: none.
- R3 evidence naming a change: none.
- R4 rejected alternatives over one line or outside a decision: one, inside DEC-4, one line; nothing added to the other records.
- R5 pull request or issue references remaining: none.
- R6 ruled open questions still listing options: none.

## Scope

- [x] This PR contains specification records only.
- [x] It records observable behavior and preserves equivalent internal implementations.
- [x] Any named internal mechanism is intentionally public, names its dependent caller/system, and explains why an equivalent implementation would not satisfy the contract.
- [x] Verification states the evidence layer and failure signal without prescribing CI job/workflow topology.
- [x] It records one intentional durable decision, not a rescue for a separable tagalong.
- [x] The canonical knowledge owner, affected current claims, and overlapping open work are named; any ownership collision is resolved or called out for human decision.
- [x] Adjacent API, visual, accessibility, composition, and implementation facts remain outside scope unless this exact decision depends on them.
- [x] Public text and artifacts contain no internal Meta context.

## Validation

Local gates at 2b84901: `pnpm check:knowledge` (records and templates aligned), Prettier, `git diff --check`, `pnpm check:repo`.

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants