vuejs / core

🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
https://vuejs.org/
MIT License
45.64k stars 8k forks source link

Teleport does not work for elements controlled by vue itself #2015

Open Fuzzyma opened 3 years ago

Fuzzyma commented 3 years ago

What problem does this feature solve?

Oftentimes I find myself writing components that semantically belong where they are but need to be displayed somewhere else in my app. Teleport is there to solve the problem - but you can only port outside of the app. Whenever I target an element which is rendered by vue itself it echos a warning that the element needs to be mounted first. Unfortunately, not every usecase can be rewritten in a way, that it falls into the simply modal-button category.

I opened a stackoverflow issue with a reproduction: https://stackoverflow.com/questions/63652288/does-vue-3-teleport-only-works-to-port-outside-vue

What does the proposed API look like?

Either have a teleport target component as in portal-vue or allow targeting ids of other components

HcySunYang commented 3 years ago

I think the following approach should work correctly:

createApp({
  setup() {
    const refDom = ref(null)

    return () => [
      // ref the DOM
      h('div', { ref: refDom }),
      h(Teleport, {
        // set the target to refDom
        to: refDom.value,
        // disable teleport when refDom is not set
        disabled: !refDom.value
      }, h('h1', 'hcy'))
    ]
  }
}).mount('#app')

But that will still get warning messages:

[Vue warn]: Invalid Teleport target: null [Vue warn]: Invalid Teleport target on mount: null (object)

Maybe we need to improve it.

Fuzzyma commented 3 years ago

Interesting approach. But it becomes more complicated when you dont have access to the dom ref because the target is in some different component. How would you go about that?

unbyte commented 3 years ago

works well

  const useTele = () => {
    const target = ref(null)
    return () => target
  }

  const useThisTele = useTele()

  createApp({
    setup() {
      return { target: useThisTele() }
    },
    template: `
      <div>
      <h1>App</h1>
      <div id="dest" :ref="d => target = d"></div>
      <parent-comp/>
      </div>`
  }).component('parent-comp', {
    template: `
  <div>
    <child-comp/>
  </div>`
  }).component('child-comp', {
    setup() {
      return { target: useThisTele() }
    },
    template: `
      <div>
      <Teleport :to="target" :disabled="!target">
        Hello From Portal
      </Teleport>
      </div>`
  }).mount('#app')
LinusBorg commented 3 years ago

That still a bit fragile, however.

When the target's parent gets removed from the DOM, the portal content is removed from the DOM with it - but the source component won't be informed about this and consequently, in it's vdom, it assumes that the elements still are in the DOM. That will likely result in update errors when the source component does update later.

Fuzzyma commented 3 years ago

@unbyte so you basically pass (or import) useThisRef to the components which define it and which uses it as target. Is that correct? (so in easy terms you pass around a reference)

bjarkihall commented 3 years ago

@LinusBorg , this discussion might help, where the terminology "reparenting" is used:

Generally the discussion (incl. linked gists+rfc) revolves around the use of keys/instances to map components to their destinations but two libraries also stick out there lately:

  1. react-reverse-portal, which uses wrapping portals to move components around the VDOM.
  2. react-reparenting, which uses the underlying fiber API in react to move a fiber to a new place in the VDOM. Both make it possible to move components around without remount/rerender.

Has any similar discussion taken place in vue or its rfcs or is there even a vue lib that solves this problem already?

mayuxian commented 3 years ago

@unbyte <teleport to='#elementId'> don`t work https://github.com/vuejs/vue-next/issues/3142#issue-798032619

Should this problem should be fixed?

kaizenseed commented 2 years ago

Is there an accepted best practices approach to this issue that's different from the solution provided in https://stackoverflow.com/a/63665828/1764728?

Fuzzyma commented 2 years ago

@kadiryazici the example @unbyte provided is a good solution. It maybe looks a bit complicated but it boils down to:

With the v-if you make sure, that the teleport renders only when someVar is filled (say: the target was mounted by vue).

patforg commented 2 years ago

I've bundled @Fuzzyma solution in to a component:

TeleportWrapper.vue

<template>
    <teleport :to="target" :disabled="!target || disabled">
      <slot></slot>
    </teleport>
</template>

<script>
import { ref } from 'vue'

export default {
  props: {
    to: {
      type: String,
      default: ''
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  setup () {
    const target = ref(null)
    return {
      target
    }
  },
  mounted () {
    const el = document.querySelector(this.to)
    if (el) {
      this.target = el
    }
  }
}
</script>

Then you can use it like this:

<teleport-wrapper to="#some-id">
   my content here
</teleport-wrapper>

// somewhere else in your page
<div id="some-id"><!-- content will appear here --></div>
Obapelumi commented 1 year ago

@patforg super useful thanks!

Obapelumi commented 1 year ago

Modified @patforg's component to use the mutation observer so it works even when the teleport wrapper component gets mounted before the target element is rendered:

<template>
  <Teleport :to="target" v-if="target" :disabled="!target || disabled">
    <slot></slot>
  </Teleport>
</template>

<script setup lang="ts">
const props = defineProps<{ to: string; disabled?: boolean }>()

const target = ref<Element>(null)

onMounted(() => {
  const observer = new MutationObserver((mutationList, observer) => {
    for (const mutation of mutationList) {
      if (mutation.type !== 'childList') continue
      const el = document.querySelector(props.to)
      if (!el) continue
      target.value = el
      observer.disconnect()
      break
    }
  })
  observer.observe(document, { childList: true, subtree: true })
  return () => observer.disconnect()
})
</script>
dpmango commented 1 year ago

Wrapper hack works well. Thank you @patforg @Obapelumi

One more alternative with pooling querySelector in interval. Just be sure element teleport to is really somewhere on the page, otherwise loop wont stop. Preferably use no Teleport under v-if conditions Usually element is found on 3-4 run


<script setup>
const target = ref(null)

const trySetTarget = () => {
  try {
    const element = document.querySelector(props.to)
    if (!element) throw new Error('not ready')

    target.value = element
  } catch {
    setTimeout(tryToGetTarget, 100)
  }
}

onMounted(() => {
  trySetTarget()
})
</script>

Related issue that I initially searched for: TypeError: Cannot read properties of null (reading 'emitsOptions')

5184

jd-solanki commented 1 year ago

I think we should have vue-safe-teleport built-in.

Johnson Chu tweet reply.

achaphiv commented 1 year ago

Here was my take:

import type { VNodeRef } from 'vue'
import { Teleport, defineComponent, h, shallowRef } from 'vue'

export function createPortal() {
  const target = shallowRef<VNodeRef>()

  const Source = defineComponent((_props, context) => {
    return () =>
      h(
        Teleport,
        {
          ...context.attrs,
          to: target.value,
          disabled: !target.value,
        },
        context.slots
      )
  })

  const Target = defineComponent((_props, context) => {
    return () =>
      h('div', {
        ...context.attrs,
        ref: target,
      })
  })

  return { Source, Target }
}

Every time I want to create a portal, it's just a new file:

import { createPortal } from '@/util'

const { Source, Target } = createPortal()

export const XyzSource = Source
export const XyzTarget = Target
<XyzSource>
  blah
</XyzSource>

Else where:

<template>
  <div>
    <XyzTarget />
    <UnrelatedComponent />
  </div>
</template>
stpnov commented 1 year ago

I offer my solution based on the suggestion above:

import {defineComponent, DefineComponent, h, ShallowRef, shallowRef, Teleport, VNode} from 'vue'

type PortalKey = PropertyKey

const portals: Map<PortalKey, ShallowRef<VNode | undefined>> = new Map()

function getTargetRef(key: PortalKey): ShallowRef<VNode | undefined> {
    if (!portals.has(key)) {
        portals.set(key, shallowRef<VNode>())
    }
    return portals.get(key) as ShallowRef<VNode | undefined>
}

export function useSourcePortal(key: PortalKey): DefineComponent {
    const target = getTargetRef(key)

    const sourceComponent = defineComponent((_props, context) => {
        return (): VNode =>
            h(
                Teleport,
                {
                    ...context.attrs,
                    to: target.value,
                    disabled: !target.value,
                },
                context.slots
            )
    })
    return sourceComponent
}

export function useTargetPortal(key: PortalKey): DefineComponent {
    const target = getTargetRef(key)

    const targetComponent = defineComponent((_props, context) => {
        return (): VNode =>
            h('div', {
                ...context.attrs,
                ref: target,
            })
    })

    return targetComponent
}

Use:

Data from SourceComponent move to TargetComponent

The key must be unique, ideally a symbol: const customPortalKey = Symbol('CustomPortal')

Component A

<template>
    <SourceComponent>
        TEST
    </SourceComponent>
</template>

<script lang="ts" setup>
    import { useSourcePortal } from "@/hooks/usePortal"
    import { customPortalKey } from "@/constants/CustomPortal"
    const SourceComponent = useSourcePortal(customPortalKey)
</script>

Component B

<template>
    <TargetComponent/>
</template>

<script lang="ts" setup>
    import { useSourcePortal } from "@/hooks/usePortal"
    import { customPortalKey } from "@/constants/CustomPortal"
    const TargetComponent = useTargetPortal(customPortalKey)
</script>