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

[ShadyDOM] Add ShadyDOM.querySelectorImplementation setting. #517

Merged
merged 81 commits into from
Sep 15, 2022

Conversation

bicknellr
Copy link
Collaborator

@bicknellr bicknellr commented Jul 29, 2022

This PR splits the selectors passed to querySelector and querySelectorAll, then traverses the logical tree manually to match them. This fixes :scope by allowing Shady DOM to decide if the candidate elements being tested against simple selectors containing :scope are the context element and should therefore match.

  • Only walk ancestors and inclusive descendants of the context element.
  • Iterate simple selectors in reverse to avoid walking large trees for descendant combinators.
  • Fix the result order by matching all complex selectors simultaneously.
  • Add a setting that allows switching between the current implementation, one which calls directly into the native functions, and this implementation, along with documentation explaining why this is configurable and the tradeoffs of each.
  • Add tests.
  • Add tests specifically for behavior around selector lists with multiple, top-level complex selectors. (querySelectorAll result ordering, parsing)

Fixes #480.

@bicknellr bicknellr requested a review from sorvell July 29, 2022 23:46
@bicknellr bicknellr changed the title [ShadyDOM] Fix :scope in querySelector{,All} by traversing the logical tree. [ShadyDOM] Fix :scope in querySelector{,All} by traversing the logical tree manually to match selectors. Jul 30, 2022
@bicknellr bicknellr force-pushed the shadydom-querySelector-scope-parse branch from e703702 to 603e00f Compare August 4, 2022 00:14
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.

Looking really good. We need:

  • 3 modes: (a) existing impl, (b) native QSA + filtering, (c) this impl.
  • tests for each mode highlighting what works/fails in each.

* @return {!Array<!Element>}
*/
const logicalQuerySelectorList = (contextElement, selectorList) => {
return deduplicateAndFilterToDescendants(
Copy link
Collaborator

Choose a reason for hiding this comment

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

What about just doing Array.from(new Set(arrayWithDups))?

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 think this comment was on an older commit, but yeah this is now how the final deduplication is handled.

@@ -0,0 +1,204 @@
`logicalQuerySelectorAll` emulates `querySelectorAll` within Shady DOM's logical
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is awesome. Great write up.

combinators,
};
})
.filter(({compoundSelectors}) => compoundSelectors.length > 0);
Copy link
Collaborator

Choose a reason for hiding this comment

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

For what case is this filter needed?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This probably isn't necessary. I just haven't tested how the library responds to all whitespace / the empty string yet.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed.

const {compoundSelectors} = complexSelectorParts;
const index = compoundSelectors.length - 1;
if (matchesCompoundSelector(element, compoundSelectors[index])) {
return [
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why does this need to be an array?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This probably doesn't need to be.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed this one and a few others.

ancestor && ancestor instanceof Element;
ancestor = ancestor[utils.SHADY_PREFIX + 'parentNode']
) {
if (matchesCompoundSelector(ancestor, compoundSelector)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

It wasn't obvious to me why results would ever be an array so maybe this should be commented, but the reason is that there may be > 1 candidate in the ancestor tree...

e.g.: given c c c d and <c><c><c><d>: the first cursor will be for d and next we need a cursor for each c, but eventually the one for the top most c will survive. Is that right?

Copy link
Collaborator Author

@bicknellr bicknellr Aug 4, 2022

Choose a reason for hiding this comment

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

Yeah, each ancestor matching c needs a cursor because any of them could potentially be part of the match when it's only gotten around to testing the rightmost c in the selector. The cursors that point at the shallower <c>s will eventually be unable to find enough candidate <c> ancestors to match the rest of the selector and will be replaced with zero cursors.

That particular case will look something like this:

<c>
  <c>
    <c>
      <d> <!-- cursor[0] `c c c (d)`-->
<c> <!-- candidate for: cursor[0] `c c c( )d` -->
  <c> <!-- candidate for: cursor[0] `c c c( )d` -->
    <c> <!-- candidate for: cursor[0] `c c c( )d` -->
      <d>
<c> <!-- new cursor[2] `c c (c) d` -->
  <c> <!-- new cursor[1] `c c (c) d` -->
    <c> <!-- new cursor[0] `c c (c) d` -->
      <d>

During this step, cursor[2] doesn't find any candidates:

<c> <!-- candidate for: cursor[0] `c c( )c d` and cursor[1] `c c( )c d` -->
  <c> <!-- candidate for: cursor[0] `c c( )c d` -->
    <c>
      <d>
<c> <!-- new cursor[1] `c (c) c d`, new cursor[2] `c (c) c d` -->
  <c> <!-- new cursor[0] `c (c) c d` -->
    <c>
      <d>

During this step, cursor[1] doesn't find any candidates:

<c> <!-- candidate for: cursor[0] `c( )c c d` -->
  <c>
    <c>
      <d>
<c> <!-- new cursor[0]: `(c) c c d` -->
  <c>
    <c>
      <d>

Copy link
Collaborator Author

@bicknellr bicknellr Aug 4, 2022

Choose a reason for hiding this comment

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

The cursors that point at the shallower <c>s will eventually be unable to find enough candidate <c> ancestors to match the rest of the selector and will be replaced with zero cursors.

Or, maybe I should have said "Eventually, some of the cursors with 'algorithmic ancestor cursors' that pointed at the shallower <c>s will be unable..." since only cursors that have already succeeded in making a complete match match actually 'survive' between iterations - all others are replaced with zero or more new cursors. The overlapping tree / lineage terminology that's shared here between the DOM and the timeline of cursors makes describing this a bit difficult. :/

@@ -0,0 +1,140 @@
/**
Copy link

@romainmenke romainmenke Aug 7, 2022

Choose a reason for hiding this comment

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

This package doesn't handle escape sequences :

console.log('a\\,b,c', splitSelector('a\\,b,c'))
// a\,b,c { selectors: [ 'a\\', 'b', 'c' ], joiners: [ ',', ',' ] }
console.log('a\\",b,c', splitSelector('a\\",b,c'))
// a\",b,c { selectors: [ 'a\\",b,c' ], joiners: [] }

Choose a reason for hiding this comment

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

@bicknellr bicknellr force-pushed the shadydom-querySelector-scope-parse branch from bf502b5 to f8706be Compare August 9, 2022 01:23
@bicknellr bicknellr requested a review from sorvell August 31, 2022 01:05
@bicknellr bicknellr force-pushed the shadydom-querySelector-scope-parse branch from 0c81de3 to 0fc78a1 Compare August 31, 2022 22:29
const matchesCompoundSelector = (element, compoundSelector) => {
return (
utils.matchesSelector(element, compoundSelector) &&
(compoundSelector.indexOf(':scope') === -1 || element === contextElement)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This check is likely cheaper, do it first?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated

* - `complexSelectorParts` is the decomposed version of the selector that
* this cursor is attempting to match.
*
* - `position` is an element that matches a compound selector in
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: position referring to an element is a little odd. Some suggestions: current or cursor or matchedElement?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

SGTM, switched to matchedElement.

* Deduplicates items in an array.
*
* This function could normally be implemented as merely `Array.from(new
* Set(...))`. However, in IE 11, `Set` does not support being constructed with
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is likely more expensive than doing new Set([...]). What about doing this the good way if it's supported?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

There's also the Array.from issue. Do you think this is worth the complexity of checking if we're in the good case?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Think it's fine.

return utils.flat(
complexSelectors.map((complexSelectorParts) => {
const {compoundSelectors} = complexSelectorParts;
const index = compoundSelectors.length - 1;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's add a little comment here about why we're starting from the end (it's more efficient).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Added

// `position`.
for (
let ancestor = position[utils.SHADY_PREFIX + 'parentNode'];
ancestor && ancestor instanceof Element;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we check nodeType instead of using instanceof? Should be better perf.

And/or don't we have parentElement? Can we use that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, that should work - updated.

On another note, this question just made me realize that :host and :host-context won't work in this implementation. I don't think this is supported by the default implementation either since it also uses matches, but I've added this to the list of unsupported stuff in the docs for this flag.

this[utils.NATIVE_PREFIX + 'querySelector'](selector)
);
const root = this[utils.SHADY_PREFIX + 'getRootNode']();
return utils.createPolyfilledHTMLCollection(
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 just be the matched element?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

(reply in other comment)

@@ -118,6 +118,81 @@ if (utils.settings.inUse) {
'nativeMethods': nativeMethods,
'nativeTree': nativeTree,
'patchElementProto': patchElementProto,
// Use this setting to choose the implementation of `querySelector` and
// `querySelectorAll`. The logical tree exposed by Shady DOM via the DOM
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe add: "The options provide different trade offs between accuracy and performance, as explained below."

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 added that sentence and reworded this paragraph a bit.

// correct. When given a selector that would not parse under a full
// implementation, this implementation may fail silently.
//
// - Only top-level combinators and compound selectors are taken into
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe change this to "contextual combinators are not supported"?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, this was a bit hard to describe since there's not really a standard term for this. 'Contextual combinators' is a bit confusing to me also, so I reworded it using 'nested selectors'. Do you think it's clearer after this update?

@@ -2906,6 +3105,352 @@
document.body.removeChild(d);
});
});

suite('querySelectorImplementation', function () {
Copy link
Collaborator

Choose a reason for hiding this comment

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

  • Make sure to also add asserts for querySelector in addition to querySelectorAll.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ok, I added them for the tests for the tree-walking parts, but I left them out of the parseSelectorList tests since those are only to check if they parsed properly.

@@ -26,6 +26,12 @@
'shady-dynamic.html?forceCustomElements=true',
'shady-dynamic.html?noPatch=true&forceCustomElements=true',
'shady-dynamic.html?noPatch=on-demand&forceCustomElements=true',
'shady-dynamic.html?querySelectorImplementation=selectorEngine',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are there tests for querySelectorImplementation=native?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No, I've just added those though. I had to update some of them since they were doing things that aren't supported with that option.

@bicknellr bicknellr requested a review from sorvell September 9, 2022 00:58
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.

LGTM

@bicknellr
Copy link
Collaborator Author

I've been looking into the Sauce tests since they were passing before and nothing meaningful happened between then and now that should have caused them to start completely refusing to run on a handful of older browsers. I see the same thing when running the full list of browsers on sauce locally but running those same browsers individually seems to work. I tried debugging a run with the full list and found that the older browsers were being served code that wasn't correctly compiled for their feature sets, so my best guess as of now is that Sauce Connect Proxy might be caching responses between different browser sessions that the single tunnel is used for, causing the older browsers later in the list to see code generated for the newer browsers earlier in the list.

Sauce Connect Proxy used to have a --no-proxy-caching flag which is now deprecated, so next I'm going to try to inject Cache-Control: no-cache into the responses.

@bicknellr
Copy link
Collaborator Author

I think #522 will resolve the test issue.

@bicknellr bicknellr changed the title [ShadyDOM] Fix :scope in querySelector{,All} by traversing the logical tree manually to match selectors. [ShadyDOM] Add ShadyDOM.querySelectorImplementation setting. Sep 15, 2022
@bicknellr
Copy link
Collaborator Author

The test timeout probably needs to be increased; I had to restart them a few times.

@bicknellr bicknellr merged commit 4756d22 into master Sep 15, 2022
@bicknellr bicknellr deleted the shadydom-querySelector-scope-parse branch September 15, 2022 23:59
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.

ShadyDOM breaks querySelector polyfills
3 participants