Skip to content

refactor(Resizable): the resize model's pure parts live in one non-React module - #9197

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:create-resizable
Nov 1, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:create-resizable

Conversation

@vjeux

@vjeux vjeux commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

The first of four stacked pull requests for createResizable, split at its clean cuts: this extraction; then createResizable and the ref form, and the LayoutPanel slide, each opening as a draft stacked on the one before; then the deprecation of the configuration-object form (#9202), restacked last.

Maintainer impact

createResizable (the next pull request) has to resolve, clamp, snap and save sizes exactly as useResizable does. Without one shared module, that logic would live twice, once in the hook and once in the factory, and the two could drift.

Intended invariant

useResizable's behavior is unchanged: size parsing, bound resolution against a basis, clamping and snapping, saved-size storage and the content-box measurement give the same results, with the same warnings.

Change

The pure parts of the resize model move, unchanged, from useResizable.ts into one internal module, resizableModel.ts, which useResizable imports. The module has no React and does no DOM work on import. It is not exported. It is maintenance, not product behavior: no output, prop, default or timing changes.

A [chore] changeset names @astryxdesign/core, which the changeset-coverage check requires for package source.

Evidence

  • Failure before the change: none; this is a mechanical move.
  • Success after the change: the useResizable, ResizeHandle, Layout and SideNav suites pass unchanged.
  • Product/runtime behavior verified unchanged: the accessibility contracts for ResizeHandle, Layout, the window splitter, SideNav and AppShell, and SideNav's rail-motion browser proof, pass unchanged.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at 234bf19: unit tests (test:ui, 17,392 passed), check:repo, lint, root build then typecheck, the node project (only the known cache.test.mjs red), the Storybook build, accessibility in Chromium (77/77: ResizeHandle, Layout, the window splitter, SideNav and AppShell), and SideNav's rail-motion browser proof (10/10).

@vercel

vercel Bot commented Oct 31, 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 1, 2026 10:07am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026
@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 1, 2026, 10:14 AM UTC

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 234bf19

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
ResizeHandle 25.7 kB (−1 B) · 4.0 kB CSS (0) — ✓ · ✓ · ✗
resizable — — — · ✓ · ✗
Resizable — — ✓ · ✓ · ✗
1 more component
Component Size LoC Visual · A11y · RTL
SideNav 67.8 kB (−1 B) · 10.9 kB CSS (0) — — · — · —

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

API changes: core +8 · ~20 · −0 (report stale: run pnpm api:report); richtext +0 · ~1 · −0 (report stale: run pnpm api:report)

29 API changes (advisory)
Package Where Signature Usage
+ core . · Types · interface FieldStatusProps message: string import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {message: …}
+ core . · Types · interface FieldStatusProps ref?: React.Ref<HTMLDivElement> import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {ref: …}
+ core . · Types · interface FieldStatusProps type: InputStatusType import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {type: …}
+ core . · Types · interface FieldStatusProps variant?: FieldStatusVariant = 'attached' import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {variant: …}
+ core . · Types · interface FieldStatusProps xstyle?: StyleXStyles import type {FieldStatusProps} from '@astryxdesign/core'; const x: FieldStatusProps = {xstyle: …}
+ core . · Types · interface FieldStatusProps inherits AriaAttributes, DOMAttributes, HTMLAttributes import type {FieldStatusProps} from '@astryxdesign/core'
+ core . · Types · interface SearchContext interface SearchContext<T extends SearchableItem = SearchableItem> import type {SearchContext} from '@astryxdesign/core'
+ core ./Typeahead · Also exported from an earlier entry point · SearchContext SearchContext (.) import {SearchContext} from '@astryxdesign/core/Typeahead'
~ core . · Components · component ComplexSelector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary and ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary and ghost selectors <ComplexSelector statusVariant="attached" />
~ core . · Components · component DateInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <DateInput statusVariant="attached" />
~ core . · Components · component DateRangeInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <DateRangeInput statusVariant="attached" />
~ core . · Components · component Field prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Field statusVariant="attached" />
~ core . · Components · component FieldStatus component FieldStatus(props: FieldStatusProps) → component FieldStatus @deprecated <FieldStatus />
~ core . · Components · component FieldStatus prop variant?: FieldStatusVariant = 'attached' → prop variant: FieldStatusVariant = 'attached' <FieldStatus variant="attached" />
~ core . · Components · component FileInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <FileInput statusVariant="attached" />
~ core . · Components · component MultiSelector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary and ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary and ghost selectors <MultiSelector statusVariant="attached" />
~ core . · Components · component NumberInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <NumberInput statusVariant="attached" />
~ core . · Components · component PowerSearch prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <PowerSearch statusVariant="attached" />
~ core . · Components · component Selector prop statusVariant?: FieldStatusVariant = 'attached' for input selectors; 'detached' for secondary/ghost selectors → prop statusVariant?: FieldStatusPlacement = 'attached' for input selectors; 'detached' for secondary/ghost selectors <Selector statusVariant="attached" />
~ core . · Components · component TextArea prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TextArea statusVariant="attached" />
~ core . · Components · component TextInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TextInput statusVariant="attached" />
~ core . · Components · component TimeInput prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <TimeInput statusVariant="attached" />
~ core . · Components · component Tokenizer prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Tokenizer statusVariant="attached" />
~ core . · Components · component Typeahead prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusPlacement = 'attached' <Typeahead statusVariant="attached" />
~ core . · Types · interface FieldStatusProps interface FieldStatusProps extends BaseProps<HTMLDivElement> (props of FieldStatus) → interface FieldStatusProps extends BaseProps<HTMLDivElement> import type {FieldStatusProps} from '@astryxdesign/core'
~ core . · Types · interface FieldStatusVariantMap tooltip: true → tooltip: true @deprecated import type {FieldStatusVariantMap} from '@astryxdesign/core'; const x: FieldStatusVariantMap = {tooltip: …}
~ core . · Types · interface SearchSource search: (query: string) => Promise<T[]> | T[] → search: (query: string, context?: SearchContext<T>) => Promise<T[]> | T[] import type {SearchSource} from '@astryxdesign/core'; const x: SearchSource = {search: …}
~ core . · Types · interface UseInputStatusIconOptions statusVariant?: FieldStatusVariant → statusVariant?: FieldStatusPlacement import type {UseInputStatusIconOptions} from '@astryxdesign/core'; const x: UseInputStatusIconOptions = {statusVariant: …}
~ richtext . · Components · component RichTextEditor prop statusVariant?: FieldStatusVariant = 'attached' → prop statusVariant?: FieldStatusVariant | 'tooltip' = 'attached' <RichTextEditor statusVariant="tooltip" />

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

Current run: CI concluded failure; parts of this report may be missing.


Generated by PR Enrichment workflow | 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 needs an engineering owner's spec decision, and the change also needs work. component:Resizable FR3 now says createResizable owns the behavior that ResizeHandle and useResizable go through; an engineering owner records approval, or asks for narrower wording, by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever that decision, these need changes:

  • The docs site build stops on the ShadCN route lock: the new createResizable doc page adds hooks/create-resizable, so the lock needs refreshing with UPDATE_SHADCN_ROUTE_LOCK=1.
  • The configuration-object useResizable and createResizable each keep their own copy of the region state (bounds, collapse, saving); building one on the other would also make FR3's new sentence true.
  • architecture:public-component-api INV22 deprecates the configuration-object form when the ref form arrives, rather than in a later patch.
  • With a plain handle element, useResizable(handleRef) announces "Resize handle" and "Collapsed" untranslated and keeps its listeners on a handle that has unmounted.
  • At 4,883 changed lines the change needs splitting; landing #7782 first and moving the model extraction to its own pull request are clean cuts.

[Automated review]

// Copyright (c) Meta Platforms, Inc. and affiliates.

/** @type {import('@astryxdesign/cli/authoring').HookDoc} */
export const docs = {

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 page adds the ShadCN registry item hook-create-resizable at hooks/create-resizable, which internal/shadcn-registry/routes.lock.json doesn't list, so apps/docsite/scripts/generate-data.mjs stops with "Generated ShadCN names or routes changed" and the docs site doesn't build. Refreshing the lock with UPDATE_SHADCN_ROUTE_LOCK=1 node apps/docsite/scripts/generate-data.mjs adds just that entry.

| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| FR1 | ResizeHandle renders one focusable Handle and one enlarged Grab zone (a `--spacing-4` strip on the resize axis); the default render also contains a Grip pill, and caller `children` replace the pill. | Current source, docs, and tests |
| FR2 | The Handle carries `resize-handle`, the Grip pill carries `resize-handle-pill`, and the Grab zone carries no public target. | Current source and docs |
| FR3 | Pointer drag, keyboard, double-click collapse, snapping, persistence, reversal, and size behavior are owned by `createResizable`, which ResizeHandle and `useResizable` go through; dragging under right-to-left inverts the pointer delta so the divider follows the pointer. A disabled Handle ignores pointer and keyboard input and exposes `aria-disabled`, and keeps its tab stop (`spec:AST-085` FR1). With `autoSaveId`, storage holds the last settled size and receives the final size once when a drag ends, cancels, or unmounts. Implementation gap: a disabled Handle sets `tabIndex={-1}` (Current-state impact). | Current source, docs, and tests |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head the configuration-object useResizable({...}) and a ResizeHandle given a resizable prop don't call createResizable; they share only resizeGesture.ts and resizableModel.ts. Either build that form on the factory or word this row so it names the ref form only.

}

/** The region's state from its options, its basis and its selection. */
function compute(): void {

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 and the persistence, collapse and gesture code below repeat useSingleResizable in useResizable.ts (default latching, bound re-clamp, save once per gesture, collapse/expand/resize). Could the configuration-object form drive createResizable(null, …) so the region state lives in one place?

import type {ResizableSize} from './utils';

/** The English names the handle carries when no other is given. */
const DEFAULT_LABEL = 'Resize handle';

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 English defaults become aria-label and aria-valuetext on a plain handle, including through useResizable(handleRef), which passes no translated value. ResizeHandle resolves @astryx.resizable.handle.label and @astryx.resizable.collapsed through the translator; the ref hook could pass those as defaults.

useIsomorphicLayoutEffect(() => {
const element = ref.current;
const entry = slot.get();
if (entry != null && (element == null || entry.element === element)) {

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 handle unmounts (element == null) the old factory stays attached to the detached node, so a plain handle keeps its listeners and attributes until a new node appears or the hook unmounts. Destroying it here and continuing with a handle-less region (or a factory method that moves to a new element or to none) would release the node.

@vjeux vjeux changed the title feat(Resizable): createResizable, with useResizable(handleRef, options) as its React wrapper feat(Resizable): createResizable, with useResizable(handleRef, options) as its React wrapper; a resizable LayoutPanel slides on collapse and open Nov 1, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 1, 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 waits on an engineering owner's spec decision, and the change also needs work. component:Resizable FR3 now says createResizable owns the behavior that ResizeHandle and useResizable go through; an engineering owner approves that clause, or asks for narrower wording, by commenting /approve-spec 59ce878798f3de6f471db584bb732c2a405f6b58 on this pull request, and the author may do so as an engineering owner. Whatever that decision, these need changes:

  • The configuration-object useResizable and createResizable keep two copies of the region state and of the slide's measure-and-land logic; building that form on the factory would also make FR3's clause true.
  • The configuration-object hook now returns isCollapsed and size one commit after onCollapseChange for every caller, SideNav included, while FR8 says they stay unchanged.
  • LayoutPanel hands its pane to the region in a layout effect that its lendRef callback ref can do.
  • With a plain handle element, useResizable(handleRef) announces "Resize handle" and "Collapsed" untranslated and keeps its listeners on a handle that has unmounted.
  • architecture:public-component-api INV22 deprecates the configuration-object form when the ref form arrives, rather than in a later patch.
  • FR8's browser verification covers collapse() and Enter only; the drag past the threshold, the reopening drag, the grow arrow, a controlled change and a hook-only pane need frames too.
  • At 9,232 changed lines the change needs splitting: landing #8721 first, moving the model extraction out, and putting the slide in its own pull request are clean cuts.

[Automated review]

Comment on lines +609 to +616
useIsomorphicLayoutEffect(() => {
if (shownIsCollapsed === isCollapsed) {
return;
}
regionPane.beforeCollapseChange(isCollapsed);
// eslint-disable-next-line @eslint-react/set-state-in-effect -- the new collapse state renders one synchronous commit after the measurement above, which needs the pane still at its old size; both commits land before paint
setShownIsCollapsed(isCollapsed);
}, [isCollapsed, shownIsCollapsed, regionPane]);

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 trailing commit runs for every configuration-object region, SideNav's included, whether or not a LayoutPanel lent it a pane. After collapse() a caller's onCollapseChange state is collapsed while the hook still returns isCollapsed=false and size=200 for one commit, and a passive effect sees that mix; main renders once. Measuring in collapse/expand/resize/_onResizeMove before they set state, and trailing only a controlled isCollapsed change on a region with a lent pane, would keep FR8's "size, isCollapsed … unchanged".

Comment on lines +272 to +280
useIsomorphicLayoutEffect(() => {
if (regionPane == null) {
return;
}
regionPane.attachPane(paneLink.pane());
return () => {
regionPane.attachPane(null);
};
}, [regionPane, paneLink]);

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 lent pane to the hook's region when the element attaches, which lendRef above already observes. Calling regionPane?.attachPane(...) from lendRef (with regionPane in its dependencies) would let this Effect go.

@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 waits on an engineering owner's spec decision, and the change also needs work. component:Resizable FR3 now says createResizable owns the behavior that ResizeHandle and useResizable go through; an engineering owner approves that clause, or asks for narrower wording, by commenting /approve-spec ee2328ac6afff2c1d96507794979285256f301c2 on this pull request, and the author may do so as an engineering owner. Whatever that decision, these need changes:

  • The configuration-object useResizable and createResizable keep two copies of the region state and of the slide's measure-and-land logic; building that form on the factory would also make FR3's clause true.
  • The configuration-object hook now returns isCollapsed and size one commit after onCollapseChange for every caller, SideNav included, while FR8 says they stay unchanged.
  • LayoutPanel hands its pane to the region in a layout effect that its lendRef callback ref can do.
  • With a plain handle element, useResizable(handleRef) announces "Resize handle" and "Collapsed" untranslated and keeps its listeners on a handle that has unmounted.
  • INV22 deprecates the configuration-object form when the ref form arrives, rather than in a later patch.
  • The committed browser spec covers only collapse() and Enter; FR8's verification also asks for the drag paths, double-click, grow arrow, a controlled change and a hook-only pane, and the two records still list the slide and focus move as gaps.
  • At 9,624 changed lines the change needs splitting: landing #8721 first, moving the model extraction out, and putting the slide in its own pull request are clean cuts.

[Automated review]

cixzhang pushed a commit that referenced this pull request Nov 1, 2026
…ch the SideNav rail moves onto (#8721)

Implements the records #8783 put on main: `spec:AST-105` FR2 and FR9, `architecture:react-component-runtime` INV12, `component:SideNav` FR6, `component:Resizable` FR8, `component:LayoutPanel` FR7 and AR2, and `component:Table` FR15. Resizable's and LayoutPanel's slide and LayoutPanel's focus rule (AR2: focus moves to the ResizeHandle only when a collapsing pane holds it) are built on this path in #9197 and #9202. This PR moves no focus anywhere.

Behavior-module PR: `packages/core/src/motion/animatedLayout.ts` (FR9's shared edge reveal).

## User need

A panel that collapses beside content, a Collapsible closed while it opens, a resizable pane sliding shut: each reveals or conceals an element from one of its edges while the content beside it moves. Before this change Core had one such reveal of its own per component. The SideNav rail clipped and rode its content in its own code. Collapsible's reveal could only open downward, and a close while it opened restarted the conceal from fully shown while the content below kept gliding, so a gap opened. Nothing a resize controller could reuse existed. #8783 made these one shared edge reveal in the animated-layout module.

## Problem and solution fit

The animated-layout module now produces `spec:AST-105` FR9's edge reveal, in three forms over one ride:

- **One-state** (`playLayoutChange`, `holdLayoutExit`): reveals and conceals from the block-end edge by default, or from the block-start or an inline edge it is given, mirrored right to left. Once the content on that edge's side glides, the moving edge rides that glide frame by frame (its keyframes replaced, its clock the glide's), so no gap opens. It rides the content's offset relative to the element: when the same batch also moves the element itself, such as an item above them closing in a single-open CollapsibleGroup, the edge follows only the difference. A conceal by a start edge frees the element's space on that edge's side, so the element stays where it shows while the content before it moves; a layout where that would move the element itself is not held, and the element goes at once. A reversal starts from what the element shows. Collapsible, TreeList and Table's row expansion already play this block-end form, so they gain the ride, and Collapsible and TreeList, which hold their exits, the reversal too.
- **Two-state** (`holdLayoutEdge`, `playLayoutEdge`): holds the old state as FR8's swap copy, clips the larger state on the edge from the extent it shows, rides a gliding element, and raises the element above the copy while it conceals. The SideNav rail now plays through it on its inline-end edge and keeps only its labels' and icons' fades (`component:SideNav` FR6); its frames are unchanged.
- **A resized element with no copy** (`reportLayoutEdge`): measured before a size change commits and played after the new size applies. A larger extent reveals from the extent shown. A smaller one holds the element at the extent it was laid out at, freeing the difference by the margin on that edge's side so what lies beyond stays where the new size puts it, then clips down to the new extent. This is the path a resize controller's pane uses (`component:Resizable` FR8 and `component:LayoutPanel` FR7 in #9197 and #9202).

Reduce Motion follows `design:reduced-motion` DR2 under a coarse primary pointer and DR3 otherwise.

## Current specification

`spec:AST-105` FR9 ("One shared edge reveal") and FR2, `architecture:react-component-runtime` INV12, `component:SideNav` FR6, `component:Resizable` FR8, `component:LayoutPanel` FR7 and AR2, and `component:Table` FR15, all current on main since #8783. A LayoutPanel given a hand-built `ResizableProps` gets no slide, and the hook's own `props` passed through `resizable` slide; both are #9197 and #9202's to implement.

## Public delta and caller burden

None. The new functions and types stay internal: no entry point exports them (`animatedLayout.boundary.test.ts` checks this), and `useAnimatedLayout`'s contract is unchanged. The visible changes are Collapsible's and TreeList's: content closed while it opens, or opened while it closes, continues from what it shows, and its edge stays on the content after it (`[fix]` changeset). Table's row expansion reveal rides the rows after it.

## Design and alternatives

- **Riding:** the edge's keyframes are sampled on the glide's own sample grid and clock, so the clip and the glide are complementary by construction. The alternative, a separate spring tuned to match, drifts whenever the glide carries a running motion.
- **Resized element without a copy:** holding the old extent keeps the pane's content laid out as it was. Clipping a pane already at its new size would need its content to overflow, or a copy, which the runtime record rules out for a resize controller.
- **Morph routing** of the glide stays out until the shared rect morph lands (#8329), as INV12's "until then" clause says.

## Evidence

Status at this head: rebased onto main 8a03d75 after the component records moved to `docs/specs/components/`; the patch is byte-identical to 70ebcc7's. Here, build, type check, CI-strict lint, `check:knowledge`, the record-path tests (knowledge paths, check-knowledge, specs-for-file), `check:changesets` and the motion, SideNav and AppShell unit suites (1,020) pass, and `specs:for` still returns `component:SideNav` and `spec:AST-105` for the changed files. At 70ebcc7 every gate finished: `test:ui`, `check:repo`, the node suites (only the two `cache.test.mjs` lock cases that fail on main), and the three motion specs ×10 in WebKit under the CI job's command (740, with 100 Chromium-only skips) and in Chromium pinned to 2 cores at 4 workers (840). **Still running:** `test:ui`, the node suites, `check:repo` and the three motion specs ×10 in both engines at this head.

Each new proof failed on main first:
- `animatedLayout.edge.test.ts`: 17 of the 18 one- and two-state cases fail on main (the block-end default passes there and guards it), and the resized-element cases have no function there.
- `SideNavRailMotion.test.tsx`: a new case asserts the rail plays through the module's two-state form with no clip, copy or ride of its own. It fails on main; the rail's existing cases pass unchanged with the rail moved.
- `animatedLayout.interaction.browser.spec.ts` (FR9): a block-end reveal reversed into a conceal, an inline-end reveal and conceal (LTR and RTL), the two-state form, and a resized pane (start pane LTR to 48 px, start pane RTL to 0, end pane LTR to 48), each with a reversal. On every frame the moving edge stays within 1.5 px of what it rides. All fail on main: the block-end reversal jumped 40 px, the inline edge was clipped from the bottom, and the other forms don't exist there.
- `animatedLayoutAdopters.interaction.browser.spec.ts`: a Collapsible closed while it opens, and a TreeList row collapsed while it expands, keep their edge on the content after them. On main each conceal restarted from fully shown: −16 px and −36 px gaps.

Two more cases guard this PR's own paths. A block-start reveal and conceal in a column that stacks from its end keep the edge on the content before it, with the element staying where it shows; before the start-edge fix the conceal freed its space at its end, so the element jumped by its 80 px and an 80 px gap opened. And in a single-open CollapsibleGroup switching from a taller item to a shorter one below it, the opening item's edge stays on the next item on every frame; before the ride was taken relative to the element, the content showed 31 px over the next item.

How the proofs read a ride: both sides on the module's clock, the edge from its clip animation's keyframes and what it rides from its layout position plus its glide's keyframes, at the animations' current time. Chromium also asserts that its evaluated clip and rect equal those (under 0.5 px). In WebKit the two run a little apart, and that skew is reported per test, not hidden: a compositor-run `translate` is evaluated a little ahead of the frame and, on a late first frame, a main-thread clip reads one frame behind. Under the CI WebKit job's command (`LP_NUM_THREADS=4`), the summed skew per frame reached 0.7 to 3.4 px depending on the test. The reversals pause and seek every animation to 120 ms, and the adopters' wait for the component to commit the close before playing on, so neither a late first frame nor a component committing in a later task moves the moment they test.

## Scope

- [x] One capability is added (FR9's shared edge reveal), with its adopters in this PR's scope (the SideNav rail; Collapsible, TreeList and Table's row expansion keeping their form).
- [x] Unrelated fixes, cleanup, and policy changes were removed or split. Resizable's and LayoutPanel's adoption is #9197 and #9202.
- [x] Consumer docs, focused tests, and migration evidence ship with the feature.
- [x] Public text and artifacts contain no internal Meta context.

Overlaps: #8677 also edits `motion/animatedLayout.ts` (the theme's motion knobs), and #3890 edits `SideNav/SideNav.tsx`. This PR leaves `SideNav.tsx` unchanged.

## Testing

```
pnpm exec vitest run --project ui packages/core/src/motion packages/core/src/SideNav packages/core/src/AppShell packages/core/src/Collapsible packages/core/src/TreeList packages/core/src/Table
pnpm build && pnpm storybook:build
pnpm test:interaction packages/core/src/motion/__tests__/animatedLayout.interaction.browser.spec.ts packages/core/src/motion/__tests__/animatedLayoutAdopters.interaction.browser.spec.ts packages/core/src/SideNav/__tests__/SideNavRailMotion.interaction.browser.spec.ts --project chromium
```
For WebKit, use the CI WebKit job's container command (`internal/interaction-harness/scripts/webkit-container.mjs`) with podman `--cpus=4`, `CI=1`, `LP_NUM_THREADS=4` and 1 worker.
…act module

Size parsing and resolution against a basis, clamping and snapping,
persistence and the content-box measurement move from useResizable.ts
to resizableModel.ts unchanged, so the factory that comes next resolves,
clamps, snaps and persists exactly as the hook does. No behavior change.
@github-actions github-actions Bot removed the needs:design-review Affects visuals — Design should review label Nov 1, 2026
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 1, 2026
@cixzhang
cixzhang marked this pull request as ready for review November 1, 2026 09:08

@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 waits on an engineering owner's spec decision, and the change also needs work. component:Resizable FR3 now says createResizable owns the behavior that ResizeHandle and useResizable go through; an engineering owner approves that clause, or asks for narrower wording, by commenting /approve-spec 57eccddb88ef560c25bd4b2d13d19d0414eb305c on this pull request, and the author may do so as an engineering owner. Whatever that decision, these need changes:

  • The configuration-object useResizable and createResizable keep two copies of the region state; building that form on the factory would also make FR3's clause true.
  • The configuration-object hook now returns isCollapsed and size one commit after onCollapseChange for every caller, SideNav included, while FR8 says they stay unchanged.
  • useResizable(handleRef) on a plain element keeps the factory's listeners on a handle that has unmounted and announces "Resize handle" and "Collapsed" untranslated.
  • INV21 and INV22 deprecate the configuration-object form when the ref form arrives rather than in a later patch, and LayoutPanel hands its pane to the region in a layout effect that its lendRef callback ref can do.
  • FR8's browser verification covers collapse() and Enter only, and at 6,454 authored lines the change needs splitting, with the resizableModel.ts extraction and the LayoutPanel slide as clean cuts.

[Automated review]

}
regionPane.beforeCollapseChange(isCollapsed);
// eslint-disable-next-line @eslint-react/set-state-in-effect -- the new collapse state renders one synchronous commit after the measurement above, which needs the pane still at its old size; both commits land before paint
setShownIsCollapsed(isCollapsed);

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 trailing commit applies to every configuration-object region, SideNav's included, whether or not a LayoutPanel lent a pane: a caller's effect first sees its onCollapseChange state collapsed while isCollapsed is still false and size still the old value, where FR8 says they stay unchanged. Measuring in collapse, expand, resize and _onResizeMove before they set state, as createResizable's commit() does, would leave the trailing commit only for a controlled isCollapsed change.

if (regionPane == null) {
return;
}
regionPane.attachPane(paneLink.pane());

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 hand-off is work caused by the element attaching, and lendRef above already runs on attach and detach: calling regionPane?.attachPane(paneLink.pane()) there, with its cleanup and regionPane in its dependencies, removes this Effect (architecture:react-component-runtime INV2, INV3).

| FR1 | ResizeHandle renders one focusable Handle and one enlarged Grab zone (a `--spacing-4` strip on the resize axis); the default render also contains a Grip pill, and caller `children` replace the pill. | Current source, docs, and tests |
| FR2 | The Handle carries `resize-handle`, the Grip pill carries `resize-handle-pill`, and the Grab zone carries no public target. | Current source and docs |
| FR3 | Pointer drag, keyboard, double-click collapse, snapping, persistence, reversal, and size behavior are owned by ResizeHandle and useResizable; dragging under right-to-left inverts the pointer delta so the divider follows the pointer. A disabled Handle ignores pointer and keyboard input and exposes `aria-disabled`, and keeps its tab stop (`spec:AST-085` FR1). With `autoSaveId`, storage holds the last settled size and receives the final size once when a drag ends, cancels, or unmounts. Implementation gap: a disabled Handle sets `tabIndex={-1}` (Current-state impact). | Current source, docs, and tests |
| FR3 | Pointer drag, keyboard, double-click collapse, snapping, persistence, reversal, and size behavior are owned by `createResizable`, which ResizeHandle and `useResizable` go through; dragging under right-to-left inverts the pointer delta so the divider follows the pointer. A disabled Handle ignores pointer and keyboard input and exposes `aria-disabled`, and keeps its tab stop (`spec:AST-085` FR1). With `autoSaveId`, storage holds the last settled size and receives the final size once when a drag ends, cancels, or unmounts. Implementation gap: a disabled Handle sets `tabIndex={-1}` (Current-state impact). | Current source, docs, and tests |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head only useResizable(handleRef, options) and a ResizeHandle given that ref go through createResizable; useResizable({...}) and a ResizeHandle with a resizable prop share only resizeGesture.ts and resizableModel.ts. Either build the configuration-object form on the factory or word the clause for the ref form; an engineering owner records the decision with /approve-spec <full head SHA>.

const element = ref.current;
const entry = slot.get();
if (entry != null && (element == null || entry.element === element)) {
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.

When a plain handle unmounts, this early return keeps the factory bound to the detached node: its 7 listeners and role/aria-label stay until the hook unmounts (react-component-runtime INV6). Destroying the entry and creating createResizable(null, …) seeded from readResizableSelection, as the hook does for a new element, handles the null detachment.

import type {ResizableSize} from './utils';

/** The English names the handle carries when no other is given. */
const DEFAULT_LABEL = 'Resize handle';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With useResizable(handleRef) on a plain element these English defaults reach aria-label and aria-valuetext in every locale (architecture:internationalization INV1a); the hook can pass @astryx.resizable.handle.label and @astryx.resizable.collapsed from the translator as the defaults, as ResizeHandle does.

@vjeux vjeux changed the title feat(Resizable): createResizable, with useResizable(handleRef, options) as its React wrapper; a resizable LayoutPanel slides on collapse and open refactor(Resizable): the resize model's pure parts live in one non-React module Nov 1, 2026
@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Nov 1, 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.

Recommended for approval: the helpers move into resizableModel.ts unchanged, useResizable keeps its behavior and warnings, and no public API changes. One optional wording fix: the changeset and the new module's header name createResizable and "the factory that follows", which this pull request does not add, so a release cut before the next stacked pull request lands would point readers at a missing factory. [Automated review]

'@astryxdesign/core': patch
---

[chore] `useResizable`'s size parsing, bound resolution, clamping and snapping, saved-size storage and content-box measurement move unchanged into one internal module, so the factory that follows resolves, clamps, snaps and saves exactly as the hook does. No behavior changes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: this entry reaches the changelog under Other Changes, and createResizable is not part of this pull request. Something like "move unchanged into one internal module that a second caller can share. No behavior changes." stays accurate if the factory ships in a later release.

@cixzhang
cixzhang merged commit 00146a7 into meta:main Nov 1, 2026
53 of 54 checks passed
@vjeux
vjeux deleted the create-resizable branch November 1, 2026 10:21

This branch was successfully deployed

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