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 (