LinusBorg / portal-vue

A feature-rich Portal Plugin for Vue 3, for rendering DOM outside of a component, anywhere in your app or the entire document. (Vue 2 version: v2.portal-vue.linusb.org)
http://portal-vue.linusb.org
MIT License
3.89k stars 186 forks source link

Nested portals #102

Closed lazlo-bonin closed 6 years ago

lazlo-bonin commented 6 years ago

How can I have a portal inside a portal, both pointing to the same portal-target?

I'm using PortalVue to display all my modal dialogs in a top-level div to avoid z-ordering issues.

For this, I have a my-dialog component that defines a portal at its root:

<template>
    <portal to="dialogs">
        <div class="dialog-mask">
            <div class="dialog-container">
                ...

And a single dialogs portal-target:

<div id="app">
    ...
    <portal-target name="dialogs" multiple></portal-target>
    ....
</div>

Many of my components "include" a dialog (e.g. for their related messages, etc.). This leads to the case where, sometimes, a dialog can include another dialog (e.g. when an action button in the top-level dialog defines its own nested success/failure dialogs as part of its template).

This leads to very weird behaviour in PortalVue. Sometimes, the component that defines the nested dialog will stop rendering when it gets opened. I created the following test case to try isolating the issue:

<my-button @click="showOuter = true">Open Outer Dialog</my-button>

<my-dialog v-if="showOuter" title="Outer" @close="showOuter = false">
    <my-button @click="showInner = true">Open Inner Dialog</my-button>

    <my-dialog v-if="showInner" title="Inner" @close="showInner = false">
        Inner
    </my-dialog>
</my-dialog>

This led to the following error (and infinite recursion) whenever the inner dialog is opened:

[Error] [Vue warn]: You may have an infinite update loop in a component render function.
found in
---> <PortalTarget>
           <Root>
       warn (app.js:24505)
       flushSchedulerQueue (app.js:26884)
       (anonymous function) (app.js:25738)
       flushCallbacks (app.js:25659)
       promiseReactionJob

How would you advise to setup PortalVue in this case?

LinusBorg commented 6 years ago

Hm, that's tricky one. Neve thought of that use case.

The infinite loop is because you are essentially sending content from within the target to itself over and over.

I can't think of a workaround for you on the spot. Maybe this is fixable in the lib if we somehow can make a <portal> realize it's itself being rendered in the <portal-target>its sending its content to.

For a secon I thought a solution based on provide/inject would work, but it won't, since the $parent chain of the portal won't contain the <portal-target >. But maybe I can make that $parent redirection optional?

Anyhow, I have to think about this more deeply. As a heads up I will tell you that I'm in the middle of preparing for a move to a new flat, as well as some trips, so I probably won't find time to work on this in the coming weeks until March.

Help would be welcome, even if it's just throwing in ideas.

lazlo-bonin commented 6 years ago

Thanks for the quick reply; unfortunately this is an urgent issue for our project which is nearing deployment in a few days. If you can think of any workaround, please let me know!

LinusBorg commented 6 years ago

I will think about it a bit, but will be on by brother's bachelor trip this weekend, so there won't be much time.

As I indicated previously, a not so clean but maybe doable workaround would be to provide a sequence of targets (like z-indexes) and tell the dialog compontent to which one to portal with a prop.

LinusBorg commented 6 years ago

Maybe you could even automate that by having your portal check how many other dialogs are in its $parent chain and determine the right target accordingly

LinusBorg commented 6 years ago

Example: Given these targets:

<portal-target name="level-0" />
<portal-target name="level-1" />
<portal-target name="level-2" />

You could dynamically determine how many dialogs you are nested by doing this in your dialog:

function getLevel() {
  let level = 0
  let parent = this.$parent
  while (parent) {
    if (parent.$options.name === 'dialog') {
      level++
    }
    parent = parent.$parent
  }
return level
}
<portal :to="`level-${getLevel()}`">

Obviously if you have different kinds of controls that can be nested with portals inside them (dropdowns ...) the detection via parent.$options.name would have take this into account, but I hope this gets you on a workable path until we find a proper solution.

lazlo-bonin commented 6 years ago

Clever workaround! Tried it, but it seems like parent becomes the portal target's parent itself once the dialog is moved by PortalVue. Therefore, getLevel() always returns 0 and the problem persists. In other words, I think PortalVue moves the DOM before getLevel() gets called.

LinusBorg commented 6 years ago

Hm I didn't expect that, won't be able to test this weekend, unfortunately.

I thought it should work because I explicitly took care in the lib that $parent inside a portal points to the portal's parent, not the target's.

lazlo-bonin commented 6 years ago

Other workaround I tried: having a global dialogLevel variable that gets incremented when the dialog component is mounted, and decremented when it gets destroyed. However, it seems that the component gets mounted/destroyed when being moved by PortalVue, and thus going back to the infinite loop...

Edit: that was just me being daft about Vue's property watching. This workaround works!

data() {
    return {
        level: 0
    }
},

mounted() {
    this.level = window.app.dialogLevel;
    window.app.dialogLevel++;
},

destroyed() {
    window.app.dialogLevel--;
}
LinusBorg commented 6 years ago

Oh, now I see what's happening... of course. The template with all nested slots is fully turned into vnodes within the parent component's context, and then the vnodes are passed down through the slots. So this won't work indeed.

Still should work (or could, don't want to sound too secure) if:

<wrapped-portal to="level">

</wrapped-portal>
// wrapped-portal.js
export default {
  render(h) {
    props: ['to'],
    return h('portal', { 
      props: {
        ...this.$attrs, 
        to: this.to  + '-'  + this.getLevel()
      }
    }, this.$slots.default)
  },
  methods: {
    getLevel() { ... }
  }
}

Might work better...

Anyway, I won't be able to do much more until sunday, maybe even tuesday, sorry :/

lazlo-bonin commented 6 years ago

See my edit, I found a working, automated workaround :) Thanks for all the help!

LinusBorg commented 6 years ago

Oh, glad it works! Good luck with the deadline!

andrey-hohlov commented 6 years ago

I make this component. It's cut-version, in full I mark the previous modal as "freezed" to remove the background and wrap all in animations with anime.js.

Example

There is some strange moments like `this.mountedComp.$el.parentNode.removeChild(this.mountedComp.$el);` But only with it it working (include hot module replacement). Modal.vue ``` ```

LinusBorg commented 6 years ago

I'm not surei I understand what has to do with IOS issue of nested portals, can you explain?

andrey-hohlov commented 6 years ago

@LinusBorg do you ask me?

LinusBorg commented 6 years ago

Yes

andrey-hohlov commented 6 years ago

Sorry if my message not clear. I just show my component, that solve problem with nesting portals.

LinusBorg commented 6 years ago

I decided to close this feature request as it hasn't come up from anyone else so far and would be very complex to solve.

hrakotom commented 6 years ago

Actually, I would have a use case for this one :

It would be possible to handle this via vuex but I would lose all routing/hierarchy info. I had a glimmer of hope when I saw this lib, but this just crushed it.

Could you point me to a potential solution, so I can see if I can help ? Would adding a unique id to source portals potentially solve the problem ?

hrakotom commented 5 years ago

OK, named portals and a specialized container worked... Thanks for the lib :)

renatodeleao commented 1 year ago

I was about to create a new issue for 3.0 but realised this existing one which is on the same subject so decided to post it here. (sry for reviving this super old thread šŸ˜… )

@LinusBorg What's weirder to me is that nested portals (on dialogs) always worked for me in vue2 with portal-vue@2.1.7 ā€” I've used it heavily as part of my a11y-vue-dialog plugin

I've recently migrated a big codebase to vue3 that made use of this feature and noticed that the same usages of the portal were now broken: dropdown menus with nested levels and nested dialogs stopped working (infinite loop).

Can this be considered a regression in vue3?

[Vue warn]: Maximum recursive updates exceeded in component <portalTarget>.

As a workaround, from a quick test, the native vue3 Teleport component seems to handle nesting without issues, but Teleport still isn't a full drop-in replacement for portal-vue yet (https://github.com/vuejs/core/issues/2015)

Kotoriii commented 10 months ago

Having the same issue as @renatodeleao. Nesting dialogs used to work on vue 2, now it doesn't on vue 3, warning about infinite recursions. The native Teleport doesn't work for me, as the portal target needs to be inside my app.