Optimize tippy by lazily instantiation

This commit is contained in:
Jon Staab 2026-08-29 10:12:09 -07:00
parent ee09c975f8
commit 749e87f228
14 changed files with 66 additions and 29 deletions

View file

@ -56,11 +56,12 @@
if (popoverIsVisible) { if (popoverIsVisible) {
popover?.hide() popover?.hide()
} else { } else {
popover?.show() showPopover()
} }
} }
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let popoverIsVisible = $state(false) let popoverIsVisible = $state(false)
</script> </script>
@ -71,6 +72,7 @@
{#if !isMobile} {#if !isMobile}
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
component={ChatMessageMenu} component={ChatMessageMenu}
props={{event, pubkeys, popover, replyTo, edit}} props={{event, pubkeys, popover, replyTo, edit}}
params={{ params={{

View file

@ -55,11 +55,12 @@
let wrapper: Element | undefined = $state() let wrapper: Element | undefined = $state()
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let instance: any = $state() let instance: any = $state()
$effect(() => { $effect(() => {
if ($term) { if ($term) {
popover?.show() showPopover()
} else { } else {
popover?.hide() popover?.hide()
} }
@ -82,6 +83,7 @@
{/if} {/if}
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
bind:instance bind:instance
component={Suggestions} component={Suggestions}
props={{ props={{

View file

@ -28,8 +28,6 @@
const shouldProtect = $relays.hasNip(url, 70) const shouldProtect = $relays.hasNip(url, 70)
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide() const hidePopover = () => popover?.hide()
const onEmoji = async (emoji: NativeEmoji) => { const onEmoji = async (emoji: NativeEmoji) => {
@ -48,6 +46,7 @@
} }
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
</script> </script>
<div class="items-center join"> <div class="items-center join">
@ -62,6 +61,7 @@
<Button onclick={showPopover} class="flex join-item button button-neutral button-xs"> <Button onclick={showPopover} class="flex join-item button button-neutral button-xs">
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
component={EventMenu} component={EventMenu}
props={{url, noun, event, customActions, onClick: hidePopover}} props={{url, noun, event, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}> params={{trigger: "manual", interactive: true}}>

View file

@ -15,16 +15,16 @@
const {pubkey, url, customActions}: Props = $props() const {pubkey, url, customActions}: Props = $props()
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide() const hidePopover = () => popover?.hide()
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
</script> </script>
<Button onclick={showPopover} class="button button-circle button-ghost button-sm"> <Button onclick={showPopover} class="button button-circle button-ghost button-sm">
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
component={ProfileMenuList} component={ProfileMenuList}
props={{pubkey, url, customActions, onClick: hidePopover}} props={{pubkey, url, customActions, onClick: hidePopover}}
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}> params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>

View file

@ -55,6 +55,7 @@
let label: Element | undefined = $state() let label: Element | undefined = $state()
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let instance: any = $state() let instance: any = $state()
$effect(() => { $effect(() => {
@ -62,7 +63,7 @@
oninput?.($term) oninput?.($term)
if ($term) { if ($term) {
popover?.show() showPopover()
} else { } else {
popover?.hide() popover?.hide()
} }
@ -97,6 +98,7 @@
</label> </label>
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
bind:instance bind:instance
component={Suggestions} component={Suggestions}
props={{ props={{

View file

@ -52,8 +52,6 @@
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run()) const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide() const hidePopover = () => popover?.hide()
const submit = async () => { const submit = async () => {
@ -70,6 +68,7 @@
} }
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let content = $state( let content = $state(
initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""), initialValues?.type === "text" ? initialValues.value : (draftKey?.get()?.content ?? ""),
) )
@ -126,6 +125,7 @@
class="join-item h-10 w-10 min-w-10 button button-neutral"> class="join-item h-10 w-10 min-w-10 button button-neutral">
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
component={ComposeMenu} component={ComposeMenu}
props={{url, h, onClick: hidePopover}} props={{url, h, onClick: hidePopover}}
params={{trigger: "manual", interactive: true}}> params={{trigger: "manual", interactive: true}}>

View file

@ -9,8 +9,6 @@
const {url, event} = $props() const {url, event} = $props()
const open = () => popover?.show()
const onClick = () => popover?.hide() const onClick = () => popover?.hide()
const onShow = () => { const onShow = () => {
@ -32,6 +30,7 @@
} }
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let open: () => void = $state(() => {})
let visible = $state(false) let visible = $state(false)
</script> </script>
@ -40,6 +39,7 @@
<Button onclick={open} class="button button-xs button-neutral join-item"> <Button onclick={open} class="button button-xs button-neutral join-item">
<Tippy <Tippy
bind:popover bind:popover
bind:show={open}
component={RoomItemMenu} component={RoomItemMenu}
props={{url, event, onClick}} props={{url, event, onClick}}
params={{trigger: "manual", interactive: true, onShow, onHidden}}> params={{trigger: "manual", interactive: true, onShow, onHidden}}>

View file

@ -67,11 +67,12 @@
let label: Element | undefined = $state() let label: Element | undefined = $state()
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
let instance: any = $state() let instance: any = $state()
$effect(() => { $effect(() => {
if ($term.trim()) { if ($term.trim()) {
popover?.show() showPopover()
} else { } else {
popover?.hide() popover?.hide()
} }
@ -101,6 +102,7 @@
</label> </label>
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
bind:instance bind:instance
component={Suggestions} component={Suggestions}
props={{ props={{

View file

@ -8,8 +8,6 @@
const {tippyParams = {}, ...props} = $props() const {tippyParams = {}, ...props} = $props()
const open = () => popover?.show()
const onClick = (emoji: NativeEmoji) => { const onClick = (emoji: NativeEmoji) => {
props.onEmoji(emoji) props.onEmoji(emoji)
popover?.hide() popover?.hide()
@ -34,6 +32,7 @@
}) })
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let open: () => void = $state(() => {})
let visible = $state(false) let visible = $state(false)
</script> </script>
@ -42,6 +41,7 @@
<Button onclick={open} class={props.class}> <Button onclick={open} class={props.class}>
<Tippy <Tippy
bind:popover bind:popover
bind:show={open}
class="flex" class="flex"
component={EmojiPicker} component={EmojiPicker}
props={{onClick}} props={{onClick}}

View file

@ -14,7 +14,7 @@
if (isMobile) { if (isMobile) {
pushModal(IconPickerModal, {onSelect: onClick}, {nested: true}) pushModal(IconPickerModal, {onSelect: onClick}, {nested: true})
} else { } else {
popover?.show() showPopover()
} }
} }
@ -42,12 +42,14 @@
}) })
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
</script> </script>
<svelte:document onmousemove={onMouseMove} /> <svelte:document onmousemove={onMouseMove} />
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
component={IconPickerPopover} component={IconPickerPopover}
props={{onSelect: onClick}} props={{onSelect: onClick}}
params={{trigger: "manual", interactive: true, placement: "top-end"}}> params={{trigger: "manual", interactive: true, placement: "top-end"}}>

View file

@ -22,16 +22,16 @@
"aria-label": ariaLabel, "aria-label": ariaLabel,
}: Props = $props() }: Props = $props()
const showPopover = () => popover?.show()
const hidePopover = () => popover?.hide() const hidePopover = () => popover?.hide()
let popover: Instance | undefined = $state() let popover: Instance | undefined = $state()
let showPopover: () => void = $state(() => {})
</script> </script>
<Button class={className} aria-label={ariaLabel} onclick={showPopover}> <Button class={className} aria-label={ariaLabel} onclick={showPopover}>
<Tippy <Tippy
bind:popover bind:popover
bind:show={showPopover}
{component} {component}
props={{...componentProps, onClick: hidePopover}} props={{...componentProps, onClick: hidePopover}}
params={{trigger: "manual", interactive: true, placement: "bottom-end"}}> params={{trigger: "manual", interactive: true, placement: "bottom-end"}}>

View file

@ -4,7 +4,7 @@
import tippy from "tippy.js" import tippy from "tippy.js"
import type {Instance} from "tippy.js" import type {Instance} from "tippy.js"
import {onMount, mount, unmount} from "svelte" import {onMount, mount, unmount} from "svelte"
import {isMobile} from "@lib/html" import {getTippyTarget, isMobile} from "@lib/html"
let { let {
component, component,
@ -13,6 +13,7 @@
params = {}, params = {},
popover = $bindable(), popover = $bindable(),
instance = $bindable(), instance = $bindable(),
show = $bindable(),
...restProps ...restProps
} = $props() } = $props()
@ -23,17 +24,14 @@
// the props it was first mounted with, showing stale data after the source updates. // the props it was first mounted with, showing stale data after the source updates.
const mountedProps = $state({...props}) const mountedProps = $state({...props})
$effect(() => { // Building a tippy costs a popper element and a set of listeners, which is wasted on the
Object.assign(mountedProps, props) // hover menus of a chat row nobody ever opens. Only a real trigger needs the instance up
}) // front — tippy is the one listening for it. A manual one can wait for `show`.
const create = () => {
onMount(() => { popover ??= tippy(element, {
const target = document.createElement("div")
popover = tippy(element, {
content: target, content: target,
animation: "shift-away", animation: "shift-away",
appendTo: document.querySelector(".tippy-target")!, appendTo: getTippyTarget(),
trigger: isMobile ? "click" : "mouseenter focus", trigger: isMobile ? "click" : "mouseenter focus",
...params, ...params,
onShow: (tippyInstance: Instance) => { onShow: (tippyInstance: Instance) => {
@ -43,6 +41,22 @@
}, },
}) })
return popover
}
const target = document.createElement("div")
show = () => create().show()
$effect(() => {
Object.assign(mountedProps, props)
})
onMount(() => {
if (params.trigger !== "manual") {
create()
}
return () => { return () => {
popover?.destroy() popover?.destroy()

View file

@ -30,7 +30,7 @@
import tippy from "tippy.js" import tippy from "tippy.js"
import {onMount} from "svelte" import {onMount} from "svelte"
import {isMobile} from "@lib/html" import {getTippyTarget, isMobile} from "@lib/html"
let { let {
content, content,
@ -50,7 +50,7 @@
interactive, interactive,
animation: "shift-away", animation: "shift-away",
theme: "tooltip", theme: "tooltip",
appendTo: document.querySelector(".tippy-target")!, appendTo: getTippyTarget(),
trigger: isMobile ? "click" : "mouseenter focus", trigger: isMobile ? "click" : "mouseenter focus",
}) })

View file

@ -1,5 +1,6 @@
import {readable} from "svelte/store" import {readable} from "svelte/store"
import {sleep, randomId} from "@welshman/lib" import {sleep, randomId} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
export {preventDefault, stopPropagation} from "svelte/legacy" export {preventDefault, stopPropagation} from "svelte/legacy"
@ -152,6 +153,18 @@ export const createScroller = ({
export const isMobile = "ontouchstart" in document.documentElement export const isMobile = "ontouchstart" in document.documentElement
// The layout's single popover host. Cached, because with a couple of popovers per chat row
// this runs thousands of times against a document that is itself thousands of nodes.
let tippyTarget: Maybe<Element>
export const getTippyTarget = () => {
if (!tippyTarget?.isConnected) {
tippyTarget = document.querySelector(".tippy-target")!
}
return tippyTarget
}
export const downloadText = async (filename: string, text: string) => { export const downloadText = async (filename: string, text: string) => {
// The <a download> blob trick is a no-op in native WebViews (Android in // The <a download> blob trick is a no-op in native WebViews (Android in
// particular never triggers a download), so on device we write the file and // particular never triggers a download), so on device we write the file and