typescriptvuejs3infinite-scrollintersection-observervuejs-slots

Vue3 template ref via default slot always null (render function syntax)


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>

Here you can see vue dev tools shows that InfiniteScroll component is aware of the ref:

Everything appears normal except the List component shows no reactive properties in dev tools


Solution

  • 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.