Skip to content

Enhanced Runtime Shadow DOM Support #3746

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

Open
2 tasks done
iammerrick opened this issue May 2, 2025 · 1 comment
Open
2 tasks done

Enhanced Runtime Shadow DOM Support #3746

iammerrick opened this issue May 2, 2025 · 1 comment

Comments

@iammerrick
Copy link

Clear and concise description of the problem

As a developer using Module federation enhanced runtime I want to use module federations that render into Shadom DOM instances from web components so that CSS can be isolated.

Suggested solution

In the loadRemote function we could provide a separate root, loadRemote(moduleId, shadowRoot). This would allow the user to declare where the CSS should be linked.

This would allow using federated modules inside of web components using shadow roots:

class CustomElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  async connectedCallback() {
    if (!this.shadowRoot) return;

    const module = await loadRemote('dynamic-remote/button', {
      root: this.shadowRoot,
    });
    createRoot(this.shadowRoot).render(React.createElement(module.default));
  }
}

customElements.define('custom-element', CustomElement);

Since this element can be rendered multiple times, the CSS would need to be linked for each different root:

<custom-element /> // Link it in here
<custom-element /> // And in here

And if the the user doesn't provide a root, it should load it in the document.head using the behavior we have today:

const module = await loadRemote('dynamic-remote/button'); // Uses document.head!

In order for this to work, module federation can no longer assume "if it was loaded once, we're loaded and done!" it has to treat attachment as something that happens per root, which is not just about loading but how the browser indicates scoping for styles made available to shadow dom.

I've provided a PR here #3740 but it isn't complete because the preload cache assumes a single CSS attachment phase due to a global cache. I'm not sure the best path forward from here.

Alternative

I considered the idea of returning a different root via a runtime plugin, however, this suffers the same "it isn't a single attach phase anymore" problem and it doesn't naturally support the reality of multiple instances of a web component on a page, each component needed it's css attached.

Additional context

Encapsulation from CSS Shadow DOM

Validations

  • Read the Contributing Guidelines.
  • Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
@iammerrick
Copy link
Author

My current work-around is to opt-out of runtime CSS support using a plugin like this:

const assets = new Map();

  function registerCss(
    id: string,
    publicPath: string,
    css: StatsAssets['css']
  ) {
    css.sync.forEach((cssUrl) => {
      const existing = assets.get(id) || [];
      existing.push(`${publicPath}${cssUrl}`);
      assets.set(id, existing);
    });
    // Opt-out of module-federation
    return {sync: [], async: []};
  }

  const runtimePlugin: () => FederationRuntimePlugin = function () {
    return {
      name: 'opt-out-css-runtime-plugin',
      async fetch(manifestUrl, requestInit) {
        const request = await fetch(manifestUrl, requestInit);
        const manifest = (await request.json()) as Manifest;
        const exposes = manifest.exposes.map((expose) => ({
          ...expose,
          assets: {
            ...expose.assets,
            css: registerCss(
              `${hostName}/${manifest.id}/${expose.name}`,
              // @ts-expect-error Public path does exist, bad type.
              manifest.metaData.publicPath,
              expose.assets.css
            ),
          },
        }));

        return new Response(
          JSON.stringify({
            ...manifest,
            exposes,
          })
        );
      },
    };
  };

registerPlugins([runtimePlugin()]);

And then later use that assets map in the web component:

cons stylesheets = assets.get(this.moduleId)
stylesheets.forEach((stylesheet) => {
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = stylesheet;
        shadowRoot.appendChild(link);
});

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

1 participant