Skip to content

feat(ContextMenu): ContextMenu built on createContextMenu's context request (spec:AST-107 FR8, FR7) - #8747

Closed
vjeux wants to merge 1960 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-on-factory
Closed

vjeux wants to merge 1960 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-on-factory

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8693 (createContextMenu) and, through it, #8228 and #8670. Through #8201, it carries the static-import offer on #8125 (16dc55e) as a dependency; if #8125 lands differently, this stack follows her decision. Two commits change #8125's files and are offered there: vjeux@6193c2a803 and vjeux@883bf8ef2b. Own commits: vjeux@e1b7586d57, vjeux@500fd27a4d, vjeux@6193c2a803, vjeux@e6576c816c, vjeux@65fa39d232, vjeux@883bf8ef2b, vjeux@2d0fc1c323, vjeux@6d4267e634, vjeux@d4a15a4274, vjeux@69c07e2e96, vjeux@ad0a884ded

User need

Part of the UIKit-parity program. spec:AST-107 FR8 builds ContextMenu on the public headless context menu, so ContextMenu and createContextMenu behave alike and Core keeps one implementation.

Problem and solution fit

ContextMenu attaches createContextMenu to its trigger. The factory opens the menu, runs the touch model (UIKit's hold, the press feedback, the lift) and renders the menu in ContextMenu's own tree, as a portal into a container beside the trigger, so the caller's contexts reach it and it unmounts with ContextMenu.

What led to this push:

  • The merged FR8 fails a ContextMenu that bypasses the factory; this PR first only shared its request core.
  • Inside a shadow root, React committed a request's open a task late, so a press outside in between found no listener; the open now commits in the request's own task.
  • Unmounting ContextMenu made React log an error: the menu's own React root was unmounted inside React's commit. The menu now lives in ContextMenu's tree.

Current specification

spec:AST-107 FR8 and FR7; component:ContextMenu FR10; spec:AST-094 FR43 (the context request), FR33 and FR35.

Public delta and caller burden

No ContextMenu prop or type changes; a finger's hold yields to a gesture that claimed the finger first. createContextMenu gains ContextMenu's touch model, and render fills the menu once, cleaned up on destroy. Size (gzip): ContextMenu JS 118,491 → 177,185 B, CSS unchanged; this includes react-dom/client, which the factory imports for the menu root it gives a page without React. Public-API gaps (FR7): the lift, its springs and UIKit tables, the input hold.

Design and alternatives

  • On useContextMenu, menuContent would lose the caller's contexts; on the factory's internals, FR8 fails it. So ContextMenu uses the factory and portals its rows; size, width, styles and the trigger's anchor reach the menu through an internal setter.
  • The menu's container could sit at the end of the body or dialog, as for a plain element, keeping a root of its own; that loses CSS variables a wrapper sets inline around the trigger, which the container beside the trigger keeps.
  • Owner question: Does FR8's 'focus starts on the first enabled row' cover a touch open, where ContextMenu lights no row as UIKit does (feat(ContextMenu): open from a held finger on iOS 26's timeline #8125's timeline)?

Evidence

MenuPressShadowRoot.a11y:76: 10 of 12 failed before, 12 of 12 after. ContextMenu's own suites pass unchanged on the factory; unit tests cover FR10 and an unmount that logs nothing.

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

In the ContextMenu stories:

  1. "Inside a scroll container": right-click; the menu opens at the pointer and scrolls with the content.
  2. "Touch / long press", on a phone: hold the card; it lifts at 0.4 s, the menu shows at 0.7 s.
  3. "a11y / Menu press in a shadow root": open the menu, then press the host; it closes.
  4. "Headless / createContextMenu on a list row": the same menu opens.

pnpm test:interaction ContextMenu createContextMenu and pnpm test:a11y-contract run these in Chromium and WebKit, plus a story switch that unmounts the whole React root around an open menu and throws nothing. One existing test changed, to the new contract and no looser: createContextMenu's "fills the menu through render in plain DOM once, and runs its cleanup when destroyed" (it said "on close"), per the render doc line "Fills the menu's container with custom content in plain DOM, once, as the menu is attached; return a cleanup, which runs as it is destroyed." React's act() warnings left in ContextMenu's unit tests come from main's right-click case and #8125's haptic cases. Unrelated failures: #8638, #8671; Vercel's deploy of this head failed on its side, and its build command passes locally.

vjeux and others added 30 commits October 26, 2026 21:10
…dge pan holds a lone finger only; meta#8589's watcher browser test) into meta#8293
… is down off the sheet (spec:AST-094 FR10)

The lone-finger hold counts only the fingers the pan tracks, so a second
finger on plain page content left the sheet's finger held, and the moves
of a pinch across the sheet's edge were cancelled. This test fails until
the host counts every finger on the screen.
…is down anywhere (spec:AST-094 FR10)

The lone-finger hold counted only the fingers the pan tracks, so a second
finger on plain page content left the sheet's finger held, and a pinch
across the sheet's edge had its moves cancelled. The host now applies a
recognizer's hold only while the move's event reports one finger on the
screen; the pan's own count still covers two fingers on the sheet.
An attach reads the element's own will-change in a task of its own. A
shared animator (animatorFor) is never detached, so its node can leave the
document with that read still waiting, and the read then forces a style
pass on a node with no style: a test run that mounts and unmounts many
pagers piled up such reads until a later wait timed out. The read now
skips a node out of the document; a promotion that needs it reads it then,
as one on a node attached in the same task does. A detach and dispose
already cancel it.

The new test fails on the previous code (one read of a removed node); the
in-task read test now puts its element in the document, where the read
happens.
…ter the browser takes the touch

A host that stopped feeding the finger at the takeover and still delivered
the lift would have passed. Each transition now records the list's
scrollTop, and the test expects a 'changed' while the list is scrolled
(spec:AST-094 FR20: it follows the scroll to the lift).
…her browser test) into the arena fixes

The host-owner changeset keeps this PR's own listeners for the scroll lock
and finger-scroll watch, with meta#8336's lone-finger scroll-edge hold.
…he numbers after it as they were

A lifted row's spot was held by a span, so a list held a span among its
items and an ordered list numbered the items after it one higher while the
row was lifted. Two browser proofs, red on the span: every child of the
list is an li while lifted, at the row's size whatever the page styles an li
with, and a later item's marker paints the same.
…mber

In a ul, ol or menu the placeholder is an li, so the list keeps only li
children, as a row menu attached to its list item needs. It is laid out as a
block, never a list item, so the items after it keep their numbers, and its
own minimum and maximum sizes, padding and border are reset, so the page's
rules for an li never change the spot it holds.
The touch layer is imported directly, so it runs from the first render under
the touch model. Where both sides edited a header, the offer's loading
wording is kept along with the lift; the lazy loader and its test go with
the offer.
…ads on demand

With the touch layer imported directly, it marks the trigger pressable from
the first render under the touch model.
…sture system (spec:AST-094 FR24, FR43)

utils/pressFeedback.ts reaches gestures/coarsePointer.ts, so a page whose
only touch work is press feedback carries a gesture module, and a bundle
check that counts gestures/ code finds it in the root and hooks entries.
The coarse-pointer query belongs with the environment queries. This test
walks the controller's import graph and fails until it reaches no module
under gestures/.
…ries in utils/ (spec:AST-094 FR43)

gestures/coarsePointer.ts becomes utils/coarsePointer.ts, beside
utils/touchQueries.ts, with its test; the gesture host's selection lock,
the press controller and the pointer test helper import it from there. The
press controller's import graph now reaches no module under gestures/, so
a page whose only touch work is press feedback carries none of the gesture
system. No behavior changes.
CI's lint rejected the three Array<T> types the previous commit added
(@typescript-eslint/array-type); the test now names its record once
(WatchTransition) and reuses it.
vjeux added 8 commits October 27, 2026 15:52
…adow root

Red: an element inside a shadow root gets its menu in the document's
body, outside the element's own tree, where the shadow root's styles do
not reach and the document judges presses on it as presses on the shadow
host.
…dow root

The menu's container now belongs to the element's own tree: the end of
its nearest dialog or popover, or of its shadow root, or of the body. A
shadow root's styles reach the menu there, and the menu's tree judges
presses on it before the document does, as ContextMenu's own menu does.
…Escape before the menu

Red: the hook's rows render in its own root, outside the menu's layer
depth, so a dismissible layer inside them ranks below the menu and Escape
closes the menu first; ContextMenu's own rows rank above it.
…scape before the menu

The `render` slot's rows context now carries the layer depth inside the
menu, and the hook's rows read it, so a dismissible layer in them ranks
above the menu, as one in ContextMenu's own rows does: Escape dismisses
that layer first, and the menu after.
internal/shadcn-registry/routes.lock.json: regenerated with
UPDATE_SHADCN_ROUTE_LOCK=1 node apps/docsite/scripts/generate-data.mjs;
against main it adds this PR's two hook routes.
…its menu in the element's dialog, popover or shadow root; its rows at the menu's layer depth; main dcdb356) into ContextMenu on the factory

createContextMenu.tsx: meta#8693's container placement, rows context and
wrapper access, with this branch's request core (attachContextMenuRequests)
in place of the factory's own context request.
… whose menu runs ContextMenu's touch model (spec:AST-107 FR8, FR7)

Red: ContextMenu shares the factory's request core but renders its own
menu, so it never calls createContextMenu, which FR8 builds it on (its
verification fails a ContextMenu that bypasses the factory): with `items`
it should hand the factory its items, and with `menuContent` a `render`.
The factory's menu under a coarse pointer opens a held finger at 500 ms,
where ContextMenu's touch model opens it at UIKit's moment (FR8: the shared
options mean what ContextMenu's mean). And `render` fills the menu once,
as it is attached, keeping the rows in the closed menu as ContextMenu does;
its cleanup runs when the menu is destroyed. Also guards that a row in
ContextMenu's menuContent still reads the caller's own context (passes).
…T-107 FR8, FR7)

ContextMenu renders its trigger and attaches the public createContextMenu
to it. The factory renders ContextMenu's menu beside the trigger, in a slot
ContextMenu gives it, so the menu takes the trigger's theme, direction,
dialog, popover and shadow root as before; its cursor anchor stays inside
the trigger, so the menu follows the content on scroll (meta#3465). `items`
go to the factory as they are; `menuContent` renders in ContextMenu's own
tree and is portaled into the menu (contextMenuRows.tsx, shared with
useContextMenu), so the caller's contexts reach it and its rows read the
menu's row context, open state, layer and layer depth.

The factory's menu now runs ContextMenu's touch model on any element: the
element's request takes UIKit's hold under the touch model from the start
(the menu refines it, and plays the haptic at the commit), the element is
a pressable surface for the press feedback, and a block element lifts. The
factory's own open() and close() take the same path as the element's
requests, and an open from a request commits in its own task (flushSync),
inside a shadow root too. `render` fills the menu once, as it is attached;
its cleanup runs when the menu is destroyed. Size, menu width, styles and
the trigger's anchor reach the menu through an internal view setter beside
the factory's public options.
vjeux added 4 commits October 27, 2026 17:19
packages/core/src/ContextMenu/ContextMenu.spec.md: main's text as meta#8648
merged it (FR10, its verification row and the layer note), with this
stack's DEC-2 verification row (from meta#8201) re-applied after it.
… or finger (spec:AST-107 FR8)

meta#8648's FR8 states that a press outside the menu closes it, skipping the
compatibility mouse events a finger's press sends, and its verification
names an outside press with a mouse and with a finger. A mouse press
outside closes the factory's menu; the opening finger's own compatibility
mousedown leaves it open, and a later press outside closes it. Both pass:
they cover behavior the menu already has.
…pec; the factory closes on a press outside, mouse or finger) into ContextMenu on the factory

createContextMenu.test.tsx: both sides add cases at its end; this keeps
both, and the finger's outside-press cases take the file's shared clock
epoch, since the touch-model cases before them have moved the gesture
host's clock past a fresh 100 s.
…extMenu's source attaches no request (component:ContextMenu FR10)

meta#8648 makes ContextMenu FR10 current: ContextMenu's invocation, open state
and cleanup are the factory's. Its verification row names an unmount
while open and a source check. Unmounting a ContextMenu while its menu is
open removes the menu, the factory's container and the trigger's request
(the browser keeps its own menu after); ContextMenu.tsx makes no request
attachment call. The source check fails on this PR's first version
(883bf8e), whose ContextMenu attached its trigger's request itself;
both pass on the factory.

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

This pull request is on hold for an engineering owner's decision on four spec changes it carries from the stack it depends on: architecture:layer-runtime INV13 (one exit copy per closing layer), the springs.contextMenuLift, springs.contextMenuDismiss and springs.contextMenuMorph roles in spec:AST-094 FR4, spec:AST-057's move of row-swipe motion onto theme motion roles, and component:ContextMenu DEC-2. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:

  • Land or retarget the stacked dependencies so the diff is only this pull request's own commits (47,023 authored lines today).
  • ContextMenu's items now render in the factory's own React root, so an item label or icon that reads an app context loses it (ContextMenu.tsx:466-478); keep the item nodes in ContextMenu's tree, as menuContent is.
  • Two repository tests fail at this head because of this diff: the root-entry case in scripts/touch-support-bundle.test.mjs and the actionGroupTracker.ts case in pressableCoverage.test.ts.
  • Export useContextMenu from @astryxdesign/core/hooks as spec:AST-107 documents, and open the menu from the request handler rather than the layout effect at createContextMenu.tsx:327-336.

[Automated review]

};
return rowsSlot != null
? {...shared, render: rowsSlot.render}
: {...shared, items: items ?? []};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

items go to the factory here, and the factory renders them in its own React root with only Astryx's theme, layer, link and i18n providers (createContextMenu.tsx:416-431, contextMenuRows.tsx:120-136). An item label, icon, description or endContent that reads an app context (an i18n library's message component, a router link, a store hook) now gets its default or throws, where the base rendered the menu in ContextMenu's own tree. Rendering the item nodes from ContextMenu's tree, as menuContent is, would keep them, and a test whose item label reads a caller context would pin it.

@@ -63,7 +66,8 @@ const source = new Map(
files.map(file => [path.relative(SRC, file), fs.readFileSync(file, 'utf8')]),
);

const PRESS_ARM = /':active|'\[data-astryx-press=|interactionOverlayStyles\./;
const PRESS_ARM =
/':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Widening the pattern to getAttribute(PRESSED_ATTRIBUTE) makes this test flag hooks/actionGroupTracker.ts:271, which writes the pressed state without usePressFeedback(), and CI's test-ui 2/4 fails on it at this head (pressableCoverage.test.ts:156). The fix needs to ride with this change, or land before it.

const hadFocus = host.contains(doc.activeElement);
isDestroyed = true;
requests.destroy();
root.unmount();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

destroy() is called from ContextMenu's and useContextMenu's effect cleanups, where React 19.2's development build logs “Attempted to synchronously unmount a root while React was already rendering” for this call, because passive effects run in the commit context. Detaching the requests here and deferring root.unmount() past the commit avoids it.


/**
* Renders a menu's content into `container` without React; returns a cleanup
* that runs when the menu closes.

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 cleanup runs when the menu is destroyed, not when it closes: render fills the container once, as the menu is attached (:351-353, and the doc page's option text at createContextMenu.doc.mjs:38). The doc page's summary (createContextMenu.doc.mjs:139, “when it opens”) has the same mismatch.

@@ -130,7 +130,11 @@ export type {
} from './useInteractiveRole';

export {useLongPress} from './useLongPress';
export type {UseLongPressOptions, UseLongPressHandlers} from './useLongPress';
export type {
LongPressStage,

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 entry gains LongPressStage, which no current spec record states and no Core component uses at this head, but not useContextMenu, which spec:AST-107's public contract imports from @astryxdesign/core/hooks. Exporting useContextMenu, UseContextMenuOptions and UseContextMenuResult here, and dropping or specifying stages, would match the spec.

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

This pull request is on hold for an engineering owner's decision on four spec changes it carries from the stack it depends on: architecture:layer-runtime INV13 (one exit copy per closing layer), the springs.contextMenuLift, springs.contextMenuDismiss and springs.contextMenuMorph roles in spec:AST-094 FR4, spec:AST-057's move of row-swipe motion onto theme motion roles, and component:ContextMenu DEC-2. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:

  • Land or retarget the stacked dependencies so the diff is only this pull request's own commits (47,149 authored lines today).
  • ContextMenu's items now render in the factory's own React root, so an item label that reads an app context loses it (ContextMenu.tsx:477); keep the item rows in ContextMenu's tree, as menuContent is.
  • scripts/touch-support-bundle.test.mjs (the root-entry case) and pressableCoverage.test.ts (the actionGroupTracker.ts case) fail at this head because of this diff.
  • Export useContextMenu from @astryxdesign/core/hooks as spec:AST-107 documents, and open the menu from the request handler rather than the layout effect at createContextMenu.tsx:327-336.

[Automated review]

};
return rowsSlot != null
? {...shared, render: rowsSlot.render}
: {...shared, items: items ?? []};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

items go to the factory here, and the factory renders them in its own React root with only Astryx's theme, layer, link and i18n providers (createContextMenu.tsx:416-431, contextMenuRows.tsx:120-136). An item label that reads an app context (an i18n message component, a router-aware link, a store hook) gets the default or throws, where it used to render in ContextMenu's tree. Rendering the item rows through the same MenuRows slot menuContent uses would keep them in the caller's tree; a test whose item label reads a caller context would pin it.

} else {
handleRef.current?.open(forwarded.request);
}
}, [forwarded]);

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 layout effect opens or closes the menu, and so fires the caller's onOpenChange, in reaction to the forwarded prop rather than in the request handler that caused it, which architecture:react-component-runtime INV2 rules out, and it names no external system (INV3). Keeping the menu's handle where forward can reach it and calling it inside the existing flushSync would avoid the round trip.

const hadFocus = host.contains(doc.activeElement);
isDestroyed = true;
requests.destroy();
root.unmount();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

destroy() runs from ContextMenu's and useContextMenu's effect cleanups, where React DOM 19.2's development build logs “Attempted to synchronously unmount a root while React was already rendering” for this call, because passive effects run with the commit context set. Detaching the requests here and unmounting the root after the commit would avoid it.


/**
* Renders a menu's content into `container` without React; returns a cleanup
* that runs when the menu closes.

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 cleanup runs when the menu is destroyed, not when it closes: render fills the container once, as the menu is attached (:351-353, and the option text at createContextMenu.doc.mjs:38). The doc page's summary (createContextMenu.doc.mjs:139, “when it opens”) has the same mismatch.

@@ -130,7 +130,11 @@ export type {
} from './useInteractiveRole';

export {useLongPress} from './useLongPress';
export type {UseLongPressOptions, UseLongPressHandlers} from './useLongPress';
export type {
LongPressStage,

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 entry gains LongPressStage, which no current spec record states and no Core component uses at this head, but not useContextMenu, which spec:AST-107's public contract imports from @astryxdesign/core/hooks. Exporting useContextMenu, UseContextMenuOptions and UseContextMenuResult here, and correcting useContextMenu.doc.mjs:110's importPath, would match the contract.

@@ -63,7 +66,8 @@ const source = new Map(
files.map(file => [path.relative(SRC, file), fs.readFileSync(file, 'utf8')]),
);

const PRESS_ARM = /':active|'\[data-astryx-press=|interactionOverlayStyles\./;
const PRESS_ARM =
/':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Widening the pattern to getAttribute(PRESSED_ATTRIBUTE) makes this test flag hooks/actionGroupTracker.ts:271, which reads the pressed state without usePressFeedback(), and CI's test-ui 2/4 fails on it at this head (:156). The fix needs to ride with this change: list the tracker as a model file, mark it, or narrow the pattern.

vjeux added 8 commits October 27, 2026 19:08
…t a React error; the factory's menu opens at the pointer in a transformed dialog

Red, three ways:
- The hook reads its ref once, so a ref moved to another element keeps
  the menu on the old one: a right click on the new element opens
  nothing.
- Unmounting the hook makes React log "Attempted to synchronously unmount
  a root while React was already rendering": the factory's root and the
  hook's rows root are unmounted inside React's own commit.
- Inside a dialog that keeps a transform, as a Drawer does, the menu
  opens about 40 px from the pointer in Chromium and WebKit: the anchor
  is a fixed box at the client point, and the transformed dialog is its
  containing block.
… unmounting them inside React's commit

Unmounting a component that called useContextMenu made React log
"Attempted to synchronously unmount a root while React was already
rendering": its effect cleanup destroyed the factory, which unmounted the
menu's root, and unmounted the hook's rows root, both inside React's
commit. From React, the factory now removes the element's request and
the menu's node at once and has its root render nothing, an update React
takes in its own time, instead of unmounting it (an internal destroy for
React callers; the public destroy() still unmounts at once for a page
without React). The rows root does the same.
…ints at now

The hook read its ref once, when it mounted, so a ref moved to another
element (a keyed swap, a conditional branch) kept the menu on the old
element and a right click on the new one opened nothing. Each commit now
checks the element the ref points at and, when it changed, detaches the
menu from the old element and attaches it to the new one; options still
reach an attached menu without recreating it.
…e a transformed dialog or popover

The factory's anchor is a fixed box at the client point, in a container
inside the element's dialog or popover. A fixed box's containing block is
the viewport unless an ancestor sets one up: a Drawer's dialog keeps a
transform while it is open, so the anchor, and the menu with it, landed
offset by the dialog's position (about 40 px in the case, off-screen in a
Drawer on the right). The anchor is now placed through its actual
containing block: placed at two offsets, where it lands is measured, and
the client point is mapped through that block's offset and scale.
The factory renders its menu in a root of its own as it attaches; each
case attached it outside act, so React logged "An update to Root inside a
test was not wrapped in act(...)" once per case (17 cases).
…ng its roots without unmounting them inside React's commit; the factory's anchor through a transformed dialog) into ContextMenu on the factory

createContextMenu.tsx: the wrapper access carries both this branch's view
setter and meta#8693's destroy for React callers; teardown destroys this
branch's request core. useContextMenu.tsx: the rows slot's host goes at
once and its root renders nothing.
…nmounting a root, and unmounting its whole React root throws nothing

Red: unmounting a ContextMenu makes React log "Attempted to synchronously
unmount a root while React was already rendering" (136 times across
ContextMenu's suites): ContextMenu's effect cleanup destroys the factory,
whose menu root then unmounts inside React's commit.

Guards, passing now: unmounting the whole React root around an open
ContextMenu throws nothing afterwards, in jsdom and, through Storybook's
switch to another story, in Chromium and WebKit. The menu's layer leaves
ContextMenu's span slot for the slot's parent, which that root owns and
clears as it unmounts; a menu root that unmounted after that would find
its layer gone.
Unmounting a ContextMenu destroyed the factory, whose menu root unmounted
inside React's commit, and React logged an error each time. Unmounting
that root later is no fix either: the menu's layer leaves ContextMenu's
span slot for the slot's parent, which the caller's root owns, so a whole
root unmounting around the menu clears the layer first, and the menu's
own root would then find it gone.

ContextMenu now hosts the factory's menu in its own tree: just before it
calls createContextMenu on its trigger, it registers an internal host
(hostContextMenuInReact), and the factory renders its menu through it, as
a portal into its container, instead of in a root of its own. The factory
still opens, closes and destroys the menu (component:ContextMenu FR10);
ContextMenu's contexts reach the menu without a bridge, and the menu
unmounts with ContextMenu. The factory renders nothing new for the same
options or view (shallow-equal), so ContextMenu's per-render update does
not loop. Plain elements and useContextMenu keep the factory's own root.

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

This pull request is on hold for an engineering owner's decision on spec direction it carries from its stack: architecture:layer-runtime INV13 (one exit copy per closing layer), the three context-menu springs added to spec:AST-094 FR4, spec:AST-057's move of row-swipe motion onto theme motion roles, component:ContextMenu DEC-2, whether spec:AST-094 FR33's no-row rule governs createContextMenu's held-finger open, and useLongPress's new stages option. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever the decision, these still need changes:

  • Land or retarget the stacked dependencies so the diff is only this pull request's own change (47,504 authored lines today).
  • scripts/touch-support-bundle.test.mjs (the root-entry case) and pressableCoverage.test.ts (the actionGroupTracker.ts case) fail at this head because of this diff.
  • Export useContextMenu and its two types from @astryxdesign/core/hooks, as spec:AST-107 documents.
  • Open the menu and call onOpenChange from the request handler rather than the layout effect at createContextMenu.tsx:377-386, keep the menu slot's node out of React state, and correct render's documented cleanup.

[Automated review]

Comment on lines +133 to +137
export type {
LongPressStage,
UseLongPressOptions,
UseLongPressHandlers,
} from './useLongPress';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-107's public contract imports useContextMenu from @astryxdesign/core/hooks, but this entry gains only LongPressStage. Exporting useContextMenu, UseContextMenuOptions and UseContextMenuResult here (and pointing useContextMenu.doc.mjs:110 at this entry) makes the documented import resolve.

Comment on lines +377 to +386
useLayoutEffect(() => {
if (forwarded == null) {
return;
}
if (forwarded.request === 'close') {
handleRef.current?.close();
} else {
handleRef.current?.open(forwarded.request);
}
}, [forwarded]);

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 layout effect opens or closes the menu, and so fires the caller's onOpenChange (through layer.show() and onShow), in reaction to the forwarded prop rather than in the request handler that caused it, which architecture:react-component-runtime INV2 rules out, and it names no external system (INV3). Calling the menu's handle from the request handler inside its flushSync keeps the callback in the handler.

// content on scroll (#3465); the menu places it.
const cursorAnchorRef = useRef<HTMLSpanElement>(null);
// The menu's container lives here, beside the trigger.
const [menuSlot, setMenuSlot] = useState<HTMLSpanElement | null>(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV1: DOM nodes MUST NOT be stored in React state. This keeps the slot span in state through ref={setMenuSlot}, and the factory attaches only after the re-render it causes; a ref for the slot, read in the attach effect, avoids both.

Comment on lines +69 to +70
const PRESS_ARM =
/':active|'\[data-astryx-press=|interactionOverlayStyles\.|getAttribute\(PRESSED_ATTRIBUTE\)/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Widening the pattern to getAttribute(PRESSED_ATTRIBUTE) makes this guard flag hooks/actionGroupTracker.ts:271, which writes and reads the pressed state itself to fade an action group's press, and CI's test-ui 2/4 fails on it at this head (:156). Narrowing the pattern, or listing the tracker as a pressed-state writer, needs to ride with this change.

Comment on lines +76 to +79
/**
* Renders a menu's content into `container` without React; returns a cleanup
* that runs when the menu closes.
*/

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 cleanup runs when the menu is destroyed, not when it closes: render fills the container once, as the menu is attached (:318-329, :401-403, and the option text at createContextMenu.doc.mjs:38). The doc page's summary (createContextMenu.doc.mjs:139, “when it opens”) has the same mismatch.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 28, 2026
… the factory's menu with ContextMenu's touch model) into the headless context menu, as asked on meta#8693
@vjeux

vjeux commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Merged into #8693, as asked there, so this one closes; the branch stays.

@vjeux vjeux closed this Oct 28, 2026

This branch had an error being deployed

1 failed deployment
Preview — ad0a884d Deployed Oct 28, 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. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants