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

feat(compiler): add disableSyntheticShadowSupport option #3229

Merged
merged 4 commits into from
Jan 17, 2023

Conversation

nolanlawson
Copy link
Collaborator

@nolanlawson nolanlawson commented Dec 16, 2022

Details

Partially addresses #3228.

Introduces a new option to the compiler: disableSyntheticShadowSupport. This option is plumbed through from @lwc/rollup-plugin to @lwc/compiler and then to @lwc/style-compiler. When set to true, the output stylesheet functions are much smaller when minified.

Example input:

:host(.foo) :dir(rtl) {}

Output using master (minified with Terser):

function stylesheet(r, t, o) {
    return t ? (o ? "" : '[dir="rtl"]') + " :host(.foo) " + (o ? ":dir(rtl)" : "") + " {}" : (o ? "" : '[dir="rtl"]') + " " + (r ? "[" + r + "-host]" : "") + ".foo " + (o ? ":dir(rtl)" : "") + " {}"
}
Click to see unminified
function stylesheet(token, useActualHostSelector, useNativeDirPseudoclass) {
  var shadowSelector = token ? ("[" + token + "]") : "";
  var hostSelector = token ? ("[" + token + "-host]") : "";
  return ((useActualHostSelector ? (useNativeDirPseudoclass ? '' : '[dir="rtl"]') + " :host(.foo) " + (useNativeDirPseudoclass ? ':dir(rtl)' : '') + " {}" : (useNativeDirPseudoclass ? '' : '[dir="rtl"]') + " " + hostSelector + ".foo " + (useNativeDirPseudoclass ? ':dir(rtl)' : '') + " {}"));
  /*LWC compiler vX.X.X*/
}

Output with the option enabled (minified with Terser):

function stylesheet() {
    return " :host(.foo) :dir(rtl) {}"
}
Click to see unminified
function stylesheet() {
  var token;
  var useActualHostSelector = true;
  var useNativeDirPseudoclass = true;
  var shadowSelector = token ? ("[" + token + "]") : "";
  var hostSelector = token ? ("[" + token + "-host]") : "";
  return ((useActualHostSelector ? (useNativeDirPseudoclass ? '' : '[dir="rtl"]') + " :host(.foo) " + (useNativeDirPseudoclass ? ':dir(rtl)' : '') + " {}" : (useNativeDirPseudoclass ? '' : '[dir="rtl"]') + " " + hostSelector + ".foo " + (useNativeDirPseudoclass ? ':dir(rtl)' : '') + " {}"));
}

With the option enabled, Terser is able to strip away all the synthetic-specific code and have a simple function that returns a string.

Note this only works for unscoped styles, because scoped styles (in both light DOM and shadow DOM) have various runtime-specific parameters passed in. (Not just the scope token – light DOM in scoped mode also transforms :host into an identifier corresponding to the component root tag.)

Does this pull request introduce a breaking change?

  • ✅ No, it does not introduce a breaking change.

Does this pull request introduce an observable change?

  • ✅ No, it does not introduce an observable change.

GUS work item

W-12130393

@@ -32,3 +32,4 @@ export default {
- `experimentalDynamicComponent` (type: `DynamicComponentConfig`, default: `null`) - The configuration to pass to `@lwc/compiler`.
- `enableLwcSpread` (type: `boolean`, default: `false`) - The configuration to pass to the `@lwc/template-compiler`.
- `enableScopedSlots` (type: `boolean`, default: `false`) - The configuration to pass to `@lwc/template-compiler` to enable scoped slots feature.
- `disableSyntheticShadowSupport` (type: `boolean`, default: `false`) - Set to true if synthetic shadow DOM support is not needed, which can result in smaller output.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could have put this boolean on the stylesheetConfig, but that didn't seem right to me since this optimization could apply to the template compiler (and maybe the component compiler?) as well.

In this PR, though, I've only implemented the optimization for the style compiler.

buffer += ` var ${USE_NATIVE_DIR_PSEUDOCLASS} = true;\n`;
} else {
buffer += `function ${STYLESHEET_IDENTIFIER}(${TOKEN}, ${USE_ACTUAL_HOST_SELECTOR}, ${USE_NATIVE_DIR_PSEUDOCLASS}) {\n`;
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arguably we could do something much fancier by modifying serialize.ts to avoid outputting any of these variables in the first place. But that seemed unnecessary to me since Terser is already perfectly capable of removing unused variables.

@@ -81,6 +84,7 @@ commands:
<<# parameters.enable_native_custom_element_lifecycle >> ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 <</ parameters.enable_native_custom_element_lifecycle >> \
<<# parameters.enable_scoped_custom_element_registry >> ENABLE_SCOPED_CUSTOM_ELEMENT_REGISTRY=1 <</ parameters.enable_scoped_custom_element_registry >> \
<<# parameters.disable_aria_reflection_polyfill >> DISABLE_ARIA_REFLECTION_POLYFILL=1 <</ parameters.disable_aria_reflection_polyfill >> \
<<# parameters.disable_synthetic_shadow_support_in_compiler >> DISABLE_SYNTHETIC_SHADOW_SUPPORT_IN_COMPILER=1 <</ parameters.disable_synthetic_shadow_support_in_compiler >> \
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified manually that this Karma test works with the config passed in.

Screen Shot 2022-12-15 at 5 33 58 PM

In the above screenshot, some of the functions still have their arguments, because they are scoped (stylesheet.$scoped$ = true).

@nolanlawson nolanlawson changed the title feat(compiler): add disableSyntheticShadowSupport option feat(compiler): add disableSyntheticShadowSupport option [PoC] Dec 20, 2022
@nolanlawson
Copy link
Collaborator Author

Feedback from Ravi: we can use the shadowSupportMode = 'any' signal in addition to disableSyntheticShadowSupport to know if a component will only run in native mode. Not a 100% perfectly accurate signal (due to transitivity), but we may as well use it.

@nolanlawson
Copy link
Collaborator Author

Just occurred to me that using shadowSupportMode = 'any' has some problems:

  1. We would need to plumb the version from the Babel plugin through to the template compiler to the style compiler. These are stateless today; it would need to be accomplished with query params (e.g. import template from './app.html?nativeShadow=true'). This could cause module resolution bugs if some package (Jest/LWR/etc) doesn't support this syntax (as we learned with ?scoped=true).
  2. This gets weird with HTML and CSS files that are shared between components. One component would import foo.css?nativeShadow=true whereas the other would import foo.css?nativeShadow=false. So the same file would be compiled twice.

It doesn't seem unsolvable, but maybe it's best to save for a future PR.

@nolanlawson nolanlawson changed the title feat(compiler): add disableSyntheticShadowSupport option [PoC] feat(compiler): add disableSyntheticShadowSupport option Jan 11, 2023
@nolanlawson
Copy link
Collaborator Author

Another issue with shadowSupportMode = 'any' – it could be declared on a superclass. (In fact LBC does this.) So maybe it's not viable.

Copy link
Member

@jmsjtu jmsjtu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a quick test with CSS keyframes and noticed that terser doesn't quite return the CSS directly as a string. The full example can be found here.

This is the CSS:

.fix-notify_toast_animation {
    animation-name: fadeIn;
    animation-duration: 0.4s;
}

@keyframes fadeIn {
    0% {
        opacity: 0;
    }

    100% {
        opacity: 1;
    }
}

Here's the output:

function stylesheet() {
  var token;
  var useActualHostSelector = true;
  var useNativeDirPseudoclass = true;
  var shadowSelector = token ? ("[" + token + "]") : "";
  var hostSelector = token ? ("[" + token + "-host]") : "";
  return ".fix-notify_toast_animation" + shadowSelector + " {animation-name: fadeIn" + (shadowSelector ? ('-' + shadowSelector.substring(1, shadowSelector.length - 1)) : '') + ";animation-duration: 0.4s;}@keyframes fadeIn" + (shadowSelector ? ('-' + shadowSelector.substring(1, shadowSelector.length - 1)) : '') + " {0% {opacity: 0;}100% {opacity: 1;}}";
  /*LWC compiler vX.X.X*/
}

There are a few lines with shadowSelector.substring(1, shadowSelector.length - 1) in the returned value of the stylesheet function and the output from the terser playground (you'll have to copy and paste the stylesheet function) also has the call to substring.

Terser output:

export default[function(){var n="";return".fix-notify_toast_animation"+n+" {animation-name: fadeIn"+(n?"-"+n.substring(1,n.length-1):"")+";animation-duration: 0.4s;}@keyframes fadeIn"+(n?"-"+n.substring(1,n.length-1):"")+" {0% {opacity: 0;}100% {opacity: 1;}}"}];

Looks like the call to substring is added here because keyframes are transformed as part of the postCssLwcPlugin.

Probably not be a big deal since in the end it will evaluate to the same thing but wanted to mention this observation.

If we wanted to return the exactly CSS string in this case, we could add a check in the postCssLwcPlugin to look for the disableSyntheticShadowSupport compiler option and prevent it from making transformations.

There might also be a terser setting that takes care of these types of transformations for us.

@nolanlawson
Copy link
Collaborator Author

Ah, that is a good find. I think we could fix this by removing the substring() call, or changing shadowSelector.substring(...) to shadowSelector && shadowSelector.substring(...) to avoid the terser de-opt.

@nolanlawson
Copy link
Collaborator Author

I played around with Terser a bit. It's not quite as smart as I expected (SWC neither) – some cases I found it behaved differently if you ran the content twice through Terser rather than once.

In any case, I think I found a system that works.

Input:

.foo {
    animation-name: fadeIn;
}

@keyframes fadeIn {
    0% {
        opacity: 0;
    }

    100% {
        opacity: 1;
    }
}

Output:

function stylesheet() {
  var token;
  var useActualHostSelector = true;
  var useNativeDirPseudoclass = true;
  var shadowSelector = token ? ("[" + token + "]") : "";
  var hostSelector = token ? ("[" + token + "-host]") : "";
  var suffixToken = token ? ("-" + token) : "";
  return ".foo" + shadowSelector + " {animation-name: fadeIn" + suffixToken + ";}@keyframes fadeIn" + suffixToken + " {0% {opacity: 0;}100% {opacity: 1;}}";
  /*LWC compiler vX.X.X*/
}
export default [stylesheet];

Minified:

function stylesheet() {
    return ".foo {animation-name: fadeIn;}@keyframes fadeIn {0% {opacity: 0;}100% {opacity: 1;}}"
}
export default [stylesheet];

@nolanlawson
Copy link
Collaborator Author

Another option I thought of: we could have a "magic string" inside of CSS files like:

/* lwc-native-shadow-only */

If this string were detected, then the file would be treated as native-shadow-only. This would be a contract between the component author and LWC that a certain CSS file will only ever use native shadow.

Interestingly, this should work even for @imported stylesheets, so you can mix-and-match stylesheets that are native-only vs native-and-synthetic.

Copy link
Member

@jmsjtu jmsjtu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes look good to me!

There's one more thing I think we might want to address. I was looking at #3266 and found that the custom properties transformations will always be applied to the stylesheet function.

CSS:

div { color: var(--lwc-color); }
div { color: var(--lwc-color, black); }
div { color: var(--lwc-color) important; }

Output:

import varResolver from "custom-properties-resolver";
function stylesheet() {
  var token;
  var useActualHostSelector = true;
  var useNativeDirPseudoclass = true;
  var shadowSelector = token ? ("[" + token + "]") : "";
  var hostSelector = token ? ("[" + token + "-host]") : "";
  return "div" + shadowSelector + " {color: " + (varResolver("--lwc-color")) + ";}div" + shadowSelector + " {color: " + (varResolver("--lwc-color","black")) + ";}div" + shadowSelector + " {color: " + (varResolver("--lwc-color")) + " important;}";
  /*LWC compiler vX.X.X*/
}
export default [stylesheet];

Terser output:

import o from"custom-properties-resolver";export default[function(){return"div {color: "+o("--lwc-color")+";}div {color: "+o("--lwc-color","black")+";}div {color: "+o("--lwc-color")+" important;}"}];

Based on #3266, it sounds like this is something that could also be tackled using terser. The call to varResolver is
generated here, I think we could add a check to skip the transformation for native shadow.

What are your thoughts on this @nolanlawson? (We might need to do this in a separate PR since this kind of change is marked as a breaking change in #3266).

@nolanlawson
Copy link
Collaborator Author

@jmsjtu I think this is different. This is not about synthetic vs native shadow, but instead about whether the browser supports CSS custom properties or not.

The varResolver code also only gets emitted if you're using the customProperties.resolverModule option. So there is already a way to disable this behavior – just don't include that option.

Copy link
Member

@jmsjtu jmsjtu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification, LGTM!

@nolanlawson
Copy link
Collaborator Author

Random thought: I wonder if it would be worth it to detect if a native-only stylesheet is loaded in synthetic shadow. Might be an edge case, but we could at least warn the developer.

I might save that for a future PR though.

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

Successfully merging this pull request may close these issues.

2 participants