Skip to content

explore: engine option to support floating-ui based positioning in headless - #36675

Draft
Victor Genaev (mainframev) wants to merge 1 commit into
microsoft:masterfrom
mainframev:feat/headless-positioning-engine
Draft

explore: engine option to support floating-ui based positioning in headless#36675
Victor Genaev (mainframev) wants to merge 1 commit into
microsoft:masterfrom
mainframev:feat/headless-positioning-engine

Conversation

@mainframev

@mainframev Victor Genaev (mainframev) commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Previous Behavior

Headless components position surfaces exclusively with native CSS anchor positioning. That covers position, align, offset, coverTarget, matchTargetSize, pinned and fallbackPositions, but it structurally cannot express several behaviours the canonical positioning contract offers — it flips between discrete fallbacks rather than sliding, it resolves collisions against the viewport or containing block rather than an element you choose, and it needs a real DOM node to anchor to.

Options such as autoSize, flipBoundary and shift are correctly rejected by the type after #36623, but there was no supported path to obtain them. PolyfillsAndFallbacks.mdx told consumers to write their own measurement-based positioner.

New Behavior

positioning accepts an engine:

// default — CSS anchor positioning (also what you get by omitting `engine`)
<Menu positioning={{ position: 'below', engine: 'default' }} />

// delegated
import { usePositioning } from '@fluentui/react-positioning';

<Menu positioning={{ flipBoundary: scrollBox, engine: usePositioning }} />

No adapter. usePositioning from @fluentui/react-positioning satisfies the PositioningEngine contract as-is — asserted by a compiling type binding in positioningEngine.test.tsx. This PR adds no wrapper, no subpath and no invented contract.

Nothing is bundled unless you supply it. No default entry point references a JavaScript positioner. @floating-ui/* is now in the package's verify-bundle-isolation forbidden list, so the guarantee is CI-enforced rather than documented. I confirmed the check actually catches a leak before relying on it.

The engine is passed uncalled, and the component invokes it with options it has already merged. This is the load-bearing decision: useMenu derives position: 'after', align: 'top' and a six-entry fallback chain from isSubmenu, which lives in menu context and is not observable at the call site. Any shape where the consumer calls the positioner inverts that ownership and needs a back-channel to undo it — @fluentui/react-positioning exposes no channel to push options into an already-invoked positioner (checked against PositioningImperativeRef, PositioningConfigurationProvider, and the options argument). Invoking it from the component means submenus and context menus keep their derived placement with zero consumer configuration.

Two concerns stay with headless regardless of engine, because they belong to the surface rather than the positioner:

  • the top-layer reset — [popover]:popover-open applies inset: 0; margin: auto, and a JS positioner writes only left/top, leaving right/bottom at 0 and stretching the surface across the viewport. The reset is deliberately partial: inset ends up 0px auto auto 0px, since top/left are the positioner's translate origin.
  • reporting resolved placement through data-placement, mapped from physical to logical vocabulary via the public onPositioningEnd option. Consumer styling keyed on placement — arrows especially — keeps working across engines.

Typing. positioning={{ flipBoundary: el }} is still a compile error without an engine, and legal with one. No never mapping is needed: the engine branch requires engine, and PositioningProps is a weak type, so both the fresh-literal and through-a-variable cases are already rejected. Four @ts-expect-error assertions cover this and fail in both directions.

Notes for reviewers

  • Rules of hooks. The engine is invoked as a hook, so its identity must be stable. A module-scope import always is; engine={o => usePositioning(o)} is not and fails as a hook-order error. I built a dev-time guard naming the cause, then removed it — the failure is already loud and immediate, and it cost a conditional hook plus a lint suppression to improve a message. The constraint is documented on PositioningEngine and in the positioning docs.
  • React Compiler suppression. preserve-manual-memoization is disabled at file scope in usePositioning.ts. Dynamic hook dispatch can't be traced by the compiler, verified by substituting a static call. Memoization is explicit and correct; only the automatic optimisation is lost.
  • ESLint override. PositioningEngine.stories.tsx opts out of the barrel-import rule. That rule steers v9 stories to @fluentui/react-components, which would tell headless consumers to pull the entire suite for one hook. docs/architecture/layers.md rule 3 also states stories may depend on anything.
  • Coverage gap. Component-derived placement reaching an engine was verified in a browser during design exploration, but those scenarios are not in the suite. FlipBoundary.cy.tsx covers the simpler claim that an engine-only option takes effect.

Verification

type-check · lint · 1043 unit tests · 219 Cypress tests · bundle isolation · React 17/18/19 integration targets · API reports regenerated — all passing.

Related Issue(s)

Follows #36623 (narrowed the positioning prop, introduced the headless-local resolver this extends).
Related: #36662.

@mainframev Victor Genaev (mainframev) changed the title feat(headless): add engineoption to positioning feat(headless): engine option to support floating-ui based positioning Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-headless-components-preview
react-headless-components-preview: entire library
247.572 kB
71.442 kB
239.924 kB
67.571 kB
-7.648 kB
-3.871 kB
react-headless-components-preview
@fluentui/react-headless-components-preview/tag-picker
0 B
0 B
54.782 kB
17.953 kB
🆕 New entry
react-headless-components-preview
@fluentui/react-headless-components-preview/teaching-popover
0 B
0 B
36.857 kB
12.224 kB
🆕 New entry

🤖 This report was generated against 78ea7c4de683945b759a5e3431fb4e7d9ff451c9

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Pull request demo site: URL

@mainframev Victor Genaev (mainframev) changed the title feat(headless): engine option to support floating-ui based positioning DO NOT MERGE: feat(headless): engine option to support floating-ui based positioning Sep 1, 2026
@mainframev Victor Genaev (mainframev) changed the title DO NOT MERGE: feat(headless): engine option to support floating-ui based positioning feat(explore): engine option to support floating-ui based positioning in headless Sep 1, 2026
@mainframev Victor Genaev (mainframev) changed the title feat(explore): engine option to support floating-ui based positioning in headless explore: engine option to support floating-ui based positioning in headless Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant