From a3f7055ef0de5d186dc9be12b7641bb4ed7a018a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Fri, 13 Sep 2024 19:33:26 +0200 Subject: [PATCH 1/8] Fix static params in experiments --- docs/app/experiments/[slug]/page.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/app/experiments/[slug]/page.tsx b/docs/app/experiments/[slug]/page.tsx index 60ed954ea..ab2f3df3a 100644 --- a/docs/app/experiments/[slug]/page.tsx +++ b/docs/app/experiments/[slug]/page.tsx @@ -1,7 +1,9 @@ +import * as React from 'react'; +import { type Metadata } from 'next'; import { notFound } from 'next/navigation'; import { type Dirent } from 'node:fs'; +import { basename, extname } from 'node:path'; import { readdir } from 'node:fs/promises'; -import * as React from 'react'; interface Props { params: { @@ -31,5 +33,13 @@ export async function generateStaticParams() { entry.name !== 'layout.tsx' && entry.isFile(), ) - .map((entry: Dirent) => ({ slug: entry.name })); + .map((entry: Dirent) => ({ slug: basename(entry.name, extname(entry.name)) })); +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = params; + + return { + title: `${slug} - Experiments`, + }; } From ad03599aa60794e273446030c9986cb77b32efd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Fri, 13 Sep 2024 19:33:40 +0200 Subject: [PATCH 2/8] Experiments --- docs/app/experiments/menu-anchor-el.tsx | 33 +++++++++++++++++++ docs/app/experiments/menu-anchor-ref.tsx | 30 +++++++++++++++++ .../src/utils/useAnchorPositioning.ts | 21 +++++++++--- 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 docs/app/experiments/menu-anchor-el.tsx create mode 100644 docs/app/experiments/menu-anchor-ref.tsx diff --git a/docs/app/experiments/menu-anchor-el.tsx b/docs/app/experiments/menu-anchor-el.tsx new file mode 100644 index 000000000..883bd9a72 --- /dev/null +++ b/docs/app/experiments/menu-anchor-el.tsx @@ -0,0 +1,33 @@ +'use client'; + +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; + +export default function Page() { + const [anchorEl, setAnchor] = React.useState(null); + const handleRef = React.useCallback((element: HTMLDivElement | null) => { + setAnchor(element); + }, []); + + return ( +
+

Element passed to anchor

+ + Trigger + + + One + Two + + + +
+ Anchor +
+
+ ); +} diff --git a/docs/app/experiments/menu-anchor-ref.tsx b/docs/app/experiments/menu-anchor-ref.tsx new file mode 100644 index 000000000..e375b201d --- /dev/null +++ b/docs/app/experiments/menu-anchor-ref.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import * as Menu from '@base_ui/react/Menu'; + +export default function Page() { + const anchor = React.useRef(null); + + return ( +
+

Ref passed to anchor

+ + Trigger + + + One + Two + + + +
+ Anchor +
+
+ ); +} diff --git a/packages/mui-base/src/utils/useAnchorPositioning.ts b/packages/mui-base/src/utils/useAnchorPositioning.ts index ee15ef958..30d92e0c9 100644 --- a/packages/mui-base/src/utils/useAnchorPositioning.ts +++ b/packages/mui-base/src/utils/useAnchorPositioning.ts @@ -19,9 +19,9 @@ import { type FloatingContext, } from '@floating-ui/react'; import { getSide, getAlignment } from '@floating-ui/utils'; -import { isElement } from '@floating-ui/utils/dom'; import { useEnhancedEffect } from './useEnhancedEffect'; import { useLatestRef } from './useLatestRef'; +import { ownerWindow } from './owner'; export type Side = 'top' | 'bottom' | 'left' | 'right'; export type Alignment = 'start' | 'center' | 'end'; @@ -210,9 +210,6 @@ export function useAnchorPositioning( const anchorRef = useLatestRef(anchor); useEnhancedEffect(() => { - function isRef(param: unknown): param is React.MutableRefObject { - return {}.hasOwnProperty.call(param, 'current'); - } const resolvedAnchor = typeof anchorRef.current === 'function' ? anchorRef.current() : anchorRef.current; if (resolvedAnchor && !isElement(resolvedAnchor)) { @@ -275,3 +272,19 @@ export function useAnchorPositioning( ], ); } + +function isRef(param: unknown): param is React.RefObject { + return {}.hasOwnProperty.call(param, 'current'); +} + +function isElement(value: unknown): value is Element { + if (value == null) { + return false; + } + + return ( + (typeof Element !== 'undefined' && value instanceof Element) || + (typeof window !== 'undefined' && value instanceof (ownerWindow(undefined) as any).Element) || + 'innerHTML' in (value as {}) + ); +} From cd69068bbe36ceb16dabe4726a5c094d0c7bbb3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Fri, 13 Sep 2024 19:43:55 +0200 Subject: [PATCH 3/8] Fix --- .../Menu/Positioner/MenuPositioner.test.tsx | 42 ++++++++++++++++++- .../src/utils/useAnchorPositioning.ts | 17 ++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx b/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx index 440d548e1..bf6d98a0f 100644 --- a/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx +++ b/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx @@ -46,7 +46,7 @@ describe('', () => { })); describe('prop: anchor', () => { - it('should be placed near the specified element', async function test() { + it('should be placed near the specified element when a ref is passed', async function test() { if (/jsdom/.test(window.navigator.userAgent)) { this.skip(); } @@ -83,6 +83,46 @@ describe('', () => { ); }); + it('should be placed near the specified element when an element is passed', async function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + function TestComponent() { + const [anchor, setAnchor] = React.useState(null); + const handleRef = React.useCallback((element: HTMLDivElement | null) => { + setAnchor(element); + }, []); + + return ( +
+ + + + 1 + 2 + + + +
+
+ ); + } + + const { getByRole, getByTestId } = await render(); + + const popup = getByRole('menu'); + const anchor = getByTestId('anchor'); + + const anchorPosition = anchor.getBoundingClientRect(); + + await flushMicrotasks(); + + expect(popup.style.getPropertyValue('transform')).to.equal( + `translate(${anchorPosition.left}px, ${anchorPosition.bottom}px)`, + ); + }); + it('should be placed at the specified position', async function test() { if (/jsdom/.test(window.navigator.userAgent)) { this.skip(); diff --git a/packages/mui-base/src/utils/useAnchorPositioning.ts b/packages/mui-base/src/utils/useAnchorPositioning.ts index 30d92e0c9..20f5c2adc 100644 --- a/packages/mui-base/src/utils/useAnchorPositioning.ts +++ b/packages/mui-base/src/utils/useAnchorPositioning.ts @@ -20,7 +20,6 @@ import { } from '@floating-ui/react'; import { getSide, getAlignment } from '@floating-ui/utils'; import { useEnhancedEffect } from './useEnhancedEffect'; -import { useLatestRef } from './useLatestRef'; import { ownerWindow } from './owner'; export type Side = 'top' | 'bottom' | 'left' | 'right'; @@ -206,16 +205,20 @@ export function useAnchorPositioning( }); // We can assume that element anchors are stable across renders, and thus can be reactive. - const reactiveAnchorDep = anchor == null || isElement(anchor); - const anchorRef = useLatestRef(anchor); + const [reactiveAnchorDep, setReactiveAnchorDep] = + React.useState(anchor == null) || ('current' in anchor! && isElement(anchor.current)); + + React.useEffect(() => { + setReactiveAnchorDep(anchor == null || ('current' in anchor! && isElement(anchor.current))); + }, [anchor, setReactiveAnchorDep]); useEnhancedEffect(() => { - const resolvedAnchor = - typeof anchorRef.current === 'function' ? anchorRef.current() : anchorRef.current; - if (resolvedAnchor && !isElement(resolvedAnchor)) { + const resolvedAnchor = typeof anchor === 'function' ? anchor() : anchor; + + if (resolvedAnchor) { refs.setPositionReference(isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor); } - }, [refs, anchorRef, reactiveAnchorDep]); + }, [refs, anchor, reactiveAnchorDep]); React.useEffect(() => { if (keepMounted && mounted && elements.domReference && elements.floating) { From 2fb14215f775fc731451b6aacc805e4e374087e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Fri, 13 Sep 2024 20:03:47 +0200 Subject: [PATCH 4/8] Also fix? --- packages/mui-base/src/utils/useAnchorPositioning.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/mui-base/src/utils/useAnchorPositioning.ts b/packages/mui-base/src/utils/useAnchorPositioning.ts index 20f5c2adc..66aa899f8 100644 --- a/packages/mui-base/src/utils/useAnchorPositioning.ts +++ b/packages/mui-base/src/utils/useAnchorPositioning.ts @@ -204,21 +204,13 @@ export function useAnchorPositioning( nodeId, }); - // We can assume that element anchors are stable across renders, and thus can be reactive. - const [reactiveAnchorDep, setReactiveAnchorDep] = - React.useState(anchor == null) || ('current' in anchor! && isElement(anchor.current)); - - React.useEffect(() => { - setReactiveAnchorDep(anchor == null || ('current' in anchor! && isElement(anchor.current))); - }, [anchor, setReactiveAnchorDep]); - useEnhancedEffect(() => { const resolvedAnchor = typeof anchor === 'function' ? anchor() : anchor; if (resolvedAnchor) { refs.setPositionReference(isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor); } - }, [refs, anchor, reactiveAnchorDep]); + }, [refs, anchor]); React.useEffect(() => { if (keepMounted && mounted && elements.domReference && elements.floating) { From 566218b52a5e77e94bd1aab43fedaa6db4d4e76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Mon, 16 Sep 2024 12:19:23 +0200 Subject: [PATCH 5/8] Fix ref anchors --- .../src/utils/useAnchorPositioning.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/mui-base/src/utils/useAnchorPositioning.ts b/packages/mui-base/src/utils/useAnchorPositioning.ts index 66aa899f8..9d42929ab 100644 --- a/packages/mui-base/src/utils/useAnchorPositioning.ts +++ b/packages/mui-base/src/utils/useAnchorPositioning.ts @@ -20,7 +20,7 @@ import { } from '@floating-ui/react'; import { getSide, getAlignment } from '@floating-ui/utils'; import { useEnhancedEffect } from './useEnhancedEffect'; -import { ownerWindow } from './owner'; +import { useForcedRerendering } from './useForcedRerendering'; export type Side = 'top' | 'bottom' | 'left' | 'right'; export type Alignment = 'start' | 'center' | 'end'; @@ -204,14 +204,30 @@ export function useAnchorPositioning( nodeId, }); + const rerender = useForcedRerendering(); + + const registeredPositionReference = React.useRef(null); + useEnhancedEffect(() => { const resolvedAnchor = typeof anchor === 'function' ? anchor() : anchor; if (resolvedAnchor) { - refs.setPositionReference(isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor); + const unwrappedElement = isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor; + refs.setPositionReference(unwrappedElement); + registeredPositionReference.current = unwrappedElement; } }, [refs, anchor]); + React.useEffect(() => { + // Refs from parent components are set after useLayoutEffect runs and are available in useEffect. + // Therefore, if the anchor is a ref, we need to update the position reference in useEffect. + const resolvedAnchor = typeof anchor === 'function' ? anchor() : anchor; + if (isRef(resolvedAnchor) && resolvedAnchor.current !== registeredPositionReference.current) { + refs.setPositionReference(resolvedAnchor.current); + registeredPositionReference.current = resolvedAnchor.current; + } + }, [refs, anchor, rerender]); + React.useEffect(() => { if (keepMounted && mounted && elements.domReference && elements.floating) { return autoUpdate(elements.domReference, elements.floating, update); @@ -269,17 +285,5 @@ export function useAnchorPositioning( } function isRef(param: unknown): param is React.RefObject { - return {}.hasOwnProperty.call(param, 'current'); -} - -function isElement(value: unknown): value is Element { - if (value == null) { - return false; - } - - return ( - (typeof Element !== 'undefined' && value instanceof Element) || - (typeof window !== 'undefined' && value instanceof (ownerWindow(undefined) as any).Element) || - 'innerHTML' in (value as {}) - ); + return param != null && {}.hasOwnProperty.call(param, 'current'); } From db20d8918c904cb9d30d10b58f2a7357d6dabdc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Mon, 16 Sep 2024 17:40:56 +0200 Subject: [PATCH 6/8] Add a test for function as an anchor --- .../Menu/Positioner/MenuPositioner.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx b/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx index bf6d98a0f..a4ee8750b 100644 --- a/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx +++ b/packages/mui-base/src/Menu/Positioner/MenuPositioner.test.tsx @@ -123,6 +123,48 @@ describe('', () => { ); }); + it('should be placed near the specified element when a function returingn an element is passed', async function test() { + if (/jsdom/.test(window.navigator.userAgent)) { + this.skip(); + } + + function TestComponent() { + const [anchor, setAnchor] = React.useState(null); + const handleRef = React.useCallback((element: HTMLDivElement | null) => { + setAnchor(element); + }, []); + + const getAnchor = React.useCallback(() => anchor, [anchor]); + + return ( +
+ + + + 1 + 2 + + + +
+
+ ); + } + + const { getByRole, getByTestId } = await render(); + + const popup = getByRole('menu'); + const anchor = getByTestId('anchor'); + + const anchorPosition = anchor.getBoundingClientRect(); + + await flushMicrotasks(); + + expect(popup.style.getPropertyValue('transform')).to.equal( + `translate(${anchorPosition.left}px, ${anchorPosition.bottom}px)`, + ); + }); + it('should be placed at the specified position', async function test() { if (/jsdom/.test(window.navigator.userAgent)) { this.skip(); From 4c9aa1bd511e0be1b2bc3baf6d6da13537c99ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Mon, 16 Sep 2024 17:43:15 +0200 Subject: [PATCH 7/8] Optimize --- .../mui-base/src/utils/useAnchorPositioning.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/mui-base/src/utils/useAnchorPositioning.ts b/packages/mui-base/src/utils/useAnchorPositioning.ts index 9d42929ab..bde36cc5d 100644 --- a/packages/mui-base/src/utils/useAnchorPositioning.ts +++ b/packages/mui-base/src/utils/useAnchorPositioning.ts @@ -221,10 +221,13 @@ export function useAnchorPositioning( React.useEffect(() => { // Refs from parent components are set after useLayoutEffect runs and are available in useEffect. // Therefore, if the anchor is a ref, we need to update the position reference in useEffect. - const resolvedAnchor = typeof anchor === 'function' ? anchor() : anchor; - if (isRef(resolvedAnchor) && resolvedAnchor.current !== registeredPositionReference.current) { - refs.setPositionReference(resolvedAnchor.current); - registeredPositionReference.current = resolvedAnchor.current; + if (typeof anchor === 'function') { + return; + } + + if (isRef(anchor) && anchor.current !== registeredPositionReference.current) { + refs.setPositionReference(anchor.current); + registeredPositionReference.current = anchor.current; } }, [refs, anchor, rerender]); @@ -284,6 +287,8 @@ export function useAnchorPositioning( ); } -function isRef(param: unknown): param is React.RefObject { - return param != null && {}.hasOwnProperty.call(param, 'current'); +function isRef( + param: Element | VirtualElement | React.RefObject | null | undefined, +): param is React.RefObject { + return param != null && 'current' in param; } From e2362e338c61afc4203124eacf64476bd791da41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Tue, 17 Sep 2024 09:22:54 +0200 Subject: [PATCH 8/8] Cleanup --- packages/mui-base/src/utils/useAnchorPositioning.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/mui-base/src/utils/useAnchorPositioning.ts b/packages/mui-base/src/utils/useAnchorPositioning.ts index bde36cc5d..55fd04438 100644 --- a/packages/mui-base/src/utils/useAnchorPositioning.ts +++ b/packages/mui-base/src/utils/useAnchorPositioning.ts @@ -20,7 +20,6 @@ import { } from '@floating-ui/react'; import { getSide, getAlignment } from '@floating-ui/utils'; import { useEnhancedEffect } from './useEnhancedEffect'; -import { useForcedRerendering } from './useForcedRerendering'; export type Side = 'top' | 'bottom' | 'left' | 'right'; export type Alignment = 'start' | 'center' | 'end'; @@ -204,9 +203,7 @@ export function useAnchorPositioning( nodeId, }); - const rerender = useForcedRerendering(); - - const registeredPositionReference = React.useRef(null); + const registeredPositionReferenceRef = React.useRef(null); useEnhancedEffect(() => { const resolvedAnchor = typeof anchor === 'function' ? anchor() : anchor; @@ -214,7 +211,7 @@ export function useAnchorPositioning( if (resolvedAnchor) { const unwrappedElement = isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor; refs.setPositionReference(unwrappedElement); - registeredPositionReference.current = unwrappedElement; + registeredPositionReferenceRef.current = unwrappedElement; } }, [refs, anchor]); @@ -225,11 +222,11 @@ export function useAnchorPositioning( return; } - if (isRef(anchor) && anchor.current !== registeredPositionReference.current) { + if (isRef(anchor) && anchor.current !== registeredPositionReferenceRef.current) { refs.setPositionReference(anchor.current); - registeredPositionReference.current = anchor.current; + registeredPositionReferenceRef.current = anchor.current; } - }, [refs, anchor, rerender]); + }, [refs, anchor]); React.useEffect(() => { if (keepMounted && mounted && elements.domReference && elements.floating) {