svelte-tag icon indicating copy to clipboard operation
svelte-tag copied to clipboard

nested customElements conflict with slots

Open Vanillabacke opened this issue 3 years ago • 14 comments
trafficstars

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! 👍

Vanillabacke avatar Apr 05 '22 12:04 Vanillabacke

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 avatar Apr 05 '22 14:04 crisward

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 avatar Apr 05 '22 15:04 crisward

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>')
  })

crisward avatar Apr 05 '22 15:04 crisward

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>

Vanillabacke avatar Apr 05 '22 15:04 Vanillabacke

@Vanillabacke did you manage to solve it?

pySilver avatar Aug 09 '22 01:08 pySilver

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

Vanillabacke avatar Aug 22 '22 11:08 Vanillabacke

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 avatar Jun 03 '23 01:06 patricknelson

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 avatar Jun 06 '23 04:06 patricknelson

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: https://github.com/patricknelson/svelte-retag/issues/6
  • See my PR on svelte-retag at https://github.com/patricknelson/svelte-retag/pull/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 avatar Jun 08 '23 01:06 patricknelson

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

patricknelson avatar Jun 10 '23 01:06 patricknelson

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

Vanillabacke avatar Jun 20 '23 09:06 Vanillabacke

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 https://github.com/sveltejs/svelte/issues/8681#issuecomment-1583128649 for more details on that. 😊

patricknelson avatar Jun 21 '23 00:06 patricknelson

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.

crisward avatar Jun 21 '23 08:06 crisward

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.

patricknelson avatar Jun 21 '23 16:06 patricknelson