diff --git a/packages/react/README.md b/packages/react/README.md index 5d665ef5..89ab9b71 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -60,12 +60,18 @@ Slash menu host items can include `keywords` to match hidden terms without chang Mount `WikilinkHoverCard` inside `MeowdownEditor` and render host-owned preview content from the hovered wiki link's `target`. Returning `null` renders no -card. +card. The render function may also return a promise: the card stays closed +until it resolves, resolving to `null` (or rejecting) renders no card, and a +result that lands after the pointer moved on is discarded, so a host can look +up local content without ever flashing an empty card. ```tsx - {(hit) => } + {async (hit) => { + const note = await readLocalNote(hit.target) + return note == null ? null : + }} ``` diff --git a/packages/react/src/components/wikilink-hover-card.test.tsx b/packages/react/src/components/wikilink-hover-card.test.tsx index 44b62658..e15e9eab 100644 --- a/packages/react/src/components/wikilink-hover-card.test.tsx +++ b/packages/react/src/components/wikilink-hover-card.test.tsx @@ -1,7 +1,7 @@ import '../testing/index.ts' -import { createRef } from 'react' -import { describe, expect, it } from 'vitest' +import { createRef, type ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' import { render } from 'vitest-browser-react' import { page } from 'vitest/browser' @@ -94,6 +94,118 @@ describe('WikilinkHoverCard', () => { await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Known') }) + it('opens with the resolved body of an async render function', async () => { + await unhover() + await render( + + + {async (hit) => { + await new Promise((resolve) => setTimeout(resolve, 50)) + return
Async preview: {hit.target}
+ }} +
+
, + ) + + await hover(pmRoot.getByTestId('wikilink')) + await expect.element(card).not.toBeInTheDocument() + await expect.element(card, { timeout: 2000 }).toHaveTextContent('Async preview: Note') + }) + + it('renders no card when the promise resolves to null', async () => { + await unhover() + await render( + + + {async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + return null + }} + + , + ) + + await hover(pmRoot.getByTestId('wikilink')) + await new Promise((resolve) => setTimeout(resolve, 600)) + await expect.element(card).not.toBeInTheDocument() + }) + + it('renders no card when the render promise rejects', async () => { + await unhover() + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + await render( + + + {async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + throw new Error('load failed') + }} + + , + ) + + await hover(pmRoot.getByTestId('wikilink')) + await new Promise((resolve) => setTimeout(resolve, 600)) + await expect.element(card).not.toBeInTheDocument() + expect(consoleError).toHaveBeenCalledWith( + '[meowdown] wikilink hover card body rejected:', + expect.any(Error), + ) + consoleError.mockRestore() + }) + + it('discards a result that resolves after the pointer left', async () => { + await unhover() + let resolveBody: ((node: ReactNode) => void) | undefined + await render( + + + {() => + new Promise((resolve) => { + resolveBody = resolve + }) + } + + , + ) + + await hover(pmRoot.getByTestId('wikilink')) + await vi.waitFor(() => expect(resolveBody).toBeDefined()) + await unhover() + resolveBody?.(
Late preview
) + await new Promise((resolve) => setTimeout(resolve, 200)) + await expect.element(card).not.toBeInTheDocument() + }) + + it('shows only the newest target when an older promise resolves late', async () => { + await unhover() + const resolvers = new Map void>() + await render( + + + {(hit) => + new Promise((resolve) => { + resolvers.set(hit.target, resolve) + }) + } + + , + ) + const links = pmRoot.getByTestId('wikilink') + + await hover(links.nth(0)) + await vi.waitFor(() => expect(resolvers.has('Alpha')).toBe(true)) + await hover(links.nth(1)) + await vi.waitFor(() => expect(resolvers.has('Beta')).toBe(true)) + + resolvers.get('Alpha')?.(
Preview: Alpha
) + await new Promise((resolve) => setTimeout(resolve, 100)) + await expect.element(card).not.toBeInTheDocument() + + resolvers.get('Beta')?.(
Preview: Beta
) + await expect.element(card, { timeout: 1000 }).toHaveTextContent('Preview: Beta') + }) + it('removes the card when the hovered link is deleted', async () => { await unhover() const handleRef = createRef() diff --git a/packages/react/src/components/wikilink-hover-card.tsx b/packages/react/src/components/wikilink-hover-card.tsx index 639e2f56..ac142e41 100644 --- a/packages/react/src/components/wikilink-hover-card.tsx +++ b/packages/react/src/components/wikilink-hover-card.tsx @@ -17,9 +17,13 @@ const CLOSE_DELAY = 200 export interface WikilinkHoverCardProps { /** * Render the card body from the hovered wiki link. Returning `null` renders - * no card. + * no card. A returned promise keeps the card closed until it resolves; + * resolving to `null` or rejecting renders no card, and a result that lands + * after the pointer moved on is discarded. The function runs once per + * hovered link rather than on every render, and a new function identity + * re-runs it for the current link. */ - readonly children: (hit: WikilinkHoverHit) => ReactNode + readonly children: (hit: WikilinkHoverHit) => ReactNode | Promise /** Optional class applied to the popup, after the default card surface. */ readonly className?: string } @@ -32,6 +36,7 @@ export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProp const lastRectRef = useRef(null) const [displayed, setDisplayed] = useState() const [open, setOpen] = useState(false) + const [body, setBody] = useState(null) const [hoverExtension] = useState(() => { return defineWikilinkHoverHandler((nextHit) => setHit(nextHit)) @@ -50,11 +55,41 @@ export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProp return { getBoundingClientRect: getRect } }, [getRect]) + // Resolve the body for the current request. A superseded request's result + // is discarded, and the previous body stays until the new one settles, so + // an open card does not flash empty while moving between links. + useEffect(() => { + let stale = false + const resolveBody = async () => { + try { + const resolved = await (displayed ? children(displayed) : null) + if (!stale) setBody(resolved) + } catch (error) { + if (stale) return + console.error('[meowdown] wikilink hover card body rejected:', error) + setBody(null) + } + } + void resolveBody() + return () => { + stale = true + } + }, [children, displayed]) + const hasDisplayed = !!displayed + const hasBody = body != null useEffect(() => { if (!hit) { - const timer = setTimeout(() => setOpen(false), CLOSE_DELAY) + // Without a visible body there is no close animation to preserve; the + // request drops right away and the next hover dwells afresh. + const timer = setTimeout( + () => { + setOpen(false) + if (!hasBody) setDisplayed(undefined) + }, + hasBody ? CLOSE_DELAY : 0, + ) return () => clearTimeout(timer) } @@ -65,9 +100,7 @@ export function WikilinkHoverCard({ children, className }: WikilinkHoverCardProp setOpen(true) }, openDelay) return () => clearTimeout(timer) - }, [hit, hasDisplayed]) - - const body = displayed ? children(displayed) : null + }, [hit, hasDisplayed, hasBody]) return (