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

Support for Web Components, Shadow DOM, adoptedStyleSheets #1092

Open
fregante opened this issue Mar 25, 2024 · 23 comments
Open

Support for Web Components, Shadow DOM, adoptedStyleSheets #1092

fregante opened this issue Mar 25, 2024 · 23 comments

Comments

@fregante
Copy link

Context

I want to generate a standalone component to be loaded in a shadow DOM.

It seems that this gets me most of what I need:

  • own JS chunk
  • standalone CSS chunk that includes all the CSS dependencies (sub-imports of other .css files)
import shadow from 'react-shadow';

/// partial, pseudo-code

const {Component} = await import('./Component');

return <shadow.div><Component/></shadow.div>

At this point, a Component.css file is being generated and appended to document.head.

**The problem is that this stylesheet should be inside shadow.div

Feature Proposal

I'm not sure what would be the best way to achieve this, but perhaps:

  • add a magic comment to the import() to disable the automatic injection of the stylesheet
    e.g. import(/* webpackCss: no-inject */ './Component');
  • return the URL as an additional property, so that it can be handled autonomously
    e.g. const {Component, __miniCssUrl} = await import('./Component');

Related links

@alexander-akait
Copy link
Member

Currently possible only with css-loader https://github.com/webpack-contrib/css-loader?tab=readme-ov-file#exporttype

@fregante
Copy link
Author

The TL;DR of this issue is: can I specify anything other than document.head as a dynamic chunk CSS injection point?

This is possible via style-loader so perhaps it's possible to let style-loader deal with dynamic chunks:

@fregante
Copy link
Author

Currently possible only with css-loader webpack-contrib/css-loader#exporttype

Indeed, the main challenge is that all the existing solutions seem to be around the way the CSS file is imported, but I need to act on the generated CSS chunk instead, as a whole, not on a specific real CSS file.

@alexander-akait
Copy link
Member

Indeed, the main challenge is that all the existing solutions seem to be around the way the CSS file is imported, but I need to act on the generated CSS chunk instead, as a whole, not on a specific real CSS file.

How do you imagine this if you have about 100 components?

@fregante
Copy link
Author

It's not an issue with the number of components but rather 100 imports of one component, because each import would need to disable/alter the import() magic comment.

In my project there would be a single import() of the private component (seen above) and a wrapped component that returns a shadow DOM component with the stylesheet pre-loaded.

I'm not super convinced about the solution suggested above, I think that from webpack's viewpoint it's difficult to know in what context the code is being imported.

As a better solution, I think I'd have to move Component.tsx to webpack's entry points and then load the stylesheet myself, however any sub-import() calls would still load the stylesheet in the main document.

The easiest solution is to wrap the component in an iframe so that document.head is always the right component, but iframes are hard to deal with.

@fregante fregante closed this as not planned Won't fix, can't repro, duplicate, stale Mar 25, 2024
@alexander-akait
Copy link
Member

alexander-akait commented Mar 25, 2024

@fregante Can you provide a small example how do you see it (using github repo), maybe I can provide solution right now, because specification clearly says what you should write https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets#examples,

What can I do here? Just allow to this plugin autogenerate:

// Create an empty "constructed" stylesheet
const sheet = new CSSStyleSheet();
// Apply a rule to the sheet
sheet.replaceSync("Your CSS code");

But you don't need this plugin, because css-loader already can do it, extra async import will decrease your perf...

@alexander-akait
Copy link
Member

Another solution - you can do it on own side using fetch(new URL("./file.css", import.meta.url)) and make CSSStyleSheet by default, not sure we support new URL(...) here right now, but it is not a big problem

@fregante
Copy link
Author

The only way to do it in this scenario would be for import('./component.tsx') to return something is not exported by component.tsx, e.g.

// component.js
import './some-style.css'
import './some-other-style.css'
const {__stylesheet} = await import('./component.js')

const node = document.createElement("div");
const shadow = node.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [__stylesheet];

@fregante
Copy link
Author

you can do it on own side using fetch(new URL("./file.css", import.meta.url))

That's the issue, I don't have a file.css, the file to load is what import() will generate after resolving all the dependencies of component.js

Best I can do is use import(/* webpackChunkName: "file" */ "./component.js") and then expect file.css to live in dist as well. But the issue is that mini-css-extract-plugin is still loading file.css in document.head

@alexander-akait
Copy link
Member

You can disable runtime logic here using https://github.com/webpack-contrib/mini-css-extract-plugin?tab=readme-ov-file#runtime

@fregante
Copy link
Author

fregante commented Apr 3, 2024

Thank you, finally got around checking that out. Unfortunately that disables the injection for all generated bundles.

any sub-import() calls would still load the stylesheet in the main document

I don’t think there's really a comprehensive solution here that works with nested import() statements, because two import("./more.js") calls will only load the file and CSS once, even if they appear in two "contexts"


For truly isolated components, webpack needs to export a distinct configuration per component. This way I could set runtime: false and deal with the loading.

In this scenario, I could also ask mini-css-extract-plugin to accept a custom inject function so that all of its injections will be in the right host (it would be a feature request)

@fregante
Copy link
Author

fregante commented Apr 3, 2024

It turns out, my first feature request was still the best way to achieve this:

add a magic comment to the import() to disable the automatic injection of the stylesheet
e.g. import(/* webpackCss: no-inject */ './Component');

In the PR linked above you can see I'm manually detecting and disabling the injected stylesheet. It still won't supported nested import()s, but it's something.

In my case I want to preserve the behavior so that a missed stylesheet can be detected and reported as an error, but if it might be useful to others.

@alexander-akait
Copy link
Member

alexander-akait commented Apr 3, 2024

Why do not use new URL("./file.css", import.meta.url), I can improve supporting it

@fregante
Copy link
Author

fregante commented Apr 3, 2024

What's "./file.css"? Where should I use that URL? How would it fix/avoid the issue?

@alexander-akait
Copy link
Member

alexander-akait commented Apr 4, 2024

As I understand it, you want to load CSS chunk and get CSSStyleSheet, i.e:

// Pseudocode and yes, it is not statistical analizable
async function loadCssChunk(filename) {
  const url = new URL(filename, import.neta.url);
  const response = await fetch(url.toString());
  const css = await response.text();
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(css);

  return sheet;
}

Shortly - import("./my-styles-for-component.css", { with { type: "css" } }) like we should in future due according spec

You don't want to use "css-loader" because it bloats chunks due to storing the CSS inside the JS file, right?

@fregante
Copy link
Author

fregante commented Apr 4, 2024

What CSS chunk?

If you're talking about the chunks generated by import(), I'm already importing it via link, it's a solved problem.

The problems here where a combination of:

  • NOT injecting it into the main document by webpack
  • injecting it into the shadow DOM by webpack
  • knowing which stylesheets are created (webpackChunkName fixes this on one level, but not in nested dynamic imports)

@fregante
Copy link
Author

fregante commented Apr 4, 2024

  • knowing which stylesheets are created

It turns out, this is still an issue. There are situations where the CSS bundle name is not what's suggested in webpackChunkName: presumably this happens when part of the CSS is already injected as part of the main bundle, so webpack rightfully does not also add it to the deferred chunk.

Likely repro:

import "./file.css";
import(/* webpackChunkName: FOO */ "./file.js")
// file.js
import "./file.css";
import "./other.css";
console.log('file');

It will likely create:

  • main.js: with the import() statement
  • main.css: with the contents of file.css
  • FOO.js: with the console.log
  • vendors-node_modules_primeicons_primeicons_css-node_modules_primereact_resources_primereact_m-b15087.css: with the contents of other.css

The long filename doesn't match the example but it's a real filename I'm seeing.

So I'm back to having to specify file.js as one of the webpack entries as well, which safely generates file.css.

The problem now is removing vendors-node_modules_primeicons_primeicons_css-node_modules_primereact_resources_primereact_m-b15087.css from the document.


Reopening for this request specifically:

import(/* webpackCss: no-inject */ "./file.js");

@fregante fregante reopened this Apr 4, 2024
@fregante
Copy link
Author

fregante commented Apr 6, 2024

Alternative solutions:

  • don't extract CSS (leave it to css-loader)
  • don't generate CSS file at all (drop CSS)

It feels like either one of these is already possible via some webpack config.

In my PR above I'm detecting and disabling any local stylesheets added while import() is pending. This is quite verbose and might catch unrelated injections:

https://github.com/pixiebrix/pixiebrix-extension/blob/a6dc0fed1cf3a32d865e2177edd26a4f3dedbf14/src/components/IsolatedComponent.tsx#L35-L76

@alexander-akait
Copy link
Member

alexander-akait commented Apr 8, 2024

Reopening for this request specifically:

import(/* webpackCss: no-inject */ "./file.js");

I don't feel it is a right idea, what this import should return?

@fregante
Copy link
Author

fregante commented Apr 8, 2024

The import should keep working as expected for the JS part; the magic comment would only prevent the extraction, generation and/or injection of any CSS encountered.

Depending on the specific verb chosen:

  • no-inject: generate file.css, but don't inject it (this is either lost, or picked up by another plugin)
  • no-extract: ignore any CSS found, leave it to the next CSS plugin, if any; it's as if mini-css-extract-plugin wasn't installed at all
  • discard: drop any CSS module found, don't generate file.css at all

For me, the first choice is enough, while the last would be great

@alexander-akait
Copy link
Member

I don't really like this approach, since it will contradict the specification import itself as, because - import should return module, this approach only creates problems, which is why I want to find out what you ultimately want to get and where you are passing it to in order to understand how to solve it correctly without violating the specification.

Now we suppirt three things:

  • import * styles from "./style.css" and import(...) return module itself (locals)
  • new URL(..., import.meta.url) returns URL to CSS files, so you can write a custom logic for inejction
  • Future (not implemented right now) - import styles from "./style.css" with { type: "css" } (or asset keyword, old spec) should generate CSS file, load them and create CSSStyleSheet as I written above

These three things should solve any things.

Also we can setup the plugin only for CSS generation without runtime using runtime: false, so CSS is like just assets.

For more flexibility we can implement import(/* webpackCss: no-inject */ "./file.js");, but I want to make sure that this is really the last correct solution that we can implement, because it also, to some extent, looks more like a hack than a real solution

@fregante
Copy link
Author

fregante commented Apr 8, 2024

import should return module

Where did I say to change that behavior? What I'm suggesting with no-inject is exactly what the plugin already does with runtime: false, except that it's applied to a specific import() location rather than to all chunks.

Perhaps it needs to be import(/* mini-css-extract-plugin: no-runtime */ "./file.js"); to match the existing config name.

@alexander-akait
Copy link
Member

Got it, I will try to find a time on it, but I don't know when, anyway if you want to send a PR, feel free to do it

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

2 participants