Skip to content

feat(core): createReorderable makes List rows reorderable - #8139

Draft
vjeux wants to merge 15 commits into
meta:mainfrom
vjeux:uikit/c13-list-reorder
Draft

vjeux wants to merge 15 commits into
meta:mainfrom
vjeux:uikit/c13-list-reorder

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #8139 → #8143 → #8163, on main (#8106 has merged)

#8106 has merged, so this sits on main and its own commits are the whole diff. It holds fifteen commits: List's reorder (be86903); reorder mode's hint, with its move buttons and Done (03572ec), which was #8337, folded in as the stack review asked; a move that renames a row keeps reorder mode (d5b4b15); the CLI lists a create… factory beside its own hook (3ceafec); comment sentences split at 25 words (5d5acf7); a held finger selects no text (bb24139); a drag reads no layout (7d5ca01); the touch hit area's import from its new place (13fab2d); and the stack review's fixes with their two records commits (a025edb, aa6f013, 6ff0ba0, d5c3f6b, c010af7, 5668583, a395e20).

Why

People reorder playlists, to-do lists and settings on their phones. List can't do it, so apps build their own, often without a keyboard or screen-reader path.

What led to it

This is part of the UIKit-parity program, which makes Astryx's components behave like their iOS counterparts on touch screens. spec:AST-103 defines a reorderable List: createReorderable({onReorder}) makes the behavior, List takes it as reorderable, and each ListItem with an itemKey gets a handle.

  • A finger, on any device, holds a handle 150 ms to pick a row up, and so does a pen where the primary pointer is coarse. A mouse, or a pen where the primary pointer is fine, drags it directly.
  • Activating the handle starts reorder mode. The insertion line marks the row's place, the arrow keys move the row and announce its place, and Escape ends the mode. A move that renames the row, as a list named by place does, keeps the mode.
  • In reorder mode a hint below the handle shows Move up, Move down and Done, so taps or clicks alone can reorder (WCAG 2.2 SC 2.5.7). Each does what its key does, with the same announcement, and focus stays on the handle.
  • Each move calls onReorder({key, to}) once.
<List reorderable={reorderable}>
  {items.map(i => <ListItem key={i.id} itemKey={i.id} label={i.name} />)}
</List>

This builds spec:AST-103 FR1 (but positions() and labels) and FR2 to FR8. AST-103 gains "Implementation gap: not built" notes for FR1's positions() and FR4b's attach, beside its labels note. component:List's readiness line says reorderable is stable and built, but for its labels and positions. family:overlay-dismissal lists the hint among its members. The UIKit table's measured reorder values and the drop model come in here with their first user. With an animatedLayout handle, a drag's commits stay the reorder's own. The CLI lists createReorderable with the hooks.

Options and tradeoffs

  • During the drag: slide rows aside, as UIKit does, or keep them still with a line marking the drop.
  • On touch: pick the row up at once, or after a hold, so a flick still scrolls.
  • In the markup: a wrapper around each row, or only the handle.
  • Moves without a drag: a "Move before / Move after" menu, or a hint that shows them by the row.

Why this choice

design:ordered-collection-reordering chose still rows and a line, because they need no layout work during the drag and look the same everywhere. The hold is UIKit's measured 150 ms. Only the handle is added (spec:AST-103 FR5), so rows keep their layout. DEC-5 chose the hint, because it shows the moves where the row is, without opening a menu. It is the keyboard hint's own layer, and each button's tap area grows toward 44 px on touch through the shared touch hit area. The API is spec:AST-103 FR1's: one behavior object, with no hook twin.

Rebuilt on current main

This head is the change rebuilt on current main, which now has #8106. Five commits follow it:

  • The CLI's hook list: a create… factory documented as a hook lists with the hooks unless its doc names its own use… hook in relatedHooks. Then it is listed beside that hook. So astryx hook --list adds only createReorderable; createAnimator, createPanGesture and createDragToSnapPoints stay beside their hooks, as their docs say.
  • Comments: six comment sentences over 25 words are split, with the same meaning.
  • No text selection under a held handle (vjeux@4613c6f): on iOS a finger held on a reorder handle started a text selection on the row's label, and the drag grew it across the rows. The hold attaches with manageStyle: false, so its handles keep their own touch-action, and the gesture host then leaves the hold's selection lock to the list, which brought none. The list now locks selection on itself from a finger's or a pen's touch-down on a handle until it lifts, through utils/selectionLock; a mouse never takes the lock. A unit test and a Chromium touch case fail without it.
  • No layout read during a drag: the insertion line only writes. The dragged row's span is read once at the pick-up, beside the drag's other measurements, and reorder mode reads before it writes (architecture:react-component-runtime INV18: a per-frame path reads no layout, never interleaved with writes). A new Chromium case traces the crossings of a three-row touch drag: on the previous head each crossing forced a style pass, 4 in all; on this head none.
  • The touch hit area's new place (13fab2d): main's fix(Button): under a finger, a Button's target grows toward 44 px through the shared touch band #10216 moved touchHitArea.stylex from utils/ to internal/, so the reorder handle and its engine import it from there.

The stack review's fixes

  • Edge autoscroll is in the record (d5c3f6b). The spec owners ruled it a consistency change under spec:AST-103 FR3. FR3 gains their bullet verbatim, "a held drag near the scroll container's edge scrolls it toward that edge, faster the nearer it is, and stops at the end", and a verification row with its failure expectation. The engine keeps UIKit's measured autoscroll. It starts with the dragged row's edge 8 px inside the scroll edge, at 2 px per 20 ms tick (100 px/s), and rises to 10 px per tick (500 px/s) with that edge 22 px past it. The steps were measured on a UITableView on the iOS 26.2 simulator; the start inset is a guess inside the measured bracket of 3 to 12 pt.
  • One scroller walk (a025edb). The engine had its own copy of core's scroll-ancestor walk. nearestScroller, which wraps that walk and falls back to the document's scroller, moves from the keyboard module to utils/scrollingAncestor.ts, and the reorder takes its scroller from there. The keyboard module re-exports it, so its importers keep their imports. The reorder's modules gain no keyboard module, a List without reorderable bundles to the same bytes, and the keyboard target's bundle does not grow.
  • The inline start in right-to-left (aa6f013). The preview and the insertion line took the row's physical left as their inline start. So in right-to-left, a row set in from the list's start drew both shifted by its inset. There, they now count the start from the list's right edge.
  • The preview joins no form or radio group (c010af7). The preview was a plain clone of the row, so it kept each control's name. Laid beside the row, a copied checked radio took its group from the row's own: the row's radio showed unchecked for the whole drag, and a drop in place left none checked. Core's clone that takes no part in the page (names, ids and labels stripped, no form, inert and hidden) moves from motion/transientCopy.ts into its own module, motion/inertClone.ts, with paintedBackground. The swap's and the layer exit's copies keep building on it, and the transient-copy mechanism stays theirs (architecture:react-component-runtime INV12's boundary test, which gains a case naming the clone's users). The preview is now that clone, with its background from paintedBackground; both read before the pick-up's first write. A row holding a canvas that reads back empty drags as its bare box.
  • A scroll during a drag (5668583, a395e20). A wheel or trackpad scroll during a mouse drag moved the rows under a still pointer: the preview and the line rode with them, and the drop landed where the pointer had been. The engine now folds a scroll it did not cause into the drag. It reads its scroller's offset once, before its writes: at the next move, autoscroll tick or drop, or else in a later frame once its writes have rendered. The spec owners ruled it a consistency change, and architecture:react-component-runtime INV18 gains their clause with a verification row: "during a reorder drag, the engine reads its scroller's scroll offset once per frame after a scroll it didn't cause, before its writes". A known limit: while autoscroll runs, a wheel delta that coalesces with the engine's own scroll in one frame is undone by the next tick's write.
  • One layout at the pick-up (6ff0ba0). The pick-up read the list's position and the row's box after its first writes, and the preview's first offset made its animator read its style back. Every read now comes first, the preview's first offset (none) is not written, and the preview holds its own will-change (architecture:react-component-runtime INV18).

How to check it

In Storybook Core/List, on a phone or with touch emulation:

  1. Reorderable: hold a grip, drag it two rows down and let go. The row lands there and the others glide.
  2. Flick a grip: the list scrolls.
  3. Tab to a grip, press Enter, press Down twice, then Escape. A screen reader reads each position.
  4. Click a grip: the hint appears with ↑, ↓ and Done. Click ↓ twice, then Done. The row moves two places, and the moves stay.
  5. Reorder From Your UI: choose Move in a row's menu. Reorder mode starts on that row.
  6. Reorder With Pinned Rows: nothing drops above Favorites, but rows move past All mail.
  7. On a desktop, drag a grip with a mouse or a pen: it moves at once, with no hold.
  8. Reorder Long List: drag a grip to the bottom of the screen and hold it there. The list scrolls, faster nearer the edge, and stops once its end is in view.

Gates at the stack's top, 87f5aad (#8163, merged with main), which carries this head:

  • Build, typecheck, ESLint (0 errors), INV29 0, check:knowledge, check:repo, check:i18n-catalog, check:i18n-runtime, check:use-client, check:package-boundaries and the changeset check.
  • Units, after pnpm install --frozen-lockfile: List, createReorderable, motion, Layer, Core's utils and internal entries, the keyboard module, Button, the CLI's hook discovery, every Lab test and the touch-support bundle check, 5,271 of 5,271. The full UI project (22,409 tests) and the full node project (17,368 tests) pass.
  • Red-first (from the stamped head, with the same patch): a move that renamed its row ended reorder mode and was announced with the old names. The new case fails without the fix and passes with it.
  • The stack review's fixes, red-first or against a positive control:
    • The pick-up, traced under a finger from the touch to the first frame: 6 forced style or layout passes on the previous head, none on this one. The crossings stay at none.
    • A wheel scroll of three rows mid-drag: on the previous head the preview and the line rode 111 px from the pointer, and the drop landed where the pointer had been. On this head both are back under the pointer within two rendered frames of the last scroll, and the drop lands three rows further. A layout trace of the wheel, with the pointer moving, finds no forced pass and at most one offset read per scrolled frame; a build that reads inside the scroll event forces a style pass there.
    • A checked radio in the dragged row: it showed unchecked during the drag on the previous head, and stays checked through a drop in place on this one.
    • Right-to-left, with each row set in 32 px from the list's start: the preview and the line sat 32 px off the row on the previous head. On this head they match it in both directions.
    • Autoscroll: a row held at the page's bottom scrolls the page, and stops once the list's end is in view, with the preview still under the pointer. In a 400 px box the list scrolls in, the box scrolls and the page stays. These pass on the previous head and on this one. A build that takes the document as the scroller fails the box case, and one that scrolls past the end fails the stop case.
  • Browser: List's interaction specs in Chromium, 90 passed and 1 skipped. They include the selection lock, both forced-style probes, the radio case, the line in a list set in from its container (LTR and RTL, red by 56 and 16 px before the line-span change), and the two wheel cases. WebKit, in the CI lane's profile: the reorder specs pass 22, and skip 12 Chromium-only cases.

Bundle cost

Measured with the PR size report's build options on the built package (the eager chunks, minified, gzip): List and ListItem import 70.0 KB. With createReorderable they import 114.8 KB, so the reorder adds 44.8 KB where it is used. A List without reorderable imports no reorder, gesture or animator module, only the small module that names the object's key. The stack review's fixes add about 0.9 KB gzip to the reorder's modules, and nothing to a List without reorderable: the same minified bytes (measured on the source with the same options).

Writing rubric results

Over component:List (the readiness line and verified_by), spec:AST-103 (two gap notes, and FR3's edge-autoscroll bullet with its verification row), family:overlay-dismissal (the hint joins the members) and architecture:react-component-runtime (INV18's reorder scroll read with its verification row):

R1 narration: none; each edit states what the system does and does not do now.
R2 research: none.
R3 evidence: none; the verification rows and verified_by name tests, not changes.
R4 rejected alternatives: none added.
R5 change references: none; no pull request or issue number in any edit.
R6 open questions: none touched.

Pull request size

This is a behavior-module pull request: 9,235 authored lines in 57 files, without the generated API report: 4,678 of source, 4,125 of tests, and the rest stories, strings, docs and records. The reorder engine, its touch layer, its model, its handle and its hint work only together, so they land as one module.

Evidence

On the iOS simulator, a held drag on a handle before and after the selection lock: the long list before and after, and the ListInput stress story before and after. Before, the loupe and a blue selection show in 2 of 3 takes of the long list and 1 of 2 of ListInput; after, none in 3 and 2 takes.

On iOS 26.2 Safari: a held drag, the drop with the main thread blocked, and tap, Move down, Done. The last was captured before Done took the hint's text role, so Done there is darker and bolder than it is now.

@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 Nov 7, 2026 11:52am UTC

Request Review

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

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 7, 2026, 12:42 PM UTC

@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 is waiting on an engineering-owner decision. No current spec governs List.onReorder, ListItem.isReorderDisabled or the UIKit reorder model (rows slide aside, springs kept under Reduce Motion, no insertion line), so an owner needs to settle that contract and which record governs it: an amended List contract, or spec:AST-094 with its draft requirements approved. Once the governing record states it, any engineering owner, the author included, can record the decision by commenting /approve-spec <full head SHA>. These still need changes whatever that decision is:

  • The diff against main is about 31,000 authored lines from the predecessor stack. Land those pull requests or update the branch so this one carries only the reorder change.
  • Keyboard focus on a reorder handle draws two focus rings, the handle's and the whole row's, and the handle's ring is clipped at the list's edge.
  • The stacked touch row minimum heights change default List row geometry, which the List contract's FR2 says padded preserves.

[Automated review]

@github-actions

github-actions Bot commented Oct 24, 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
Contributor

Choose a reason for hiding this comment

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

Approval is waiting on an engineering-owner decision: no current spec governs List.onReorder, ListItem.isReorderDisabled or the UIKit reorder model (rows slide aside, springs kept under Reduce Motion, no insertion line), so an owner needs to settle that contract and which record governs it, an amended List contract or spec:AST-094 with its draft requirements approved. Once the governing record states it, any engineering owner, the author included, can record the decision by commenting /approve-spec <full head SHA>. These still need changes whatever that decision is:

  • The diff against main is about 31,000 authored lines from the predecessor stack; land those pull requests or update the branch so this one carries only the reorder change.
  • Keyboard focus on a reorder handle draws two focus rings, the handle's and the whole row's, and the handle's ring is clipped at the list's edge.
  • The stacked touch row minimum heights change default List row geometry, which the List contract's FR2 says padded preserves.
  • spec:AST-094 FR7 cites an AST-090 record that does not exist in the repository.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 24, 2026
@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

On the predecessor stack: this pull request is stacked, as its first line says, and only its last commit (d67ffca, 10 files, +1,703 −7) is its own. It rebases onto main as each predecessor lands, and the diff then shrinks to that commit.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

The reorder also now keys its motion on the resolver's touch presentation (useTouchPresentation), the same on every touch screen, instead of a platform check.

@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec ee13b42

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

This pull request is reviewed as part of its stack; see the review on #8163.

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

Changes requested. The List reorder follows spec:AST-103 and the reorder design, and both its drag and its reorder mode work, but two layout reads need fixing before it can land.

  • Miss: showLine (packages/core/src/List/listReorderEngine.tsx:297-302) reads row.offsetWidth right after writing the line's --astryx-reorder-line-start, so each place a drag crosses, and each reorder-mode move, lays the page out again, which architecture:react-component-runtime INV18 rules out; read the dragged row's offsetLeft and offsetWidth once when the drag begins (and with the row's other reads in placeModeLine) and pass them in.
  • Newly live check: #8106's measure, which this branch carries (packages/core/src/List/listLayoutUpdates.ts:461-482), reads getBoundingClientRect in rowsMeeting and in each measureBefore() after writing the settles, so a change with a swiped row near the view lays the page out an extra time (INV18); fix it in #8106 by taking the visible range from the first pass and measuring before the first settle is written, then bring that fix in here.

Two smaller notes are inline. The stack lines in #8143 and #8163 still build on this branch's previous commit, so rebase them once this head settles. This pull request lands only after #8106.

[Automated review]

inlineSize: `${row.offsetWidth}px`,
boxSizing: 'border-box',
boxShadow: 'none',
opacity: MOVING_OPACITY,

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 opacity applies to the preview's background too, so the row under the preview shows through it (mid-drag, "Sent" and "Spam" read on top of each other), although the background is there to cover it (lines 1120-1123). Dimming the preview's content and keeping its background opaque would keep both legible.

@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: the pull request is a draft from a fork, so CI runs once it is marked ready for review and a maintainer adds the ci:run label.

The earlier blocker is fixed: the insertion line now reads the dragged row's span once at pick-up, and the new trace spec shows no forced style or layout pass as a drag crosses rows (it fails with the previous engine).

Non-blocking:

  • In right-to-left, when rows are inset more at one inline edge than the other (edge="outset" in a host with unequal inline padding), the moving copy and the insertion line sit offset from the row by the difference; details are inline.
  • The earlier notes on the preview's opacity, the reorderPickedUp translator note and the anchoredHint.tsx header still stand.

This pull request lands only after #8106, and the layout read in #8106's measure is judged on #8106.

[Automated review]

* The span of `row` in `list`'s coordinates, read before anything in the
* frame writes a style. The list may not be the row's offset parent yet.
*/
function lineSpanOf(row: HTMLElement, list: HTMLElement): LineSpan {

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 (missed in earlier reviews): this span is measured from the list's left edge, but showLine writes it to inset-inline-start, which counts from the right in RTL, and previewOf does the same with row.offsetLeft (line 276). With edge="outset" in a host whose inline padding is 48 px at the start and 8 px at the end, an RTL row spans 17–535 px while the line and the moving copy span 57–575 px. In RTL, write list.clientWidth - (start + size) instead, for both. The new RTL spec case passes only because its rows fill the list.

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

This pull request is reviewed as part of its stack; see the review on #8163.

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

This pull request is reviewed as part of its stack; see the review on #8163.

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

This pull request is reviewed as part of its stack; see the review on #8163.

@cixzhang

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 76cd760

vjeux added 8 commits November 6, 2026 21:02
createReorderable({onReorder, isReorderable}) makes a collection's
reorder behavior, and List takes it as `reorderable`. Each ListItem
with an itemKey gets a reorder handle at its end, and each committed
move calls onReorder({key, to}) once.

Under a finger, a 150 ms hold on a handle picks its row up, and a
flick on it still scrolls. The rows stay still while a dimmed source,
a preview and an insertion line show the move. On release they glide
on the shared settle role. A pen holds the same way where the primary
pointer is coarse. A mouse, or a pen where the primary pointer is
fine, drags a handle at once. A click, Space or Enter turns on reorder
mode: the insertion line marks the row's place, the arrow keys move
the row and announce its place, and Escape ends the mode.

The reorder's machinery ships with the object, so a List without
`reorderable` loads none of it. The UIKit table's reorder values and
the drop model come in here with their first user. With an
animatedLayout handle, a drag's commits stay the reorder's own. The
CLI lists createReorderable with the hooks.
While a reorderable List row is in reorder mode, a hint anchored below
its handle shows Move up, Move down and Done until the mode ends. Each
move does what its key does, with the same announcement, and leaves the
focus on the handle. Done ends the mode as Escape does, keeping the
moves. So a pointer or a finger can reorder by taps alone, as WCAG
2.5.7 asks. A screen reader that moves its focus into the hint keeps
the mode, and the focus goes back to the handle.

The hint shares the keyboard hint's layer: hooks/anchoredHint.tsx holds
the anchored hint both render, and useKeyboardHint renders its hint
through it unchanged. The hint joins the shared dismissal stack, so an
Escape in it ends the mode and leaves a Dialog below it open.
A row named by its place, as ListInput names its records, gets a new
name after each move. The handle now records its name apart from its
registration, so a new name leaves the row in reorder mode. A move is
announced by the names the rows have when it lands.

Comments now say that a drag ends reorder mode with its announcement,
and test comments match the hold and blur behavior.
createReorderable's discovery rule lists a create*.doc.mjs documented
as a hook. createAnimator, createPanGesture and createDragToSnapPoints
are documented as listed beside their own hooks, and each names that
hook in relatedHooks. Such a factory now stays out of the hook list,
so hook --list matches main for them and adds only createReorderable.
The CLI's search goldens match main's again.
Five comments in the reorder's engine, touch glide, drop model and
UIKit table each held one sentence over 25 words, and the reorder
mode spec's header ran two sentences together. Each is now split, with
the same meaning.
The list attaches its hold with manageStyle: false, so its handles keep
their own touch-action. The gesture host then leaves the hold's
selection lock to the list as well, and the list brought none. On iOS a
still finger on a handle started a text selection on the row's label,
and the drag grew it across the rows. The list now locks selection on
itself from a finger's or a pen's touch-down on a handle until it
lifts, through utils/selectionLock. A mouse never takes the lock.
The insertion line read the dragged row's offsetLeft and offsetWidth
right after writing the line's position, at each crossing and each
reorder-mode move. Each read forced a style pass. The drag now reads
the row's span once at the pick-up, in the list's coordinates and
beside its other measurements, and reorder mode reads everything
before its first write. A comment on the touch hold now says that the
list locks text selection itself.
Main moved touchHitArea.stylex from utils to internal. The List
reorder handle and its engine now import it from there.
@cixzhang

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 13fab2d

@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 an engineering owner's decision, and three fixes are needed.

Edge autoscroll during a reorder drag (listReorderEngine.tsx:1281-1319, reorderModel.ts autoscrollStep, clampTop) is behavior that no current record states: spec:AST-103 FR3 does not mention it, and spec:AST-094 FR30 covers menus only. An engineering owner records the decision by commenting /approve-spec <full head SHA> once the governing record states it (the author may do so as an engineering owner), or the drag stops scrolling.

Blocking findings:

  • In right-to-left, a dragged row with an inline-start margin draws its preview and insertion line shifted by that margin (a row indented 32 px gets both 32 px past its edge), because both use the physical offsetLeft as inset-inline-start (listReorderEngine.tsx:276 and :303-305, ListReorderHandle.tsx:201); place them with the physical left, or convert under RTL, and add an indented RTL case.
  • Miss: picking a row up lays the page out twice before its first frame, because previewOf reads offsetLeft and offsetWidth after begin writes the rows' translate and the list's position (listReorderEngine.tsx:1151-1159, :276-277), and track's set on the new preview reads its style again (:1234); size the preview from the lineSpan read at :1116 and start it where the first track puts it (architecture:react-component-runtime INV18).
  • Regression: isScrollContainer and scrollerOf (listReorderEngine.tsx:358-363, :401-418) copy the walk utils/scrollingAncestor.ts now shares; call scrollingAncestor(list) and keep only the band reads here.

CI has not run on this head (a draft from a fork); a maintainer adds the ci:run label so CI runs.

[Automated review]

position: number;
}

function scrollerOf(list: HTMLElement): Scroller {

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.

This walk, with isScrollContainer above it, is utils/scrollingAncestor.ts scrollingAncestor plus the document fallback that keyboard/keyboardTarget.ts nearestScroller adds. Call scrollingAncestor(list) and keep only the band and position reads here, so a fix to the shared walk reaches reorderable lists.

vjeux added 4 commits November 6, 2026 22:56
The reorder engine walked a list's ancestors for its scroller with its
own copy of the walk that scrollingAncestor shares. nearestScroller,
which wraps that walk and falls back to the document's scroller, moves
from the keyboard module to utils/scrollingAncestor.ts, and the reorder
takes its scroller from there. The keyboard module re-exports it, so
its importers keep their imports.

New browser cases hold a dragged row near a scroll edge. The page
scrolls, and stops once the list's end is in view, with the preview
still under the pointer. A box the list scrolls in scrolls while the
page stays.
The preview and the insertion line took the row's physical left as
their inline start. In right-to-left that is the right edge, so a row
set in from the list's start drew both shifted by its inset. Each now
converts the left to an inline start: the distance from the list's
right edge in right-to-left.

A new browser case drags a row set in 32 px from the list's start, in
both directions, and matches the preview and the line to the row.
The pick-up read the list's position after the rows' first writes and
read the dragged row's box again to place its preview once the list
was positioned. Then it wrote the preview's first offset, and the
preview's animator read its style back twice. Each read forced a style
or layout pass before the first frame, which
architecture:react-component-runtime INV18 rules out.

The pick-up now makes every read first. The preview's span is measured
from the list's padding edge before the list is positioned, and its
first offset, none, needs no write. The preview holds its own
will-change, so its animator reads no stylesheet at its first move.

A new browser case traces the pick-up under a finger: six forced passes
before, none after.
FR3 now states the reorder's edge autoscroll in the spec owners' words:
a held drag near the scroll container's edge scrolls it toward that
edge, faster the nearer it is, and stops at the end. Its verification
row names the browser checks of a drag held near the page's edge and
near the edge of a box the list scrolls in, the unit checks of the
autoscroll step and its timer, and the failures they catch.
@cixzhang

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec d5c3f6b

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

This pull request is reviewed as part of its stack; see the review on #8163.

The preview was a plain clone of the row, so it kept each control's
name. Laid beside the row, a copied checked radio took its group from
the row's own: the row's radio showed unchecked for the whole drag, and
a drop in place left no radio checked.

The clone that takes no part in the page moves from transientCopy.ts
into its own module, inertClone.ts, with paintedBackground: names, ids
and labels stripped, no form, inert and hidden. The swap's and the
layer exit's copies keep it through inertCopy, and the transient copy
mechanism stays theirs. The preview is now that clone, and its
background comes from paintedBackground. The clone reads the row's
styles, so it is made before the pick-up's first write. A boundary test
keeps the clone to the copy mechanism and the reorder's preview.

A new browser case drags a row whose radio is checked: the radio showed
unchecked during the drag before, and stays checked now, through a drop
in place.
@cixzhang

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec c010af7

vjeux added 2 commits November 7, 2026 02:55
The drag placed its preview and line from the pick-up's snapshot, plus
its own autoscroll. A wheel or trackpad scroll during a mouse drag
moved the rows under a still pointer: the preview and the line rode
with them, and the drop landed where the pointer had been.

The engine now watches its scroller's scroll events through
subscribeInput, and skips the ones its own autoscroll causes. After any
other, it reads the scroller's offset once, before its writes: at the
next move, the next autoscroll tick or the drop, or else in a later
frame once the writes so far have rendered. So the read forces no
layout. The change in the offset joins the drag's own scrolled
distance, and the preview and the line are placed again.

Two new browser cases wheel three rows mid-drag. On the previous head
the preview and the line rode 111 px from the pointer; now both stay
under it, and the drop lands three rows further. A layout trace of the
wheel with the pointer moving finds no forced pass, and at most one
offset read per scrolled frame. A build that reads in the scroll event
itself forces a style pass there.
INV18 names one more read a scroll path may make, in the spec owners'
words: during a reorder drag, the engine reads its scroller's scroll
offset once per frame after a scroll it didn't cause, before its
writes. A verification row names the browser checks: a wheel scroll
mid-drag keeps the preview and the drop line under the pointer, with
no forced layout and one read per such frame.
@cixzhang

cixzhang commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

/approve-spec a395e20

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

This pull request is reviewed as part of its stack; see the review on #8163.

This branch was successfully deployed

1 active deployment
Preview — a395e207 Deployed Nov 7, 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. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants