Repository navigation
refactor(Resizable): one internal createResizable factory, which useResizable's configuration-object form and ResizeHandle's resizable path go through - #9297
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
PR Analysis Report
Generated by PR Enrichment workflow | View current CI run |
There was a problem hiding this comment.
This needs a spec decision before it can be approved. The rewritten component:Resizable FR3 owner sentence is a new normative statement, so an engineering owner (the author qualifies) needs to approve it by commenting /approve-spec <full head SHA>, or say what to change. Separately, these need changes:
- Split the 5,756 changed lines: land #9197 first, then the factory, the
useResizable(handleRef, options)overload, and the move of the configuration-object form and ResizeHandle onto the factory. - A
ResizeHandleor plain element that a child mounts from its own state is never bound to the region (no value, and keys and drags do nothing), becauseuseResizablere-reads the ref only when its own component commits. - A configuration object kept stable across renders no longer follows a container replaced behind its
containerRef, as it does on the base, so the changeset's "behavior unchanged" does not hold yet.
[Automated review]
| // attributes, and a factory with no handle keeps the region answering | ||
| // collapse, expand and resize; a new element gets a new factory. Each new | ||
| // factory starts where the old one stood. | ||
| useIsomorphicLayoutEffect(() => { |
There was a problem hiding this comment.
This effect re-reads ref.current only when this hook's component commits. A ResizeHandle or plain element that a child mounts from its own state is therefore never bound: the separator has no aria-valuenow, and keys and drags do nothing. An element a child removes keeps its factory until the owner re-renders. ResizeHandle already receives this ref object as ref, so registering the slot against the ref object and letting ResizeHandle's claimRef ask it to re-check on attach and detach would cover the component case.
There was a problem hiding this comment.
Fixed in #9370: ResizeHandle's callback ref tells the hook watching its ref as the element attaches and lets go, so a handle a child mounts, or hides and shows in its own <Activity>, is bound at once (tests there).
| }, | ||
| [slot], | ||
| ); | ||
| useIsomorphicLayoutEffect(() => { |
There was a problem hiding this comment.
update runs only when options changes identity, and the factory re-reads containerRef.current only inside commit(). With a config kept stable (a module constant or useMemo) and its container replaced behind the same ref, the factory keeps observing the old element: maxSize: '50%' stays at 199px, where the base moves to 399px. Re-syncing the container on every commit would keep the released behavior.
There was a problem hiding this comment.
Fixed here: the factory re-reads the container at each commit of the hook's component, so a configuration object kept across renders follows a swapped container (test added).
| name: 'options.isDisabled', | ||
| type: 'boolean', | ||
| description: | ||
| 'Ignores the drag and the keys and sets `aria-disabled`. The handle keeps its tab stop, so it stays discoverable. On a ResizeHandle, its own `isDisabled` prop does the same: either one disables the handle.', |
There was a problem hiding this comment.
On a ResizeHandle this option gives tabindex="-1" (ResizeHandle.tsx:599, the FR3 gap), so "keeps its tab stop" holds only for a plain element. Likewise, a ResizeHandle ignores collapsedLabel.
There was a problem hiding this comment.
The doc page, now in #9372, says a plain element keeps its tab stop and a ResizeHandle takes its own label; collapsedLabel is gone.
eedebe1 to
73ca4d9
Compare
There was a problem hiding this comment.
This needs a spec decision before it can be approved. The rewritten component:Resizable FR3 owner sentence and the factory's new handle options (isReversed, isDisabled, label, collapsedLabel) have no current spec authority, so an engineering owner (the author qualifies) needs to approve them by commenting /approve-spec <full head SHA>, or say what to change. Separately, these need changes:
- A configuration-object region returns to its default size when an
<Activity>hides and shows it, because the hook's unmount cleanup discards the factory's state. - A ref-form
ResizeHandlethat a child mounts, or hides and shows inside its own<Activity>, is never bound, so it has no value and ignores keys and drags. - A configuration object kept stable across renders no longer follows a container swapped behind
containerRef, so the changeset's "behavior unchanged" does not hold yet. - The new warn-once Effect in
ResizeHandleshould useuseDevWarning, and the 5,045 changed lines should be split into the factory, the ref form, and the migration onto it.
[Automated review]
| useIsomorphicLayoutEffect( | ||
| () => () => { | ||
| slot.get()?.resizable.destroy(); | ||
| slot.set(null); | ||
| }, | ||
| [slot], | ||
| ); |
There was a problem hiding this comment.
This cleanup also runs when an <Activity> hides the subtree, and the reveal then creates a fresh factory from optionsRef.current, so the user's size is lost: a sidebar dragged to 300px comes back at its 250px default, where the base keeps 300px. Keeping readResizableSelection(...) of the destroyed factory in the slot and seeding the next factory from it, as the swap path above does, would keep it.
There was a problem hiding this comment.
Fixed here: the hook keeps where the region stood while an <Activity> hides it and seeds the next factory from it (test added).
| export function releaseResizeHandle( | ||
| element: Element, | ||
| claim: ResizeHandleClaim, | ||
| ): void { | ||
| if (claims.get(element) === claim) { | ||
| claims.delete(element); | ||
| claim.set(null); | ||
| } | ||
| } |
There was a problem hiding this comment.
Releasing clears the claim, but the factory binds only once, at creation (createResizable.ts:981), and the hook's effect returns early for the same element. After a child's <Activity> hides and shows the handle it stays unbound, with no aria-valuenow and no key or drag input, even after the owner re-renders. Keeping the binding per element and restoring it in claimResizeHandle would cover this.
There was a problem hiding this comment.
Fixed in #9370: a factory stays bound to its element while the factory lives, so a handle that lets go and claims the element again renders it again.
| useEffect(() => { | ||
| if (resizable != null && binding != null && !didWarnBothRef.current) { | ||
| didWarnBothRef.current = true; | ||
| devWarn( | ||
| 'ResizeHandle', | ||
| 'has a `resizable` prop and a useResizable(handleRef) region at ' + | ||
| 'once. The `resizable` prop wins; pass one of them.', | ||
| ); | ||
| } | ||
| toggleCollapse(resizable); | ||
| }, [isDisabled, resizable]); | ||
| }, [resizable, binding]); |
There was a problem hiding this comment.
useDevWarning('ResizeHandle', message, resizable != null && binding != null) (hooks/useDevWarning.ts) already owns warn-once-per-mount; this Effect and didWarnBothRef copy it, and the axis warning above can move to it too.
There was a problem hiding this comment.
Fixed in #9370: the warn-once uses useDevWarning.
| '@astryxdesign/core': patch | ||
| --- | ||
|
|
||
| [feat] `createResizable(handle, options)` makes a plain element a window splitter that resizes a region, without React: a pointer drag that holds its pointer across embedded frames, the arrow keys, Home, End, Enter and a double-click, with bounds, snap points, collapse and saved sizes. It reports the size through `subscribe` and `onSizeChange`, and you size the panel; `destroy` removes every listener and attribute it added. `useResizable(handleRef, options)` is its React form, beside the configuration-object form: give the same ref to `ResizeHandle` (or to a plain element) and render the panel at `size`; a plain element is named and announced through the message catalog. `ResizeHandle` and the configuration-object form go through the same implementation, with their behavior unchanged. |
There was a problem hiding this comment.
"with their behavior unchanged" does not hold yet for the configuration-object form: its size is lost across an <Activity> hide and reveal, and a memoized config no longer follows a container swapped behind containerRef.
There was a problem hiding this comment.
This pull request's [chore] changeset says no behavior changes, and both cases named here are fixed and tested.
73ca4d9 to
a8a8a2f
Compare
There was a problem hiding this comment.
Approval waits on an engineering owner's decision: no current spec decides createResizable's isReversed, isDisabled and label options, the controller's update and subscribe, or a null handle, so an owner needs to name the record that governs them, or have them added to component:Resizable's public contract and record the approval by commenting /approve-spec <full head SHA> (the author may do so as an engineering owner). Independently of that decision, these need changes:
resizeGesture.tsandcreateResizable.tsre-implement ResizeHandle's input anduseResizable's region state; route ResizeHandle throughcreateResizeGestureand share the region logic so each concern has one implementation.update()replaces every option, so a partial call reopens a collapsed region and drops its bounds, label and saving; merge the options or document that the full set is required.- The change is 2,325 authored lines, over the 2,000-line limit; recheck the size once the points above are settled.
[Automated review]
| * region holding an embedded frame otherwise loses the drag the moment the | ||
| * pointer crosses the frame. | ||
| */ | ||
| export function createResizeGesture( |
There was a problem hiding this comment.
This re-implements ResizeHandle's own handlers (ResizeHandle.tsx:518-688, with adjacentSnapPoint, toggleCollapse and the body cursor at :107-115 and :373-419), and ResizeHandle does not call it. Routing ResizeHandle through createResizeGesture here (its event types already accept React events) would leave one implementation of the splitter's input.
There was a problem hiding this comment.
Done here: ResizeHandle's resizable path now goes through createResizeGesture, so the splitter's input has one implementation.
| } | ||
|
|
||
| /** The region's state from its options, its basis and its selection. */ | ||
| function compute(): void { |
There was a problem hiding this comment.
compute(), commit(), persist() and the model below repeat useResizable's single-region state (useResizable.ts:278-291, :482-570, :628-653, :668-759, :792-805), so a clamping or saving fix in one misses the other. Sharing these decisions through resizableModel.ts, or having the hook delegate to this factory, keeps one path.
There was a problem hiding this comment.
Done here: useResizable(config) builds on this factory, so the region's state has one copy.
| if (isDestroyed) { | ||
| return; | ||
| } | ||
| opts = next; |
There was a problem hiding this comment.
update replaces the whole options object. On a collapsed, saved region with bounds 200–400, update({isDisabled: true}) reopens it at 300px, resets the bounds to 50 and unbounded, renames the handle "Resize handle", stops saving and drops onSizeChange, while the JSDoc and doc page say state is kept. Either merge next over the current options or document that the full set is required.
There was a problem hiding this comment.
Documented: update() takes the full set of options, as a new render does; an option left out returns to its default, and the region keeps its size and collapse state. A test pins it.
| }, | ||
| sidebar: { | ||
| flexShrink: 0, | ||
| padding: spacingVars['--spacing-3'], |
There was a problem hiding this comment.
With content-box sizing this padding stays when width is 0px, so the collapsed sidebar keeps a 24px strip with a clipped "S" and "0". Padding an inner element avoids it.
There was a problem hiding this comment.
Fixed in #9372: the sidebar pads an inner element, so collapsed to 0 it leaves no strip.
a8a8a2f to
86812b8
Compare
There was a problem hiding this comment.
Changes requested: the internal factory looks correct and its tests pass, but resizeGesture.ts and createResizable.ts copy ResizeHandle's input and useResizable's single-region state instead of extracting them, so after this merges every splitter fix has to land twice, and the copies already differ (a disabled handle's tab stop, and which document gets the drag cursor). Please extract rather than copy, as #9197 did for the model: route ResizeHandle's handlers through createResizeGesture, and share the region decisions through resizableModel.ts (or land the factory together with the stacked move of useResizable onto it). No spec decision is needed for this cut, since nothing exports the factory, and the earlier notes about update() and the story no longer apply.
[Automated review]
69262c9 to
ee538dd
Compare
There was a problem hiding this comment.
This needs an engineering owner's decision before it can be approved, and one change either way.
Because this branch is stacked on #9529, it also ships the Grab zone change, which no current spec settles: component:Resizable FR1 describes the Grab zone as a fixed --spacing-4 strip and says nothing about keeping it centred for every pillPlacement, narrowing it beside a short pane, or letting the painted pill start a drag. An engineering owner records that decision by amending FR1 (here or in #9529) and commenting /approve-spec <full head SHA> on the pull request; the author may do so as an engineering owner.
Required either way:
- At 4,347 changed lines, 1,571 of them from #9680 and #9529, please land those first (or take #9529 out from under this branch) so this pull request ships only the factory and the hook moved onto it.
The factory, the hook migration and the three [fix] entries look right: the Resizable, Layout, SideNav and AppShell suites pass, each new fix test fails on main, and two non-blocking notes are inline.
[Automated review]
| // When an `<Activity>` shows this component, React subscribes to the slot | ||
| // again but does not read it. The factory made for the reveal reports in | ||
| // that same commit, so render again with what it reports. |
There was a problem hiding this comment.
Non-blocking: on a reveal, follow() resumes the same factory (:516-525), so no factory is made for the reveal. Suggested: "When an <Activity> shows this component, React subscribes to the slot again but does not read it. The factory resumes and reports in that same commit. Render again with what it reports."
There was a problem hiding this comment.
Taken in f76c7f8: "The factory resumes and reports in that same commit. Render again with what it reports."
| // A stand-in controller without the internal channel reports only its | ||
| // size and collapse state. | ||
| const next = readResizableInternals(resizable)?.snapshot() ?? { | ||
| ...initial, | ||
| size: resizable.size, | ||
| isCollapsed: resizable.isCollapsed, | ||
| expandedSize: resizable.size, | ||
| }; |
There was a problem hiding this comment.
Non-blocking: createResizableAs always registers the internal channel (createResizable.ts:795), so only the vi.mock('./createResizable') stand-in in useResizableConfig.test.tsx reaches this branch (and the ones at :429-432 and :539-545). Registering the stand-in through the channel, or testing against the real factory, would let the production code drop it.
There was a problem hiding this comment.
Agreed that only the stand-in reaches these branches. I'm keeping them for now, because dropping them means the config test's stand-in has to register the internal channel. That reshapes the hook code the next two pull requests in this stack build on. I'll take it as a follow-up once the stack lands.
ee538dd to
f76c7f8
Compare
There was a problem hiding this comment.
This needs an engineering owner's spec decision before it can be approved, and two changes either way.
Because this branch is stacked on #9529, it amends component:Resizable FR1 (the Grab zone stays centred on the Handle, the painted grip is its own grab target, and the zone shrinks on a short pane). An engineering owner approved that same text on #9529, but this pull request's head has no approval, so an engineering owner records the decision by commenting /approve-spec <full head SHA> here (the author may, as an engineering owner), or this waits until #9529 lands and FR1 is current on main.
Required either way:
- A grip shown only on hover (
isAlwaysVisible={false}) and drawn outside the Grab zone hides as the pointer moves onto it, so the resizable SideNav can no longer be dragged from its grip in right-to-left (it still can in left-to-right); please keep that grip reachable from the zone and add browser cases for an overlay Handle with a hover-revealed grip in left-to-rightstartand right-to-leftend. - At 4,367 changed lines, 1,579 of them from #9680 and #9529, please land those first (with the fix above in #9529), or take #9529 out from under this branch, so this pull request ships only the factory and the hook moved onto it.
The factory, the hook migration and the three [fix] entries look right: the Resizable, Layout, SideNav and AppShell suites pass, and each new fix test fails on main.
[Automated review]
| onPointerLeave: () => { | ||
| if (!isDragging) { | ||
| setIsHovered(false); | ||
| } | ||
| }, |
There was a problem hiding this comment.
In right-to-left, SideNav's overlay Handle draws its end grip 4px outside this zone. Leaving the zone to reach it clears the hover, so pillHidden (:231) hides the grip and drops its pointer events before the pointer arrives; a drag from the grip then does nothing (260px on this head, 300px on main). Keeping a hover-revealed grip reachable from the zone, and a browser case for isAlwaysVisible={false} in LTR start and RTL end, would close it.
…ment says, and a short item keeps half of itself for its own drag (component:Resizable FR1) A browser fixture story draws a calendar column at 48px an hour with a 30-minute and a 15-minute event, each moved by a drag on its body and resized by the ResizeHandle on its bottom edge. Red before the fix: - with pillPlacement auto, start or end, the Grab zone's centre sits 5.5 to 8.5px off the Handle's, because it follows the pill; - the zone covers 13.5px of the 24px event with the pill on the event's side, and 8px of the 15-minute event's 12px even when centred, so the events' own drag misses points of their upper half; - the same holds for position="overlay". A drag on the 30-minute event's body moves it, and a drag on its Handle's line resizes it.
…ent says, and takes at most half of a short pane (component:Resizable FR1) FR1 puts the Grab zone, a --spacing-4 strip on the resize axis, on the Handle; pillPlacement only places the pill. The zone followed the pill instead, shifting up to a strip's width toward it, so on a short item (a 30-minute calendar event resized from its edge) it took most of the item and stole its own drag. The zone now sits centred on the Handle for every pillPlacement. On a pane shorter than the zone, it takes at most half the pane, as spec:AST-094 FR42's grown targets stop at half the gap: an inline Handle's zone is at most the pane's length, so half of it reaches into the pane, and an overlay Handle takes at most half of the pane it sits in, with the zone kept inside it. A collapsed pane keeps the whole zone, which reopens it. The unit tests that pinned the zone to the pill now pin it to the Handle, with one for the short-pane limit and the collapsed pane; the docs say pillPlacement moves only the pill, and a [fix] changeset records it.
…Grab zone A region at 0px that is not collapsed gets a zero-length inline zone (min(var(--spacing-4), 0px)) and an overlay Handle clamped to half of nothing, so no drag can reopen it. Both cases fail here.
…rab zone A pane with no length has nothing to protect, so it is treated like a collapsed one: the inline zone and the overlay Handle keep their full length, and a drag can reopen the pane. The duplicate comment at the zone's call site goes.
With the Grab zone centred on the Handle, a pill that pillPlacement draws outside the zone takes no pointer: on an overlay Handle the start pill sits before the Handle's box, so a drag on it moves a 30-minute event instead of resizing it, and an inline end pill on a short pane hangs past the zone's end. The browser spec drags the pill's centre for every pillPlacement and both positions, and the unit test starts a drag from the pill. The upper-half check now skips the pill's own box.
The pill is the Grip, so it stays grabbable wherever pillPlacement paints it: while it is painted, it takes the same pointer handlers as the Grab zone, with the resize cursor, and a hidden or disabled pill takes none. The Grab zone keeps its short-pane limit, and pillPlacement still moves only the pill. The docs and the [fix] changeset say so, and the changeset now says that an overlay Handle takes at most half of the pane it sits in.
Each comment sentence the Grab zone change adds has at most 20 words, and the test comment cites no record. ResizeHandle's header drops the paragraph on the pill's dynamic offset, which now sits on that style. No code change.
…s and how it shrinks on a short pane FR1 named the Grab zone only as a --spacing-4 strip. It now adds the owner's ruling, word for word: the grab zone is centred on the Handle for every pillPlacement, and the painted pill is its own grab target; on a pane too short for the full strip, the zone shrinks so the pane keeps at least half of itself. FR1's basis names the owner ruling, and its verification row names the browser spec that checks the zone's place and its short-pane limit.
…e it starts, and a restating comment goes A drag that starts on the Grip pill takes capture on the pill, not on the grab zone, so the header names the element the drag starts on. The comment over the overlay zone's width restated its min(…, 100%).
… reachable from the Grab zone An overlay Handle can draw its pill outside the Grab zone: at start in left-to-right, and at end in right-to-left. A pill that shows only on hover then hides as the pointer leaves the zone to reach it, and a drag from where it showed does nothing. A story puts a hover-revealed pill beside an overlay Handle, and the browser spec crosses from the zone to the pill and drags it, in both directions. They fail here: the size stays 250 after the drag.
An overlay Handle can draw its pill outside the Grab zone, so the pointer leaves the zone to reach it. The hover now stays for 150 ms after the pointer leaves the zone or the pill, and entering either one keeps it. So a hover-revealed pill stays shown and takes the pointer while the pointer crosses to it. The pill's offset functions take -1 | 1.
…ct, the configuration-object useResizable builds on it, and a server-rendered region hydrates with the server markup - createResizable: collapse, expand and resize on request, a gesture through the region's model as a ResizeHandle drives it, bounds and snap points, a percentage against its container, saving once per gesture, update() with the full set of options, subscribe() and destroy, and an import that does no DOM work. They fail here: the module does not exist. - With a stand-in factory, useResizable(config) creates one, from the configuration, passes later options through update(), destroys it on unmount and returns what it reports. They fail here. - A region with an autoSaveId whose size is saved hydrates over server markup with the default, and a '30%' default with no container hydrates over markup the server resolved at its 1200px basis. Both should hydrate cleanly, then take the client's size. Both fail here: the first logs a hydration mismatch and keeps 200, the second keeps 360. - Guards that pass here and after: the configuration-object form reports in the commit of onCollapseChange, controlled or not; it keeps its size and collapse state across an <Activity>; collapse(), expand() and resize() apply before its first layout effect and while hidden; its warnings start useResizable: in the words they always used; it follows a container swapped behind its ref; and a '30%' default resolves against its container once measured, after an <Activity> hid and showed it.
…and the configuration-object useResizable builds on it createResizable, internal to the package, keeps a resizable region with no React and no element: it resolves, clamps, snaps, collapses and saves the size through the model in resizableModel.ts, follows a percentage's basis, and reports through subscribe and onSizeChange. ResizeHandle's resizable path drives it through resizeGesture.ts's model. The configuration-object useResizable is that region in React, with one factory, so the region's state has one copy. Its props carry what the factory reports, and its signature, props, warnings and one-render-pass mounts are unchanged. While its component is unmounted or an <Activity> hides it, the factory lets go of the page and keeps the region, so collapse(), expand() and resize() apply and report whenever they are called. Its server snapshot is the state the server renders, so a hydrating render matches the server's markup, and a saved size or a percentage default resolved against the window applies in the next commit. The comment-provenance baseline drops the useResizable.ts sites this removes. A [chore] changeset records the move, and two [fix] changesets record the hydration behaviors.
On an <Activity> reveal, follow() resumes the same factory; none is made for the reveal. The comment now says so.
f76c7f8 to
f8deefe
Compare
Stacked on #9529; until it merges, this branch shows its commits. Its own commits: vjeux/astryx@b79aa8e...f8deefe.
One internal resize region, with the configuration-object
useResizablemoved onto it. Next come the factory's element path (#9718),useResizable(handleRef, options)(#9370), the public export (#9372), the LayoutPanel slide (#9300) and the deprecation (#9202).Maintainer impact
architecture:public-component-apiINV22 has every public interaction hook delegate to acreate…(element, options)factory (spec:AST-107FR6). The region's state lived in useResizable. A factory beside it would leave two copies of the size model, so each sizing fix would land twice. This pull request adds the region and moves the configuration-object form onto it at once.Intended invariant
No public API or record changes. The configuration-object form keeps its signature, props, warnings, one-render-pass mounts and same-commit reports (
component:ResizableFR8).Two behaviors change. Each is a
[fix]that restores whatcomponent:ResizableFR3 promises for persistence and size (spec:AST-017FR5):autoSaveIdhydrates with the server's markup, then takes its saved size.defaultSizeand nocontainerRefhydrates with the server's markup, then resolves against the window.Change
createResizable(options)keeps a resizable region with no React and no element. It resolves, clamps, snaps, collapses and saves the size throughresizableModel.ts, follows a percentage's basis, and reports the size. ResizeHandle'sresizablepath drives it through the gesture model. feat(Resizable): createResizable drives a plain handle element, with one separator derivation #9718 adds the element path.<Activity>hides it, the factory keeps the region, socollapse(),expand()andresize()always apply.useResizable.tssites. One[chore]and two[fix]changesets.Evidence
onCollapseChange's commit, the size across an<Activity>, early and hidden calls, the warning words and a swapped container. The ResizeHandle, Layout, SideNav and AppShell suites pass unchanged.Scope
[fix]entries above.Testing
Local checks at f8deefe: core typecheck, the Resizable, Layout, SideNav and AppShell units (779 passed), strict eslint and prettier on the changed files, the comment-length check, and
pnpm review:pre. The test commit fails before the change: the region's module is missing, the stand-in tests fail, and both hydration cases fail. Under load,utils.test.ts's package-root import can time out at an older base; main fixed that in #8471. The full gates run at this head once CodeBunny approves.