Skip to content

Primitive shadow parts support #329

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

Merged
merged 6 commits into from
Jun 8, 2020
Merged

Primitive shadow parts support #329

merged 6 commits into from
Jun 8, 2020

Conversation

aomarks
Copy link
Contributor

@aomarks aomarks commented May 19, 2020

Note: suggest reviewing the two commits separately, since the first one contains some reformatting.

This supports basic patterns of styling parts whenever a node is inserted into a shadow root. It also adds and tests a disableShadowParts ShadyCSS setting.

Support for more kinds of mutations, exportparts, CSS custom properties are coming in follow up PRs.

Part of #311 and #252. Fixes #337.

@aomarks aomarks requested a review from sorvell May 19, 2020 03:54
@aomarks aomarks requested a review from bicknellr as a code owner May 19, 2020 03:54
aomarks added 2 commits May 19, 2020 12:36
This supports basic patterns of styling parts whenever a node is inserted into a
shadow root. It also adds and tests a disableShadowParts ShadyCSS setting.

Support for more kinds of mutations, exportparts, CSS custom properties are
coming in follow up PRs.
@aomarks aomarks force-pushed the shady-parts-basic branch from bc6557e to 4e29618 Compare May 19, 2020 19:37
@@ -23,6 +23,8 @@ import templateMap from './template-map.js';
import * as ApplyShimUtils from './apply-shim-utils.js';
import {updateNativeProperties, detectMixin} from './common-utils.js';
import {CustomStyleInterfaceInterface, CustomStyleProvider} from './custom-style-interface.js'; // eslint-disable-line no-unused-vars
import * as shadowParts from './shadow-parts.js';
Copy link
Collaborator

Choose a reason for hiding this comment

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

Did you consider whether shadow parts should be a part of the scoping shim or given separate opt-in support in ShadyCSS, like @apply or custom style interface?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good thought, hadn't considered that. What are the main advantages you are thinking of?

The main one I can think of is that there would be more options for shipping smaller bundles to users who don't need shadow parts. We could create more permutations of the bundles, and update the loader to check the disableShadowParts flag, and only load shadow parts support when not true.

Does this suggestion imply you think shadow parts should be opt-in rather than opt-out? I've been thinking we should do opt-out, because 1) shadow parts are so widely supported now that it feels like they are part of the core set of web components APIs, and 2) I think the performance cost to applications that don't end up using shadow parts is very small (since we will do almost nothing until a ::part style is first detected in at least one template).

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you've evaluated it correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Filed #339, will think about this a little more and refactor in a separate PR.

export function onInsertBefore(parentNode, newNode, referenceNode) {
/* eslint-enable no-unused-vars */
if (!parentNode.getRootNode) {
// TODO(aomarks) Why is it in noPatch mode on Chrome 41 and other older
Copy link
Collaborator

Choose a reason for hiding this comment

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

In noPatch mode, the DOM is not patched so getRootNode is not brought into existence because of the polyfill. On browsers that don't require the polyfill, getRootNode already exists.

Copy link
Collaborator

Choose a reason for hiding this comment

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

To include support for noPatch mode, ShadyDOM.wrap must be called.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm having trouble getting tests to work with noPatch enabled -- it looks like there are no tests under shadycss with noPatch enabled yet. I'll need to spend some more time on it, so I've updated the TODO and filed #343 and will do noPatch support as a separate PR in next sprint, if that's ok.

// we don't waste time querying it.
return;
}
const parts = parentNode.querySelectorAll('[part]');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Again, to support noPatch mode any DOM API must use ShadyDOM.wrap.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this do a querySelectorAll on every call to insertBefore? If so, have you measured the performance impact of this? I'd expect it to be noticeable. ShadyDOM already conditionally does a walk during insertBefore (for example, to find slots); maybe consider a tighter integration to be able to participate in that walk.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also consider doing this in prepareTemplateDom so that only dynamic parts need to be changed here.

Copy link
Collaborator

Choose a reason for hiding this comment

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

This should be searching newNode and newNode.querySelectorAll, not parentNode.querySelectorAll.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also consider doing this in prepareTemplateDom so that only dynamic parts need to be changed here.

Good idea. Probably missing something obvious here and I can look into it myself, but how can I avoid not querying the template again to look for parts when the template is stamped? Would I need to add some new tracing to understand when an insertBefore is happening as a result of the element's shadycss-registered template stamping, or is there already a way I could figure that out?

Also, it seems like this could cut down on the cost of the querySelectorAll since I guess I could store some kind of index of the part locations (or at the very least, a bit to tell me if it is even worth querying for parts), but I couldn't actually fully pre-process the template to add the shady-part attribute, since the value of that attribute depends on knowing the super-scope, which we don't know until we're connected. Does that sound right?

Copy link
Collaborator

Choose a reason for hiding this comment

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

You'd need to add support for knowing when to not support the querySelector, but there's precedent for this: https://github.com./webcomponents/polyfills/blob/master/packages/shadydom/src/patches/Node.js#L309.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should be searching newNode and newNode.querySelectorAll, not parentNode.querySelectorAll.

Done. I also needed some other changes to make this happen (since newNode can sometimes be a DocumentFragment or Text), and added tests for various kinds of inserts.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Again, to support noPatch mode any DOM API must use ShadyDOM.wrap.

As mentioned in other comment, will do in follow up (#343).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does this do a querySelectorAll on every call to insertBefore? If so, have you measured the performance impact of this? I'd expect it to be noticeable. ShadyDOM already conditionally does a walk during insertBefore (for example, to find slots); maybe consider a tighter integration to be able to participate in that walk.

No benchmarks yet. I will add them in an upcoming task: #344

Will look into replacing the querySelectorAll with an integration into the ShadyDOM walk as an upcoming task: #345

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, it seems like this could cut down on the cost of the querySelectorAll since I guess I could store some kind of index of the part locations (or at the very least, a bit to tell me if it is even worth querying for parts), but I couldn't actually fully pre-process the template to add the shady-part attribute, since the value of that attribute depends on knowing the super-scope, which we don't know until we're connected. Does that sound right?

@sorvell any thoughts on this? I've filed #346 for future investigation.


/* eslint-disable no-unused-vars */
/**
* Perform any needed Shadow Parts updates after a ShadyDOM insertBefore call
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you describe in more detail what this does and why it's necessary?

Copy link
Collaborator

@sorvell sorvell May 26, 2020

Choose a reason for hiding this comment

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

It would be much less stress if we didn't have to alter the part attribute. Can you describe how this would limit the fidelity of the shim?

In particular, naively, a rule like x-super x-host [part~=foo] is at least somewhat similar to what you have. This doesn't do lower bound encapsulation at all, but ShadyCSS already addresses this, so could it be x-super .x-host[part~=foo] ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, interesting. I thought of an example that seemed sort of realistic where your suggestion would have caused an error, but then I thought of a modification that resolves it:

(1)

<style>
  /* error, the <div> grand-child of <x-other> shouldn't match */
  x-super .x-host[part~=foo] { color: red; }
  /* but is OK with this modification */
  x-super x-host.x-super .x-host[part~=foo] { color: red; }
</style>

<x-super>
  <x-host class="x-super">
    <div class="x-host" part="foo">
  <x-other class="x-super">
    <x-host class="x-other">
      <div class="x-host" part="foo">

However, you can still construct some patterns that fail:

(2)

<style>
  x-super x-host.x-super .x-host[part~=foo] { color: red; }
</style>

<x-super>
  <x-host class="x-super">
    <div class="x-host" part="foo">
    <x-other class="x-super">
      <x-host class="x-other">
        <!-- error: should not be red -->
        <div class="x-host" part="foo">

Whereas with the shady-parts attribute, the selectors are much tighter:

(3)

<style>
  x-super x-host [shady-part~="x-super:x-host:foo"] { color: red; }
</style>

<x-super>
  <x-host>
    <div class="x-host" part="foo" shady-part="x-super:x-host:foo">
    <x-other>
      <x-host>
        <!-- OK -->
        <div class="x-host" part="foo" shady-part="x-other:x-host:foo">

This is not to say that the shady-parts approach is perfect -- once you start repeating the same elements in a nesting hierarchy then you get similar problems (as described at https://github.com./webcomponents/polyfills/blob/shady-parts-basic/packages/shadycss/shadow-parts.md#3-recursion-is-crude). But it is at least a more limited set of patterns.

I guess the next question is, are examples like (2) above acceptable gaps in fidelity? It seems a little tricky to workaround the (2) example unless you can control the part names. WDYT? Can you think of any other selector tricks that would work even better?


The other consideration here is exported parts. In the implementation in #331, the way we support export parts is by adding additional shady-parts attributes to each part node based on the exportparts attributes found when we walk up the tree of shadow hosts. This allows one transformed style rule to match parts in all possible export paths.

I guess the way to do this if we used this other approach would be: each time we discover that an exported part needs to receive some style, we clone that style rule (or update its selector) with the new scope. We'd need to do per-instance styling here, though, in cases where two instances of the same element have different exportparts attributes. I already interact with per-instance styling in #333 to support custom properties within part rules, so it wouldn't be that wild. The difference here, though, would be that we'd be doing per-instance styling for more cases, even in Edge when native custom property support is available.

WDYT, am I thinking about this right?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for considering this so carefully. To summarize, nesting is a problem with any of our approaches, but with the current approach nesting of (A->B) inside an (A->B) is problematic whereas with the proposed approach nesting an A inside an A is problematic.

I was considering this without exportparts. I think considering that argues for how you've done it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged.

if (!disableShadowParts && PART.test(selector)) {
// Hacky transform "::part(foo bar)" to "::part(foo,bar)" so that
// SIMPLE_SELECTOR_SEP isn't confused by the spaces.
// TODO(aomarks) Can we make SIMPLE_SELECTOR_SEP smarter instead?
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be nice to work this out, but acknowledge that it may be non-trivial.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged. Leaving for now with TODO.

@@ -396,6 +405,20 @@ class StyleTransformer {
return output.join('');
}

_transformPartSelector(selector, scope) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Doc with an example transformation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

@@ -375,6 +375,12 @@ export const NodePatches = utils.getOwnPropertyDescriptors({
} else if (node.ownerDocument !== this.ownerDocument) {
this.ownerDocument.adoptNode(node);
}
if (!utils.disableShadowParts) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

See previous comment about performance concerns and consider instead a tighter integration at https://github.com./webcomponents/polyfills/blob/master/packages/shadydom/src/patches/Node.js#L311-L333.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged. Added TODO and filed #345 for upcoming task.

@@ -12,6 +12,10 @@ import {shadyDataForNode} from './shady-data.js';
/** @type {!Object} */
export const settings = window['ShadyDOM'] || {};

/** @type {boolean} */
export const disableShadowParts =
Boolean(window['ShadyCSS'] && window['ShadyCSS']['disableShadowParts']);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Shouldn't this be true if ShadyCSS does not exist?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think so, should it? If there's no ShadyCSS, then the user hasn't set any initialization flags, so the default should hold, which would be disableShadowParts set to false.

Copy link
Contributor Author

@aomarks aomarks 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 review. Addressed some comments, but the others I've filed as follow up tasks, in the interest of incremental progress and getting the 3 PRs landed (note I'm merging all these PRs into the shady-parts dev branch). Hope that's ok!

@@ -23,6 +23,8 @@ import templateMap from './template-map.js';
import * as ApplyShimUtils from './apply-shim-utils.js';
import {updateNativeProperties, detectMixin} from './common-utils.js';
import {CustomStyleInterfaceInterface, CustomStyleProvider} from './custom-style-interface.js'; // eslint-disable-line no-unused-vars
import * as shadowParts from './shadow-parts.js';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Filed #339, will think about this a little more and refactor in a separate PR.


/* eslint-disable no-unused-vars */
/**
* Perform any needed Shadow Parts updates after a ShadyDOM insertBefore call
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged.

export function onInsertBefore(parentNode, newNode, referenceNode) {
/* eslint-enable no-unused-vars */
if (!parentNode.getRootNode) {
// TODO(aomarks) Why is it in noPatch mode on Chrome 41 and other older
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm having trouble getting tests to work with noPatch enabled -- it looks like there are no tests under shadycss with noPatch enabled yet. I'll need to spend some more time on it, so I've updated the TODO and filed #343 and will do noPatch support as a separate PR in next sprint, if that's ok.

// we don't waste time querying it.
return;
}
const parts = parentNode.querySelectorAll('[part]');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should be searching newNode and newNode.querySelectorAll, not parentNode.querySelectorAll.

Done. I also needed some other changes to make this happen (since newNode can sometimes be a DocumentFragment or Text), and added tests for various kinds of inserts.

@@ -12,6 +12,10 @@ import {shadyDataForNode} from './shady-data.js';
/** @type {!Object} */
export const settings = window['ShadyDOM'] || {};

/** @type {boolean} */
export const disableShadowParts =
Boolean(window['ShadyCSS'] && window['ShadyCSS']['disableShadowParts']);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think so, should it? If there's no ShadyCSS, then the user hasn't set any initialization flags, so the default should hold, which would be disableShadowParts set to false.

// we don't waste time querying it.
return;
}
const parts = parentNode.querySelectorAll('[part]');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does this do a querySelectorAll on every call to insertBefore? If so, have you measured the performance impact of this? I'd expect it to be noticeable. ShadyDOM already conditionally does a walk during insertBefore (for example, to find slots); maybe consider a tighter integration to be able to participate in that walk.

No benchmarks yet. I will add them in an upcoming task: #344

Will look into replacing the querySelectorAll with an integration into the ShadyDOM walk as an upcoming task: #345

// we don't waste time querying it.
return;
}
const parts = parentNode.querySelectorAll('[part]');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, it seems like this could cut down on the cost of the querySelectorAll since I guess I could store some kind of index of the part locations (or at the very least, a bit to tell me if it is even worth querying for parts), but I couldn't actually fully pre-process the template to add the shady-part attribute, since the value of that attribute depends on knowing the super-scope, which we don't know until we're connected. Does that sound right?

@sorvell any thoughts on this? I've filed #346 for future investigation.

if (!disableShadowParts && PART.test(selector)) {
// Hacky transform "::part(foo bar)" to "::part(foo,bar)" so that
// SIMPLE_SELECTOR_SEP isn't confused by the spaces.
// TODO(aomarks) Can we make SIMPLE_SELECTOR_SEP smarter instead?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged. Leaving for now with TODO.

@@ -396,6 +405,20 @@ class StyleTransformer {
return output.join('');
}

_transformPartSelector(selector, scope) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

@@ -375,6 +375,12 @@ export const NodePatches = utils.getOwnPropertyDescriptors({
} else if (node.ownerDocument !== this.ownerDocument) {
this.ownerDocument.adoptNode(node);
}
if (!utils.disableShadowParts) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged. Added TODO and filed #345 for upcoming task.

@aomarks aomarks requested a review from sorvell June 4, 2020 22:00
parts = newNode.querySelectorAll('[part]');
} else if (newNode instanceof Text) {
// E.g. an innerHTML assignment.
parts = parentNode.querySelectorAll('[part]');
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why is this here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question, I got muddled in some debugging and was thinking the innerHTML call would show up as an insert with text, but obviously that's wrong. Removed.

document.body.removeChild(xa);
});

test('appendChild: part', () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add test for appending/inserting a Text node?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call, added test and an early return so that we don't crash in this case.

@aomarks aomarks requested a review from sorvell June 5, 2020 00:21
Copy link
Collaborator

@sorvell sorvell left a comment

Choose a reason for hiding this comment

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

One little nit but otherwise looks good.

@aomarks aomarks requested a review from sorvell June 5, 2020 02:14
@aomarks aomarks merged commit 33afe7d into shady-parts Jun 8, 2020
@aomarks aomarks deleted the shady-parts-basic branch June 8, 2020 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants