Skip to content

feat(lab): useRefreshControl, UIKit's pull to refresh for an app's scroll container - #9865

Draft
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:uikit/c28-rc-batch
Draft

vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:uikit/c28-rc-batch

Conversation

@vjeux

@vjeux vjeux commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Lab demo: pull to refresh as a hook. Supersedes #8170, #8171, #8175, #8251, #8688, #8694, #8697.

Stack: #9807 → #9865

Review only this PR: vjeux/astryx@bbd153f...dee16ac

About 4,900 added lines, two thirds tests.

User need

Phone apps need UIKit's pull to refresh; a browser's own pull reloads the page.

Problem and solution fit

useRefreshControl gives an app's scroll container UIKit's pull. The app keeps its markup and draws the indicator (Core's Spinner here). The pull follows UIKit's measured mapping, triggers with the finger down and settles on the bounce.

Current specification

  • Lab: not shipped. Pull values come from the iOS 26.2 simulator (spec:AST-094 FR1–FR2).
  • Inputs: a finger and a trackpad's two-finger scroll pull. A mouse or pen drag stays the browser's, for text selection, and wheel notches don't pull.
  • Where: in the nearest scroll container that overflows. A container that fits, and the page itself, keep the browser's scroll.
  • One refresh at a time: a pull while refreshing only stretches.

Public delta and caller burden

New Lab exports useRefreshControl, its types and REFRESH_REVEAL_VAR. The hook returns refs for the content and indicator slot, isRefreshing and refresh():

const pull = useRefreshControl({onRefresh: reload, announce: 'Refreshing'});
// The app's scroll container, then:
<div ref={pull.contentRef} aria-busy={pull.isRefreshing || undefined}>
  <List>…</List>
  {/* The indicator band, after the content: */}
  <div ref={pull.indicatorRef} aria-hidden><Spinner /></div>
</div>

The app supplies the announcement, the indicator and another way to refresh: a pull has no keyboard equivalent.

Design and alternatives

  • Finger tracking writes one style per move, no render.
  • The hook pins the indicator at the scroll view's top and writes the pull's reveal.
  • Core's animator runs the motion, so a finger can catch it. A release settles from the finger's speed; the end plays --duration-medium (300 ms in neutral) on --ease-move.

Evidence

A person checks the pull on a phone, on the preview.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

Gates at this head, on main 9a4627b: typecheck, lint and unit tests clean. The interaction spec passes in Chromium (23) and WebKit ×3 (54).

The busy-end proof checks the end's start is not dated before the busy block. Under load (WebKit, 4 workers on 4 CPUs) it passed 40/40 where the old bound failed 5/40. Not-for-landing red-firsts: b2c6335 (the end played early) fails it 3/10. The new unit tests fail on the earlier code at 0109ea1, 1b34a90, a497571, dace28d and bde0539.

@vercel

vercel Bot commented Nov 4, 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 4, 2026 8:37pm UTC

Request Review

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

astracat-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 4, 2026, 8:52 PM UTC

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

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

Needs an engineering owner's decision, then changes.

RefreshControl ships here together with Core, CLI, Storybook and test-harness changes, so the Lab exemption does not apply, and no current spec record governs the component. An engineering owner needs to provide or identify that record (the pull mapping and trigger, the page top-edge claim on touch screens, the mouse and pen pull, the announcements, its motion roles, and the title prop name next to Spinner's label) or decide that it proceeds as Lab without one, and records the decision by commenting /approve-spec 11602123b977c5f3d9b2fa5f814c368301bab900 on this pull request; the author may do so as an engineering owner.

Required changes either way:

  • A mouse or pen drag down over list text at the top becomes a pull and clears the text selection (refreshTouchController.ts lines 913-920), which contradicts spec:AST-094 FR41: a drag that starts on selectable text keeps its normal selection.
  • The docs say "A trackpad's or a wheel's scroll at the top pulls too" (RefreshControl.doc.mjs lines 67, 146, 180 and 183), but the component and its tests never pull on mouse-wheel notches, and the Chinese title text describes a status line the component does not render.
  • At 8,241 authored lines with no stack, behavior-module or batch exemption, the change is too large for one review, so land the Core part (#9807) and the harness helpers separately from the component.

[Automated review]

Comment on lines +913 to +920
// A scroll selects nothing and keeps the page's selection; a pen's
// drag gives up what it selected before the pull took it.
if (pan.scrollType == null) {
if (pan.pointerType !== 'touch') {
clearDragSelection();
}
holdSelection();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A pen or mouse drag that moves down at the top becomes a pull here, and clearDragSelection() drops the text it was selecting. spec:AST-094 FR41 says a drag that starts on selectable text is not claimed and keeps its normal selection. Please start no mouse or pen pull on a drag that starts on selectable text, and drop the clearing.

},
usage: {
description:
'Pull to refresh following UIRefreshControl, with a finger, a pen or a mouse: pulling the content down past the top reveals a spinner; pulling far enough starts a refresh while the pointer is still down; letting go settles the content just below the spinner until the refresh ends, then it slides back up. Under a finger (a coarse primary pointer, on iOS and Android alike) it replaces the browser’s own pull to refresh near the top of the page while mounted. A trackpad’s or a wheel’s scroll at the top pulls too. Pair it with a visible refresh button. useRefreshControl gives the same behavior to a custom indicator.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"A trackpad's or a wheel's scroll at the top pulls too" does not match the component: mouse-wheel notches never pull (the wheel tests and FR43). Suggest: "A trackpad's two-finger scroll at the top pulls too; a mouse wheel's notches never do." The same sentence is at lines 146, 180 and 183.

Comment on lines +32 to +37
// Every pointer pulls with the same mapping, trigger, settle and drawing. A
// pen's or a mouse's pull clears the text selection that its drag started. A
// trackpad's two-finger scroll pulls once the content is at its top, and keeps
// the selection. Only under a coarse primary pointer does the pull replace the
// browser's own pull to refresh. Under a fine pointer, a programmatic refresh
// only reports busy and announces itself.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: this paragraph repeats useRefreshControl.ts lines 23-27 almost word for word. Keep it in one file, with only the reasons.

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

Needs an engineering owner's decision, then changes.

RefreshControl ships here together with Core, CLI, Storybook and test-harness changes, so the Lab exemption does not apply, and no current spec record governs the component. An engineering owner needs to provide or identify that record (the pull mapping and trigger, the top-edge overscroll claim on touch screens, the mouse, pen and trackpad pull, the Refreshing and changed-label announcements, the programmatic refresh that shows no indicator under a fine pointer, and its motion roles) or decide that it proceeds as Lab without one, and records the decision by commenting /approve-spec 4592820c910c9cbddcbf00d70f4f106af4ec68bf on this pull request; the author may do so as an engineering owner.

The text-selection drag, the wheel wording in the docs and the label name are fixed since the last review.

Required changes either way:

  • The pull re-implements Core's drag-selection hold instead of calling it: selectionOf (RefreshPullRecognizer.ts lines 137-148) and restoreSelection, holdSelection and releaseSelection (refreshTouchController.ts lines 327-360) copy selectionRangesOf and lockDragSelection from packages/core/src/utils/selectionLock.ts and already lack its document-wide selectstart refusal and selectionchange restore for WebKit, so re-export those two from the internal touch entry, call them, and delete the copies.
  • At 8,548 authored lines with no stack, behavior-module or batch exemption, the change is too large for one review, so land the Core part (#9807) and the harness helpers (#9872, #9874) separately from the component.

[Automated review]

Comment on lines +327 to +360
/** Puts back the page's selection as a pen's or a mouse's press found it. */
const restoreSelection = useCallback((ranges: readonly Range[]) => {
const selection = contentElement.current?.ownerDocument.getSelection();
if (selection == null) {
return;
}
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
}, []);
const holdSelection = useCallback(() => {
const element = contentElement.current;
if (element == null || selection.current != null) {
return;
}
selection.current = element.style.getPropertyValue('-webkit-user-select');
element.style.setProperty('-webkit-user-select', 'none');
element.style.setProperty('user-select', 'none');
}, []);
const releaseSelection = useCallback(() => {
const element = contentElement.current;
if (element == null || selection.current == null) {
return;
}
if (selection.current === '') {
element.style.removeProperty('-webkit-user-select');
element.style.removeProperty('user-select');
} else {
element.style.setProperty('-webkit-user-select', selection.current);
element.style.setProperty('user-select', selection.current);
}
selection.current = 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.

These three callbacks copy Core's lockDragSelection (packages/core/src/utils/selectionLock.ts): they put back the press selection and set user-select: none on the content, but leave out Core's document-wide selectstart refusal and selectionchange restore, which Core keeps because WebKit extends a drag's selection beyond the held elements. Re-export lockDragSelection from @astryxdesign/core/internal/touch, hold the drag with it from the pull's start (line 913) to pullEnd or pullCancel (line 967), and delete these copies.

Comment on lines +137 to +148
/** A copy of the selection in the document a contact landed in. */
function selectionOf(touch: GestureTouch): readonly Range[] {
const target = touch.target;
const selection =
target instanceof Node ? target.ownerDocument?.getSelection() : null;
if (selection == null) {
return [];
}
return Array.from({length: selection.rangeCount}, (_, index) =>
selection.getRangeAt(index).cloneRange(),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

selectionOf re-implements Core's selectionRangesOf (packages/core/src/utils/selectionLock.ts), which also drops a collapsed selection and reads ranges inside shadow roots. Re-export it from the internal touch entry and call it here.

Comment on lines +462 to +464
* Under Reduce Motion, the fade is the shared 200 ms opacity fade. It is a
* plain Web Animation: on a fresh element, the animator's start-up cost a
* busy main thread's first frame one more interval. The copy goes once its

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking (comments): this says Reduce Motion plays the shared 200 ms fade, but startSpinning skips the cross-fade under Reduce Motion (line 548), as the comment at line 216 says. Suggested: "Under Reduce Motion there is no copy and no fade (startSpinning)."

Comment on lines +13 to +14
/** CSS custom property the spokes read: the alpha step while spinning. */
export const REFRESH_SPIN_STEP_VAR = '--astryx-refresh-spin-step';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: nothing reads REFRESH_SPIN_STEP_VAR; useRefreshControl.ts only re-exports it. Delete both.

@vjeux
vjeux force-pushed the uikit/c28-rc-batch branch from 4592820 to ade2f5b Compare November 4, 2026 09:24
@vjeux vjeux changed the title feat(lab): RefreshControl, pull to refresh as UIRefreshControl feat(lab): useRefreshControl, UIKit's pull to refresh for an app's scroll container Nov 4, 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.

Needs an engineering owner's decision, then changes.

No current spec record governs useRefreshControl, and this pull request also changes Core, Storybook and a script test, so the Lab exemption does not apply. An engineering owner needs to provide or identify that record (the pull mapping and trigger, which pointers pull, the container's overscroll-behavior-y claim, when announcement is spoken, a pull while refreshing that calls onRefresh again, and a programmatic refresh whose indicator stays hidden), or decide that it proceeds as Lab, and record the decision with an /approve-spec comment naming the full head commit SHA, which the author may post if they are an engineering owner. These findings need changes either way:

  • Miss: the hook sets overscroll-behavior-y: none on the nearest overflow-y: auto ancestor even when that container does not overflow (refreshTouchController.ts:294), so a short list nested in a scrolling page blocks wheel and touch scrolling of the page and pulls to refresh mid-page, against spec:AST-025 FR14 and FR18.
  • Newly live check: the end of a refresh slides over a fixed 300 ms (refreshTouchController.ts:480), but spec:AST-094 FR45 requires motion the program starts to play the theme's duration and easing tokens scaled by tempo.
  • At 4,489 authored lines with no qualifying exemption, the change is too large for one review, so land the Core internal-entry part on its own first and consider moving the story and browser spec to a follow-up.

[Automated review]

const near =
isPullEnabledRef.current &&
current != null &&
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.

This claims overscroll-behavior-y: none on the nearest overflow-y: auto ancestor whether or not it overflows. A short list nested in a scrolling page then blocks wheel and touch scrolling of the page, and a swipe on it pulls to refresh mid-page. spec:AST-025 FR14 keeps containment to axes that overflow, and FR18 picks the nearest effective owner. Resolve the owner by overflow (content taller than its viewport), and claim only while it overflows.

// Motion, the animator cross-fades the slide.
const from = content.animator.get('y');
const element = contentElement.current;
const duration = refreshControl.endMs;

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 program starts the end, when onRefresh settles or isRefreshing turns false, so spec:AST-094 FR45 has it play the theme's duration and easing tokens scaled by tempo, not the measured 300 ms. The browser spec's 300 ms assertion (line 909) changes with it.

effects: [],
};
case 'pullMove': {
// GUESS (M-RC3): a pull while refreshing counts from the inset.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the header at lines 19-20 states this re-trigger as UIKit's behavior, but here it is a guess, and M-RC3 is named nowhere else. Suggested header line: "A pull while refreshing triggers again a trigger distance past the inset. No UIKit capture confirms this yet."

* SYNC: When modified, update:
* - /packages/lab/src/RefreshControl/refreshTouchController.ts
* - /packages/lab/src/RefreshControl/useRefreshControl.test.tsx
* - /packages/lab/src/RefreshControl/useRefreshControl.doc.mjs

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: useRefreshControl.doc.mjs does not exist, so this SYNC line points nowhere. Remove it.

@vjeux
vjeux force-pushed the uikit/c28-rc-batch branch from ade2f5b to 1d3e1ae Compare November 4, 2026 14:07
@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 1d3e1ae

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

Needs an engineering owner's decision, then changes.

No current spec record governs useRefreshControl, and this pull request also changes Core, Storybook and a script test, so the Lab exemption does not apply. An engineering owner needs to provide or identify that record (the pull mapping and trigger, which pointers pull, the scroll container's overscroll-behavior-y claim, when the announcement is spoken, a pull while refreshing that calls onRefresh again, a programmatic refresh whose indicator stays hidden, and which theme duration the end plays), or decide that it proceeds as Lab, and records that decision by commenting /approve-spec <full head SHA> on this pull request, which the author may post as an engineering owner. These findings need changes either way:

  • refreshPull.ts copies Core's parseCssEasing and duration parsing from packages/core/src/motion/roles.ts, so please re-export Core's through the internal touch entry and delete the copies.
  • refreshTouchController.ts creates its own ResizeObserver for each instance, so please register with Core's shared observeResize instead.
  • Miss: each enabled instance keeps a capture scroll listener on document while idle and under every pointer, which spec:AST-094 FR24 rules out, so please listen on the scroll owner instead.
  • Miss: the announcement option names a concept that Core's useClipboard already calls announce (architecture:public-component-api INV2, DEC-1), so please rename it.
  • Miss: the GUESS (M-RC3) re-trigger names a measurement that no list carries, which spec:AST-094 FR2 requires, so please list it or measure it.
  • At 4,894 authored lines with no qualifying exemption, the change is too large for one review, so please land the Core internal-entry part first and consider moving the story and browser spec to a follow-up.

[Automated review]

* A CSS easing as cubic-bézier control points. Null for an easing that has
* none, such as `steps()`.
*/
export function parseCssEasing(value: string): CurvePoints | 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.

This copies Core's parseCssEasing (packages/core/src/motion/roles.ts), and parseCssTimeMs above copies parseMotionRoleValue('duration', …) from the same module. Please re-export those two from @astryxdesign/core/internal/touch, call them in endTiming, and delete the copies, so a fix to Core's token parsing reaches the refresh end too.

// container resizes, with no scroll event. So the owner is read again on
// each resize of the content and of its scroll containers.
const resizes =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core already has one shared resize observer, observeResize from @astryxdesign/core/utils. Please register the content and each scroll container with it, and call the returned unsubscribe functions in the cleanup, instead of creating a ResizeObserver per hook instance.

update();
}
};
document.addEventListener('scroll', onScroll, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Miss: this keeps a capture scroll listener on document for every enabled instance, idle and under a fine pointer too. spec:AST-094 FR24 allows document listeners only during a press, and its verification fails a per-instance document listener. Please listen for scroll on the resolved scroll owner instead, re-attached when resolveOwner changes it.

* Announced politely when a refresh starts, for example "Refreshing". The
* app supplies it in its own language. Omit it to announce nothing.
*/
announcement?: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Miss: Core's useClipboard already names this option announce: a localized message spoken in a polite live region, omitted for none. architecture:public-component-api INV2 and DEC-1 keep one word per concept, so please rename it announce.

effects: [],
};
case 'pullMove': {
// GUESS (M-RC3): a pull while refreshing counts from the inset.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Miss: spec:AST-094 FR2 has a guess name the measurement that will replace it, and its verification fails a guess without a listed measurement. M-RC3 is listed nowhere. Please list it, with its setup and the value it replaces, or measure it.

const indicator = useMotionAnimator();
const contentElement = useRef<HTMLElement | null>(null);
const indicatorElement = useRef<HTMLElement | null>(null);
// Whether the content holds the refreshing inset (only under a finger).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: begin() also sets this flag (line 766), so "only under a finger" is not true. Suggested: // Whether the content holds the refreshing inset.

* @input Touches from the gesture arena (Core's internal touch entry)
* @output Exports RefreshPullRecognizer, the vertical pan that reads a pull
* to refresh without taking the touch, and RefreshScrollPullRecognizer
* @position The gesture of RefreshControl, on the foundation's

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: no RefreshControl exists any more. Name useRefreshControl here and in useMotionAnimator.ts:10.

@vjeux

vjeux commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

The Core internal-entry part lands first as #9807. This pull request then rebases onto it, so its own diff is Lab-only and the Lab exemption applies.

@vjeux
vjeux force-pushed the uikit/c28-rc-batch branch from 1d3e1ae to e60e757 Compare November 4, 2026 15:10

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

Changes requested.

The earlier findings are fixed: Core's parsers and observeResize replace the copies, no document listener remains, the option is announce, and the guess is gone. Two items remain:

  • The scroll owner's scroll listener moves only inside update() (refreshTouchController.ts:353-365), so when resolveOwner() changes the owner at touch-down, or the content mounts after the hook, the container's top edge is claimed only at touch-down and keeps overscroll-behavior-y: none after scrolling away; please move the listener wherever the owner changes, re-run that setup when contentRef attaches a node, and add a test for content that mounts after the hook.
  • At 4,817 authored lines with no qualifying exemption the change is too large for one review, so please either declare the existing stack with an identical Stack: #9807 → #9865 line in both pull request bodies or split it further.

[Automated review]

Comment on lines +361 to +365
const update = () => {
resolveOwner();
applyEdge();
listen();
};

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 is the only place the scroll listener moves, but resolveOwner() also changes the owner at touch-down (line 706), at a pan's start (603), in end() (487) and in begin() (761), and content that mounts more than two frames after the hook never reaches update() at all. The container's top edge is then claimed only at touch-down and never released after scrolling away, so its bottom edge keeps overscroll-behavior-y: none (architecture:react-component-runtime INV6). Please move the listener wherever owner.current changes (inside resolveOwner, with the Effect's cleanup removing the last one), re-run this setup when contentRef attaches a node, and add a test for content that mounts after the hook.

// - A pull triggers once, with the finger down, when its overscroll reaches
// the trigger distance. Only overscroll that a finger drives triggers, never
// a momentum overshoot.
// - A pull while refreshing only stretches: one refresh runs at a time.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the header calls these rules "as UIKit runs them", but no UIKit capture backs this one, and the previous version said UIKit fires valueChanged again. Suggested: keep "One refresh runs at a time: a pull while refreshing only stretches." and drop the UIKit attribution for this rule.

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

Needs an engineering owner's decision, then one change.

#9807: approved pending CI. Core's internal touch entry re-exports exactly the names Lab's hook imports, inside the internal-subpath exception; CI has not run on this draft, so once it is ready for review a maintainer adds the ci:run label so CI runs.

#9865: needs an engineering owner's decision and a change. No current spec record governs useRefreshControl and the story sits outside packages/lab, so an engineering owner records the decision for this head by commenting /approve-spec <full head SHA> (the author may, as one) or provides the governing record; the earlier approval named the previous head. The scroll-owner listener fix and the stack declaration clear the two earlier findings.

  • Miss: the indicator's fade in startSettle (refreshTouchController.ts:447-472) re-solves the settle spring and differentiates it locally, which Core's mapProgress in packages/core/src/motion/progress.ts already does for companion motion, so please re-export it through the internal touch entry and build the fade from the settle's curve('y') with it.

Stack: the two pull requests fit together, and a pull request lands only when it and every pull request below it are approved.

[Automated review]

if (to === 'top' && !model.current.isRefreshing && from > 0) {
// The indicator fades as the content covers it: started in the same
// task, the two start on the same compositor frame.
const curve = solveSpring(toSpring(scroll.bounce), {from, to: 0});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Miss: this fade re-solves scroll.bounce with the animator's own 0.05 px threshold and differentiates it locally (derivative), which is what Core's mapProgress (packages/core/src/motion/progress.ts) does for companion motion; MotionHandle.curve says to map the settle's curve with it and play that on the same clock. Please re-export mapProgress through the internal touch entry and build the fade as mapProgress(settle.curve('y'), y => revealProgress(y, dimension) / start) from the content's settle handle, then drop this local companion.

const refreshToken = useRef(0);
const isControlled = isRefreshingProp !== undefined;
const latest = useRef({onRefresh, isControlled, announce, announcer});
useLayoutEffect(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: this layout Effect has no note on what it syncs. Suggested: "Syncs the handlers' ref with the newest options."

@vjeux
vjeux force-pushed the uikit/c28-rc-batch branch from 31ca01f to 680bf0d Compare November 4, 2026 17:06
@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 680bf0d

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

Needs an engineering owner's decision, then one change.

#9807: approved pending CI. Core's internal touch entry re-exports exactly the names Lab's hook imports; once the draft is ready for review, a maintainer adds the ci:run label so CI runs.

#9865: needs an engineering owner's decision, then the change below. No current spec record governs useRefreshControl and its story sits outside packages/lab, so an engineering owner records the decision for this head by commenting /approve-spec <full head SHA> (the author may, as an engineering owner) or provides the record that governs the pull, its inputs, the container's top-edge claim, the announcement and the end.

  • Miss: a finger that catches the settle back to the top leaves the indicator's opacity fade running, because stopPlacing and onTouchDown in refreshTouchController.ts stop only y, so the indicator fades out while it is pulled deeper and pops back when the fade ends; in onTouchDown, multiply the reveal by the indicator's presented opacity and call restoreIndicator(), so the pull and a resumed settle start from what is on screen, and add a test that catches a settle and pulls on.

Stack: the two pull requests fit together, and a pull request lands only when it and every pull request below it are approved.

[Automated review]

Comment on lines +726 to +731
pan.onTouchDown = () => {
resolveOwner();
applyEdge();
cancelPendingEnd();
stopPlacing();
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Miss: a finger landing on a settle back to the top stops only the y channels here, so the indicator's opacity fade from startSettle keeps running under the next pull. In Chromium on the story, caught at 54 px and pulled on to 84 px, the indicator's visible amount fell from 0.32 to 0; the finished fade then wrote reveal 0 under the still finger, and the next move popped it to 0.67. A tap that catches it restarts the fade at opacity 1 over the old reveal, so it flashes. Suggested: end the fade here and keep what it shows.

const shown = Number(indicatorElement.current?.style.getPropertyValue(REFRESH_REVEAL_VAR) || 0);
setReveal(shown * indicator.animator.get('opacity'));
restoreIndicator();

with a test that catches a settle back to the top and pulls on.

@vjeux
vjeux force-pushed the uikit/c28-rc-batch branch from 680bf0d to 9b255ea Compare November 4, 2026 17:51
@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 9b255ea

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

Approved pending CI: CI has not run on this head yet.

  • #9807: approved pending CI. Core's internal touch entry re-exports exactly the names Lab's hook imports, with no change to the public entry.
  • #9865: approved pending CI. An engineering owner recorded the decision on useRefreshControl for this head, and the fix for a finger catching the settle keeps the indicator in view, with a test.
  • Stack: the two fit together and merge cleanly onto main; a pull request lands only when it and every pull request below it are approved, and once the drafts are ready for review, a maintainer adds the ci:run label so CI runs.

[Automated review]

@vjeux
vjeux marked this pull request as ready for review November 4, 2026 18:17
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 4, 2026
@cixzhang
cixzhang marked this pull request as draft November 4, 2026 18:53
@cixzhang
cixzhang marked this pull request as ready for review November 4, 2026 18:53
@vjeux
vjeux marked this pull request as draft November 4, 2026 19:19
… reads

Core's internal touch entry re-exports, by name, what Lab's
useRefreshControl imports: the pan recognizer, its hook and the gesture
types; the motion animator; the cubic Bézier, the rubber band and its
inverse, and the map of a companion curve; the UIKit scroll table; and
the motion-role parsers. Each one is an existing internal, and Core's
behavior does not change. A bundle test checks that the public entry
exports none of them.

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

Needs an engineering owner's decision on #9865; #9807 is approved pending CI.

#9807

Approved pending CI: CI has not run on this head yet. Core's internal touch entry re-exports exactly the names Lab's hook reads, with no change to Core's public entry.

#9865

No current spec record governs useRefreshControl, and the last /approve-spec named the previous head, so approval needs an engineering owner to record the decision for this rebased head by commenting /approve-spec <full head SHA> (the author may, as an engineering owner) or to provide the governing record, covering the pull and its trigger, which inputs pull, the scroll container's top-edge claim, the announcement and the end.

  • Miss: a release settles from rest whatever the finger's speed (refreshTouchController.ts:454-463), where spec:AST-094 FR45 asks a gesture's settle to start at the release velocity; the decision should say whether that stands.

Stack

#9807 exports exactly what #9865 imports, and a pull request lands only when it and every pull request below it are approved; both drafts come from a fork, so once they are ready for review, a maintainer adds the ci:run label so CI runs.

[Automated review]

const touchRelease = pullPointer.current === 'touch';
const contentSettle = content.animator.animate(
{y: target},
{spring: scroll.bounce, velocity: {y: 0}},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Miss: every release settles from rest here (velocity: {y: 0}, and again at line 461), whatever the finger's speed. spec:AST-094 FR45 asks a gesture's settle to start at the release velocity, while its Non-goals keep scroll physics native and OQ1 leaves the scroll view's bounce-back open, so the engineering owner's decision for this head should say whether this stands. If it should carry the speed, the pan's release velocity would go here, as Core's other scroll.bounce users hand theirs.

vjeux added 2 commits November 4, 2026 12:26
…roll container

Lab's useRefreshControl gives an app's own scroll container UIKit's pull
to refresh. The pull follows UIKit's measured mapping and triggers with
the finger down. A release settles onto the refreshing inset on the
scroll view's bounce. The end slides away on the theme's duration and
move easing tokens.

The app draws the indicator. The hook pins it at the top of the scroll
view and writes how much of it the pull reveals. The app also supplies
the announcement text. A finger and a trackpad's two-finger scroll pull.
A mouse or pen drag stays the browser's, for text selection. The pull
applies in the nearest scroll container that overflows, never on the
page itself.
A Storybook story shows useRefreshControl on a plain scroll container,
with Core's Spinner as the app's indicator. The interaction spec opens
it, and the story can hold a refresh until a test ends it.
@vjeux
vjeux force-pushed the uikit/c28-rc-batch branch from 705f4cb to dee16ac Compare November 4, 2026 20:30
@cixzhang

cixzhang commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

/approve-spec dee16ac

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

Needs an engineering owner's decision on #9865, then two changes; #9807 is approved pending CI.

#9807

Approved pending CI: CI has not run on this head yet. Core's internal touch entry re-exports exactly the names Lab's hook reads, with no change to Core's public entry.

#9865

No current spec record governs useRefreshControl, and the last /approve-spec named an earlier head. Approval needs an engineering owner to record the decision for this head by commenting /approve-spec <full head SHA>, which the author may do as an engineering owner, or to provide the governing record. The decision covers the pull and its trigger, which inputs pull, the scroll container's top-edge claim, the announcement, the end, and the release settle, which now starts at the finger's speed.

Required changes:

  • The release hands the settle pan.velocity() (refreshTouchController.ts:724), but the pull is the scroll view's own drag, and spec:AST-100 FR2 gives a scrolling surface the scroll view's release velocity, so read pan.scrollVelocity() as the pager and the picker wheel do, and add an accelerating stream to the release test.
  • pullSlope (refreshPull.ts:70-93) re-derives by finite difference the slope Core already exports as rubberBandSlope, so re-export that through the internal touch entry and call it.

Stack

#9807 exports exactly what #9865 imports, and the rubberBandSlope fix adds one name. A pull request lands only when it and every pull request below it are approved. Both drafts come from a fork, so once they are ready for review, a maintainer adds the ci:run label so CI runs.

[Automated review]

// A lift hands the content's speed to the settle. A cancelled pull's
// finger never lifted, so its settle starts from rest.
releaseVelocity.current =
pan.state === 'ended' ? pan.velocity().y * fingerSlope.current : 0;

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 hands the settle the pan's velocity, the dragged-surface filter. The pull is the scroll view's own drag and settles on the scroll view's bounce, so spec:AST-100 FR2 gives it the scroll view's release velocity, which Core reads as scrollVelocity() (the pager and the picker wheel use it). On an accelerating flick (10, 10, 10, 20, 30 px per 16 ms), velocity() reads 1,375 px/s and scrollVelocity() reads 906 px/s.

pan.state === 'ended' ? pan.scrollVelocity().y * fingerSlope.current : 0;

Comment on lines +70 to +93
/** The step of the finite difference taken on a travel, px. */
const SLOPE_STEP = 0.01;

/**
* How fast the overscroll moves per px of finger travel at `travel`: 1 where
* it tracks the finger, less in the rubber band. A release's speed times
* this slope is the content's speed, which its settle starts at.
*/
export function pullSlope(
travel: number,
dimension: number,
mode: PullMode,
): number {
if (!(travel > 0)) {
return 0;
}
const before = Math.max(0, travel - SLOPE_STEP);
const after = travel + SLOPE_STEP;
return (
(pullOverscroll(after, dimension, mode) -
pullOverscroll(before, dimension, mode)) /
(after - before)
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Core already exports this slope as rubberBandSlope (motion/rubberBand.ts), described there as "what a finger velocity becomes on screen while banding". Re-export it through the internal touch entry and call it, so the slope cannot drift from Core's rubber band:

const linear = linearStretch(mode);
return travel <= linear ? 1 : rubberBandSlope(travel - linear, dimension);

This branch was successfully deployed

1 active deployment
Preview — dee16ac3 Deployed Nov 4, 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