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

make it possible to render svelte-components inside a shadow dom #5870

Merged
merged 31 commits into from
Jul 21, 2021
Merged

make it possible to render svelte-components inside a shadow dom #5870

merged 31 commits into from
Jul 21, 2021

Conversation

ivanhofer
Copy link
Contributor

@ivanhofer ivanhofer commented Jan 8, 2021

This PR slightly increases the output size for a single component, but the size gets smaller and smaller the more components are used.

Will not effect generated output when using the compiler option css: false.

With this PR it is possible to use Svelte inside a shadow dom.

Usage

With a few changes to main.js it is now possible to render a Svelte-component/-application inside a shadow root, so you can take advantage of style encapsulation.

import App from './App.svelte';

+ const target = document.body;
+ const root = target.attachShadow({ mode: 'open' });
+
const app = new App({
-	target: document.body,
+	target: root,
	props: {
		name: 'world'
	}
});

export default app;

I currently build my application with svelte from this branch to take advantage of the style encapsulation from the shadow dom.

Notes

  • the unusual syntax (destrucuring and variable declaration) in the append_styles function makes it possible to save a few bytes.
  • This is a clean re-implementation of my previous PR Custom style tag #5639, where I experimented a bit to much

@ivanhofer ivanhofer changed the title make it possible to render svelte-components inside a shadowDOM make it possible to render svelte-components inside a shadow dom Jan 8, 2021
@ivanhofer ivanhofer mentioned this pull request Jan 8, 2021
@antony
Copy link
Member

antony commented Jan 20, 2021

Thanks for this. I'll hopefully get a chance to review it soon. I do agree about stylesTarget though, the name seems a bit clunky. Perhaps we need to think on that a bit.

@Papooch
Copy link

Papooch commented Jan 26, 2021

This would be awesome if it gets merged. My use case is similar - I am building a web extension that injects stuff into other webpages and I need to prevent the page styles leaking into my app (tried using an iframe, but that's a pain).

As for styleTarget, may I suggest styleRoot or cssRoot or maybe sumething entirely different. By default, the styles are appended to the document.head, so maybe add a flag that would instead append them to target, like encapsulateStyle: true.

@tepose
Copy link

tepose commented Feb 19, 2021

This is just perfect @ivanhofer! I'm working on multiple projects where the addition of styleTarget would be a killer feature (I do not want to disclose how I solve this today🙊). As suggested by @Papooch I find styleRoot a good name.

Thus I hope this PR would be merged! Is there anything I could do to help move this forward?

@ivanhofer
Copy link
Contributor Author

Hi @tepose,
from my side everything should be complete. I am waiting for one of the svelte maintainer to take a look at the PR.
It seems they are currently really busy with the svelte-kit project.

I think we could raise some more attention to this PR by linking other related issues. I saw a few open issues already, but haven't got the time to link all of them. Maybe you could help me with that?

@MrBigBanks
Copy link

MrBigBanks commented Feb 24, 2021 via email

@tepose
Copy link

tepose commented Mar 3, 2021

@ivanhofer, I tried installing your branch as an npm module, but running it using Snowpack I get this error:

[1] Error: Cannot find module 'css-tree/lib/parser/index.js'
[1] Require stack:
[1] - / <path>/node_modules/svelte/compiler.js
[1] - / <path>/node_modules/@snowpack/plugin-svelte/plugin.js
[1] - / <path>/node_modules/snowpack/lib/index.js
[1] - /<path>/node_modules/snowpack/index.bin.js

Not sure if it really is an issue though. Installing css-tree as a dependency in the application solved it, but that shouldn't be necessary.

Also, after installing css-tree, I couldn't get it working. The module installed in node_modules seems to be correct. Do you have any suggestions for debugging? The options provided are

{
  target: root,
  stylesTarget: this.shadowRoot,
}

Thank you for the effort put into this!

@ivanhofer
Copy link
Contributor Author

hi @tepose,
do you get this issue only on this branch?
Can you checkout master to see if you get the same error?

@tepose
Copy link

tepose commented Mar 5, 2021

@ivanhofer, I regret mentioning it without first checking with the master branch. In fact, it is the same when installing from sveltejs' master also. So ignore me on this matter!

@ivanhofer
Copy link
Contributor Author

@ivanhofer, I regret mentioning it without first checking with the master branch. In fact, it is the same when installing from sveltejs' master also. So ignore me on this matter!

You could open a new issue, if this is a general svelte-problem :)

@WaltzingPenguin
Copy link

Adding a new parameter stylesTarget shouldn't be necessary. target is already required and we can compute the correct location based on that:

const root = (target.getRootNode ? target.getRootNode() : target.ownerDocument) // Check for getRootNode because IE is still supported
const style_append_target = (root.host ? root : root.head)

@WaltzingPenguin
Copy link

This PR will also resolve this ticket: #1825

@ivanhofer ivanhofer marked this pull request as draft March 29, 2021 06:34
@ivanhofer ivanhofer marked this pull request as ready for review April 3, 2021 05:52
@ivanhofer
Copy link
Contributor Author

I now have removed the stylesTarget option. When adding the styles it will detect if the node is inside the shadow root and apply the styles inside the shadow dom instead of document.head. Thanks @ADantes for pointing me to that solution.

@lseguin1337 I now pass the target option to each components $$ object, instead of storing it in a global variable. So you should be able to use multiple components from the same bundle as a application root.

@hgiesel
Copy link
Contributor

hgiesel commented Apr 3, 2021

Shouldn't <svelte:window>, <svelte:head>, <svelte:body> also get extra attention in this case?
If Svelte components were rendered in a shadow dom, something like this:

<svelte:head>
	<link rel="stylesheet" href="tutorial/dark-theme.css">
</svelte:head>

Would not result in the expected behavior. (Or at least you'd have to be aware, whether this Svelte component will be rendered in a shadow root or not, which might not be the design idea behind the PR)
Same for non-composed events.

@WaltzingPenguin
Copy link

WaltzingPenguin commented Apr 3, 2021

@hgiesel

At least in my code, svelte:window and svelte:body are usually used to listen for scroll and/or resize events. I still need access to those events, regardless of whether the component is rendered in light or shadow DOM. Svelte events don't bubble, so I'm not sure how composed events factor in here.

For the multiple uses of svelte:head, <link> and <style> are the only ones I can think of that don't work as expected and could theoretically be made to work. However, there's a really easy fix to make them work for both light and shadow DOM: just don't wrap them in svelte:head. Dropping them in to the document body works just fine.

Things like <script>, <title>, <meta name="twitter:title"> are all global and, if they appear inside <svelte:head>, need to keep their current behavior. At some point it has to be on component authors not to do things that don't make sense.

@ivanhofer
Copy link
Contributor Author

@hgiesel I agree with @ADantes. It should be the component's author responsibility to make sure the component works inside the shadow dom. If there is a need for that component to be inside a shadow dom, the author will take it into account and don't use svelte:head. In my opinion svelte:head should only be used if you have full control over a website.

I created this PR because I needed a solution to render my svelte-application on any possible website without fearing my styles would be overwritten by the website's global styles. If I would have full control over the host-websites, I don't think I would need the shadow dom.

@syffs
Copy link

syffs commented Apr 10, 2021

this pr looks amazing: it answers to a very basic need for custom elements to work with nested components

@antony it's been around for 4 months: is there any reason why it hasn't been merged ? cheers

@antony
Copy link
Member

antony commented Apr 10, 2021

Very likely because none of us actually use custom elements, and as such we haven't had a chance to look at it. Have you tried this fork? does it work? It would be good to hear from some people who are using this, so that we know all bases are covered.

@ivanhofer
Copy link
Contributor Author

I use it myself in production since ~3 months.

@tepose
Copy link

tepose commented Apr 16, 2021

I'm happy to say that as of today, it's also used in production by Amedia's editorial developer team. A huge thank you @ivanhofer!

@ivanhofer
Copy link
Contributor Author

@antony I am building my application with this fork since a few months.
The application consists of 73 components. It uses #if/else-, #each-, #await-blocks, slots, fade-, fly-, slide-transitions, normal and global scoped styles.
The style encapsulation inside the shadow-dom works great.

How can we get this PR merged? Is something still missing?

If you have an own application, you could test it in a few minutes:

file: src/main.js

import App from './App.svelte';

+ const target = document.body;
+ const root = target.attachShadow({ mode: 'open' });
+
const app = new App({
-	target: document.body,
+	target: root,
	props: {
		name: 'world'
	}
});

export default app;

configure your rollup.config.js file to include css in the js bundle

import svelte from 'rollup-plugin-svelte'

export default {
   plugins: [
      svelte({
         emitCss: false,
      }),
   ]
}

@pfz
Copy link

pfz commented May 2, 2021

Context: app target is the shadow dom, which is hosted by a document>body>div.
Bug: transitions are not working (fly on mount/unmount).

The following correction makes it work for me for src/runtime/internal/style_manager.ts :

@@ -31,7 +31,7 @@ export function create_rule(node: Element & ElementCSSInlineStyle, a: number, b:
const name = `__svelte_${hash(rule)}_${uid}`;
- const doc = node.ownerDocument as ExtendedDoc;
+ const doc = (node.getRootNode && (node.getRootNode() as ShadowRoot).host ? node.getRootNode() : node.ownerDocument) as ExtendedDoc;

What do you think ? You can test is on my fork pfz/svelte.

@ivanhofer
Copy link
Contributor Author

any ETA when this can get merged?

@Conduitry Conduitry merged commit 5cfefeb into sveltejs:master Jul 21, 2021
@jacksteamdev
Copy link

Love it! https://svelte.dev/repl/a37927e4bb4a408ea837f3b429b91e9f?version=3.40.3

@BerndWessels
Copy link

@ivanhofer @Conduitry @jacksteamdev hi, sorry, I can't get this to work at all and there seems to be no documentation.

I have my own custom element that renders a svelte component into a shadow root - but the styles are still attached to the document head :(

this.#svelteComponent = new opts.component({ target: this.#shadow, props: svelteProperties })

Any ideas why your PR here doesn't work for me?

/**
 * Add a prefix to a camel-case property key.
 */
const addPrefix = (key, prefix) => {
  return key.replace(/(^[a-z])/gi, function (g) { return `${prefix}${g.toUpperCase()}` })
}

/**
 * Remove a prefix from a camel-case property key.
 */
const removePrefix = (key, prefix) => {
  return key.replace(new RegExp(`^${prefix}`, 'gi'), '').replace(/(^[A-Z])/g, function (g) { return g.toLowerCase() });
}

/**
 * Convert from kebab-case to camel-case.
 */
const kebabToCamelCase = (key) => {
  return key.replace(/-([a-z])/g, function (g) {
    return g[1].toUpperCase()
  })
}

/**
 * Convert camel-case to kebab-case.
 */
const camelToKebabCase = (key) => {
  return key.replace(/([a-z][A-Z])/g, function (g) {
    return g[0] + '-' + g[1].toLowerCase()
  })
}

/**
 * Create a custom element wrapper for a given svelte component.
 */
export default function (opts) {
  /**
   * The custom element wrapper.
   */
  class Wrapper extends HTMLElement {
    /**
     * Internal properties,
     * prefixed to prevent collision with HTMLElement properties.
     */
    #properties = {}
    #svelteComponent = null
    #shadow = null

    /**
     * Initialize the custom element wrapper.
     */
    constructor() {
      // Always call super first in constructor.
      super()
      // Attach the shadow dom.
      this.#shadow = this.attachShadow({ mode: 'open' });
      // Subscribe to host properties.
      this.#subscribeToProperties('host', opts.properties);
      // Prefix custom properties.
      const prefixedCustomProperties = Object
        .entries(opts.customProperties || {})
        .reduce((a, [key, value]) => (
          { ...a, [addPrefix(key, 'ds')]: value }
        ), {})
      // Subscribe to custom properties.
      this.#subscribeToProperties('custom', prefixedCustomProperties);
    }

    /**
     * Subscribe to the custom element attributes.
     * Attributes will override host properties.
     */
    static get observedAttributes() {
      return (
        opts.attributes.map((attr) =>
          camelToKebabCase(attr)
        ) || []
      )
    }

    /**
     * Forward custom element attribute value changes to the svelte element.
     * Kebab-case attributes are mapped to standard properties in camel-case and prefixed with 'host'.
     * `aria-label` attribute becomes `hostAriaLabel` svelte property.
     */
    attributeChangedCallback(name, oldValue, newValue) {
      if (this.#svelteComponent && newValue != oldValue) {
        this.#svelteComponent.$set({
          [addPrefix(kebabToCamelCase(name), 'host')]: newValue,
        })
      }
    }

    /**
     * Forward custom element property value changes to the svelte element.
     * Custom properties prefixed with `ds` will become camel-case svelte properties without the `ds` prefix.
     * `dsAriaLabel` custom property becomes `ariaLabel` svelte property.
     * Standard properties will become camel-case svelte properties prefixed with `host`.
     * `ariaLabel` standard property becomes `hostAriaLabel` svelte property.
     * TODO implement
     */
    #propertyChangedCallback(type, name, oldValue, newValue) {
      // Remember the new property value.
      this.#properties[name] = newValue
      // Make sure the svelte component exists and the value has actually changed.
      // TODO this could be improved by a deep compare of old and new value.
      if (this.#svelteComponent && newValue != oldValue) {
        // Map to the correct svelte property.
        const sveltePropertyName = type === 'host'
          // Host properties are prefixed with `host` within the svelte component.
          ? addPrefix(name, "host")
          // Custom properties are not prefixed in the svelte component.
          : removePrefix(name, "ds");
        // Set the new property value on the svelte component.
        this.#svelteComponent.$set({ [sveltePropertyName]: newValue })
      }
    }

    /**
     * Subscribe to properties.
     */
    #subscribeToProperties(type, properties) {
      // Iterate through all properties.
      const propertyMap = Object.entries(properties).reduce(
        // Create a getter and setter for each possible property.
        (a, [key, value]) => ({
          ...a,
          [`${key}`]: {
            configurable: true,
            enumerable: true,

            get: () => {
              const currentValue = Object.prototype.hasOwnProperty.call(
                this.#properties,
                key
              )
                ? this.#properties[key]
                : value

              return currentValue
            },

            set: (newValue) => {
              const oldValue = Object.prototype.hasOwnProperty.call(
                this.#properties,
                key
              )
                ? this.#properties[key]
                : value

              this.#propertyChangedCallback(type, key, oldValue, newValue)
            },
          },
        }),
        {}
      )
      // Apply the getters and setters to the custom element.
      Object.defineProperties(this, propertyMap)
    }

    /**
     * Create the svelte element when the custom element connects.
     */
    connectedCallback() {
      // Map the default properties and custom properties to the correct svelte properties.
      const svelteProperties =
        Object
          // Host properties are prefixed with `host` within the svelte component.
          .entries(opts.properties)
          // Custom properties are not prefixed in the svelte component.
          .reduce((a, [key, value]) => ({ ...a, [addPrefix(key, 'host')]: value }), opts.customProperties || {});
      // Map the current attributes for the custom element to the host properties.
      Array.from(this.attributes).forEach(
        (attr) =>
          (svelteProperties[addPrefix(kebabToCamelCase(attr.name), 'host')] = attr.value)
      )
      // Svelte stuff.
      svelteProperties.$$scope = {}
      // Add the custom element as host element to the properties.
      svelteProperties.hostElement = this
      // Create and render the svelte element.
      this.#svelteComponent = new opts.component({ target: this.#shadow, props: svelteProperties })
    }

    /**
     * Clean up when the custom element disconnects.
     */
    disconnectedCallback() {
      // Destroy the svelte element when removed from dom.
      try {
        this.#svelteComponent.$destroy()
      } catch (err) { }
    }
  }

  /**
   * Register the custom element.
   */
  if (!window.customElements.get(opts.tagname)) {
    window.customElements.define(opts.tagname, Wrapper)
  } else {
    console.log(`Trying to define already defined ${opts.tagname}`)
  }
}

@BerndWessels
Copy link

Actually this works (styles are rendered in the shadow root), so there must be something wrong with my original implementation I posted in the previous comment.

So nothing to worry about I guess ;)

import MyOtherComponent from './my-other-component.svelte';

class CustomElement extends HTMLElement {
    #shadow = null
    constructor() {
        super()
        this.#shadow = this.attachShadow({ mode: 'open' });
    }
    connectedCallback() {
        new MyOtherComponent({
            target: this.#shadow,
            props: {
                myTitle: 'Shadow DOM',
            }
        });
    }
}

if (!window.customElements.get('my-other-component')) {
    window.customElements.define('my-other-component', CustomElement)
} else {
    console.log(`Trying to define already defined 'my-other-component'`)
}

@ivanhofer
Copy link
Contributor Author

@BerndWessels have you set the compiler option
css to true?

@BerndWessels
Copy link

@ivanhofer not exactly sure what was wrong, but this rollup.config actually made it work. Thanks for responding and thanks for the PR - it made my life much easier.

image

@BerndWessels
Copy link

@ivanhofer

BTW we are building independent and individual components as custom elements rendering svelte inside the shadow dom.

What I noticed is that you use <style id="..."> to inject the styles. That is great, but if I use the same custom element multiple times then the dev tools complain that the same id is used multiple times.

I wonder if you could change from <style id="..."> to something else that wouldn't cause that problem, like <style data-id="...">

Please consider it if possible, thanks.

@ivanhofer
Copy link
Contributor Author

@BerndWessels setting the emitCss option to false made it work. Its a bit confusing that svelte-preprocess uses another flag than the svelte compiler (and also inverts its meaning 😅).

If you render the component multiple times, the styles should get only injected once into the dom (once per shadow root if you have multiple roots).

If you want to change this behavior, you should create a new issue.

@BerndWessels
Copy link

@ivanhofer thanks, I created an issue that explains it in much more detail - please have a look at it here #6719
Thank you

@jakobrosenberg
Copy link

Great work @ivanhofer. Is emitCss: false a requirement for using styles in shadow DOM?

@ivanhofer
Copy link
Contributor Author

@jakobrosenberg if you use emitCss: false Svelte will deal with the injection of all style sheets. You can probably also use emitCss: true and then manually inject the resulting style sheet into the shadow dom.

@jakobrosenberg
Copy link

@ivanhofer thanks. Any idea how I would manually inject the stylesheet into the shadow dom?

@ivanhofer
Copy link
Contributor Author

@jakobrosenberg just like you would with a normal DOM:

// entry point of your Svelte application
import App from './App.svelte'

const target = document.body
const root = target.attachShadow({ mode: 'open' })

const app = new App({
   target: root,
   props: {
      name: 'world'
   }
})

// inject styles
const linkElement = document.createElement('link')
linkElement.setAttribute('rel', 'stylesheet')
linkElement.setAttribute('href', 'build/bundle.css')
root.appendChild(linkElement)

export default app

The .css file get's probably hosted at the same location as your .js bundle. You then can create a link element and add it inside the shadow dom.

Hope this helps :)

@Fxlr8
Copy link

Fxlr8 commented Sep 10, 2022

There is an important catch. For the time of writing @font-family rule does not load fonts if it is located inside shadow DOM. It should be included in the main page and the shadow DOM to work.

@kizivat
Copy link

kizivat commented Oct 14, 2022

I'm having a problem using Tailwind CSS inside a shadow DOM in Svelte. If I import 'tailwindcss/tailwind.css' the styles are put into the main document's head. If I use

<style lang="postcss">
	@tailwind base;
	@tailwind components;
	@tailwind utilities;
</style>

The @tailwind stuff won't be transpiled by PostCSS.

Any hints please? Thanks.

@pfz
Copy link

pfz commented Oct 14, 2022

I'm having a problem using Tailwind CSS inside a shadow DOM in Svelte. If I import 'tailwindcss/tailwind.css' the styles are put into the main document's head. If I use

<style lang="postcss">
	@tailwind base;
	@tailwind components;
	@tailwind utilities;
</style>

The @tailwind stuff won't be transpiled by PostCSS.

Any hints please? Thanks.

I think that tailwind declarations must be "global". For example, you should put <style global lang="postcss"> in your layout-like or root component. Then your component injected in a shadow DOM would inject compiled style in the right place for the whole component structure (app?)

@antony
Copy link
Member

antony commented Oct 14, 2022

Hi @kizivat, please ask help and support questions on the svelte discord - https://discord.gg/svelte rather than attaching them to merged and closed pull requests.

@sveltejs sveltejs locked as off-topic and limited conversation to collaborators Oct 14, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.