Skip to content

feat(Resizable): useResizable(handleRef, options) is createResizable's thin React wrapper, and ResizeHandle renders the factory bound to its element - #9370

Draft
vjeux wants to merge 10 commits into
meta:mainfrom
vjeux:use-resizable-handle-ref
Draft

vjeux wants to merge 10 commits into
meta:mainfrom
vjeux:use-resizable-handle-ref

Conversation

@vjeux

@vjeux vjeux commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

The second of five stacked pull requests for createResizable. It is built on the internal factory and the move of the configuration-object form and ResizeHandle onto it (#9297), and merges after it; this branch carries #9297's commits. This head's own changes: vjeux/astryx@315af0f...4905b79. The public export of createResizable with its docs and component:Resizable FR3 (#9372) is stacked on it, then the LayoutPanel slide (#9300) and the deprecation (#9202), which lands in the same release as this pull request.

User need

spec:AST-107 FR6 has each interaction hook be its factory's thin React wrapper. With #9297 the factory exists inside the package and the configuration-object form builds on it, but React callers have no form that takes the handle itself.

Problem and solution fit

  • The ref form. useResizable(handleRef, options) creates createResizable on the element behind the ref as it mounts, passes later options through update, destroys it as it unmounts, and returns the factory's size, isCollapsed and controls.
  • Same commit. A controlled isCollapsed is read from the options in the render that passes it, so size and isCollapsed land in the commit of the state onCollapseChange set (component:Resizable FR8).
  • ResizeHandle renders the factory bound to its element. Given the ref, it renders the region's separator values and forwards its pointer, keyboard and double-click events to the factory's input, after a caller's onKeyDown (FR7). Its own isReversed and isDisabled apply with the factory's options: either one reverses or disables the handle.
  • A handle the owner does not render. ResizeHandle's callback ref tells the hook watching its ref as the element attaches and lets go. A handle that a child mounts from its own state, or hides and shows in its own <Activity>, is bound at once, without the hook's component rendering.
  • Across an <Activity>. The region keeps its size and collapse state when an <Activity> hides and shows the hook's own component: the factory goes as the effects unmount, and the next one starts where the region stood.
  • A plain element behind the ref gets the factory's attributes and input, drops them when it unmounts, and is named and announced with the provider's translation of the catalog messages, unless the caller passes label. The hook reads a plain element when its own component renders.
  • The container. The same options follow a container swapped behind containerRef, at each commit of the hook's component.

Current specification

  • spec:AST-107 FR6
  • architecture:public-component-api INV22
  • component:Resizable FR7 and FR8

Public delta and caller burden

A useResizable(handleRef, options) overload and the UseResizableResult type; the core API report gains both. Its options are the single-region options plus isReversed, isDisabled and label; their type, CreateResizableOptions, exports with createResizable and ResizableController in #9372, with FR3. The configuration-object form, ResizeHandle's resizable prop and their behavior are unchanged.

const handleRef = useRef(null);
const sidebar = useResizable(handleRef, {defaultSize: 260});
<LayoutPanel width={sidebar.size} />
<ResizeHandle ref={handleRef} />

Design and alternatives

  • A thin wrapper. The hook holds one factory in a store React reads, so a mount that settles where it started renders once, and a hydrating render reads the state the server rendered.
  • The registry. A factory created on ResizeHandle's own element binds to the component through an internal registry instead of driving the element. It adds no listener or attribute there; the component renders the separator's values and forwards its events. Data attributes on the element were considered, but React would overwrite them on its next render. A factory stays bound when the handle lets go of its element and claims it again.
  • The layout phase. React's store subscriptions start in the passive phase and do not read the store again when an <Activity> shows its subtree, so a factory bound in the commit that mounts or shows a handle, or made for a reveal, would be missed until its next change. ResizeHandle and the hook follow the binding and the region from the layout phase, so both render before paint.
  • The collapsed text and the expanded size. The provider's translation of the collapsed text reaches the factory through the package's internal channel, and the hook reads the expanded size there, for a controlled isCollapsed in the parent's own commit and an <Activity> reveal; neither adds a public option or member.
  • The warn-once. A ResizeHandle with a resizable prop and a bound region warns once, through useDevWarning.

Evidence

Unit tests (red before this change: 23 failed):

  • The hook only delegates: with a stand-in factory, it creates it on the element, passes later options through update, destroys it on unmount, and returns what it reports.
  • Same commit: for the ref form and the configuration object, controlled and not, every commit shows the parent's state from onCollapseChange and the hook's size and isCollapsed together, through collapse(), Enter, expand(), double-clicks and a drag past the threshold.
  • ResizeHandle on a factory's region: keys, drag, FR7's order, collapse, its own isReversed and isDisabled, unmount mid-drag, a late handle, StrictMode, and the warn-once.
  • A handle the owner does not render: a handle a child mounts and unmounts from its own state, a handle in the child's own <Activity>, and a factory made directly on a ResizeHandle's element across an <Activity>.
  • Across an <Activity>: the ref form keeps its size and collapse state when its component is hidden and shown.
  • A plain element: when it unmounts, input that still reaches it changes nothing, its attributes are restored, the region still answers its controls, and a new element starts where the region stands. Its name and collapsed text are the provider's translation, and a caller's label wins.
  • The container and the server: the same options follow a container swapped behind the ref, and a percentage default resolves against its container once measured when the handle attaches before then; importing the hook touches no DOM, and it renders its initial size on the server.

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

Local gates at 4905b79: unit tests (test:ui, 17,471 passed), check:repo, lint, root build then typecheck, the core API report regenerated from this build (it gains only this pull request's rows; on this base, 00146a7, the committed report lacks rows that #9344 later refreshed), the node project (only the known cache.test.mjs red), the docsite build and tests (732 passed), the Storybook build, accessibility in Chromium (77/77), the accessibility audit of Resizable, SideNav and Layout (0 new) and of every story (0 new), and SideNav's rail-motion browser proof in Chromium (10/10).

vjeux added 2 commits November 1, 2026 08:04
…t (spec:AST-107 FR6)

Red: there is no createResizable yet. The tests drive a plain element
through the factory: its role, name, value and tab stop, a pointer drag
that holds its pointer, the keys, collapse and restore, a controlled
collapse, disabled input, update and subscribe, a percentage basis,
persistence, destroy mid-drag, a region with no handle yet, and an
import that does no DOM work.
…ain element a window splitter that resizes a region, without React (spec:AST-107 FR6)

createResizable(handle, options) gives the element role=separator, a tab
stop and the catalog's English name when it has none of its own, its
orientation and value, and answers a pointer drag that holds its pointer
(so an embedded frame cannot swallow the drag), the arrow keys, Home,
End, and Enter and a double-click to collapse and restore. It resolves,
clamps, snaps and saves through resizableModel.ts, as useResizable does,
reports the size through subscribe and onSizeChange, takes new options
through update, and destroy removes every listener, observer and
attribute it added. With a null handle the region still answers
collapse, expand, resize and subscribe. The input lives in
resizeGesture.ts. Importing either module does no DOM work.

It is not exported from the package, so the changeset records it as
maintenance.
@vercel

vercel Bot commented Nov 1, 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 8:13pm UTC

Request Review

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

astracat-bot Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

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

@github-actions

github-actions Bot commented Nov 1, 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

…ions

update() replaces the options, as a new render does, so an option left
out returns to its default; the region keeps its size and collapse
state within the new options. Its JSDoc said the handle and the region
keep their state, which read as a merge. A test pins the contract: a
full set with one change keeps the selection, the name and the bounds,
and a set that leaves options out returns them to their defaults.

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

Requesting changes. The ref-taking useResizable(handleRef, options) follows the conformance path INV22 lists for useResizable, but this head leaves two copies of the resize logic: resizeGesture.ts repeats ResizeHandle's pointer and keyboard handling, and createResizable repeats the configuration form's size model, while both originals stay in use. Please move ResizeHandle's resizable path and the configuration form onto the factory in the change that introduces it, export CreateResizableOptions with the new overload, and land #9297 first so this diff (3,808 authored lines, 1,826 of them #9297's) drops under the 2,000-line scope limit or is split further.

[Automated review]

model.cancel();
},

keyDown(event) {

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 keyDown repeats ResizeHandle.tsx 683–781, and the pointer, snap-point, collapse and body-cursor helpers here repeat 619–680, 392–438 and 126–134. ResizeHandle still runs its own copy for the resizable path. Route that path through createResizeGesture in this change and delete the copy, so one input serves both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved into #9297: ResizeHandle's resizable path goes through createResizeGesture there, so this pull request adds no copy.

}

/** 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.

compute() repeats useSingleResizable's model in useResizable.ts (501–600, 811–824): the default that resolves once, the chosen-size latch, the bound re-clamp and the reported maximum. With both in use, a sizing fix reaches one form only. Build the configuration form on this factory in the same change, or extract the shared model both call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved into #9297: the configuration-object form builds on the factory there.

hook usePowerSearchConfig: <const D extends ReadonlyArray<FieldDefinition>>(definitions: D, configName?: string) => { config: PowerSearchConfig; applyFilters: <T extends InferData<D>>(filters: ReadonlyArray<PowerSearchFilter>, data: ReadonlyArray<T>) => T[] }
hook usePressFeedback: () => typeof pressableProps
hook useResizable
overload (handleRef: RefObject<HTMLElement | null>, options?: CreateResizableOptions) => UseResizableResult

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CreateResizableOptions appears in this public signature but no entry point exports it, so callers cannot name the new form's options. Export the type with this overload.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Per the spec owners, CreateResizableOptions exports with createResizable and ResizableController in #9372, together with FR3.

defaultIsCollapsed: previous.isCollapsed,
};
const resizable = createResizable(element, createdWith);
readResizableInternals(resizable)?.setCollapsedText(

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 translated collapsed text reaches the factory only through this internal channel, and the expanded size only through readResizableSelection, so a createResizable caller has neither. INV22 has the hook delegate entirely to a factory that takes the same options. Give the factory a collapsed-text option and an expanded-size reading before it is exported.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Outside a provider the factory announces the shipped English catalog's text (architecture:internationalization INV3); how non-React callers localize it is pending a system-wide decision. The spec owners chose no public expanded-size member: a non-React caller's update() is synchronous, so its size is current.

vjeux added 7 commits November 1, 2026 09:57
…he region's state has one copy (architecture:public-component-api INV22)

Red: the configuration-object form creates no factory yet. With a
stand-in factory, the tests check that it creates one, with no handle,
from the configuration, passes later options through update(), destroys
it on unmount and returns what it reports, in its size, collapse state
and props. Guards that pass on the current implementation and must keep
passing on the factory: the form reports size and isCollapsed in the
commit of onCollapseChange's own state, controlled or not, through
collapse(), Enter, expand(), double-clicks and a drag past the threshold
(component:Resizable FR8); it keeps its size and collapse state across
an <Activity> that hides and shows it; and a configuration object kept
across renders follows a container swapped behind containerRef.
…and ResizeHandle's `resizable` path goes through its input

The configuration-object form is createResizable with no handle of its
own, so the region's state has one copy, the factory's (spec:AST-107
FR6; architecture:public-component-api INV22). The props it returns
carry what the factory reports, and a ResizeHandle given them drives the
factory's region through its model, read at each input so a whole drag
reaches the factory as it is now. ResizeHandle's input for a
`resizable` prop is the factory's input (resizeGesture.ts) over the
props, so the handle has one implementation of the drag, the keys and
the double-click.

Behavior unchanged: size and isCollapsed land in the commit of
onCollapseChange's own state, a controlled isCollapsed included (FR8);
the region keeps its size across an <Activity> (the hook's store
remembers where it stood and seeds the next factory), and follows a
container swapped behind containerRef with the same configuration.
React's store subscriptions do not read the store again after an
<Activity> shows its subtree, so the hook renders again from the layout
phase when the factory made for the reveal reports something new. The
factory stays internal, so no public API or record changes; the
changeset records the move.
…ontainer after an <Activity> hid and showed the region before then (spec:AST-010)

Red: while the container is not laid out, the temporary 1200px basis
stands in, so a '30%' default shows 360px. A factory made for an
<Activity> reveal took that stand-in as the region's selection, kept
360px after the container measured 800px, and saved it, where the
default should resolve to 240px against the measured container and the
temporary basis is never final or saved.
…ry basis's stand-in as the region's size (spec:AST-010)

readResizableSelection reports the size only when it is a selection
(saved, chosen, or the default resolved against a measured basis); while
only the temporary 1200px basis stands in, it reports none, and the hook
seeds the next factory with the collapse state alone, so the factory
resolves its own default once the container measures. This holds for a
factory made after an <Activity> hides and shows the region.
…reateResizable, and ResizeHandle renders the factory bound to its element (spec:AST-107 FR6)

Red: there is no ref-taking useResizable yet. The tests check that it
creates the factory on the element, passes later options through
update(), destroys it on unmount and returns what it reports, in the
commit that onCollapseChange's own state lands in, controlled or not
(component:Resizable FR8); that a ResizeHandle given the ref renders the
region and forwards its drag, keys and double-click to the factory, with
its own isReversed and isDisabled, after a caller's onKeyDown (FR7);
that a handle a child mounts from its own state, or hides and shows in
its own <Activity>, is bound at once, and the region keeps its size
across an <Activity> that hides the hook's own component; that a plain
element drops its listeners when it unmounts and is named and announced
through the message catalog; that the same options follow a container
swapped behind containerRef; and that the hook renders its initial size
on the server.
…s thin React wrapper, and ResizeHandle renders the factory bound to its element (spec:AST-107 FR6)

The hook creates the factory on the element behind the ref as it mounts,
passes later options through update(), destroys it as it unmounts, and
returns what the factory reports, in the commit of the parent's own
state, a controlled isCollapsed included (component:Resizable FR8). The
factory follows the element behind the ref at every commit, and whenever
a ResizeHandle given the ref attaches or lets go of its element, which a
child can do without the hook's component rendering; each new factory
starts where the region stood. A plain element is named and announced
through the provider's catalog messages, which reach the factory through
the package's internal channel.

A ResizeHandle given the ref claims its element, so the factory binds to
it instead of driving it: the handle renders the factory's region and
forwards its pointer, keyboard and double-click events to the factory's
input, after a caller's onKeyDown (FR7), and its own isReversed and
isDisabled apply with the factory's options. A factory stays bound when
the handle lets go of its element and claims it again. The handle
follows the binding from the layout phase, so a factory bound in the
commit that mounts or shows it renders before paint. A ResizeHandle with
a `resizable` prop and a bound region warns once (useDevWarning).
…report rows and its changeset

useResizable's page documents the handle ref passed first, with its
options, and that a ResizeHandle reports its element as it mounts while
a plain element is read when the hook's own component renders.
UseResizableResult is exported, and the core API report gains the
overload and the type (only those rows; on this base the committed
report lacks rows that a later main refreshed).

@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 deciding the option set of the new useResizable(handleRef, options) overload: its isReversed, isDisabled and label options beside ResizeHandle's own props, the unexported CreateResizableOptions type, and the attributes a plain element gets. An engineering owner can record that decision by commenting /approve-spec <full head SHA> (the author may, as one) or point to the governing record. Two changes are needed either way:

  • The configuration form's collapse(), expand() and resize() now do nothing when called before the hook's factory exists (for example from a child's mount useLayoutEffect) or while an <Activity> hides the region, which worked on the base; please keep them working, add tests for those cases, and correct the "No behavior changes" changeset.
  • The diff is 5,090 authored lines, 3,836 of them from the still-open #9297; landing #9297 first and rebasing brings this pull request to about 1,410.

[Automated review]

Comment on lines +660 to +672
const getResizable = useCallback(() => slot.get()?.resizable ?? null, [slot]);
const collapse = useCallback(() => {
getResizable()?.collapse();
}, [getResizable]);
const expand = useCallback(() => {
getResizable()?.expand();
}, [getResizable]);
const resize = useCallback(
(next: number) => {
getResizable()?.resize(next);
},
[getResizable],
);

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 controls reach only the factory the slot holds now. Before the first follow() layout effect, and after the hide cleanup sets the slot to null, getResizable() is null and the call is dropped, with no onCollapseChange or onSizeChange. On the base, resize(300) or collapse() from a child's mount useLayoutEffect, or collapse() while an <Activity> hides the region, takes effect. A no-factory path that applies the request to the slot's remembered selection, and fires the callbacks, would keep them working.

'@astryxdesign/core': patch
---

[chore] The configuration-object `useResizable` and `ResizeHandle`'s `resizable` path go through one internal resize factory and one input module, through the same size model as before, so the region's state and the handle's input each have one implementation. 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.

"No behavior changes" doesn't hold at this head: configuration-form controls called before the factory exists or while an <Activity> hides the region are now dropped, and its dev warnings start createResizable: instead of useResizable:.

This branch was successfully deployed

1 active deployment
Preview — 4905b795 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

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.

1 participant