From fca219991ed9e907b01274bd89ac1431e51c573a Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 20 Oct 2026 10:32:16 +0200 Subject: [PATCH 1/6] test(react-positioning): pin browser geometry and lifecycle contracts --- ...-76925269-6201-44d2-ad51-c66d334c5418.json | 6 + .../react-positioning/library/README.md | 37 ++ .../react-positioning/library/project.json | 5 + .../usePositioning.geometry-baseline.cy.ts | 266 ++++++++ .../src/usePositioning.geometry.cy.tsx | 611 ++++++++++++++++++ 5 files changed, 925 insertions(+) create mode 100644 change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json create mode 100644 packages/react-components/react-positioning/library/src/usePositioning.geometry-baseline.cy.ts create mode 100644 packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx diff --git a/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json b/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json new file mode 100644 index 00000000000000..ca9152382229a1 --- /dev/null +++ b/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json @@ -0,0 +1,6 @@ +{ + "type": "none", + "comment": "test(react-positioning): pin Floating UI browser geometry and lifecycle contracts", + "packageName": "@fluentui/react-positioning", + "email": "dmytrokirpa@microsoft.com" +} diff --git a/packages/react-components/react-positioning/library/README.md b/packages/react-components/react-positioning/library/README.md index c5e5f78fb9052b..bf26e17ba5dc47 100644 --- a/packages/react-components/react-positioning/library/README.md +++ b/packages/react-components/react-positioning/library/README.md @@ -23,3 +23,40 @@ const PopupExample: React.FC = ({ children }) => { ); }; ``` + +## Browser behavior contracts + +`src/usePositioning.geometry.cy.tsx` exercises the public hook with real DOM layout and the +unmocked positioning engine. Run it through the existing component-test target: + +```sh +yarn nx run react-positioning:e2e --spec=src/usePositioning.geometry.cy.tsx --browser=electron --config=retries=0 +``` + +The committed expectations in `src/usePositioning.geometry-baseline.cy.ts` were captured from +master baseline `f626209351609e43bc98eb8cc1296273faa679b7` using Cypress 14.4.1 / Electron 130 +at an 800-by-600 viewport. They pin exact CSS-pixel rectangles, placement, strategy, transform +serialization, left/top, maximum sizes, arrow coordinates, visibility attributes, match-target +CSS variable, and overflow styles. No tolerance or engine-derived expected coordinates are used. +The baseline helper only expands literal expected values. Engine replacements must pass these +expectations, not regenerate them. + +Coverage includes the finite 4-position by 5-alignment by 2-direction matrix (including invalid +axis combinations), edge flips, ordered fallbacks and best fit, pinned shifting, tether options, +shift-to-cover limits, number/object/function offsets, arrows, rectangular/element/intersected +element-array/window/scroll/clipping boundaries, independent flip/overflow boundaries, logical +padding, auto-size aliases, target-width matching, target covering, containing-block scaling and +transforms, virtual targets, and fractional rectangles at DPR 1, 1.25, 1.5, 2, and 3. DPR is +controlled independently of the host display; geometry and the engine are never mocked. +Scrollbars are hidden without disabling scrolling so native scrollbar widths do not alter the fixture. + +Lifecycle cases cover imperative updates and retargeting, target prop removal, option removal, +enable/disable, resize and scroll updates, visibility recovery, native end events, detached +container cleanup, idle behavior, and inline end callbacks that update React state. A safety cap +on the state-updating callback makes an update loop fail without hanging the browser. + +These are geometry contracts, not screenshot rasterization baselines or a claim of every +possible combination. Font rasterization, browser zoom, native high-DPI capture, cross-browser +layout differences, iframe/shadow-root targets, and visual-viewport/mobile behavior are not +covered. The explicit `strategy: 'absolute'` case deliberately records the existing baseline's +`fixed` result; changing that behavior requires a separate intentional compatibility decision. diff --git a/packages/react-components/react-positioning/library/project.json b/packages/react-components/react-positioning/library/project.json index 4e7e9301e1a9e3..d9aa9855e4ac2b 100644 --- a/packages/react-components/react-positioning/library/project.json +++ b/packages/react-components/react-positioning/library/project.json @@ -3,6 +3,11 @@ "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "projectType": "library", "sourceRoot": "packages/react-components/react-positioning/library/src", + "targets": { + "e2e": { + "inputs": ["default", "^production", "{projectRoot}/cypress.config.ts"] + } + }, "tags": ["vNext", "platform:web"], "implicitDependencies": [] } diff --git a/packages/react-components/react-positioning/library/src/usePositioning.geometry-baseline.cy.ts b/packages/react-components/react-positioning/library/src/usePositioning.geometry-baseline.cy.ts new file mode 100644 index 00000000000000..0688e4fb1ad1ef --- /dev/null +++ b/packages/react-components/react-positioning/library/src/usePositioning.geometry-baseline.cy.ts @@ -0,0 +1,266 @@ +// Captured from unchanged Floating UI baseline f626209351609e43bc98eb8cc1296273faa679b7, +// Cypress 14.4.1 / Electron 130, viewport 800 x 600. Do not regenerate for an engine replacement. +type Overrides = Partial<{ + width: number; + height: number; + position: string; + transform: string; + left: string; + top: string; + maxWidth: string; + maxHeight: string; + arrowLeft: string; + arrowTop: string; + flags: string; + matchTargetSize: string; + overflowX: string; + overflowY: string; +}>; + +// This only expands literal expected values; it never calls the engine or reads actual geometry. +function at(x: number, y: number, placement = 'bottom', values: Overrides = {}) { + return [ + x, + y, + values.width ?? 120, + values.height ?? 60, + placement, + values.position ?? 'absolute', + values.transform ?? `translate(${x}px, ${y}px)`, + values.left ?? '0px', + values.top ?? '0px', + values.maxWidth ?? '', + values.maxHeight ?? '', + values.arrowLeft ?? '', + values.arrowTop ?? '', + values.flags ?? '', + values.matchTargetSize ?? '', + values.overflowX ?? '', + values.overflowY ?? '', + ]; +} + +export const geometryBaseline: Record = { + 'ltr-above-top': at(280, 180, 'top'), + 'ltr-above-bottom': at(280, 180, 'top'), + 'ltr-above-start': at(300, 180, 'top-start'), + 'ltr-above-end': at(260, 180, 'top-end'), + 'ltr-above-center': at(280, 180, 'top'), + 'ltr-below-top': at(280, 280), + 'ltr-below-bottom': at(280, 280), + 'ltr-below-start': at(300, 280, 'bottom-start'), + 'ltr-below-end': at(260, 280, 'bottom-end'), + 'ltr-below-center': at(280, 280), + 'ltr-before-top': at(180, 240, 'left-start'), + 'ltr-before-bottom': at(180, 220, 'left-end'), + 'ltr-before-start': at(180, 230, 'left'), + 'ltr-before-end': at(180, 230, 'left'), + 'ltr-before-center': at(180, 230, 'left'), + 'ltr-after-top': at(380, 240, 'right-start'), + 'ltr-after-bottom': at(380, 220, 'right-end'), + 'ltr-after-start': at(380, 230, 'right'), + 'ltr-after-end': at(380, 230, 'right'), + 'ltr-after-center': at(380, 230, 'right'), + 'rtl-above-top': at(280, 180, 'top'), + 'rtl-above-bottom': at(280, 180, 'top'), + 'rtl-above-start': at(260, 180, 'top-start'), + 'rtl-above-end': at(300, 180, 'top-end'), + 'rtl-above-center': at(280, 180, 'top'), + 'rtl-below-top': at(280, 280), + 'rtl-below-bottom': at(280, 280), + 'rtl-below-start': at(260, 280, 'bottom-start'), + 'rtl-below-end': at(300, 280, 'bottom-end'), + 'rtl-below-center': at(280, 280), + 'rtl-before-top': at(380, 240, 'right-start'), + 'rtl-before-bottom': at(380, 220, 'right-end'), + 'rtl-before-start': at(380, 230, 'right'), + 'rtl-before-end': at(380, 230, 'right'), + 'rtl-before-center': at(380, 230, 'right'), + 'rtl-after-top': at(180, 240, 'left-start'), + 'rtl-after-bottom': at(180, 220, 'left-end'), + 'rtl-after-start': at(180, 230, 'left'), + 'rtl-after-end': at(180, 230, 'left'), + 'rtl-after-center': at(180, 230, 'left'), + defaults: at(280, 280), + 'flip-bottom-edge': at(280, 500, 'top'), + 'flip-top-edge': at(300, 40, 'bottom-start'), + 'flip-left-edge': at(80, 240, 'right-start'), + 'flip-right-edge': at(620, 220, 'left-end'), + 'flip-alignment': at(680, 280, 'bottom-end'), + 'pinned-bottom': at(280, 600, 'bottom', { flags: 'data-popper-escaped' }), + 'pinned-still-shifts': at(680, 280, 'bottom-start'), + 'fallback-first-fit': at(300, 500, 'top-start'), + 'fallback-reversed': at(180, 540, 'left-end'), + 'fallback-best-fit': at(150, 320, 'bottom', { width: 500, height: 400, flags: 'data-popper-is-intersecting' }), + 'fallback-rtl': at(260, 500, 'top-start'), + 'shift-left': at(0, 280), + 'shift-right': at(680, 280), + 'tether-default': at(280, 690, 'bottom', { flags: 'data-popper-escaped,data-popper-reference-hidden' }), + 'tether-disabled': at(280, 690, 'bottom', { flags: 'data-popper-escaped,data-popper-reference-hidden' }), + 'tether-disabled-all': at(280, 540, 'bottom', { flags: 'data-popper-reference-hidden' }), + 'shift-to-cover': at(280, 540), + 'shift-to-cover-limit': at(280, 590, 'bottom', { flags: 'data-popper-reference-hidden,data-popper-is-intersecting' }), + 'offset-zero': at(280, 280), + 'offset-positive': at(280, 293), + 'offset-negative': at(280, 191, 'top'), + 'offset-object': at(317, 289, 'bottom-start'), + 'offset-object-rtl': at(243, 289, 'bottom-start'), + 'offset-horizontal': at(171, 213, 'left'), + 'offset-function': at(277, 167, 'top-end'), + 'offset-function-number': at(280, 290), + 'offset-flipped': at(280, 488, 'top'), + 'arrow-centered': at(280, 280, 'bottom', { arrowLeft: '55px' }), + 'arrow-start': at(300, 290, 'bottom-start', { arrowLeft: '35px' }), + 'arrow-horizontal': at(380, 240, 'right-start', { arrowTop: '15px' }), + 'arrow-padding': at(277, 280, 'bottom-start', { arrowLeft: '20px' }), + 'arrow-shift': at(680, 280, 'bottom', { arrowLeft: '110px' }), + 'arrow-flip': at(280, 500, 'top', { arrowLeft: '55px' }), + 'boundary-rect': at(380, 310, 'top'), + 'boundary-element': at(380, 310, 'top'), + 'boundary-array': at(330, 310, 'top'), + 'boundary-flip-only': at(450, 310, 'top'), + 'boundary-overflow-only': at(380, 410), + 'boundary-window': at(680, 500, 'top'), + 'boundary-scroll-parent': at(320, 220, 'top', { transform: 'translate(280px, 190px)' }), + 'boundary-clipping': at(320, 220, 'top', { transform: 'translate(280px, 190px)' }), + 'padding-number': at(664, 280), + 'padding-logical-ltr': at(653, 280), + 'padding-logical-rtl': at(669, 280), + 'padding-partial': at(23, 280), + 'match-width': at(280, 280, 'bottom', { matchTargetSize: '80px' }), + 'match-intrinsic-width': at(300, 280, 'bottom', { width: 80, matchTargetSize: '80px' }), + 'match-width-horizontal': at(380, 230, 'right', { matchTargetSize: '80px' }), + 'cover-above': at(280, 220, 'top'), + 'cover-below': at(280, 240), + 'cover-before': at(260, 230, 'left'), + 'cover-after': at(300, 230, 'right'), + 'auto-size-true': at(12, 280, 'bottom', { + width: 776, + height: 308, + maxWidth: '776px', + maxHeight: '308px', + overflowX: 'auto', + overflowY: 'auto', + }), + 'auto-size-height': at(12, 280, 'bottom', { width: 900, height: 308, maxHeight: '308px', overflowY: 'auto' }), + 'auto-size-width': at(12, 280, 'bottom', { + width: 776, + height: 700, + maxWidth: '776px', + overflowX: 'auto', + flags: 'data-popper-is-intersecting', + }), + 'auto-size-always': at(12, 280, 'bottom', { + width: 776, + height: 308, + maxWidth: '776px', + maxHeight: '308px', + overflowX: 'auto', + overflowY: 'auto', + }), + 'auto-size-height-always': at(12, 280, 'bottom', { width: 900, height: 308, maxHeight: '308px', overflowY: 'auto' }), + 'auto-size-width-always': at(12, 280, 'bottom', { + width: 776, + height: 700, + maxWidth: '776px', + overflowX: 'auto', + flags: 'data-popper-is-intersecting', + }), + 'auto-size-false': at(12, 280, 'bottom', { width: 900, height: 700, flags: 'data-popper-is-intersecting' }), + 'auto-size-boundary': at(100, 100, 'top', { + width: 400, + height: 140, + maxWidth: '400px', + maxHeight: '140px', + overflowX: 'auto', + overflowY: 'auto', + }), + 'auto-size-match': at(300, 280, 'bottom', { + width: 80, + maxWidth: '800px', + maxHeight: '320px', + matchTargetSize: '80px', + }), + // Baseline behavior: explicitly passing either strategy currently resolves to fixed. + 'strategy-absolute': at(280, 280, 'bottom', { position: 'fixed' }), + 'strategy-fixed': at(280, 280, 'bottom', { position: 'fixed' }), + 'strategy-deprecated-fixed': at(280, 280, 'bottom', { position: 'fixed' }), + 'no-transform-absolute': at(280, 280, 'bottom', { position: 'fixed', transform: '', left: '280px', top: '280px' }), + 'no-transform-fixed': at(280, 280, 'bottom', { position: 'fixed', transform: '', left: '280px', top: '280px' }), + 'no-transform-fixed-rtl': at(260, 280, 'bottom-start', { + position: 'fixed', + transform: '', + left: '260px', + top: '280px', + }), + 'fractional-dpr-1-transform-true': at(280, 281, 'bottom', { width: 120.25, height: 60.25 }), + 'fractional-dpr-1-transform-false': at(280, 281, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280px', + top: '281px', + }), + 'fractional-dpr-1.25-transform-true': at(280, 280.79998779296875, 'bottom', { + width: 120.25, + height: 60.25, + transform: 'translate3d(280px, 280.8px, 0px)', + }), + 'fractional-dpr-1.25-transform-false': at(280, 280.796875, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280px', + top: '280.8px', + }), + 'fractional-dpr-1.5-transform-true': at(280.6666564941406, 280.6666564941406, 'bottom', { + width: 120.25, + height: 60.25, + transform: 'translate3d(280.667px, 280.667px, 0px)', + }), + 'fractional-dpr-1.5-transform-false': at(280.6640625, 280.6640625, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280.667px', + top: '280.667px', + }), + 'fractional-dpr-2-transform-true': at(280.5, 281, 'bottom', { + width: 120.25, + height: 60.25, + transform: 'translate3d(280.5px, 281px, 0px)', + }), + 'fractional-dpr-2-transform-false': at(280.5, 281, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280.5px', + top: '281px', + }), + 'fractional-dpr-3-transform-true': at(280.3333435058594, 280.6666564941406, 'bottom', { + width: 120.25, + height: 60.25, + transform: 'translate3d(280.333px, 280.667px, 0px)', + }), + 'fractional-dpr-3-transform-false': at(280.328125, 280.6640625, 'bottom', { + width: 120.25, + height: 60.25, + transform: '', + left: '280.333px', + top: '280.667px', + }), + 'nested-scroll': at(320, 210, 'top', { transform: 'translate(280px, 180px)' }), + 'scaled-containing-block': at(390, 380, 'bottom', { width: 150, height: 75, transform: 'translate(280px, 280px)' }), + 'transformed-containing-block': at(345, 325, 'bottom', { transform: 'translate(280px, 280px)' }), + 'fixed-transformed-containing-block': at(345, 325, 'bottom', { + position: 'fixed', + transform: 'translate(280px, 280px)', + }), + 'virtual-target': at(280, 280), + 'virtual-zero-size': at(240, 240), + 'hidden-target': at(320, 470, 'top', { + transform: 'translate(280px, 440px)', + flags: 'data-popper-escaped,data-popper-reference-hidden', + }), + 'partially-clipped': at(320, 350, 'bottom', { transform: 'translate(280px, 320px)', flags: 'data-popper-escaped' }), +}; diff --git a/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx b/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx new file mode 100644 index 00000000000000..1477040446caec --- /dev/null +++ b/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx @@ -0,0 +1,611 @@ +import * as React from 'react'; +import { mount } from '@fluentui/scripts-cypress'; +import { FluentProvider } from '@fluentui/react-provider'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import { usePositioning } from './usePositioning'; +import type { + Alignment, + OnPositioningEndEventDetail, + Position, + PositioningBoundary, + PositioningImperativeRef, + PositioningOptions, + PositioningProps, + PositioningRect, +} from './types'; +import { geometryBaseline } from './usePositioning.geometry-baseline.cy'; + +type Scenario = { + name: string; + dir?: 'ltr' | 'rtl'; + options?: PositioningOptions; + target?: Partial; + floating?: { width: number; height: number }; + arrow?: boolean; + boundary?: 'rect' | 'element' | 'array' | 'window' | 'scrollParent' | 'clippingParents'; + boundaryMode?: 'flip' | 'overflow'; + host?: 'scroll' | 'scale' | 'transform'; + virtual?: boolean; + dpr?: number; + intrinsic?: boolean; + updateStateOnEnd?: boolean; +}; + +// All dimensions in this fixture are deliberate geometry inputs, not product styling. +const positions: Position[] = ['above', 'below', 'before', 'after']; +const alignments: Alignment[] = ['top', 'bottom', 'start', 'end', 'center']; +const scenarios: Scenario[] = []; +for (const dir of ['ltr', 'rtl'] as const) { + for (const position of positions) { + for (const align of alignments) { + scenarios.push({ name: `${dir}-${position}-${align}`, dir, options: { position, align } }); + } + } +} + +scenarios.push( + { name: 'defaults' }, + { name: 'flip-bottom-edge', target: { y: 560 } }, + { name: 'flip-top-edge', target: { y: 0 }, options: { position: 'above', align: 'start' } }, + { name: 'flip-left-edge', target: { x: 0 }, options: { position: 'before', align: 'top' } }, + { name: 'flip-right-edge', target: { x: 740 }, options: { position: 'after', align: 'bottom' } }, + { name: 'flip-alignment', target: { x: 760 }, options: { align: 'start' } }, + { name: 'pinned-bottom', target: { y: 560 }, options: { pinned: true } }, + { name: 'pinned-still-shifts', target: { x: 780 }, options: { pinned: true, align: 'start' } }, + { + name: 'fallback-first-fit', + target: { y: 560 }, + options: { fallbackPositions: ['after-top', 'above-start', 'before-bottom'] }, + }, + { + name: 'fallback-reversed', + target: { y: 560 }, + options: { fallbackPositions: ['before-bottom', 'above-start', 'after-top'] }, + }, + { + name: 'fallback-best-fit', + target: { x: 360, y: 280 }, + floating: { width: 500, height: 400 }, + options: { fallbackPositions: ['after-top', 'above', 'before-bottom'] }, + }, + { + name: 'fallback-rtl', + dir: 'rtl', + target: { y: 560 }, + options: { fallbackPositions: ['after-top', 'above-start'] }, + }, + { name: 'shift-left', target: { x: -40 } }, + { name: 'shift-right', target: { x: 780 } }, + { name: 'tether-default', target: { y: 650 }, options: { pinned: true } }, + // eslint-disable-next-line @typescript-eslint/naming-convention + { name: 'tether-disabled', target: { y: 650 }, options: { pinned: true, unstable_disableTether: true } }, + // eslint-disable-next-line @typescript-eslint/naming-convention + { name: 'tether-disabled-all', target: { y: 650 }, options: { pinned: true, unstable_disableTether: 'all' } }, + { name: 'shift-to-cover', target: { y: 570 }, options: { pinned: true, shiftToCoverTarget: true } }, + { name: 'shift-to-cover-limit', target: { y: 650 }, options: { pinned: true, shiftToCoverTarget: true } }, + { name: 'offset-zero', options: { offset: 0 } }, + { name: 'offset-positive', options: { offset: 13 } }, + { name: 'offset-negative', options: { position: 'above', offset: -11 } }, + { name: 'offset-object', options: { align: 'start', offset: { mainAxis: 9, crossAxis: 17 } } }, + { name: 'offset-object-rtl', dir: 'rtl', options: { align: 'start', offset: { mainAxis: 9, crossAxis: 17 } } }, + { name: 'offset-horizontal', options: { position: 'before', offset: { mainAxis: 9, crossAxis: -17 } } }, + { + name: 'offset-function', + options: { + position: 'above', + align: 'end', + offset: ({ targetRect, positionedRect, position, alignment }) => ({ + mainAxis: targetRect.height / 4 + (position === 'above' ? 3 : 0), + crossAxis: positionedRect.width / 10 + (alignment === 'end' ? 5 : 0), + }), + }, + }, + { name: 'offset-function-number', options: { offset: ({ targetRect }) => targetRect.width / 8 } }, + { name: 'offset-flipped', target: { y: 560 }, options: { offset: 12 } }, + { name: 'arrow-centered', arrow: true }, + { name: 'arrow-start', arrow: true, options: { align: 'start', offset: 10 } }, + { name: 'arrow-horizontal', arrow: true, options: { position: 'after', align: 'top' } }, + { name: 'arrow-padding', arrow: true, target: { width: 4 }, options: { align: 'start', arrowPadding: 20 } }, + { name: 'arrow-shift', arrow: true, target: { x: 780 }, options: { pinned: true } }, + { name: 'arrow-flip', arrow: true, target: { y: 560 } }, + { name: 'boundary-rect', boundary: 'rect', target: { x: 470, y: 370 } }, + { name: 'boundary-element', boundary: 'element', target: { x: 470, y: 370 } }, + { name: 'boundary-array', boundary: 'array', target: { x: 470, y: 370 } }, + { name: 'boundary-flip-only', boundary: 'rect', boundaryMode: 'flip', target: { x: 470, y: 370 } }, + { name: 'boundary-overflow-only', boundary: 'rect', boundaryMode: 'overflow', target: { x: 470, y: 370 } }, + { name: 'boundary-window', boundary: 'window', target: { x: 780, y: 560 } }, + { name: 'boundary-scroll-parent', boundary: 'scrollParent', host: 'scroll', target: { x: 350, y: 250 } }, + { name: 'boundary-clipping', boundary: 'clippingParents', host: 'scroll', target: { x: 350, y: 250 } }, + { name: 'padding-number', target: { x: 780 }, options: { overflowBoundaryPadding: 16 } }, + { + name: 'padding-logical-ltr', + target: { x: 780 }, + options: { overflowBoundaryPadding: { start: 11, end: 27, top: 13, bottom: 19 } }, + }, + { + name: 'padding-logical-rtl', + dir: 'rtl', + target: { x: 780 }, + options: { overflowBoundaryPadding: { start: 11, end: 27, top: 13, bottom: 19 } }, + }, + { name: 'padding-partial', target: { x: -40 }, options: { overflowBoundaryPadding: { start: 23 } } }, + { name: 'match-width', options: { matchTargetSize: 'width' } }, + { name: 'match-intrinsic-width', intrinsic: true, options: { matchTargetSize: 'width' } }, + { name: 'match-width-horizontal', options: { position: 'after', matchTargetSize: 'width' } }, + ...positions.map(position => ({ + name: `cover-${position}`, + options: { position, coverTarget: true }, + })), + ...([true, 'height', 'width', 'always', 'height-always', 'width-always', false] as const).map(autoSize => ({ + name: `auto-size-${autoSize}`, + floating: { width: 900, height: 700 }, + options: { autoSize, pinned: true, overflowBoundaryPadding: 12 }, + })), + { name: 'auto-size-boundary', boundary: 'rect', floating: { width: 600, height: 500 }, options: { autoSize: true } }, + { name: 'auto-size-match', options: { autoSize: true, matchTargetSize: 'width' } }, + { name: 'strategy-absolute', options: { strategy: 'absolute' } }, + { name: 'strategy-fixed', options: { strategy: 'fixed' } }, + { name: 'strategy-deprecated-fixed', options: { positionFixed: true } }, + { name: 'no-transform-absolute', options: { strategy: 'absolute', useTransform: false } }, + { name: 'no-transform-fixed', options: { strategy: 'fixed', useTransform: false } }, + { name: 'no-transform-fixed-rtl', dir: 'rtl', options: { strategy: 'fixed', useTransform: false, align: 'start' } }, + ...[1, 1.25, 1.5, 2, 3].flatMap(dpr => + [true, false].map(useTransform => ({ + name: `fractional-dpr-${dpr}-transform-${useTransform}`, + dpr, + target: { x: 300.25, y: 240.25, width: 80.5, height: 40.5 }, + floating: { width: 120.25, height: 60.25 }, + options: { useTransform }, + })), + ), + { name: 'nested-scroll', host: 'scroll' }, + { name: 'scaled-containing-block', host: 'scale' }, + { name: 'transformed-containing-block', host: 'transform' }, + { name: 'fixed-transformed-containing-block', host: 'transform', options: { strategy: 'fixed' } }, + { name: 'virtual-target', virtual: true }, + { name: 'virtual-zero-size', virtual: true, target: { width: 0, height: 0 } }, + { name: 'hidden-target', host: 'scroll', target: { x: 450, y: 500 } }, + { name: 'partially-clipped', host: 'scroll', target: { y: 280 }, options: { pinned: true } }, +); + +type Controls = { + positioning: PositioningImperativeRef | null; + setOptions: React.Dispatch>; + setVisible: React.Dispatch>; + rerender: () => void; + events: OnPositioningEndEventDetail[]; +}; + +const Fixture = ({ scenario, controls }: { scenario: Scenario; controls: Controls }) => { + const { targetDocument } = useFluent_unstable(); + const [options, setOptions] = React.useState(scenario.options ?? {}); + const [visible, setVisible] = React.useState(true); + const [endCount, setEndCount] = React.useState(0); + const [, forceRender] = React.useReducer(n => n + 1, 0); + const [boundary, setBoundary] = React.useState(null); + const target = { x: 300, y: 240, width: 80, height: 40, ...scenario.target }; + const floating = scenario.floating ?? { width: 120, height: 60 }; + const virtualTarget = React.useMemo( + () => ({ + getBoundingClientRect: () => ({ + ...target, + top: target.y, + left: target.x, + right: target.x + target.width, + bottom: target.y + target.height, + }), + contextElement: targetDocument?.body, + }), + // Geometry is fixed for the lifetime of a mounted scenario. + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + let resolvedBoundary: PositioningBoundary | undefined; + if (scenario.boundary === 'rect') { + resolvedBoundary = { x: 100, y: 100, width: 400, height: 300 }; + } else if (scenario.boundary === 'element' || scenario.boundary === 'array') { + const innerBoundary = boundary?.querySelector('[data-inner-boundary]'); + resolvedBoundary = boundary + ? scenario.boundary === 'array' && innerBoundary + ? [boundary, innerBoundary] + : boundary + : undefined; + } else { + resolvedBoundary = scenario.boundary; + } + const refs = usePositioning({ + position: 'below', + ...options, + ...(resolvedBoundary && scenario.boundaryMode !== 'overflow' && { flipBoundary: resolvedBoundary }), + ...(resolvedBoundary && scenario.boundaryMode !== 'flip' && { overflowBoundary: resolvedBoundary }), + ...(scenario.virtual && { target: virtualTarget }), + positioningRef: value => { + controls.positioning = value; + }, + onPositioningEnd: event => { + controls.events.push(event.detail); + // Bound a broken engine's feedback loop so the regression fails rather than hanging capture. + if (scenario.updateStateOnEnd && endCount < 32) { + setEndCount(endCount + 1); + } + options.onPositioningEnd?.(event); + }, + }); + controls.setOptions = setOptions; + controls.setVisible = setVisible; + controls.rerender = forceRender; + + return ( +
+
+
+
+
+
+
+ {visible && ( +
+
+ {scenario.arrow && ( +
+ )} +
+ )} +
+
+
+ ); +}; + +function mountScenario(scenario: Scenario) { + const controls: Controls = { + positioning: null, + setOptions: () => undefined, + setVisible: () => undefined, + rerender: () => undefined, + events: [], + }; + cy.window().then(win => { + // Exercise the real DPR rounding branch without replacing any geometry APIs or engine code. + Object.defineProperty(win, 'devicePixelRatio', { configurable: true, value: scenario.dpr ?? 1 }); + }); + mount( + + + , + ); + cy.get('[data-floating]').should('have.attr', 'data-popper-placement'); + cy.window().then( + win => + new Cypress.Promise(resolve => { + win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve())); + }), + ); + return controls; +} + +function geometry(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + const arrow = element.querySelector('[data-arrow]'); + return [ + rect.x, + rect.y, + rect.width, + rect.height, + element.getAttribute('data-popper-placement'), + element.style.position, + element.style.transform, + element.style.left, + element.style.top, + element.style.maxWidth, + element.style.maxHeight, + arrow?.style.left ?? '', + arrow?.style.top ?? '', + ['data-popper-escaped', 'data-popper-reference-hidden', 'data-popper-is-intersecting'] + .filter(attribute => element.hasAttribute(attribute)) + .join(','), + element.style.getPropertyValue('--fui-match-target-size'), + element.style.overflowX, + element.style.overflowY, + ]; +} + +describe('usePositioning browser geometry contracts', () => { + let originalDpr: PropertyDescriptor | undefined; + beforeEach(() => { + cy.viewport(800, 600); + cy.window().then(win => { + originalDpr = Object.getOwnPropertyDescriptor(win, 'devicePixelRatio'); + }); + }); + afterEach(() => { + cy.window().then(win => { + if (originalDpr) { + Object.defineProperty(win, 'devicePixelRatio', originalDpr); + } else { + Reflect.deleteProperty(win, 'devicePixelRatio'); + } + }); + }); + + it('has exactly one committed baseline for every unique scenario', () => { + expect(new Set(scenarios.map(scenario => scenario.name)).size).to.equal(scenarios.length); + expect(Object.keys(geometryBaseline).sort()).to.deep.equal(scenarios.map(scenario => scenario.name).sort()); + }); + scenarios.forEach(scenario => { + it(scenario.name, () => { + const controls = mountScenario(scenario); + cy.get('[data-floating]').should($floating => { + const actual = geometry($floating[0]); + expect(actual).to.deep.equal(geometryBaseline[scenario.name]); + expect(controls.events[controls.events.length - 1]).to.deep.equal({ + placement: $floating.attr('data-popper-placement'), + escaped: $floating.is('[data-popper-escaped]'), + referenceHidden: $floating.is('[data-popper-reference-hidden]'), + }); + }); + }); + }); + + function expectRect(x: number, y: number, width = 120, height = 60) { + cy.get('[data-floating]').should($floating => { + const rect = $floating[0].getBoundingClientRect(); + expect([rect.x, rect.y, rect.width, rect.height]).to.deep.equal([x, y, width, height]); + }); + } + + function expectIdle(controls: Controls) { + cy.then(() => { + const count = controls.events.length; + // A finite quiet interval also catches asynchronous resize/position feedback loops. + cy.wait(200, { log: false }).then(() => + expect(controls.events.length, 'idle positioning updates').to.equal(count), + ); + }); + } + + it('an inline end callback that updates state settles instead of recreating the manager', () => { + const controls = mountScenario({ name: 'stateful-end', updateStateOnEnd: true }); + expectRect(280, 280); + cy.wait(200, { log: false }).then(() => { + expect(controls.events.length, 'bounded initial positioning updates').to.be.within(1, 3); + }); + expectIdle(controls); + cy.then(() => controls.rerender()); + expectIdle(controls); + }); + + it('callback-only and imperative-ref-only rerenders do not initiate positioning', () => { + const controls = mountScenario({ name: 'callback-rerender' }); + expectIdle(controls); + cy.then(() => { + const count = controls.events.length; + controls.rerender(); + cy.wait(200, { log: false }).then(() => expect(controls.events.length).to.equal(count)); + }); + }); + + it('removing options restores default offset and placement', () => { + const controls = mountScenario({ name: 'remove-options', options: { position: 'above', offset: 19 } }); + expectRect(280, 161); + cy.then(() => controls.setOptions({})); + expectRect(280, 280); + cy.get('[data-floating]').should('have.attr', 'data-popper-placement', 'bottom'); + }); + + it('imperatively updates a moved target and retargets to virtual and original references', () => { + const controls = mountScenario({ name: 'imperative' }); + cy.get('[data-target]').invoke('css', { left: '400px', top: '200px' }); + cy.then(() => controls.positioning?.updatePosition()); + expectRect(380, 240); + cy.then(() => + controls.positioning?.setTarget({ + getBoundingClientRect: () => ({ + x: 100, + y: 120, + left: 100, + top: 120, + right: 120, + bottom: 130, + width: 20, + height: 10, + }), + }), + ); + expectRect(50, 130); + cy.then(() => controls.positioning?.setTarget(null)); + expectRect(380, 240); + }); + + it('supports the target prop and restores the DOM target when it is removed', () => { + const controls = mountScenario({ name: 'target-prop' }); + cy.then(() => + controls.setOptions({ + target: { + getBoundingClientRect: () => ({ + x: 100, + y: 120, + left: 100, + top: 120, + right: 120, + bottom: 130, + width: 20, + height: 10, + }), + }, + }), + ); + expectRect(50, 130); + cy.then(() => controls.setOptions({})); + expectRect(280, 280); + }); + + it('disables automatic updates and re-enables with current target geometry', () => { + const controls = mountScenario({ name: 'enabled' }); + expectIdle(controls); + let count = 0; + cy.then(() => { + count = controls.events.length; + controls.setOptions({ enabled: false }); + }); + cy.get('[data-enabled="false"]').should('exist'); + cy.get('[data-target]').invoke('css', { left: '400px', width: '100px' }); + expectIdle(controls); + cy.then(() => expect(controls.events.length).to.equal(count)); + expectRect(280, 280); + cy.then(() => controls.setOptions({ enabled: true })); + expectRect(390, 280); + }); + + it('tracks target and floating element resize without polling while idle', () => { + const controls = mountScenario({ name: 'resize' }); + cy.get('[data-target]').invoke('css', { width: '160px', height: '70px' }); + expectRect(320, 310); + cy.get('[data-floating]').invoke('css', { width: '100px', height: '50px' }); + expectRect(330, 310, 100, 50); + expectIdle(controls); + }); + + it('disableUpdateOnResize preserves coordinates until explicitly updated', () => { + const controls = mountScenario({ name: 'resize-disabled', options: { disableUpdateOnResize: true } }); + cy.get('[data-target]').invoke('css', { width: '160px', height: '70px' }); + expectIdle(controls); + expectRect(280, 280); + cy.then(() => controls.positioning?.updatePosition()); + expectRect(320, 310); + }); + + it('does not reposition for zero-sized resize observations while the target is display:none', () => { + const controls = mountScenario({ name: 'display-none' }); + expectIdle(controls); + let count = 0; + cy.then(() => { + count = controls.events.length; + }); + cy.get('[data-target]').invoke('css', 'display', 'none'); + expectIdle(controls); + cy.then(() => expect(controls.events.length).to.equal(count)); + expectRect(280, 280); + cy.get('[data-target]').invoke('css', { display: 'block', width: '160px', height: '70px' }); + expectRect(320, 310); + }); + + it('autoSize releases stale maximum sizes when the viewport grows', () => { + const controls = mountScenario({ + name: 'auto-size-grow', + floating: { width: 900, height: 700 }, + options: { autoSize: true, pinned: true, overflowBoundaryPadding: 12 }, + }); + expectRect(12, 280, 776, 308); + cy.viewport(1000, 1000); + expectRect(12, 280, 900, 700); + cy.get('[data-floating]').should($floating => { + expect([$floating[0].style.maxWidth, $floating[0].style.maxHeight]).to.deep.equal(['976px', '708px']); + }); + expectIdle(controls); + }); + + it('updates on nested scrolling and window resize', () => { + const controls = mountScenario({ name: 'scroll-update', host: 'scroll' }); + expectRect(320, 210); + cy.get('[data-host]').scrollTo(0, 100); + expectRect(320, 210); + cy.get('[data-floating]').should('have.attr', 'data-popper-placement', 'bottom'); + cy.get('[data-target]').invoke('css', 'left', '200px'); + cy.window().trigger('resize'); + expectRect(220, 210); + expectIdle(controls); + }); + + it('removes visibility flags when a clipped target returns into view', () => { + const controls = mountScenario({ name: 'visibility', host: 'scroll', target: { x: 450, y: 500 } }); + cy.get('[data-floating]').should('have.attr', 'data-popper-reference-hidden'); + cy.get('[data-floating]').should('have.attr', 'data-popper-escaped'); + cy.get('[data-target]').invoke('css', { left: '200px', top: '100px' }); + cy.then(() => controls.positioning?.updatePosition()); + expectRect(220, 170); + cy.get('[data-floating]').should('not.have.attr', 'data-popper-reference-hidden'); + cy.get('[data-floating]').should('not.have.attr', 'data-popper-escaped'); + expectIdle(controls); + }); + + it('dispatches a native positioning-end event with the final post-flip placement', () => { + const controls = mountScenario({ name: 'native-event', target: { y: 560 } }); + const details: OnPositioningEndEventDetail[] = []; + cy.get('[data-floating]').then($floating => { + $floating[0].addEventListener('fui-positioningend', event => { + details.push((event as CustomEvent).detail); + }); + }); + cy.then(() => controls.positioning?.updatePosition()); + cy.wrap(details).should('deep.equal', [{ placement: 'top', escaped: false, referenceHidden: false }]); + }); + + it('cleans up detached containers and remounts without stale updates', () => { + const controls = mountScenario({ name: 'cleanup' }); + expectIdle(controls); + let count = 0; + let detachedEvents = 0; + let detached: HTMLElement; + cy.get('[data-floating]').then($floating => { + detached = $floating[0]; + detached.addEventListener('fui-positioningend', () => detachedEvents++); + }); + cy.then(() => { + count = controls.events.length; + controls.setVisible(false); + }); + cy.get('[data-floating]').should('not.exist'); + cy.then(() => controls.positioning?.updatePosition()); + cy.get('[data-target]').invoke('css', { width: '160px', height: '70px' }); + cy.window().trigger('resize'); + cy.window().trigger('scroll'); + expectIdle(controls); + cy.then(() => { + expect(controls.events.length).to.equal(count); + expect(detachedEvents, 'events dispatched to detached container').to.equal(0); + expect(detached.style.transform).to.equal('translate(280px, 280px)'); + }); + cy.then(() => controls.setVisible(true)); + expectRect(320, 310); + expectIdle(controls); + }); +}); From bba60dd91c5552e1ce9b10d819a028d708bdf37c Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 20 Oct 2026 13:05:41 +0200 Subject: [PATCH 2/6] test(react-positioning): isolate native fractional layout quantization --- ...-76925269-6201-44d2-ad51-c66d334c5418.json | 2 +- .../react-positioning/library/README.md | 10 +++ .../src/usePositioning.geometry.cy.tsx | 63 ++++++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json b/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json index ca9152382229a1..da166524a01be4 100644 --- a/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json +++ b/change/@fluentui-react-positioning-76925269-6201-44d2-ad51-c66d334c5418.json @@ -1,6 +1,6 @@ { "type": "none", - "comment": "test(react-positioning): pin Floating UI browser geometry and lifecycle contracts", + "comment": "test(react-positioning): pin portable browser geometry and lifecycle contracts with exact engine output", "packageName": "@fluentui/react-positioning", "email": "dmytrokirpa@microsoft.com" } diff --git a/packages/react-components/react-positioning/library/README.md b/packages/react-components/react-positioning/library/README.md index bf26e17ba5dc47..a46614758cfb24 100644 --- a/packages/react-components/react-positioning/library/README.md +++ b/packages/react-components/react-positioning/library/README.md @@ -41,6 +41,16 @@ CSS variable, and overflow styles. No tolerance or engine-derived expected coord The baseline helper only expands literal expected values. Engine replacements must pass these expectations, not regenerate them. +For fractional DPR cases with transforms disabled, only rectangle x/y are compared to an +engine-free sibling positioned with the committed literal left/top values. Native layout +quantization is host-dependent: Electron 130 lays out `280.667px` at `280.6640625` on the +original macOS host but at `280.65625` on Linux CI. Both are distinct from device-pixel rounding +performed by the engine. The sibling runs no positioning logic and never reads engine output; +all inline styles, dimensions, placement, and other fields remain exact literal assertions. +The original rectangle snapshots are retained as provenance, not regenerated for each host. +Negative-control tests at DPR 1.5 and 3 reject a `0.001px` left/top change even when native +layout produces the same rectangle, and reject displaced rectangles with unchanged left/top. + Coverage includes the finite 4-position by 5-alignment by 2-direction matrix (including invalid axis combinations), edge flips, ordered fallbacks and best fit, pinned shifting, tether options, shift-to-cover limits, number/object/function offsets, arrows, rectangular/element/intersected diff --git a/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx b/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx index 1477040446caec..b019078ff6010f 100644 --- a/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx +++ b/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx @@ -185,6 +185,10 @@ const Fixture = ({ scenario, controls }: { scenario: Scenario; controls: Control const [boundary, setBoundary] = React.useState(null); const target = { x: 300, y: 240, width: 80, height: 40, ...scenario.target }; const floating = scenario.floating ?? { width: 120, height: 60 }; + const layoutBaseline = + scenario.dpr !== undefined && scenario.options?.useTransform === false + ? geometryBaseline[scenario.name] + : undefined; const virtualTarget = React.useMemo( () => ({ getBoundingClientRect: () => ({ @@ -267,6 +271,20 @@ const Fixture = ({ scenario, controls }: { scenario: Scenario; controls: Control }} >
+ {layoutBaseline && ( +
+ )}
('[data-layout-reference]'); + if (!reference) { + throw new Error('Fractional left/top geometry requires an engine-free layout reference'); + } + // Native layout quantization varies by host; the reference uses only committed CSS literals. + const rect = reference.getBoundingClientRect(); + expected[0] = rect.x; + expected[1] = rect.y; + } + expect(geometry(element)).to.deep.equal(expected); +} + describe('usePositioning browser geometry contracts', () => { let originalDpr: PropertyDescriptor | undefined; beforeEach(() => { @@ -374,8 +407,7 @@ describe('usePositioning browser geometry contracts', () => { it(scenario.name, () => { const controls = mountScenario(scenario); cy.get('[data-floating]').should($floating => { - const actual = geometry($floating[0]); - expect(actual).to.deep.equal(geometryBaseline[scenario.name]); + expectGeometry($floating[0], scenario); expect(controls.events[controls.events.length - 1]).to.deep.equal({ placement: $floating.attr('data-popper-placement'), escaped: $floating.is('[data-popper-escaped]'), @@ -385,6 +417,33 @@ describe('usePositioning browser geometry contracts', () => { }); }); + scenarios + .filter(scenario => [1.5, 3].includes(scenario.dpr ?? 0) && scenario.options?.useTransform === false) + .forEach(scenario => { + it(`${scenario.name} rejects style drift even within one native layout step`, () => { + mountScenario(scenario); + cy.get('[data-floating]').then($floating => { + const element = $floating[0]; + expectGeometry(element, scenario); + const rect = geometry(element).slice(0, 4); + for (const property of ['left', 'top'] as const) { + const original = element.style[property]; + element.style[property] = `${parseFloat(original) - 0.001}px`; + expect(geometry(element).slice(0, 4), 'sub-layout-step drift has the same rectangle').to.deep.equal(rect); + expect(() => expectGeometry(element, scenario), `reject changed ${property}`).to.throw(); + element.style[property] = original; + } + element.style.marginLeft = '1px'; + expect( + () => expectGeometry(element, scenario), + 'reject a displaced rectangle with unchanged left/top', + ).to.throw(); + element.style.marginLeft = ''; + expectGeometry(element, scenario); + }); + }); + }); + function expectRect(x: number, y: number, width = 120, height = 60) { cy.get('[data-floating]').should($floating => { const rect = $floating[0].getBoundingClientRect(); From 85dedaeb3d1938cac02e60cb49104b77d3301c11 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Tue, 20 Oct 2026 13:39:07 +0200 Subject: [PATCH 3/6] test(react-positioning): hide fixture scrollbars in older Chromium --- .../react-components/react-positioning/library/README.md | 2 ++ .../library/src/usePositioning.geometry.cy.tsx | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/packages/react-components/react-positioning/library/README.md b/packages/react-components/react-positioning/library/README.md index a46614758cfb24..996afef57b60f5 100644 --- a/packages/react-components/react-positioning/library/README.md +++ b/packages/react-components/react-positioning/library/README.md @@ -59,6 +59,8 @@ padding, auto-size aliases, target-width matching, target covering, containing-b transforms, virtual targets, and fractional rectangles at DPR 1, 1.25, 1.5, 2, and 3. DPR is controlled independently of the host display; geometry and the engine are never mocked. Scrollbars are hidden without disabling scrolling so native scrollbar widths do not alter the fixture. +The fixture includes a scoped `::-webkit-scrollbar` rule for the older Chromium bundled with +Cypress 13 in React 17 compatibility tests, which does not support `scrollbar-width`. Lifecycle cases cover imperative updates and retargeting, target prop removal, option removal, enable/disable, resize and scroll updates, visibility recovery, native end events, detached diff --git a/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx b/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx index b019078ff6010f..c8a7b6994a5635 100644 --- a/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx +++ b/packages/react-components/react-positioning/library/src/usePositioning.geometry.cy.tsx @@ -244,6 +244,7 @@ const Fixture = ({ scenario, controls }: { scenario: Scenario; controls: Control data-enabled={options.enabled !== false} style={{ position: 'fixed', inset: 0, direction: scenario.dir ?? 'ltr' }} > +
, ); cy.get('[data-floating]').should('have.attr', 'data-popper-placement'); + if (scenario.host === 'scroll') { + cy.get('[data-host]').should($host => { + expect($host[0].clientWidth, 'scrollbar-free fixture width').to.equal(400); + expect($host[0].scrollHeight, 'fixture remains scrollable').to.be.greaterThan($host[0].clientHeight); + }); + } cy.window().then( win => new Cypress.Promise(resolve => { From 63fd2f26515bc97d97b27883375a0ceda8eb6286 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:50:20 +0000 Subject: [PATCH 4/6] revert react-positioning project config change Co-authored-by: dmytrokirpa <1492102+dmytrokirpa@users.noreply.github.com> --- .../react-components/react-positioning/library/project.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/react-components/react-positioning/library/project.json b/packages/react-components/react-positioning/library/project.json index d9aa9855e4ac2b..4e7e9301e1a9e3 100644 --- a/packages/react-components/react-positioning/library/project.json +++ b/packages/react-components/react-positioning/library/project.json @@ -3,11 +3,6 @@ "$schema": "../../../../node_modules/nx/schemas/project-schema.json", "projectType": "library", "sourceRoot": "packages/react-components/react-positioning/library/src", - "targets": { - "e2e": { - "inputs": ["default", "^production", "{projectRoot}/cypress.config.ts"] - } - }, "tags": ["vNext", "platform:web"], "implicitDependencies": [] } From 2cedf7927fd1a1d9af2bdd66c290d9e8198c3ab2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:54:02 +0000 Subject: [PATCH 5/6] revert positioning README scrollbar note Co-authored-by: dmytrokirpa <1492102+dmytrokirpa@users.noreply.github.com> --- packages/react-components/react-positioning/library/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react-components/react-positioning/library/README.md b/packages/react-components/react-positioning/library/README.md index 996afef57b60f5..a46614758cfb24 100644 --- a/packages/react-components/react-positioning/library/README.md +++ b/packages/react-components/react-positioning/library/README.md @@ -59,8 +59,6 @@ padding, auto-size aliases, target-width matching, target covering, containing-b transforms, virtual targets, and fractional rectangles at DPR 1, 1.25, 1.5, 2, and 3. DPR is controlled independently of the host display; geometry and the engine are never mocked. Scrollbars are hidden without disabling scrolling so native scrollbar widths do not alter the fixture. -The fixture includes a scoped `::-webkit-scrollbar` rule for the older Chromium bundled with -Cypress 13 in React 17 compatibility tests, which does not support `scrollbar-width`. Lifecycle cases cover imperative updates and retargeting, target prop removal, option removal, enable/disable, resize and scroll updates, visibility recovery, native end events, detached From fd6f77bc99e9f5f5189e759f5b3d4e9f01685148 Mon Sep 17 00:00:00 2001 From: Dmytro Kirpa Date: Fri, 23 Oct 2026 08:10:11 +0200 Subject: [PATCH 6/6] docs(react-positioning): revert browser contracts README section Co-Authored-By: Claude Opus 5.5 --- .../react-positioning/library/README.md | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/packages/react-components/react-positioning/library/README.md b/packages/react-components/react-positioning/library/README.md index a46614758cfb24..c5e5f78fb9052b 100644 --- a/packages/react-components/react-positioning/library/README.md +++ b/packages/react-components/react-positioning/library/README.md @@ -23,50 +23,3 @@ const PopupExample: React.FC = ({ children }) => { ); }; ``` - -## Browser behavior contracts - -`src/usePositioning.geometry.cy.tsx` exercises the public hook with real DOM layout and the -unmocked positioning engine. Run it through the existing component-test target: - -```sh -yarn nx run react-positioning:e2e --spec=src/usePositioning.geometry.cy.tsx --browser=electron --config=retries=0 -``` - -The committed expectations in `src/usePositioning.geometry-baseline.cy.ts` were captured from -master baseline `f626209351609e43bc98eb8cc1296273faa679b7` using Cypress 14.4.1 / Electron 130 -at an 800-by-600 viewport. They pin exact CSS-pixel rectangles, placement, strategy, transform -serialization, left/top, maximum sizes, arrow coordinates, visibility attributes, match-target -CSS variable, and overflow styles. No tolerance or engine-derived expected coordinates are used. -The baseline helper only expands literal expected values. Engine replacements must pass these -expectations, not regenerate them. - -For fractional DPR cases with transforms disabled, only rectangle x/y are compared to an -engine-free sibling positioned with the committed literal left/top values. Native layout -quantization is host-dependent: Electron 130 lays out `280.667px` at `280.6640625` on the -original macOS host but at `280.65625` on Linux CI. Both are distinct from device-pixel rounding -performed by the engine. The sibling runs no positioning logic and never reads engine output; -all inline styles, dimensions, placement, and other fields remain exact literal assertions. -The original rectangle snapshots are retained as provenance, not regenerated for each host. -Negative-control tests at DPR 1.5 and 3 reject a `0.001px` left/top change even when native -layout produces the same rectangle, and reject displaced rectangles with unchanged left/top. - -Coverage includes the finite 4-position by 5-alignment by 2-direction matrix (including invalid -axis combinations), edge flips, ordered fallbacks and best fit, pinned shifting, tether options, -shift-to-cover limits, number/object/function offsets, arrows, rectangular/element/intersected -element-array/window/scroll/clipping boundaries, independent flip/overflow boundaries, logical -padding, auto-size aliases, target-width matching, target covering, containing-block scaling and -transforms, virtual targets, and fractional rectangles at DPR 1, 1.25, 1.5, 2, and 3. DPR is -controlled independently of the host display; geometry and the engine are never mocked. -Scrollbars are hidden without disabling scrolling so native scrollbar widths do not alter the fixture. - -Lifecycle cases cover imperative updates and retargeting, target prop removal, option removal, -enable/disable, resize and scroll updates, visibility recovery, native end events, detached -container cleanup, idle behavior, and inline end callbacks that update React state. A safety cap -on the state-updating callback makes an update loop fail without hanging the browser. - -These are geometry contracts, not screenshot rasterization baselines or a claim of every -possible combination. Font rasterization, browser zoom, native high-DPI capture, cross-browser -layout differences, iframe/shadow-root targets, and visual-viewport/mobile behavior are not -covered. The explicit `strategy: 'absolute'` case deliberately records the existing baseline's -`fixed` result; changing that behavior requires a separate intentional compatibility decision.