diff --git a/fluxer_app/src/features/app/hooks/useAntiShiftFloating.test.tsx b/fluxer_app/src/features/app/hooks/useAntiShiftFloating.test.tsx new file mode 100644 index 000000000..c30baedeb --- /dev/null +++ b/fluxer_app/src/features/app/hooks/useAntiShiftFloating.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {useAntiShiftFloating} from '@app/features/app/hooks/useAntiShiftFloating'; +import {act, createElement, useLayoutEffect, useState} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +let host: HTMLDivElement; +let root: Root; +let target: HTMLDivElement; + +function PortalLikeFloating({onReady}: {onReady: (isReady: boolean) => void}) { + const {setFloating, state} = useAntiShiftFloating(target, true, {placement: 'top'}); + const [mounted, setMounted] = useState(false); + useLayoutEffect(() => { + setMounted(true); + }, []); + onReady(state.isReady); + return mounted ? createElement('div', {ref: setFloating, 'data-testid': 'floating'}) : null; +} + +async function settle(): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + await Promise.resolve(); + }); + } +} + +beforeEach(() => { + host = document.createElement('div'); + target = document.createElement('div'); + document.body.append(host, target); + root = createRoot(host); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.replaceChildren(); +}); + +describe('useAntiShiftFloating', () => { + it('positions a floating element that mounts after the first commit', async () => { + const readyStates: Array = []; + act(() => { + root.render(createElement(PortalLikeFloating, {onReady: (isReady) => readyStates.push(isReady)})); + }); + await settle(); + expect(readyStates.at(-1)).toBe(true); + expect(host.querySelector('[data-testid="floating"]')?.style.visibility).toBe('visible'); + }); +}); diff --git a/fluxer_app/src/features/app/hooks/useAntiShiftFloating.ts b/fluxer_app/src/features/app/hooks/useAntiShiftFloating.ts index 815691a1d..ee00e483c 100644 --- a/fluxer_app/src/features/app/hooks/useAntiShiftFloating.ts +++ b/fluxer_app/src/features/app/hooks/useAntiShiftFloating.ts @@ -279,6 +279,11 @@ export function useAntiShiftFloating( constrainHeight = false, } = options; const floatingRef = useRef(null); + const [floatingElement, setFloatingElement] = useState(null); + const setFloating = useCallback((element: HTMLElement | null) => { + floatingRef.current = element; + setFloatingElement(element); + }, []); const [state, setState] = useState(() => { const {x, y} = target ? getInitialGuess(target, placement, offsetMainAxis, offsetCrossAxis) : {x: -9999, y: -9999}; return {x, y, isReady: false, offsetX: 0, offsetY: 0, placement}; @@ -481,7 +486,7 @@ export function useAntiShiftFloating( }; }, []); useLayoutEffect(() => { - if (!enabled || target == null || floatingRef.current == null) { + if (!enabled || target == null || floatingElement == null) { setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0})); return; } @@ -489,7 +494,7 @@ export function useAntiShiftFloating( setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0})); return; } - const floating = floatingRef.current; + const floating = floatingElement; updatePosition(); if (shouldAutoUpdate) { const cleanupCallbacks = [ @@ -502,7 +507,7 @@ export function useAntiShiftFloating( } }; } else if (shouldObserveFloatingResize) { - cleanupRef.current = observeFloatingResize(floatingRef.current, updatePosition); + cleanupRef.current = observeFloatingResize(floating, updatePosition); } return () => { positionRevisionRef.current += 1; @@ -519,9 +524,18 @@ export function useAntiShiftFloating( } setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0})); }; - }, [enabled, target, shouldAutoUpdate, shouldObserveFloatingResize, updatePosition, updatePositionNow]); + }, [ + enabled, + target, + floatingElement, + shouldAutoUpdate, + shouldObserveFloatingResize, + updatePosition, + updatePositionNow, + ]); return { ref: floatingRef, + setFloating, state, style: { position: 'fixed' as const, diff --git a/fluxer_app/src/features/lexical/composer/SelectionFormattingToolbar.tsx b/fluxer_app/src/features/lexical/composer/SelectionFormattingToolbar.tsx index d521b4ea9..810dde46e 100644 --- a/fluxer_app/src/features/lexical/composer/SelectionFormattingToolbar.tsx +++ b/fluxer_app/src/features/lexical/composer/SelectionFormattingToolbar.tsx @@ -271,7 +271,7 @@ function SelectionToolbarSurface({ }), [editor], ); - const {ref, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, { + const {setFloating, state, style, updatePosition} = useAntiShiftFloating(virtualReference, true, { placement: 'top', offsetMainAxis: 8, shouldAutoUpdate: true, @@ -381,7 +381,7 @@ function SelectionToolbarSurface({ > { - (ref as React.MutableRefObject).current = element; + setFloating(element); toolbarRef.current = element; }} className={flxElementClassName(styles.toolbar)} diff --git a/fluxer_app/src/features/ui/popover/PopoverPopouts.tsx b/fluxer_app/src/features/ui/popover/PopoverPopouts.tsx index f14cf4970..c7e11c3cc 100644 --- a/fluxer_app/src/features/ui/popover/PopoverPopouts.tsx +++ b/fluxer_app/src/features/ui/popover/PopoverPopouts.tsx @@ -186,6 +186,7 @@ const PopoutItem: React.FC = observer( ); const { ref: popoutRef, + setFloating, state, style, beginManualPositioning, @@ -206,7 +207,7 @@ const PopoutItem: React.FC = observer( useLayoutEffect(() => { focusRefs.setReference(target); }, [focusRefs, target]); - const mergedPopoutRef = useMergeRefs([popoutRef, focusRefs.setFloating]); + const mergedPopoutRef = useMergeRefs([setFloating, focusRefs.setFloating]); const prefersReducedMotion = Accessibility.useReducedMotion; const [isVisible, setIsVisible] = useState(true); const [targetInDOM, setTargetInDOM] = useState(() => ownerDocument.contains(target));