Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

nested customElements conflict with slots #7

Open
Vanillabacke opened this issue Apr 5, 2022 · 14 comments
Open

nested customElements conflict with slots #7

Vanillabacke opened this issue Apr 5, 2022 · 14 comments

Comments

@Vanillabacke
Copy link

Vanillabacke commented Apr 5, 2022

At first thanks for that package!

I ran into an issue, where a customElement is nested (for example a nested accordion) and the slot selector is causing an error.

A simple quick fix for it is to change the querySelectorAll in getSlots() on line 95. my pull request failed, so I will provide my solution for it here as an issue.

// ... Line 94 ...
 getSlots(){
      // quick fix for nested custom elements
      // old selector causing the nested conflict
      
      // replace:
      // const namedSlots = this.querySelectorAll('[slot]')

      // with:
      const namedSlots = this.querySelectorAll('[slot]:not([slot] [slot])')
      
      let slots = {}
      namedSlots.forEach(n=>{
        slots[n.slot] = this.unwrap(n)
        this.removeChild(n)
      })
      if(this.innerHTML.length){
        slots.default = this.unwrap(this)
        this.innerHTML = ""
      }
      return slots
    }
//....

But there will be still an issue with the state in the nested element. For example, if you expand the nested accordion and collapse the parent and expand it right away, the nested one will reset the state.

cheers! 👍

@crisward
Copy link
Owner

crisward commented Apr 5, 2022

Thanks for reporting this.

We should really add a failing test, then make sure this update works for your specific bug. Will see if I can find some time to do that.

@crisward
Copy link
Owner

crisward commented Apr 5, 2022

Unfortunately your fix doesn't seem to solve the problem. I've written a test for tags inside tags without shadow dom and the inner slot seems to be being ignored.

Think I'll need more time on this one 🤔

@crisward
Copy link
Owner

crisward commented Apr 5, 2022

This is the failing test if you want to have a go

it.only("should allow tags in tags",async()=>{
    el = document.createElement('div')
    el.innerHTML = `<test-tag><div slot="inner"><test-tag><h2>SUB</h2></test-tag></div></test-tag>`
    document.body.appendChild(el)
    expect(el.innerHTML).to.equal('<test-tag><h1>Main H1</h1> <div class="content">Main Default <div><test-tag><h1>Main H1</h1> <div class="content"><h2>SUB</h2> <div>Inner Default</div></div></test-tag></div></div></test-tag>')
  })

@Vanillabacke
Copy link
Author

sadly i haven't worked with tests yet.
maybe my test code will help

NestedCounter.svelte

<script>
    let count = 0
    const increment = () => {
        count += 1
    }
</script>


<style>
    button {
        font-family: inherit;
        font-size: inherit;
        padding: 1em 2em;
        color: #ff3e00;
        background-color: rgba(255, 62, 0, 0.1);
        border-radius: 2em;
        border: 2px solid rgba(255, 62, 0, 0);
        outline: none;
        width: 200px;
        font-variant-numeric: tabular-nums;
        cursor: pointer;
    }

    button:focus {
        border: 2px solid #ff3e00;
    }

    button:active {
        background-color: rgba(255, 62, 0, 0.2);
    }
</style>
  


<button on:click={increment}>
    Clicks: {count}
</button>
<slot name="content" />

Register the customElement

import NestedCounter from './components/NestedCounter.svelte'

new Component({
    component: NestedCounter ,
    tagname:"nested-counter",
})

in the HTML

       <nested-counter>
            <div slot="content">
                <nested-counter>
                    <div slot="content">
                        nested content
                    </div>
                </nested-counter>
            </div>
        </nested-counter>

@pySilver
Copy link

pySilver commented Aug 9, 2022

@Vanillabacke did you manage to solve it?

@Vanillabacke
Copy link
Author

@pySilver not yet, i wasn't investing that much time on it. main problem is to keep the state of the children alive in the slots during changing the parent rendering.

@patricknelson
Copy link
Contributor

patricknelson commented Jun 3, 2023

Got some ideas for a fix for this. The reason this appears to be failing is not necessarily because of nested slots per se (although that is and issue for deeper named slots, but that is a separate additional issue!). From memory after tinkering with this yesterday was essentially:

  1. State is lost on inner custom elements if they render before their parent custom elements that utilize them in slot content. When that happens, the custom element constructor() and connectedCallback() both re-run, resulting in total loss of state because the inner slots are already elided from DOM when we try to find them and re-run the Svelte component's constructor.
  2. Named inner slots may potentially be selected by the "greedy" .querySelectorAll('[slot]') all (as noted above)

My guess (which I haven't had time to experiment with yet) is we just need to unwind the state back to original on the disconnected callback so that when it connects again in appendChild() when it gets re-slotted into the parent element on its (the parent's) connectedCallback() so that the child custom element is capable of reinitializing once again from the original slotted content it had. It's a bit inefficient but at least it would be well encapsulated and should hopefully avoid global state or maintaining some esoteric serialized (e.g. data- attributes) state management by keeping things in DOM as they should be. 🤔

@patricknelson
Copy link
Contributor

Just wanted to confirm here that I think I have this fully resolved, however my code is very dirty at the moment, so it's not ready for a real PR just yet. So, with that disclaimer out of the way, here's a peek at what I'm working on right now in my draft: #18

patricknelson added a commit to patricknelson/svelte-retag that referenced this issue Jun 8, 2023
1. Keep track of slots on connectedCallback() and then unwind them symmetrically on disconnectedCallback() to handle state/initialization bugs in case nested elements render from inside out
2. Ensure we restrict selection of slots only to the current element and not nested elements (i.e. from inverse perspective: traverse DOM up from slot and ensure nearest custom element is the current one being initialized).
@patricknelson
Copy link
Contributor

Good news: I've fixed this issue in version 0.0.8 of my fork of this package: svelte-retag. 🎉

Notes:

  • Note that svelte-retag is still a WIP and so things could change a bit. However, for now I'm committed to maintaining backward compatibility with the current svelte-tag API (at least v1 in case svelte-tag ever goes beyond that). I say "for now" since I'm considering dropping shadow DOM support, or at least leaving it broken (same issue as current svelte-tag), see: Styles not being properly imported when using shadow DOM patricknelson/svelte-retag#6
  • See my PR on svelte-retag at Address nested slot issues (light DOM) patricknelson/svelte-retag#5 if you're interested to see how this fix was done.
  • Also FWIW, I named the package with retag in the name since a bit of a rewrite of this svelte-tag package. Plus: "retag" is still a word 😄. It also helps that the name was available on NPM.

patricknelson added a commit to patricknelson/svelte-retag that referenced this issue Jun 8, 2023
@patricknelson
Copy link
Contributor

Update: svelte-retag now also supports loading compiled as iife and will proactively render your svelte components on-the-fly as the browser parses through your custom elements on page load. This means dramatically reduced CLS and much faster time to interactivity. 😄 This is in contrast to typical type="module" loading which is necessarily deferred until after DOM parsing.

Mentioning here because originally, even with deferred modules (when all initial HTML was already parsed into the DOM), slots were still an issue. But they were particularly complicated when attempting on page load. This is because the slot content isn't even available yet on connectedCallback(). Anyway, that seems to be solved and, well... I'm super excited about it and just wanted to share with the other interested nerds here. 🤓

@Vanillabacke
Copy link
Author

@patricknelson thanks for your work. hopefully i can test it soon. ❤️

@patricknelson
Copy link
Contributor

patricknelson commented Jun 21, 2023

Sweet @Vanillabacke. There's a demo now here too: https://svelte-retag.vercel.app/

Migration should be easy, svelte-retag's API is backward compatible with svelte-tag (and will remain that way for version 1). That plus additional support for other stuff (e.g. lit-style attributes for camel-case props in Svelte). Anyway, simply:

npm i svelte-retag

And (depending on what you named it):

import svelteTag from 'svelte-tag';
// ... becomes...
import svelteRetag from 'svelte-retag';

// Note: No need for "new" before svelteRetag -- that was never necessary even in svelte-tag.
svelteRetag({
	component: HelloWorld,
	tagname: 'hello-world',
	// etc...
});

Also: I also plan on supporting Svelte 4 whenever it comes out someday and at that point the API to svelte-retag would change and become v2, see sveltejs/svelte#8681 (comment) for more details on that. 😊

@crisward
Copy link
Owner

Just a note on content layout shift. This can be eliminated with

*:not(:defined){
  visibility: hidden;
}

And setting a known width / height on your custom element. The component will take up the correct amount of space and just become visible once mounted. Obviously you may not always know you components size, but thought it worth mentioning. I seen the above using display:none, which hides until mounted but causes a lot of shifting around.

@patricknelson
Copy link
Contributor

patricknelson commented Jun 21, 2023

Thanks @crisward that's a great workaround! Should be particularly useful in situations where I might end up mixing both iife compiled and es compiled components. 🤔 Yeah, display: none takes it out of flow entirely which completely negates the point of using that technique to reserve space, except maybe to prevent slot content from being visible initially.

My (totally untested) variant of that would be:

*:not(:defined){
	visibility: hidden; /* optional UNLESS you have slot content */
	min-width: var(--undefined-width, unset); /* required */
	min-height: var(--undefined-height, unset); /* required */
}

That way I guess you could abstract away some initial/undefined min widths/heights and setup some CSS custom props to initialize those values on a per-element basis. Tiny bit leaky of an abstraction since then you'll need to go yet somewhere else on a per-component (or at least per custom-element) basis and then manually plug in some initial values there. But, it's definitely a very pragmatic workaround. e.g.

some-element {
	--undefined-width: 400px;
	--undefined-height: 200px;
}
another-element {
	--undefined-width: 123px;
	--undefined-height: 456px;
}

Edit: Added examples for clarity.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants