Update ---> In hindsight, I don't recommend doing it this way. It was much easier for the slot parent (InfiniteScroll) to pass a callback via props to the child (List) which sets the child ref in the parent. Just a simple callback in the props. Live and learn...
Solved! See comments below.
Original ask: Please help me debug this always null ref value! I put together this codesandbox to help illustrate the problem. TIA 🙏
Quick summary:
I have a horizontally scrolling row in a pair of parent/child components. The parent is handling infinite scrolling, and within it is a list that is meant to be replenished. The goal is to register an IntersectionObserver within InfiniteScroll.vue
(parent), and retrieve a template ref placed after the terminal list element in List.vue
(child). Ideally, when the terminalRef
appears in the DOM, the IntersectionObserver fires it's callback, fetching and refilling the list.
So IntersectionObserver should be able to retrieve the child's terminalRef.value
. According to vue documentation, this is achievable with slots using the render and expose methods, but I am struggling to find a comprehensive example that combines passing a template ref through a slot using render
syntax. After much fiddling, I have gotten most of it functional, but still the child ref from List.vue
remains null in InfiniteScroll.vue
.
Note: I followed the instructions in the docs for Typing Component Template Refs.
Here's the code:
// InfinitScroll.vue
<script lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, h, defineComponent } from 'vue'
import List from '../List/List.vue'
export default defineComponent({
components: { List },
emits: ['infinite'],
setup(props, { slots, emit }) {
const scrollContainer = ref<HTMLElement|null>(null)
const ListInstance = ref<InstanceType<typeof List>|null>(null)
const getTerminalRef = () => ListInstance.value?.$refs.terminalRef as HTMLDivElement|null
const observer = new IntersectionObserver((entries) => {
const entry = entries[0]
if (entry.isIntersecting) {
emit('infinite')
}
}, { root: scrollContainer.value, threshold: 0.9 })
onMounted(() => {
if (ListInstance.value) { // always null, but the ref is visible in vue dev tools
debugger // <---- Never stops here
const ref = ListInstance.value.$refs.terminalRef as HTMLDivElement
if (ref) {
observer.observe(ref)
}
}
return () => h(
'div',
{ ref: scrollContainer, class: 'scroll-container' },
h('slot', { name: 'List', ref: 'ListInstance' }, slots.default && slots.default())
)
}
})
</script>
// List.vue
import { ref, h, defineComponent, onMounted } from 'vue'
import { type UIshow } from '../../client-types'
import ListItem from './ListItem.vue'
export default defineComponent({
components: { ListItem },
props: ['genre', 'shows'],
setup({ genre, shows }: { genre: string, shows: Map<string, UIshow> }, { expose }) {
const terminalRef = ref<HTMLDivElement|null>(null)
expose({ terminalRef })
return () => h('div', { class: 'genre-row' }, { default: () => [
h('h2', { class: 'title' }, [genre]),
h('ul', { class: 'primary' }, [
...Array.from(shows).map(
([sortingName, show]) => {
return h('li', { key: sortingName }, [
h(ListItem, { show })
])
}),
h('li', null, h('div', { ref: terminalRef }, '.'))
])
]})
}
})
</script>
// Lists.vue iterates over multiple InfiniteScroll/List pairs
<template>
<template v-if="genreMap.size">
<div v-for="[ genre, shows ] of genreMap" :key="genre" class="all-lists">
<template v-if="shows.size">
<InfiniteScroll @infinite="loadPage">
<List
:genre="genre"
:shows="shows"
/>
</InfiniteScroll>
</template>
</div>
</template>
<template v-else>
<h1 class="title">Loading...</h1>
</template>
</template>
You need to define the ListInstance
ref, so in InfinitScroll.vue, instead of:
h('slot', { name: 'List', ref: 'terminalRef' },
should be
h('slot', { name: 'List', ref: ListInstance },
as an object, not a string, as you said in the comments.