From e8441da5357d1bf846ea4ad751208aa49859eb8a Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Thu, 28 Jul 2022 16:39:31 -0700 Subject: [PATCH 01/76] Add --- .../src/patches/css-selector-splitter.js | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/shadydom/src/patches/css-selector-splitter.js diff --git a/packages/shadydom/src/patches/css-selector-splitter.js b/packages/shadydom/src/patches/css-selector-splitter.js new file mode 100644 index 000000000..7fe5dacec --- /dev/null +++ b/packages/shadydom/src/patches/css-selector-splitter.js @@ -0,0 +1,140 @@ +/** +@license + +MIT License + +Copyright (c) 2016 Perry Mitchell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +const BRACES = { + '(': ')', + '[': ']', + '"': '"', + "'": "'", +}; + +const CLOSING_BRACES = { + ')': '(', + ']': '[', +}; + +function splitSelector(selector, splitCharacters = [',']) { + let currentBraces = [], + selectorLen = selector.length, + selectors = [], + joiners = [], + currentSelector = '', + closingBraces = {}; + for (var i = 0; i < selectorLen; i += 1) { + let char = selector[i]; + if (BRACES.hasOwnProperty(char)) { + if (currentBraces.length === 0) { + currentBraces.push(char); + } else { + let lastBrace = currentBraces[currentBraces.length - 1]; + if (lastBrace === '"' || lastBrace === "'") { + // within quotes + if (char === lastBrace) { + // closing quote + currentBraces.pop(); + } + } else { + // inside brackets or square brackets + currentBraces.push(char); + } + } + currentSelector += char; + } else if (CLOSING_BRACES.hasOwnProperty(char)) { + let lastBrace = currentBraces[currentBraces.length - 1], + matchingOpener = CLOSING_BRACES[char]; + if (lastBrace === matchingOpener) { + currentBraces.pop(); + } + currentSelector += char; + } else if (splitCharacters.indexOf(char) >= 0) { + if (currentBraces.length <= 0) { + // we're not inside another block, so we can split using the comma/splitter + let lastJoiner = joiners[joiners.length - 1]; + if (lastJoiner === ' ' && currentSelector.length <= 0) { + // we just split by a space, but there seems to be another split character, so use + // this new one instead of the previous space + joiners[joiners.length - 1] = char; + } else if (currentSelector.length <= 0) { + // skip this character, as it's just padding + } else { + // split by this character + let newLength = selectors.push(currentSelector.trim()); + joiners[newLength - 1] = char; + currentSelector = ''; + } + } else { + // we're inside another block, so ignore the comma/splitter + currentSelector += char; + } + } else { + // just add this character + currentSelector += char; + } + } + selectors.push(currentSelector.trim()); + return { + selectors: selectors.filter((cssSelector) => cssSelector.length > 0), + joiners: joiners, + }; +} + +// output: + +function extractSelectors(selector, splitChars) { + let split = splitSelector(selector, splitChars); + return split.selectors; +} + +function extractSelectorBlocks(selector) { + return splitSelector(selector, ['+', '~', '>', ' ']); +} + +function joinParts(selectors, joiners) { + let selector = '', + selectorCount = selectors.length; + selectors.forEach(function (part, index) { + let suffix = joiners[index]; + if (!suffix) { + if (selectorCount - 1 === index) { + suffix = ''; + } else { + throw new Error(`No joiner for index: ${index}`); + } + } else { + if (suffix !== ' ') { + suffix = ` ${suffix} `; + } + } + selector += part + suffix; + }); + return selector; +} + +extractSelectors.splitSelectorBlocks = extractSelectorBlocks; + +extractSelectors.joinSelector = joinParts; + +export default extractSelectors; From 7ed74e4d917ce44793bf2db152a67a15e342d2c8 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 13:23:22 -0700 Subject: [PATCH 02/76] css-selector-parser: Make the API minification safe; use real exports. --- packages/shadydom/src/patches/css-selector-splitter.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/shadydom/src/patches/css-selector-splitter.js b/packages/shadydom/src/patches/css-selector-splitter.js index 7fe5dacec..484c4cc37 100644 --- a/packages/shadydom/src/patches/css-selector-splitter.js +++ b/packages/shadydom/src/patches/css-selector-splitter.js @@ -96,8 +96,8 @@ function splitSelector(selector, splitCharacters = [',']) { } selectors.push(currentSelector.trim()); return { - selectors: selectors.filter((cssSelector) => cssSelector.length > 0), - joiners: joiners, + 'selectors': selectors.filter((cssSelector) => cssSelector.length > 0), + 'joiners': joiners, }; } @@ -133,8 +133,8 @@ function joinParts(selectors, joiners) { return selector; } -extractSelectors.splitSelectorBlocks = extractSelectorBlocks; +export {extractSelectorBlocks as splitSelectorBlocks}; -extractSelectors.joinSelector = joinParts; +export {joinParts as joinSelector}; export default extractSelectors; From 0432af98711986aeaf61a5af715d146c6dc0777d Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 13:26:13 -0700 Subject: [PATCH 03/76] Add `logicalQuerySelectorAll` and use in `querySelector` and `querySelectorAll` wrappers. --- packages/shadydom/src/patches/ParentNode.js | 103 +++++++++++++++++--- 1 file changed, 89 insertions(+), 14 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 9cd4149f6..07ec253a8 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -10,6 +10,7 @@ subject to an additional IP rights grant found at http://polymer.github.io/PATEN import * as utils from '../utils.js'; import {shadyDataForNode} from '../shady-data.js'; +import {splitSelectorBlocks} from './css-selector-splitter.js'; /** * @param {Node} node @@ -128,6 +129,92 @@ export const ParentNodePatches = utils.getOwnPropertyDescriptors({ }, }); +/** + * @param {!Element} contextElement + * @param {string} selector + * @return {!Array} + */ +const logicalQuerySelectorAll = (contextElement, selector) => { + const { + 'selectors': simpleSelectors, + 'joiners': combinators, + } = splitSelectorBlocks(selector); + + if (simpleSelectors.length < 1) { + return []; + } + + let cursors = query( + contextElement[utils.SHADY_PREFIX + 'getRootNode'](), + (node) => { + return utils.matchesSelector(node, simpleSelectors[0]); + } + ); + + for (let i = 0; i < combinators.length; i++) { + const combinator = combinators[i]; + const simpleSelector = simpleSelectors[i + 1]; + + if (combinator === ' ') { + // Descendant combinator + cursors = cursors.flatMap((cursor) => { + return query(cursor, (descendant) => { + return utils.matchesSelector(descendant, simpleSelector); + }); + }); + } else if (combinator === '>') { + // Child combinator + cursors = cursors.flatMap((cursor) => { + for ( + let child = cursor[utils.SHADY_PREFIX + 'firstElementChild'](); + child; + child = child[utils.SHADY_PREFIX + 'nextElementSibling']() + ) { + if (utils.matchesSelector(child, simpleSelector)) { + return [child]; + } else { + return []; + } + } + }); + } else if (combinator === '+') { + // Next-sibling combinator + cursors = cursors.flatMap((cursor) => { + let nextElementSibling = cursor[ + utils.SHADY_PREFIX + 'nextElementSibling' + ](); + if ( + nextElementSibling && + utils.matchesSelector(nextElementSibling, simpleSelector) + ) { + return [nextElementSibling]; + } else { + return []; + } + }); + } else if (combinator === '~') { + // Subsequent-sibling combinator + cursors = cursors.flatMap((cursor) => { + for ( + let sibling = cursor[utils.SHADY_PREFIX + 'nextElementSibling'](); + sibling; + sibling = sibling[utils.SHADY_PREFIX + 'nextElementSibling']() + ) { + if (utils.matchesSelector(sibling, simpleSelector)) { + return [sibling]; + } else { + return []; + } + } + }); + } else { + throw new Error(`Unrecognized combinator: '${combinator}'.`); + } + } + + return cursors; +}; + export const QueryPatches = utils.getOwnPropertyDescriptors({ // TODO(sorvell): consider doing native QSA and filtering results. /** @@ -135,17 +222,7 @@ export const QueryPatches = utils.getOwnPropertyDescriptors({ * @param {string} selector */ querySelector(selector) { - // match selector and halt on first result. - let result = query( - this, - function (n) { - return utils.matchesSelector(n, selector); - }, - function (n) { - return Boolean(n); - } - )[0]; - return result || null; + return logicalQuerySelectorAll(this, selector)[0] || null; }, /** @@ -167,9 +244,7 @@ export const QueryPatches = utils.getOwnPropertyDescriptors({ ); } return utils.createPolyfilledHTMLCollection( - query(this, function (n) { - return utils.matchesSelector(n, selector); - }) + logicalQuerySelectorAll(this, selector) ); }, }); From cf213983d990c14194a79788902cdebae592a9cb Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 13:50:14 -0700 Subject: [PATCH 04/76] css-selector-splitter: Fix another minification error. --- packages/shadydom/src/patches/css-selector-splitter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shadydom/src/patches/css-selector-splitter.js b/packages/shadydom/src/patches/css-selector-splitter.js index 484c4cc37..9e2685f2b 100644 --- a/packages/shadydom/src/patches/css-selector-splitter.js +++ b/packages/shadydom/src/patches/css-selector-splitter.js @@ -105,7 +105,7 @@ function splitSelector(selector, splitCharacters = [',']) { function extractSelectors(selector, splitChars) { let split = splitSelector(selector, splitChars); - return split.selectors; + return split['selectors']; } function extractSelectorBlocks(selector) { From d112b0738c09acb29eba4b9f4223597d0df90407 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 14:20:09 -0700 Subject: [PATCH 05/76] Deduplicate results and filter to only those within the same root. --- packages/shadydom/src/patches/ParentNode.js | 56 ++++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 07ec253a8..3e4663296 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -10,7 +10,9 @@ subject to an additional IP rights grant found at http://polymer.github.io/PATEN import * as utils from '../utils.js'; import {shadyDataForNode} from '../shady-data.js'; -import {splitSelectorBlocks} from './css-selector-splitter.js'; +import extractSelectors, { + splitSelectorBlocks, +} from './css-selector-splitter.js'; /** * @param {Node} node @@ -129,16 +131,56 @@ export const ParentNodePatches = utils.getOwnPropertyDescriptors({ }, }); +/** + * @param {!Array} array + * @return {!Array} + * @template T + */ +const deduplicateArray = (array) => { + const results = []; + for (const item of array) { + if (!results.includes(item)) { + results.push(item); + } + } + return results; +}; + +/** + * @param {!Element} contextElement + * @param {!Array} elements + * @return {!Array} + */ +const deduplicateAndFilterToContextRoot = (contextElement, elements) => { + const getRoot = (e) => e[utils.SHADY_PREFIX + 'getRootNode'](); + const contextRoot = getRoot(contextElement); + return deduplicateArray(elements).filter((e) => getRoot(e) === contextRoot); +}; + +/** + * @param {!Element} contextElement + * @param {string} selectorList + * @return {!Array} + */ +const logicalQuerySelectorList = (contextElement, selectorList) => { + return deduplicateAndFilterToContextRoot( + contextElement, + extractSelectors(selectorList).flatMap((selector) => { + return logicalQuerySingleSelector(contextElement, selector); + }) + ); +}; + /** * @param {!Element} contextElement - * @param {string} selector + * @param {string} complexSelector * @return {!Array} */ -const logicalQuerySelectorAll = (contextElement, selector) => { +const logicalQuerySingleSelector = (contextElement, complexSelector) => { const { 'selectors': simpleSelectors, 'joiners': combinators, - } = splitSelectorBlocks(selector); + } = splitSelectorBlocks(complexSelector); if (simpleSelectors.length < 1) { return []; @@ -212,7 +254,7 @@ const logicalQuerySelectorAll = (contextElement, selector) => { } } - return cursors; + return deduplicateAndFilterToContextRoot(contextElement, cursors); }; export const QueryPatches = utils.getOwnPropertyDescriptors({ @@ -222,7 +264,7 @@ export const QueryPatches = utils.getOwnPropertyDescriptors({ * @param {string} selector */ querySelector(selector) { - return logicalQuerySelectorAll(this, selector)[0] || null; + return logicalQuerySelectorList(this, selector)[0] || null; }, /** @@ -244,7 +286,7 @@ export const QueryPatches = utils.getOwnPropertyDescriptors({ ); } return utils.createPolyfilledHTMLCollection( - logicalQuerySelectorAll(this, selector) + logicalQuerySelectorList(this, selector) ); }, }); From ed4caf3a1b88aa5bc7aeafddf7d844d3dcedf4fa Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 15:31:51 -0700 Subject: [PATCH 06/76] Use `Set` to simplify `deduplicateArray`. --- packages/shadydom/src/patches/ParentNode.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 3e4663296..d42b7ed42 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -136,15 +136,7 @@ export const ParentNodePatches = utils.getOwnPropertyDescriptors({ * @return {!Array} * @template T */ -const deduplicateArray = (array) => { - const results = []; - for (const item of array) { - if (!results.includes(item)) { - results.push(item); - } - } - return results; -}; +const deduplicateArray = (array) => Array.from(new Set(array)); /** * @param {!Element} contextElement From 0a04a1c8d69f8d81cb6e27b65e181100085e865f Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 15:34:27 -0700 Subject: [PATCH 07/76] Fix attempts to call properties that are getters, not functions. --- packages/shadydom/src/patches/ParentNode.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index d42b7ed42..857e9205d 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -200,9 +200,9 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { // Child combinator cursors = cursors.flatMap((cursor) => { for ( - let child = cursor[utils.SHADY_PREFIX + 'firstElementChild'](); + let child = cursor[utils.SHADY_PREFIX + 'firstElementChild']; child; - child = child[utils.SHADY_PREFIX + 'nextElementSibling']() + child = child[utils.SHADY_PREFIX + 'nextElementSibling'] ) { if (utils.matchesSelector(child, simpleSelector)) { return [child]; @@ -214,9 +214,8 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { } else if (combinator === '+') { // Next-sibling combinator cursors = cursors.flatMap((cursor) => { - let nextElementSibling = cursor[ - utils.SHADY_PREFIX + 'nextElementSibling' - ](); + let nextElementSibling = + cursor[utils.SHADY_PREFIX + 'nextElementSibling']; if ( nextElementSibling && utils.matchesSelector(nextElementSibling, simpleSelector) @@ -230,9 +229,9 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { // Subsequent-sibling combinator cursors = cursors.flatMap((cursor) => { for ( - let sibling = cursor[utils.SHADY_PREFIX + 'nextElementSibling'](); + let sibling = cursor[utils.SHADY_PREFIX + 'nextElementSibling']; sibling; - sibling = sibling[utils.SHADY_PREFIX + 'nextElementSibling']() + sibling = sibling[utils.SHADY_PREFIX + 'nextElementSibling'] ) { if (utils.matchesSelector(sibling, simpleSelector)) { return [sibling]; From 3697e0897acbcdd225d08d763389ca8f7bc9d1e4 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 15:36:39 -0700 Subject: [PATCH 08/76] Fix filtering so that results are always exclusive descendants of the context element. --- packages/shadydom/src/patches/ParentNode.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 857e9205d..731fdbc96 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -139,14 +139,14 @@ export const ParentNodePatches = utils.getOwnPropertyDescriptors({ const deduplicateArray = (array) => Array.from(new Set(array)); /** - * @param {!Element} contextElement + * @param {!Element} ancestor * @param {!Array} elements * @return {!Array} */ -const deduplicateAndFilterToContextRoot = (contextElement, elements) => { - const getRoot = (e) => e[utils.SHADY_PREFIX + 'getRootNode'](); - const contextRoot = getRoot(contextElement); - return deduplicateArray(elements).filter((e) => getRoot(e) === contextRoot); +const deduplicateAndFilterToDescendants = (ancestor, elements) => { + return deduplicateArray(elements).filter((e) => { + return e !== ancestor && ancestor.contains(e); + }); }; /** @@ -155,7 +155,7 @@ const deduplicateAndFilterToContextRoot = (contextElement, elements) => { * @return {!Array} */ const logicalQuerySelectorList = (contextElement, selectorList) => { - return deduplicateAndFilterToContextRoot( + return deduplicateAndFilterToDescendants( contextElement, extractSelectors(selectorList).flatMap((selector) => { return logicalQuerySingleSelector(contextElement, selector); @@ -245,7 +245,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { } } - return deduplicateAndFilterToContextRoot(contextElement, cursors); + return deduplicateAndFilterToDescendants(contextElement, cursors); }; export const QueryPatches = utils.getOwnPropertyDescriptors({ From 3695eebdbce4253beeb59bf978a1d6babbc5db16 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 15:46:43 -0700 Subject: [PATCH 09/76] Fix early returns in descendant and child combinators. --- packages/shadydom/src/patches/ParentNode.js | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 731fdbc96..49b319cff 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -199,17 +199,19 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { } else if (combinator === '>') { // Child combinator cursors = cursors.flatMap((cursor) => { + const results = []; + for ( let child = cursor[utils.SHADY_PREFIX + 'firstElementChild']; child; child = child[utils.SHADY_PREFIX + 'nextElementSibling'] ) { if (utils.matchesSelector(child, simpleSelector)) { - return [child]; - } else { - return []; + results.push(child); } } + + return results; }); } else if (combinator === '+') { // Next-sibling combinator @@ -221,24 +223,26 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { utils.matchesSelector(nextElementSibling, simpleSelector) ) { return [nextElementSibling]; - } else { - return []; } + + return []; }); } else if (combinator === '~') { // Subsequent-sibling combinator cursors = cursors.flatMap((cursor) => { + const results = []; + for ( let sibling = cursor[utils.SHADY_PREFIX + 'nextElementSibling']; sibling; sibling = sibling[utils.SHADY_PREFIX + 'nextElementSibling'] ) { if (utils.matchesSelector(sibling, simpleSelector)) { - return [sibling]; - } else { - return []; + results.push(sibling); } } + + return results; }); } else { throw new Error(`Unrecognized combinator: '${combinator}'.`); From b569f0b47c235fc2379e5c6ba78c25827677eaf5 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 16:15:24 -0700 Subject: [PATCH 10/76] Fix `contains` call to use the Shady DOM wrapper. --- packages/shadydom/src/patches/ParentNode.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 49b319cff..f2fb39253 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -144,8 +144,10 @@ const deduplicateArray = (array) => Array.from(new Set(array)); * @return {!Array} */ const deduplicateAndFilterToDescendants = (ancestor, elements) => { - return deduplicateArray(elements).filter((e) => { - return e !== ancestor && ancestor.contains(e); + return deduplicateArray(elements).filter((element) => { + return ( + element !== ancestor && ancestor[utils.SHADY_PREFIX + 'contains'](element) + ); }); }; From c4edbf4d994c61271575c73ee2ed484004d94829 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 16:33:57 -0700 Subject: [PATCH 11/76] Simple selectors containing `:scope` should only match if the candidate is the context element. --- packages/shadydom/src/patches/ParentNode.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index f2fb39253..b5a008ce9 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -187,6 +187,18 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { } ); + /** + * @param {!Element} element + * @param {string} simpleSelector + * @return {boolean} + */ + const matchesSimpleSelector = (element, simpleSelector) => { + return ( + utils.matchesSelector(element, simpleSelector) && + (!simpleSelector.includes(':scope') || element === contextElement) + ); + }; + for (let i = 0; i < combinators.length; i++) { const combinator = combinators[i]; const simpleSelector = simpleSelectors[i + 1]; @@ -195,7 +207,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { // Descendant combinator cursors = cursors.flatMap((cursor) => { return query(cursor, (descendant) => { - return utils.matchesSelector(descendant, simpleSelector); + return matchesSimpleSelector(descendant, simpleSelector); }); }); } else if (combinator === '>') { @@ -208,7 +220,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { child; child = child[utils.SHADY_PREFIX + 'nextElementSibling'] ) { - if (utils.matchesSelector(child, simpleSelector)) { + if (matchesSimpleSelector(child, simpleSelector)) { results.push(child); } } @@ -222,7 +234,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { cursor[utils.SHADY_PREFIX + 'nextElementSibling']; if ( nextElementSibling && - utils.matchesSelector(nextElementSibling, simpleSelector) + matchesSimpleSelector(nextElementSibling, simpleSelector) ) { return [nextElementSibling]; } @@ -239,7 +251,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { sibling; sibling = sibling[utils.SHADY_PREFIX + 'nextElementSibling'] ) { - if (utils.matchesSelector(sibling, simpleSelector)) { + if (matchesSimpleSelector(sibling, simpleSelector)) { results.push(sibling); } } From bc11939de6cc6c9682e91ccb1a43b67944aea5cd Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 29 Jul 2022 17:38:38 -0700 Subject: [PATCH 12/76] Add a default parameter value to satisfy Closure. --- packages/shadydom/src/patches/css-selector-splitter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shadydom/src/patches/css-selector-splitter.js b/packages/shadydom/src/patches/css-selector-splitter.js index 9e2685f2b..e56e89e2d 100644 --- a/packages/shadydom/src/patches/css-selector-splitter.js +++ b/packages/shadydom/src/patches/css-selector-splitter.js @@ -103,7 +103,7 @@ function splitSelector(selector, splitCharacters = [',']) { // output: -function extractSelectors(selector, splitChars) { +function extractSelectors(selector, splitChars = [',']) { let split = splitSelector(selector, splitChars); return split['selectors']; } From b23f0dbce8f6102f762afc45a4ff95a20281006c Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Mon, 1 Aug 2022 11:42:48 -0700 Subject: [PATCH 13/76] `Array`'s `flatMap` is not compiled out by Closure, so implement it manually. --- packages/shadydom/src/patches/ParentNode.js | 10 +++---- packages/shadydom/src/utils.js | 30 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index b5a008ce9..4963639bc 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -159,7 +159,7 @@ const deduplicateAndFilterToDescendants = (ancestor, elements) => { const logicalQuerySelectorList = (contextElement, selectorList) => { return deduplicateAndFilterToDescendants( contextElement, - extractSelectors(selectorList).flatMap((selector) => { + utils.flatMap(extractSelectors(selectorList), (selector) => { return logicalQuerySingleSelector(contextElement, selector); }) ); @@ -205,14 +205,14 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { if (combinator === ' ') { // Descendant combinator - cursors = cursors.flatMap((cursor) => { + cursors = utils.flatMap(cursors, (cursor) => { return query(cursor, (descendant) => { return matchesSimpleSelector(descendant, simpleSelector); }); }); } else if (combinator === '>') { // Child combinator - cursors = cursors.flatMap((cursor) => { + cursors = utils.flatMap(cursors, (cursor) => { const results = []; for ( @@ -229,7 +229,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { }); } else if (combinator === '+') { // Next-sibling combinator - cursors = cursors.flatMap((cursor) => { + cursors = utils.flatMap(cursors, (cursor) => { let nextElementSibling = cursor[utils.SHADY_PREFIX + 'nextElementSibling']; if ( @@ -243,7 +243,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { }); } else if (combinator === '~') { // Subsequent-sibling combinator - cursors = cursors.flatMap((cursor) => { + cursors = utils.flatMap(cursors, (cursor) => { const results = []; for ( diff --git a/packages/shadydom/src/utils.js b/packages/shadydom/src/utils.js index 9de04d6a8..a7e40da47 100644 --- a/packages/shadydom/src/utils.js +++ b/packages/shadydom/src/utils.js @@ -291,3 +291,33 @@ export const convertNodesIntoANode = (...args) => { } return fragment; }; + +/** + * @template T + * @param {!Array>} array + * @param {number} depth + * @return {!Array} + */ +const flat = (array, depth = 1) => { + for (; depth > 0; depth--) { + array = array.reduce((acc, item) => { + if (Array.isArray(item)) { + acc.push(...item); + } else { + acc.push(item); + } + return acc; + }, []); + } + + return array; +}; + +/** + * @template A + * @template B + * @param {!Array} array + * @param {function(!A): (!B | !Array)} mapFn + * @return {!Array} + */ +export const flatMap = (array, mapFn) => flat(array.map(mapFn), 1); From 028d03e513a5e7e1e615cbd7b1e2d83d771cc50f Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Mon, 1 Aug 2022 13:27:23 -0700 Subject: [PATCH 14/76] Evaluate selectors in reverse to avoid walking entire descendant trees for the descendant combinator. --- packages/shadydom/src/patches/ParentNode.js | 73 +++++++++++++-------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 4963639bc..594b649a2 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -180,12 +180,21 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { return []; } + /** + * @type {!Array} + */ let cursors = query( contextElement[utils.SHADY_PREFIX + 'getRootNode'](), (node) => { - return utils.matchesSelector(node, simpleSelectors[0]); + return utils.matchesSelector( + node, + simpleSelectors[simpleSelectors.length - 1] + ); } - ); + ).map((element) => ({position: element, target: element})); /** * @param {!Element} element @@ -199,44 +208,50 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { ); }; - for (let i = 0; i < combinators.length; i++) { + for (let i = combinators.length - 1; i >= 0; i--) { const combinator = combinators[i]; - const simpleSelector = simpleSelectors[i + 1]; + const simpleSelector = simpleSelectors[i]; if (combinator === ' ') { // Descendant combinator - cursors = utils.flatMap(cursors, (cursor) => { - return query(cursor, (descendant) => { - return matchesSimpleSelector(descendant, simpleSelector); - }); - }); - } else if (combinator === '>') { - // Child combinator cursors = utils.flatMap(cursors, (cursor) => { const results = []; for ( - let child = cursor[utils.SHADY_PREFIX + 'firstElementChild']; - child; - child = child[utils.SHADY_PREFIX + 'nextElementSibling'] + let ancestor = cursor.position[utils.SHADY_PREFIX + 'parentNode']; + ancestor && ancestor instanceof Element; + ancestor = ancestor[utils.SHADY_PREFIX + 'parentNode'] ) { - if (matchesSimpleSelector(child, simpleSelector)) { - results.push(child); + if (matchesSimpleSelector(ancestor, simpleSelector)) { + results.push({position: ancestor, target: cursor.target}); } } return results; }); - } else if (combinator === '+') { - // Next-sibling combinator + } else if (combinator === '>') { + // Child combinator cursors = utils.flatMap(cursors, (cursor) => { - let nextElementSibling = - cursor[utils.SHADY_PREFIX + 'nextElementSibling']; + const parent = cursor.position[utils.SHADY_PREFIX + 'parentNode']; + if ( - nextElementSibling && - matchesSimpleSelector(nextElementSibling, simpleSelector) + parent && + parent instanceof Element && + matchesSimpleSelector(parent, simpleSelector) ) { - return [nextElementSibling]; + return [{position: parent, target: cursor.target}]; + } + + return []; + }); + } else if (combinator === '+') { + // Next-sibling combinator + cursors = utils.flatMap(cursors, (cursor) => { + const sibling = + cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; + + if (sibling && matchesSimpleSelector(sibling, simpleSelector)) { + return [{position: sibling, target: cursor.target}]; } return []; @@ -247,12 +262,13 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { const results = []; for ( - let sibling = cursor[utils.SHADY_PREFIX + 'nextElementSibling']; + let sibling = + cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; sibling; - sibling = sibling[utils.SHADY_PREFIX + 'nextElementSibling'] + sibling = sibling[utils.SHADY_PREFIX + 'previousElementSibling'] ) { if (matchesSimpleSelector(sibling, simpleSelector)) { - results.push(sibling); + results.push({position: sibling, target: cursor.target}); } } @@ -263,7 +279,10 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { } } - return deduplicateAndFilterToDescendants(contextElement, cursors); + return deduplicateAndFilterToDescendants( + contextElement, + cursors.map((cursor) => cursor.target) + ); }; export const QueryPatches = utils.getOwnPropertyDescriptors({ From 569d6d38f204491c28ead956b3dbdea4d1819b30 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Mon, 1 Aug 2022 13:39:36 -0700 Subject: [PATCH 15/76] Closure isn't compiling out `includes`; replace with `indexOf`. --- packages/shadydom/src/patches/ParentNode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 594b649a2..57a7c02f4 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -204,7 +204,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { const matchesSimpleSelector = (element, simpleSelector) => { return ( utils.matchesSelector(element, simpleSelector) && - (!simpleSelector.includes(':scope') || element === contextElement) + (simpleSelector.indexOf(':scope') === -1 || element === contextElement) ); }; From bf1897e6d4a48ee104949874489d100ccea15070 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Mon, 1 Aug 2022 13:49:23 -0700 Subject: [PATCH 16/76] Remove `utils.flatMap`; use `utils.flat` directly. --- packages/shadydom/src/patches/ParentNode.js | 112 +++++++++++--------- packages/shadydom/src/utils.js | 11 +- 2 files changed, 62 insertions(+), 61 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 57a7c02f4..d974203b1 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -159,9 +159,11 @@ const deduplicateAndFilterToDescendants = (ancestor, elements) => { const logicalQuerySelectorList = (contextElement, selectorList) => { return deduplicateAndFilterToDescendants( contextElement, - utils.flatMap(extractSelectors(selectorList), (selector) => { - return logicalQuerySingleSelector(contextElement, selector); - }) + utils.flat( + extractSelectors(selectorList).map((selector) => { + return logicalQuerySingleSelector(contextElement, selector); + }) + ) ); }; @@ -214,66 +216,74 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { if (combinator === ' ') { // Descendant combinator - cursors = utils.flatMap(cursors, (cursor) => { - const results = []; - - for ( - let ancestor = cursor.position[utils.SHADY_PREFIX + 'parentNode']; - ancestor && ancestor instanceof Element; - ancestor = ancestor[utils.SHADY_PREFIX + 'parentNode'] - ) { - if (matchesSimpleSelector(ancestor, simpleSelector)) { - results.push({position: ancestor, target: cursor.target}); + cursors = utils.flat( + cursors.map((cursor) => { + const results = []; + + for ( + let ancestor = cursor.position[utils.SHADY_PREFIX + 'parentNode']; + ancestor && ancestor instanceof Element; + ancestor = ancestor[utils.SHADY_PREFIX + 'parentNode'] + ) { + if (matchesSimpleSelector(ancestor, simpleSelector)) { + results.push({position: ancestor, target: cursor.target}); + } } - } - return results; - }); + return results; + }) + ); } else if (combinator === '>') { // Child combinator - cursors = utils.flatMap(cursors, (cursor) => { - const parent = cursor.position[utils.SHADY_PREFIX + 'parentNode']; - - if ( - parent && - parent instanceof Element && - matchesSimpleSelector(parent, simpleSelector) - ) { - return [{position: parent, target: cursor.target}]; - } - - return []; - }); + cursors = utils.flat( + cursors.map((cursor) => { + const parent = cursor.position[utils.SHADY_PREFIX + 'parentNode']; + + if ( + parent && + parent instanceof Element && + matchesSimpleSelector(parent, simpleSelector) + ) { + return [{position: parent, target: cursor.target}]; + } + + return []; + }) + ); } else if (combinator === '+') { // Next-sibling combinator - cursors = utils.flatMap(cursors, (cursor) => { - const sibling = - cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; + cursors = utils.flat( + cursors.map((cursor) => { + const sibling = + cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; - if (sibling && matchesSimpleSelector(sibling, simpleSelector)) { - return [{position: sibling, target: cursor.target}]; - } + if (sibling && matchesSimpleSelector(sibling, simpleSelector)) { + return [{position: sibling, target: cursor.target}]; + } - return []; - }); + return []; + }) + ); } else if (combinator === '~') { // Subsequent-sibling combinator - cursors = utils.flatMap(cursors, (cursor) => { - const results = []; - - for ( - let sibling = - cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; - sibling; - sibling = sibling[utils.SHADY_PREFIX + 'previousElementSibling'] - ) { - if (matchesSimpleSelector(sibling, simpleSelector)) { - results.push({position: sibling, target: cursor.target}); + cursors = utils.flat( + cursors.map((cursor) => { + const results = []; + + for ( + let sibling = + cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; + sibling; + sibling = sibling[utils.SHADY_PREFIX + 'previousElementSibling'] + ) { + if (matchesSimpleSelector(sibling, simpleSelector)) { + results.push({position: sibling, target: cursor.target}); + } } - } - return results; - }); + return results; + }) + ); } else { throw new Error(`Unrecognized combinator: '${combinator}'.`); } diff --git a/packages/shadydom/src/utils.js b/packages/shadydom/src/utils.js index a7e40da47..9558701cb 100644 --- a/packages/shadydom/src/utils.js +++ b/packages/shadydom/src/utils.js @@ -298,7 +298,7 @@ export const convertNodesIntoANode = (...args) => { * @param {number} depth * @return {!Array} */ -const flat = (array, depth = 1) => { +export const flat = (array, depth = 1) => { for (; depth > 0; depth--) { array = array.reduce((acc, item) => { if (Array.isArray(item)) { @@ -312,12 +312,3 @@ const flat = (array, depth = 1) => { return array; }; - -/** - * @template A - * @template B - * @param {!Array} array - * @param {function(!A): (!B | !Array)} mapFn - * @return {!Array} - */ -export const flatMap = (array, mapFn) => flat(array.map(mapFn), 1); From c0d92e98eaf9ca7dc074cf10666204721130a42d Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Tue, 2 Aug 2022 15:05:14 -0700 Subject: [PATCH 17/76] Replace "simple selector" with "compound selector" where appropriate. --- packages/shadydom/src/patches/ParentNode.js | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index d974203b1..93b752458 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -174,11 +174,11 @@ const logicalQuerySelectorList = (contextElement, selectorList) => { */ const logicalQuerySingleSelector = (contextElement, complexSelector) => { const { - 'selectors': simpleSelectors, + 'selectors': compoundSelectors, 'joiners': combinators, } = splitSelectorBlocks(complexSelector); - if (simpleSelectors.length < 1) { + if (compoundSelectors.length < 1) { return []; } @@ -193,26 +193,26 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { (node) => { return utils.matchesSelector( node, - simpleSelectors[simpleSelectors.length - 1] + compoundSelectors[compoundSelectors.length - 1] ); } ).map((element) => ({position: element, target: element})); /** * @param {!Element} element - * @param {string} simpleSelector + * @param {string} compoundSelector * @return {boolean} */ - const matchesSimpleSelector = (element, simpleSelector) => { + const matchesCompoundSelector = (element, compoundSelector) => { return ( - utils.matchesSelector(element, simpleSelector) && - (simpleSelector.indexOf(':scope') === -1 || element === contextElement) + utils.matchesSelector(element, compoundSelector) && + (compoundSelector.indexOf(':scope') === -1 || element === contextElement) ); }; for (let i = combinators.length - 1; i >= 0; i--) { const combinator = combinators[i]; - const simpleSelector = simpleSelectors[i]; + const compoundSelector = compoundSelectors[i]; if (combinator === ' ') { // Descendant combinator @@ -225,7 +225,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { ancestor && ancestor instanceof Element; ancestor = ancestor[utils.SHADY_PREFIX + 'parentNode'] ) { - if (matchesSimpleSelector(ancestor, simpleSelector)) { + if (matchesCompoundSelector(ancestor, compoundSelector)) { results.push({position: ancestor, target: cursor.target}); } } @@ -242,7 +242,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { if ( parent && parent instanceof Element && - matchesSimpleSelector(parent, simpleSelector) + matchesCompoundSelector(parent, compoundSelector) ) { return [{position: parent, target: cursor.target}]; } @@ -257,7 +257,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { const sibling = cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; - if (sibling && matchesSimpleSelector(sibling, simpleSelector)) { + if (sibling && matchesCompoundSelector(sibling, compoundSelector)) { return [{position: sibling, target: cursor.target}]; } @@ -276,7 +276,7 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { sibling; sibling = sibling[utils.SHADY_PREFIX + 'previousElementSibling'] ) { - if (matchesSimpleSelector(sibling, simpleSelector)) { + if (matchesCompoundSelector(sibling, compoundSelector)) { results.push({position: sibling, target: cursor.target}); } } From 8f1ebdaa2d9f3f7411fad2a254fa59d131f1e62f Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Tue, 2 Aug 2022 17:38:00 -0700 Subject: [PATCH 18/76] Add documentation. --- packages/shadydom/src/patches/ParentNode.js | 58 +++++++- .../src/patches/logicalQuerySingleSelector.md | 132 ++++++++++++++++++ packages/shadydom/src/utils.js | 4 + 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 packages/shadydom/src/patches/logicalQuerySingleSelector.md diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 93b752458..bb7872f21 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -132,6 +132,8 @@ export const ParentNodePatches = utils.getOwnPropertyDescriptors({ }); /** + * Deduplicates items in an array. + * * @param {!Array} array * @return {!Array} * @template T @@ -139,6 +141,9 @@ export const ParentNodePatches = utils.getOwnPropertyDescriptors({ const deduplicateArray = (array) => Array.from(new Set(array)); /** + * Deduplicates an array of elements and removes any that are not exclusive + * descendants of `ancestor`. + * * @param {!Element} ancestor * @param {!Array} elements * @return {!Array} @@ -152,6 +157,9 @@ const deduplicateAndFilterToDescendants = (ancestor, elements) => { }; /** + * Performs the equivalent of `querySelectorAll` within Shady DOM's logical + * model of the tree for a selector list. + * * @param {!Element} contextElement * @param {string} selectorList * @return {!Array} @@ -168,6 +176,11 @@ const logicalQuerySelectorList = (contextElement, selectorList) => { }; /** + * Performs the equivalent of `querySelectorAll` within Shady DOM's logical + * model of the tree for a single complex selector. + * + * See <./logicalQuerySingleSelector.md> for implementation details. + * * @param {!Element} contextElement * @param {string} complexSelector * @return {!Array} @@ -183,10 +196,31 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { } /** - * @type {!Array} + * }} + */ + let SelectorMatchingCursor; + + /** + * The list of selector matching cursors, initialized to point at all + * descendants of `contextElement`'s root node that match the last compound + * selector in `complexSelector`. + * + * For example, if `complexSelector` is `a > b + c`, then this list is + * initialized to all descendants of `contextElement.getRootNode()` that match + * the compound selector `c`. + * + * @type {!Array} */ let cursors = query( contextElement[utils.SHADY_PREFIX + 'getRootNode'](), @@ -199,6 +233,10 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { ).map((element) => ({position: element, target: element})); /** + * Determines if a single compound selector matches an element. If the + * selector contains `:scope` (as a substring), then `element` must be + * `contextElement`. + * * @param {!Element} element * @param {string} compoundSelector * @return {boolean} @@ -210,6 +248,8 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { ); }; + // Iterate backwards through the remaining combinators and compound selectors, + // updating the cursors at each step. for (let i = combinators.length - 1; i >= 0; i--) { const combinator = combinators[i]; const compoundSelector = compoundSelectors[i]; @@ -220,6 +260,9 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { cursors.map((cursor) => { const results = []; + // For `a b`, where existing cursors have `position`s matching `b`, + // the candidates to test against `a` are all ancestors each cursor's + // `position`. for ( let ancestor = cursor.position[utils.SHADY_PREFIX + 'parentNode']; ancestor && ancestor instanceof Element; @@ -239,6 +282,9 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { cursors.map((cursor) => { const parent = cursor.position[utils.SHADY_PREFIX + 'parentNode']; + // For `a > b`, where existing cursors have `position`s matching `b`, + // the candidates to test against `a` are the parents of each cursor's + // `position`. if ( parent && parent instanceof Element && @@ -257,6 +303,9 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { const sibling = cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; + // For `a + b`, where existing cursors have `position`s matching `b`, + // the candidates to test against `a` are the immediately preceding + // siblings of each cursor's `position`. if (sibling && matchesCompoundSelector(sibling, compoundSelector)) { return [{position: sibling, target: cursor.target}]; } @@ -270,6 +319,9 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { cursors.map((cursor) => { const results = []; + // For `a ~ b`, where existing cursors have `position`s matching `b`, + // the candidates to test against `a` are all preceding siblings of + // each cursor's `position`. for ( let sibling = cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; @@ -285,6 +337,8 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { }) ); } else { + // As of writing, there are no other combinators: + // throw new Error(`Unrecognized combinator: '${combinator}'.`); } } diff --git a/packages/shadydom/src/patches/logicalQuerySingleSelector.md b/packages/shadydom/src/patches/logicalQuerySingleSelector.md new file mode 100644 index 000000000..30d51e8c9 --- /dev/null +++ b/packages/shadydom/src/patches/logicalQuerySingleSelector.md @@ -0,0 +1,132 @@ +`logicalQuerySingleSelector` emulates `querySelectorAll` for a single complex +selector within Shady DOM's logical tree by implementing the tree-traversal +portion of a selector engine. It does so by (a) finding all elements in the root +that match the final compound selector to create the initial set of 'cursors'; +(b) iterating backwards through the combinators and remaining compound +selectors, checking if the candidates implied by each combinator for each +cursor's `position` element would match the compound selector preceding them and +updating the cursors accordingly; and (c) deduplicating the set of `target` +elements of all remaining cursors and filtering out any that aren't exclusive +descendants of the context element. + +For example, consider querying for `a > b ~ c` within this tree: + +```html + + + + + + + + + + +``` + +First, all elements in the root are tested against the final compound selector +(`c`) to create the initial set of cursors: + +```html + + + + + + + + + + + + +``` + +Next, the combinators and the remaining compound selectors are iterated +backwards. Each combinator determines the set of candidate elements that must be +tested against the preceding compound selector. + +Moving backwards, the next combinator and compound selector in `a > b ~ c` are +`~` and `b`, so the next set of candidate elements are the preceding siblings of +the current cursors' `position` elements: + +```html + + + + + + + + + + + + + + + + +``` + +These candidates are filtered to those that match `b` and these matching +elements become the `position`s of the new set of cursors, with their `target` +set to the same `target` as the cursor for which they were previously a +candidate: + +```html + + + + + + + + + + + + +``` + +The process repeats again for the next combinator and compound selector: `>` and +`a`. First, the combinator (`>`) determines the candidates: + +```html + + + + + + + + + + + + + + +``` + +Then, the candidates are filtered by matching against the compound selector +(`a`), which determines the new cursors: + +```html + + + + + + + + + + + +``` + +Once all combinators and compound selectors have been iterated, the remaining +cursors' `target` elements are known to match the given selector. These elements +are then deduplicated and filtered to remove any that are not exclusive +descendants of the context element. diff --git a/packages/shadydom/src/utils.js b/packages/shadydom/src/utils.js index 9558701cb..55d4b94d4 100644 --- a/packages/shadydom/src/utils.js +++ b/packages/shadydom/src/utils.js @@ -293,6 +293,10 @@ export const convertNodesIntoANode = (...args) => { }; /** + * Equivalent to `Array.prototype.flat`. Closure does not compile out this + * function, so we need an implementation for browsers that don't natively + * support it. + * * @template T * @param {!Array>} array * @param {number} depth From 5b9a11f69a948ba0142c62308cfd80eb73ff6762 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Tue, 2 Aug 2022 17:43:35 -0700 Subject: [PATCH 19/76] Prevent the formatter from breaking the `logicalQuerySingleSelector` doc examples. (...) The formatter wants to format HTML snippets, but it keeps putting the comments in the examples on their own line, which makes it harder to tell what element they're referring to. To avoid this, I've stopped marking these snippets as HTML. --- .../src/patches/logicalQuerySingleSelector.md | 57 +++++++------------ 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/packages/shadydom/src/patches/logicalQuerySingleSelector.md b/packages/shadydom/src/patches/logicalQuerySingleSelector.md index 30d51e8c9..3cbf7ed98 100644 --- a/packages/shadydom/src/patches/logicalQuerySingleSelector.md +++ b/packages/shadydom/src/patches/logicalQuerySingleSelector.md @@ -11,7 +11,7 @@ descendants of the context element. For example, consider querying for `a > b ~ c` within this tree: -```html +``` @@ -27,17 +27,15 @@ For example, consider querying for `a > b ~ c` within this tree: First, all elements in the root are tested against the final compound selector (`c`) to create the initial set of cursors: -```html +``` - - + - - + ``` @@ -50,21 +48,15 @@ Moving backwards, the next combinator and compound selector in `a > b ~ c` are `~` and `b`, so the next set of candidate elements are the preceding siblings of the current cursors' `position` elements: -```html +``` - - - - - - + + + - - - - - - + + + ``` @@ -74,16 +66,14 @@ elements become the `position`s of the new set of cursors, with their `target` set to the same `target` as the cursor for which they were previously a candidate: -```html +``` - - + - - + @@ -92,18 +82,14 @@ candidate: The process repeats again for the next combinator and compound selector: `>` and `a`. First, the combinator (`>`) determines the candidates: -```html - - - - +``` + + - - + - - + @@ -112,9 +98,8 @@ The process repeats again for the next combinator and compound selector: `>` and Then, the candidates are filtered by matching against the compound selector (`a`), which determines the new cursors: -```html - - +``` + From 603e00fcd13895225bf13fc7bed19c4fa86ce22f Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 3 Aug 2022 17:07:34 -0700 Subject: [PATCH 20/76] Match all selectors in a selector list simultaneously. --- packages/shadydom/src/patches/ParentNode.js | 280 +++++++++--------- .../src/patches/logicalQuerySelectorAll.md | 204 +++++++++++++ .../src/patches/logicalQuerySingleSelector.md | 117 -------- 3 files changed, 352 insertions(+), 249 deletions(-) create mode 100644 packages/shadydom/src/patches/logicalQuerySelectorAll.md delete mode 100644 packages/shadydom/src/patches/logicalQuerySingleSelector.md diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index bb7872f21..1e29de201 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -131,106 +131,48 @@ export const ParentNodePatches = utils.getOwnPropertyDescriptors({ }, }); -/** - * Deduplicates items in an array. - * - * @param {!Array} array - * @return {!Array} - * @template T - */ -const deduplicateArray = (array) => Array.from(new Set(array)); - -/** - * Deduplicates an array of elements and removes any that are not exclusive - * descendants of `ancestor`. - * - * @param {!Element} ancestor - * @param {!Array} elements - * @return {!Array} - */ -const deduplicateAndFilterToDescendants = (ancestor, elements) => { - return deduplicateArray(elements).filter((element) => { - return ( - element !== ancestor && ancestor[utils.SHADY_PREFIX + 'contains'](element) - ); - }); -}; - /** * Performs the equivalent of `querySelectorAll` within Shady DOM's logical * model of the tree for a selector list. * - * @param {!Element} contextElement - * @param {string} selectorList - * @return {!Array} - */ -const logicalQuerySelectorList = (contextElement, selectorList) => { - return deduplicateAndFilterToDescendants( - contextElement, - utils.flat( - extractSelectors(selectorList).map((selector) => { - return logicalQuerySingleSelector(contextElement, selector); - }) - ) - ); -}; - -/** - * Performs the equivalent of `querySelectorAll` within Shady DOM's logical - * model of the tree for a single complex selector. - * - * See <./logicalQuerySingleSelector.md> for implementation details. + * See <./logicalQuerySelectorAll.md> for implementation details. * * @param {!Element} contextElement - * @param {string} complexSelector + * @param {string} selectorList * @return {!Array} */ -const logicalQuerySingleSelector = (contextElement, complexSelector) => { - const { - 'selectors': compoundSelectors, - 'joiners': combinators, - } = splitSelectorBlocks(complexSelector); - - if (compoundSelectors.length < 1) { - return []; - } - +const logicalQuerySelectorAll = (contextElement, selectorList) => { /** - * An object used to track the current position of a potential selector match. - * - * - `position` is the element that matches the compound selector last reached - * by the selector engine. - * - * - `target` is the element that matches the selector if the cursor - * eventually results in a complete match. + * A key-renamed version of the return value of `extractSelectors`, which + * describes a single complex selector. * * @typedef {{ - * position: !Element, - * target: !Element, + * compoundSelectors: !Array, + * combinators: !Array, * }} */ - let SelectorMatchingCursor; + let ComplexSelectorParts; /** - * The list of selector matching cursors, initialized to point at all - * descendants of `contextElement`'s root node that match the last compound - * selector in `complexSelector`. - * - * For example, if `complexSelector` is `a > b + c`, then this list is - * initialized to all descendants of `contextElement.getRootNode()` that match - * the compound selector `c`. - * - * @type {!Array} + * @type {!Array} */ - let cursors = query( - contextElement[utils.SHADY_PREFIX + 'getRootNode'](), - (node) => { - return utils.matchesSelector( - node, - compoundSelectors[compoundSelectors.length - 1] - ); - } - ).map((element) => ({position: element, target: element})); + const complexSelectors = extractSelectors(selectorList) + .map((complexSelector) => { + const { + 'selectors': compoundSelectors, + 'joiners': combinators, + } = splitSelectorBlocks(complexSelector); + + return { + compoundSelectors, + combinators, + }; + }) + .filter(({compoundSelectors}) => compoundSelectors.length > 0); + + if (complexSelectors.length < 1) { + return []; + } /** * Determines if a single compound selector matches an element. If the @@ -248,39 +190,106 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { ); }; - // Iterate backwards through the remaining combinators and compound selectors, - // updating the cursors at each step. - for (let i = combinators.length - 1; i >= 0; i--) { - const combinator = combinators[i]; - const compoundSelector = compoundSelectors[i]; + /** + * An object used to track the current position of a potential selector match. + * + * - `target` is the element that matches the selector if the cursor + * eventually results in a complete match. + * + * - `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 + * `complexSelectorParts`. + * + * - `index` is the index of the compound selector in `complexSelectorParts` + * that matched `position`. + * + * @typedef {{ + * target: !Element, + * complexSelectorParts: !ComplexSelectorParts, + * position: !Element, + * index: number, + * }} + */ + let SelectorMatchingCursor; + + /** + * The list of `SelectorMatchingCursor`s, initialized with cursors pointing at + * all descendants of `contextElement` that match the last compound selector + * in any complex selector in `selectorList`. + * + * @type {!Array} + */ + let cursors = utils.flat( + query(contextElement, (_element) => true).map((element) => { + return utils.flat( + complexSelectors.map((complexSelectorParts) => { + const {compoundSelectors} = complexSelectorParts; + const index = compoundSelectors.length - 1; + if (matchesCompoundSelector(element, compoundSelectors[index])) { + return [ + { + target: element, + complexSelectorParts, + position: element, + index, + }, + ]; + } else { + return []; + } + }) + ); + }) + ); - if (combinator === ' ') { - // Descendant combinator - cursors = utils.flat( - cursors.map((cursor) => { + // At each step, any remaining cursors that have not finished matching (i.e. + // with `cursor.index > 0`) should be replaced with new cursors for any valid + // candidates that match the next compound selector. + while (cursors.length > 0 && cursors.some((cursor) => cursor.index > 0)) { + cursors = utils.flat( + cursors.map((cursor) => { + // Cursors with `index` of 0 have already matched and should not be + // replaced or removed. + if (cursor.index <= 0) { + return cursor; + } + + const { + target, + position, + complexSelectorParts, + index: lastIndex, + } = cursor; + const index = lastIndex - 1; + const combinator = complexSelectorParts.combinators[index]; + const compoundSelector = complexSelectorParts.compoundSelectors[index]; + + if (combinator === ' ') { const results = []; // For `a b`, where existing cursors have `position`s matching `b`, // the candidates to test against `a` are all ancestors each cursor's // `position`. for ( - let ancestor = cursor.position[utils.SHADY_PREFIX + 'parentNode']; + let ancestor = position[utils.SHADY_PREFIX + 'parentNode']; ancestor && ancestor instanceof Element; ancestor = ancestor[utils.SHADY_PREFIX + 'parentNode'] ) { if (matchesCompoundSelector(ancestor, compoundSelector)) { - results.push({position: ancestor, target: cursor.target}); + results.push({ + target, + complexSelectorParts, + position: ancestor, + index, + }); } } return results; - }) - ); - } else if (combinator === '>') { - // Child combinator - cursors = utils.flat( - cursors.map((cursor) => { - const parent = cursor.position[utils.SHADY_PREFIX + 'parentNode']; + } else if (combinator === '>') { + const parent = position[utils.SHADY_PREFIX + 'parentNode']; // For `a > b`, where existing cursors have `position`s matching `b`, // the candidates to test against `a` are the parents of each cursor's @@ -290,33 +299,37 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { parent instanceof Element && matchesCompoundSelector(parent, compoundSelector) ) { - return [{position: parent, target: cursor.target}]; + return [ + { + target, + complexSelectorParts, + position: parent, + index, + }, + ]; } return []; - }) - ); - } else if (combinator === '+') { - // Next-sibling combinator - cursors = utils.flat( - cursors.map((cursor) => { + } else if (combinator === '+') { const sibling = - cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; + position[utils.SHADY_PREFIX + 'previousElementSibling']; // For `a + b`, where existing cursors have `position`s matching `b`, // the candidates to test against `a` are the immediately preceding // siblings of each cursor's `position`. if (sibling && matchesCompoundSelector(sibling, compoundSelector)) { - return [{position: sibling, target: cursor.target}]; + return [ + { + target, + complexSelectorParts, + position: sibling, + index, + }, + ]; } return []; - }) - ); - } else if (combinator === '~') { - // Subsequent-sibling combinator - cursors = utils.flat( - cursors.map((cursor) => { + } else if (combinator === '~') { const results = []; // For `a ~ b`, where existing cursors have `position`s matching `b`, @@ -324,29 +337,32 @@ const logicalQuerySingleSelector = (contextElement, complexSelector) => { // each cursor's `position`. for ( let sibling = - cursor.position[utils.SHADY_PREFIX + 'previousElementSibling']; + position[utils.SHADY_PREFIX + 'previousElementSibling']; sibling; sibling = sibling[utils.SHADY_PREFIX + 'previousElementSibling'] ) { if (matchesCompoundSelector(sibling, compoundSelector)) { - results.push({position: sibling, target: cursor.target}); + results.push({ + target, + complexSelectorParts, + position: sibling, + index, + }); } } return results; - }) - ); - } else { - // As of writing, there are no other combinators: - // - throw new Error(`Unrecognized combinator: '${combinator}'.`); - } + } else { + // As of writing, there are no other combinators: + // + throw new Error(`Unrecognized combinator: '${combinator}'.`); + } + }) + ); } - return deduplicateAndFilterToDescendants( - contextElement, - cursors.map((cursor) => cursor.target) - ); + // Map remaining cursors to their `target` and deduplicate. + return Array.from(new Set(cursors.map(({target}) => target))); }; export const QueryPatches = utils.getOwnPropertyDescriptors({ @@ -356,7 +372,7 @@ export const QueryPatches = utils.getOwnPropertyDescriptors({ * @param {string} selector */ querySelector(selector) { - return logicalQuerySelectorList(this, selector)[0] || null; + return logicalQuerySelectorAll(this, selector)[0] || null; }, /** @@ -378,7 +394,7 @@ export const QueryPatches = utils.getOwnPropertyDescriptors({ ); } return utils.createPolyfilledHTMLCollection( - logicalQuerySelectorList(this, selector) + logicalQuerySelectorAll(this, selector) ); }, }); diff --git a/packages/shadydom/src/patches/logicalQuerySelectorAll.md b/packages/shadydom/src/patches/logicalQuerySelectorAll.md new file mode 100644 index 000000000..518649489 --- /dev/null +++ b/packages/shadydom/src/patches/logicalQuerySelectorAll.md @@ -0,0 +1,204 @@ +`logicalQuerySelectorAll` emulates `querySelectorAll` within Shady DOM's logical +tree by implementing the tree-traversal portion of a selector engine. + +Consider querying for `a > b ~ c, d e` within this tree: + +``` + + + + + + + + + + + + + +``` + +First, the given selector list is split into complex selectors, which are each +split into compound selectors and combinators: + +`'a > b ~ c, d e'` → `['a > b ~ c', 'd e']` → + +``` +[{ + compoundSelectors: ['a', 'b', 'c'], + combinators: ['>', '~'], +}, { + compoundSelectors: ['d', 'e'], + combinators: [' '], +}] +``` + +Next, all exclusive descendants of the context element are tested against the +final compound selectors in each complex selector - in this example, `c` and +`e`. Any matches create an initial 'cursor', which tracks the progress of a +potential match. Cursors consist of a handful of properties: + +- `target`: the element that would be considered matching if the cursor + eventually results in a complete match + +- `complexSelectorParts`: the split complex selector which the cursor is + attempting to match + +- `index`: the index into `.complexSelectorParts.compoundSelectors` of the last + successfully matched compound selector + +- `position`: the element which successfully matched the compound selector at + `index` + +This initial walk through the descendants of the context element results in an +initial list of cursors with `target`s which are in _document order_. + +(The cursor 'properties' shown below are written in shorthand for compactness.) + +``` +cursors = [ + {target: #c_1, selector: 'a > b ~ c', index: 2, position: ...}, + {target: #e_1, selector: 'd e', index: 1, position: ...}, + {target: #c_2, selector: 'a > b ~ c', index: 2, position: ...}, + {target: #e_2, selector: 'd e', index: 1, position: ...}, +] +``` + +``` + + + + + + + + + + + + + +``` + +Next, the `position` and next combinator (iterating backwards) of each cursor +with `.index > 0` ('source' cursors) are used to determine the candidate +elements to test against that cursor's next compound selector. + +``` +cursors = [ + {target: #c_1, selector: 'a > b ~ c', index: 2, position: ...}, + {target: #e_1, selector: 'd e', index: 1, position: ...}, + {target: #c_2, selector: 'a > b ~ c', index: 2, position: ...}, + {target: #e_2, selector: 'd e', index: 1, position: ...}, +] +``` + +``` + + + + + + + + + + + + + +``` + +Candidates that do not match the next compound selector in the source cursor's +complex selector are filtered out. Those that do match result in a new cursor +being created with `position` set to the matching element and `index` +decremented by one, but with the same `target`, `complexSelectorParts` as the +source cursor. Then, source cursors are each replaced by any new cursors created +by their matching candidates. Specifically, all new cursors must maintain the +relative order of their source cursors so that their `target`s remain in +_document order_. Source cursors that had `.index === 0` remain in their +position in the list unchanged. + +``` +cursors = [ + {target: #c_1, selector: 'a > b ~ c', index: 1, position: ...}, + {target: #e_1, selector: 'd e', index: 0, position: ...}, + {target: #c_2, selector: 'a > b ~ c', index: 1, position: ...}, +] +``` + +``` + + + + + + + + + + + + + +``` + +This process repeats until all (potentially zero) remaining cursors have `.index === 0`. + +Again, candidates for cursors with `.index > 0` are selected. + +``` +cursors = [ + {target: #c_1, selector: 'a > b ~ c', index: 1, position: ...}, + {target: #e_1, selector: 'd e', index: 0, position: ...}, + {target: #c_2, selector: 'a > b ~ c', index: 1, position: ...}, +] +``` + +``` + + + + + + + + + + + + + +``` + +Again, the candidates are tested against their source cursors' next compound +selector to produce new cursors that replace the source cursors. + +``` +cursors = [ + {target: #c_1, selector: 'a > b ~ c', index: 0, position: ...}, + {target: #e_1, selector: 'd e', index: 0, position: ...}, +] +``` + +``` + + + + + + + + + + + + + +``` + +Once all remaining cursors have `.index === 0`, their `target`s are the set of +matching elements, in document order. This list is then deduplicated as a single +`target` element may match a single complex selector in many ways and may match +multiple complex selectors, which would result in multiple cursors. diff --git a/packages/shadydom/src/patches/logicalQuerySingleSelector.md b/packages/shadydom/src/patches/logicalQuerySingleSelector.md deleted file mode 100644 index 3cbf7ed98..000000000 --- a/packages/shadydom/src/patches/logicalQuerySingleSelector.md +++ /dev/null @@ -1,117 +0,0 @@ -`logicalQuerySingleSelector` emulates `querySelectorAll` for a single complex -selector within Shady DOM's logical tree by implementing the tree-traversal -portion of a selector engine. It does so by (a) finding all elements in the root -that match the final compound selector to create the initial set of 'cursors'; -(b) iterating backwards through the combinators and remaining compound -selectors, checking if the candidates implied by each combinator for each -cursor's `position` element would match the compound selector preceding them and -updating the cursors accordingly; and (c) deduplicating the set of `target` -elements of all remaining cursors and filtering out any that aren't exclusive -descendants of the context element. - -For example, consider querying for `a > b ~ c` within this tree: - -``` - - - - - - - - - - -``` - -First, all elements in the root are tested against the final compound selector -(`c`) to create the initial set of cursors: - -``` - - - - - - - - - - -``` - -Next, the combinators and the remaining compound selectors are iterated -backwards. Each combinator determines the set of candidate elements that must be -tested against the preceding compound selector. - -Moving backwards, the next combinator and compound selector in `a > b ~ c` are -`~` and `b`, so the next set of candidate elements are the preceding siblings of -the current cursors' `position` elements: - -``` - - - - - - - - - - -``` - -These candidates are filtered to those that match `b` and these matching -elements become the `position`s of the new set of cursors, with their `target` -set to the same `target` as the cursor for which they were previously a -candidate: - -``` - - - - - - - - - - -``` - -The process repeats again for the next combinator and compound selector: `>` and -`a`. First, the combinator (`>`) determines the candidates: - -``` - - - - - - - - - - -``` - -Then, the candidates are filtered by matching against the compound selector -(`a`), which determines the new cursors: - -``` - - - - - - - - - - -``` - -Once all combinators and compound selectors have been iterated, the remaining -cursors' `target` elements are known to match the given selector. These elements -are then deduplicated and filtered to remove any that are not exclusive -descendants of the context element. From 0b5467a2a484a493fe86ff07bf716337deff73c6 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Mon, 8 Aug 2022 16:29:34 -0700 Subject: [PATCH 21/76] Avoid `Set` iterable constructor and `Array.from` during deduplication for IE 11 compatibility. --- packages/shadydom/src/patches/ParentNode.js | 2 +- packages/shadydom/src/utils.js | 26 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 1e29de201..b02cc4518 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -362,7 +362,7 @@ const logicalQuerySelectorAll = (contextElement, selectorList) => { } // Map remaining cursors to their `target` and deduplicate. - return Array.from(new Set(cursors.map(({target}) => target))); + return utils.deduplicate(cursors.map(({target}) => target)); }; export const QueryPatches = utils.getOwnPropertyDescriptors({ diff --git a/packages/shadydom/src/utils.js b/packages/shadydom/src/utils.js index 55d4b94d4..3eef44944 100644 --- a/packages/shadydom/src/utils.js +++ b/packages/shadydom/src/utils.js @@ -316,3 +316,29 @@ export const flat = (array, depth = 1) => { return array; }; + +/** + * 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 + * an iterable. Further, some polyfills for `Array.from` effectively default to + * `Array.prototype.slice.call(...)` when they are unable to find + * `[Symbol.iterator]`; this is incompatible with `Set` which has no `length` or + * indexable properties. + * + * @template T + * @param {!Array} array + * @return {!Array} + */ +export const deduplicate = (array) => { + const results = []; + const set = new Set(); + for (const item of array) { + if (!set.has(item)) { + results.push(item); + set.add(item); + } + } + return results; +}; From 9fa797687d05f064341ccffe818c8fa004222543 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Mon, 8 Aug 2022 17:23:46 -0700 Subject: [PATCH 22/76] Add a new `querySelectorImplementation` setting. --- packages/shadydom/externs/shadydom.d.ts | 1 + packages/shadydom/src/patches/ParentNode.js | 60 ++++++++++++++++++--- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/shadydom/externs/shadydom.d.ts b/packages/shadydom/externs/shadydom.d.ts index 4bc596888..a3a273115 100644 --- a/packages/shadydom/externs/shadydom.d.ts +++ b/packages/shadydom/externs/shadydom.d.ts @@ -22,6 +22,7 @@ declare global { }; noPatch: boolean | string; patchElementProto: (node: Object) => void; + querySelectorImplementation?: 'native' | 'selectorEngine'; wrap: (node: Node) => Node; } diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index b02cc4518..07cf7692a 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -365,6 +365,9 @@ const logicalQuerySelectorAll = (contextElement, selectorList) => { return utils.deduplicate(cursors.map(({target}) => target)); }; +const querySelectorImplementation = + utils.settings['querySelectorImplementation']; + export const QueryPatches = utils.getOwnPropertyDescriptors({ // TODO(sorvell): consider doing native QSA and filtering results. /** @@ -372,7 +375,36 @@ export const QueryPatches = utils.getOwnPropertyDescriptors({ * @param {string} selector */ querySelector(selector) { - return logicalQuerySelectorAll(this, selector)[0] || null; + if (querySelectorImplementation === 'native') { + const candidates = Array.prototype.slice.call( + this[utils.NATIVE_PREFIX + 'querySelector'](selector) + ); + const root = this[utils.SHADY_PREFIX + 'getRootNode'](); + return utils.createPolyfilledHTMLCollection( + candidates.filter( + (e) => e[utils.SHADY_PREFIX + 'getRootNode']() == root + ) + ); + } else if (querySelectorImplementation === 'selectorEngine') { + return logicalQuerySelectorAll(this, selector)[0] || null; + } else if (querySelectorImplementation === undefined) { + // match selector and halt on first result. + let result = query( + this, + function (n) { + return utils.matchesSelector(n, selector); + }, + function (n) { + return Boolean(n); + } + )[0]; + return result || null; + } else { + throw new Error( + 'Unrecognized value of ShadyDOM.querySelectorImplementation: ' + + `'${querySelectorImplementation}'` + ); + } }, /** @@ -384,18 +416,32 @@ export const QueryPatches = utils.getOwnPropertyDescriptors({ // misses distributed nodes, see // https://github.com/webcomponents/shadydom/pull/210#issuecomment-361435503 querySelectorAll(selector, useNative) { - if (useNative) { - const o = Array.prototype.slice.call( + if (useNative || querySelectorImplementation === 'native') { + const candidates = Array.prototype.slice.call( this[utils.NATIVE_PREFIX + 'querySelectorAll'](selector) ); const root = this[utils.SHADY_PREFIX + 'getRootNode'](); return utils.createPolyfilledHTMLCollection( - o.filter((e) => e[utils.SHADY_PREFIX + 'getRootNode']() == root) + candidates.filter( + (e) => e[utils.SHADY_PREFIX + 'getRootNode']() == root + ) + ); + } else if (querySelectorImplementation === 'selectorEngine') { + return utils.createPolyfilledHTMLCollection( + logicalQuerySelectorAll(this, selector) + ); + } else if (querySelectorImplementation === undefined) { + return utils.createPolyfilledHTMLCollection( + query(this, function (n) { + return utils.matchesSelector(n, selector); + }) + ); + } else { + throw new Error( + 'Unrecognized value of ShadyDOM.querySelectorImplementation: ' + + `'${querySelectorImplementation}'` ); } - return utils.createPolyfilledHTMLCollection( - logicalQuerySelectorAll(this, selector) - ); }, }); From f8706be6e4f57799f5178d8f967d243e7b5e19ea Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Mon, 8 Aug 2022 17:39:22 -0700 Subject: [PATCH 23/76] Run all Shady DOM tests with `querySelectorImplementation === 'selectorEngine'`. --- packages/tests/shadydom/loader.js | 8 ++++++++ packages/tests/shadydom/runner.html | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/tests/shadydom/loader.js b/packages/tests/shadydom/loader.js index 6a03ef1b0..69c4cc363 100644 --- a/packages/tests/shadydom/loader.js +++ b/packages/tests/shadydom/loader.js @@ -6,6 +6,14 @@ ShadyDOM = { preferPerformance: !!window.location.search.match('preferPerformance'), }; +if ( + window.location.search.match('querySelectorImplementation=selectorEngine') +) { + ShadyDOM.querySelectorImplementation = 'selectorEngine'; +} else if (window.location.search.match('querySelectorImplementation=native')) { + ShadyDOM.querySelectorImplementation = 'native'; +} + // TODO(sorvell): noPatching does not work with the custom elements polyfill. // IF the polyfill used `ShadyDOM.wrap` throughout, it could be made to work. if (window.customElements) { diff --git a/packages/tests/shadydom/runner.html b/packages/tests/shadydom/runner.html index e60224af6..47f1177e5 100644 --- a/packages/tests/shadydom/runner.html +++ b/packages/tests/shadydom/runner.html @@ -74,7 +74,12 @@ 'filter-mutations.html', 'native-access.html', 'prefer-performance.html', - ]; + ].reduce((acc, item) => { + acc.push(item); + const joiner = item.indexOf('?') !== -1 ? '&' : '?'; + acc.push(item + joiner + 'querySelectorImplementation=selectorEngine'); + return acc; + }, []); // test only under native custom elements. if (window.customElements) { From 8feea2455b98fb88347b451a31308ed8f6380642 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 10 Aug 2022 15:00:36 -0700 Subject: [PATCH 24/76] Add docs for `querySelectorImplementation` and expose on the non-settings `ShadyDOM` global object. --- packages/shadydom/src/shadydom.js | 50 +++++++++++++++++++++++++++++++ packages/shadydom/src/utils.js | 8 +++++ 2 files changed, 58 insertions(+) diff --git a/packages/shadydom/src/shadydom.js b/packages/shadydom/src/shadydom.js index 8557ca55b..abffbef1c 100644 --- a/packages/shadydom/src/shadydom.js +++ b/packages/shadydom/src/shadydom.js @@ -118,6 +118,56 @@ 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 + // APIs it wraps doesn't match the structure of the tree seen by the browser + // (the 'real tree'), so the level to which the browser itself is used to + // match selectors (which always happens against the real tree) affects the + // accuracy and performance of these wrappers. + // + // - `undefined` (default): Shady DOM will walk the logical descendants of + // the context element and call `matches` with the given selector to find + // matching elements. + // + // This option is a balance between accuracy and performance. This option + // will be able to match all logical descendants of the context element + // (i.e. including descendants of unassigned nodes) but selectors are + // matched against the real tree, so complex selectors may fail to match + // since their combinators will attempt to match against the structure of + // the real tree. Generally, you should only pass compound selectors when + // using the default implementation. This implementation also breaks the + // semantics of `:scope` because the wrapped `querySelector` call is + // translated into many `matches` calls on different elements. + // + // - `'native'`: Shady DOM's wrapper for (e.g.) `querySelector` will call + // into the native `querySelector` function and then filter out results that + // are not in the same shadow root in the logical tree. + // + // This is the fastest option. This option will not match elements that + // are logical descendants of unassigned nodes because they will not be in + // the real tree, against which the browser matches the selector. Like the + // default option, this option may not correctly match complex selectors + // (i.e. those with combinators) due to the structural differences between + // the real tree and the logical tree. This option preserves the semantics + // of `:scope` because the context element of the call to the wrapper is + // also the context element of the call to the native function. + // + // - `'selectorEngine'`: Shady DOM's wrapper for (e.g.) `querySelector` will + // partially parse the given selector list and perform the tree-traversal + // steps of a selector engine against the logical tree. + // + // This is the slowest and most accurate option. This option is able to + // find all logical descendants of the context element by manually walking + // the tree. This option is also able to correctly match complex selectors + // because it does not rely on the browser to handle combinators. Despite + // using `matches` to match compound selectors during selector evaluation, + // this option preserves the semantics of `:scope` by only considering + // compound selectors containing `:scope` to be matching if the context + // element of the `matches` call is also the context element of the + // wrapper call. (Note that this implementation detects `:scope` by simple + // substring inclusion.) + 'querySelectorImplementation': + utils.settings['querySelectorImplementation'], }; window['ShadyDOM'] = ShadyDOM; diff --git a/packages/shadydom/src/utils.js b/packages/shadydom/src/utils.js index 3eef44944..bdb02022d 100644 --- a/packages/shadydom/src/utils.js +++ b/packages/shadydom/src/utils.js @@ -36,6 +36,14 @@ settings.noPatch = /** @type {string|boolean} */ (settings['noPatch'] || false); // eslint-disable-next-line no-self-assign settings.preferPerformance = settings['preferPerformance']; settings.patchOnDemand = settings.noPatch === 'on-demand'; +settings.querySelectorImplementation = (() => { + const acceptedValues = ['native', 'selectorEngine']; + const userValue = settings['querySelectorImplementation']; + if (acceptedValues.indexOf(userValue) > -1) { + return userValue; + } + return undefined; +})(); const IS_IE = navigator.userAgent.match('Trident'); settings.IS_IE = IS_IE; From b4cd3606b165bed0f8ffe05337cc1b1130f8764c Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 10 Aug 2022 15:14:42 -0700 Subject: [PATCH 25/76] Remove an unnecessary `filter` and some wrapper arrays. --- packages/shadydom/src/patches/ParentNode.js | 50 +++++++++------------ 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 07cf7692a..60f0eeb56 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -156,8 +156,8 @@ const logicalQuerySelectorAll = (contextElement, selectorList) => { /** * @type {!Array} */ - const complexSelectors = extractSelectors(selectorList) - .map((complexSelector) => { + const complexSelectors = extractSelectors(selectorList).map( + (complexSelector) => { const { 'selectors': compoundSelectors, 'joiners': combinators, @@ -167,8 +167,8 @@ const logicalQuerySelectorAll = (contextElement, selectorList) => { compoundSelectors, combinators, }; - }) - .filter(({compoundSelectors}) => compoundSelectors.length > 0); + } + ); if (complexSelectors.length < 1) { return []; @@ -228,14 +228,12 @@ const logicalQuerySelectorAll = (contextElement, selectorList) => { const {compoundSelectors} = complexSelectorParts; const index = compoundSelectors.length - 1; if (matchesCompoundSelector(element, compoundSelectors[index])) { - return [ - { - target: element, - complexSelectorParts, - position: element, - index, - }, - ]; + return { + target: element, + complexSelectorParts, + position: element, + index, + }; } else { return []; } @@ -299,14 +297,12 @@ const logicalQuerySelectorAll = (contextElement, selectorList) => { parent instanceof Element && matchesCompoundSelector(parent, compoundSelector) ) { - return [ - { - target, - complexSelectorParts, - position: parent, - index, - }, - ]; + return { + target, + complexSelectorParts, + position: parent, + index, + }; } return []; @@ -318,14 +314,12 @@ const logicalQuerySelectorAll = (contextElement, selectorList) => { // the candidates to test against `a` are the immediately preceding // siblings of each cursor's `position`. if (sibling && matchesCompoundSelector(sibling, compoundSelector)) { - return [ - { - target, - complexSelectorParts, - position: sibling, - index, - }, - ]; + return { + target, + complexSelectorParts, + position: sibling, + index, + }; } return []; From bf48a44af1fbc5af59be8b37de61ff9eb9570a38 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 10 Aug 2022 15:19:25 -0700 Subject: [PATCH 26/76] `Array::some` always returns `false` when the array is empty. --- packages/shadydom/src/patches/ParentNode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shadydom/src/patches/ParentNode.js b/packages/shadydom/src/patches/ParentNode.js index 60f0eeb56..4b398e3da 100644 --- a/packages/shadydom/src/patches/ParentNode.js +++ b/packages/shadydom/src/patches/ParentNode.js @@ -245,7 +245,7 @@ const logicalQuerySelectorAll = (contextElement, selectorList) => { // At each step, any remaining cursors that have not finished matching (i.e. // with `cursor.index > 0`) should be replaced with new cursors for any valid // candidates that match the next compound selector. - while (cursors.length > 0 && cursors.some((cursor) => cursor.index > 0)) { + while (cursors.some((cursor) => cursor.index > 0)) { cursors = utils.flat( cursors.map((cursor) => { // Cursors with `index` of 0 have already matched and should not be From 0cc92da49281741d625017682ffbbcc6fc7da7ff Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 10 Aug 2022 16:52:25 -0700 Subject: [PATCH 27/76] Test that `>` works across shadow root boundaries. --- packages/tests/shadydom/shady-dynamic.html | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/tests/shadydom/shady-dynamic.html b/packages/tests/shadydom/shady-dynamic.html index f618f592d..ed0c7d9e0 100644 --- a/packages/tests/shadydom/shady-dynamic.html +++ b/packages/tests/shadydom/shady-dynamic.html @@ -358,6 +358,15 @@ }); + + + From 712d1166835e3c75ebda7d29373e4c9ee84f8477 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 10 Aug 2022 17:06:34 -0700 Subject: [PATCH 28/76] Test that the semantics of `:scope` are preserved. --- packages/tests/shadydom/shady-dynamic.html | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/tests/shadydom/shady-dynamic.html b/packages/tests/shadydom/shady-dynamic.html index ed0c7d9e0..421b0264c 100644 --- a/packages/tests/shadydom/shady-dynamic.html +++ b/packages/tests/shadydom/shady-dynamic.html @@ -367,6 +367,17 @@ defineTestElement('x-slot-inside-div'); + + + From 8ff99b7d01c13bb71a6087308f2784ffa8fe878c Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Wed, 10 Aug 2022 17:29:02 -0700 Subject: [PATCH 29/76] Add tests for combinators in scenarios without shadow trees or `:scope`. --- packages/tests/shadydom/shady-dynamic.html | 141 +++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/packages/tests/shadydom/shady-dynamic.html b/packages/tests/shadydom/shady-dynamic.html index 421b0264c..fbb503245 100644 --- a/packages/tests/shadydom/shady-dynamic.html +++ b/packages/tests/shadydom/shady-dynamic.html @@ -358,6 +358,81 @@ }); + + + + + + + + + + + +