webpack-contrib / mini-css-extract-plugin

Lightweight CSS extraction plugin
MIT License
4.65k stars 390 forks source link

Support for Web Components, Shadow DOM, adoptedStyleSheets #1092

Open fregante opened 3 months ago

fregante commented 3 months ago

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:

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:

Related links

alexander-akait commented 3 months ago

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

fregante commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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.

alexander-akait commented 3 months ago

@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 commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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

fregante commented 3 months ago

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 commented 3 months ago

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 commented 3 months ago

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

fregante commented 3 months ago

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

alexander-akait commented 3 months ago

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 commented 3 months ago

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:

fregante commented 3 months ago
  • 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:

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 commented 3 months ago

Alternative solutions:

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 commented 3 months ago

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 commented 3 months ago

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:

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

alexander-akait commented 3 months ago

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:

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 commented 3 months ago

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 commented 3 months ago

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