From 441874e331425c3d6c025043857e370746535ba2 Mon Sep 17 00:00:00 2001 From: John-David Dalton Date: Fri, 4 Sep 2026 13:59:54 -0400 Subject: [PATCH] Read the last token of a selector that ends in a nested pseudo-class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before testing candidates, collect() asks reOptimizer for the last simple token of a selector and uses it to fetch the candidates by tag, class or id. The parenthesized part of that pattern is '\x28[^\x29]+(?:\x29|$)', which stops at the first ')', so a final compound holding a nested functional pseudo-class does not match at all — and a selector the optimizer cannot read is answered by walking every element in the context. 'div:not(:nth-of-type(2n))' therefore tests every element in the document instead of the divs, and since ':not()' evaluates its argument through s.match() per element, each of those elements resolves nth-of-type. On a 6300-element page that is 6344 resolutions building 3911 sibling caches over 196312 steps, for a selector whose subject is a div. The parenthesized part now tolerates two levels of nesting, which reaches ':not(:not(:not(span)))'. Deeper than that falls back to the unoptimized scan, as before. Both the old and new patterns stay linear on unbalanced input: 3200 unclosed parentheses match in 0.02ms. div:not(:nth-of-type(2n)) 45.62ms -> 134.92us 338x div:not(:nth-child(3)) 9.42ms -> 123.94us 76x div:is(.example):not(:where(.x)) 2.65ms -> 39.45us 67x div:not(.x) 27.93us -> 27.75us - Results are unchanged; the four above agree with the native engine. References: - Spec: https://drafts.csswg.org/css-syntax/#consume-simple-block — why a parenthesized part has to tolerate nesting - Spec: https://drafts.csswg.org/selectors-4/#matches — the functional pseudo-classes that put parentheses inside a compound --- docs/api.md | 62 ++++++++++++++++----------------- package.json | 3 +- src/nwsapi.mts | 14 ++++++-- test/optimizer-nesting.md | 11 ++++++ test/optimizer-nesting.test.mts | 47 +++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 34 deletions(-) create mode 100644 test/optimizer-nesting.md create mode 100644 test/optimizer-nesting.test.mts diff --git a/docs/api.md b/docs/api.md index 7821e79..d6d968c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,21 +11,21 @@ Query contexts default to the factory document when omitted. `closest()`, `first | Method | Result | | --- | --- | -| [`byClass(cls, context)`](../src/nwsapi.mts#L2972) | Returns elements with the class name. | -| [`byId(id, context)`](../src/nwsapi.mts#L2970) | Returns elements with the ID. Duplicate IDs are allowed by default. | -| [`byTag(tag, context)`](../src/nwsapi.mts#L2971) | Returns elements with the tag name. Use `*` for all elements. | -| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L2978) | Returns the nearest match, starting with the element, or `null`. | -| [`compile(selector, mode, callback)`](../src/nwsapi.mts#L2980) | Compiles a selector into a resolver function. This is an advanced API. | -| [`configure(option, clear)`](../src/nwsapi.mts#L2981) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. | -| [`emit(message, proto)`](../src/nwsapi.mts#L2983) | Reports an error using the configured error policy. | -| [`first(selectors, context, callback)`](../src/nwsapi.mts#L2974) | Returns the first matching descendant, or `null`. | -| [`install(all)`](../src/nwsapi.mts#L2989) | Replaces native selector methods. Pass `true` to also replace collection methods. | -| [`match(selectors, element, callback)`](../src/nwsapi.mts#L2975) | Returns whether the element matches. | -| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L2996) | Adds a relationship between elements using trusted resolver code. | -| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3021) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. | -| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3043) | Adds a selector pattern and a compiler callback that returns `source` and `status`. | -| [`select(selectors, context, callback)`](../src/nwsapi.mts#L2976) | Returns an array of matching descendants, or an empty array. | -| [`uninstall()`](../src/nwsapi.mts#L2990) | Restores the native methods saved by `install()`. | +| [`byClass(cls, context)`](../src/nwsapi.mts#L2982) | Returns elements with the class name. | +| [`byId(id, context)`](../src/nwsapi.mts#L2980) | Returns elements with the ID. Duplicate IDs are allowed by default. | +| [`byTag(tag, context)`](../src/nwsapi.mts#L2981) | Returns elements with the tag name. Use `*` for all elements. | +| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L2988) | Returns the nearest match, starting with the element, or `null`. | +| [`compile(selector, mode, callback)`](../src/nwsapi.mts#L2990) | Compiles a selector into a resolver function. This is an advanced API. | +| [`configure(option, clear)`](../src/nwsapi.mts#L2991) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. | +| [`emit(message, proto)`](../src/nwsapi.mts#L2993) | Reports an error using the configured error policy. | +| [`first(selectors, context, callback)`](../src/nwsapi.mts#L2984) | Returns the first matching descendant, or `null`. | +| [`install(all)`](../src/nwsapi.mts#L2999) | Replaces native selector methods. Pass `true` to also replace collection methods. | +| [`match(selectors, element, callback)`](../src/nwsapi.mts#L2985) | Returns whether the element matches. | +| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3006) | Adds a relationship between elements using trusted resolver code. | +| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3031) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. | +| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3053) | Adds a selector pattern and a compiler callback that returns `source` and `status`. | +| [`select(selectors, context, callback)`](../src/nwsapi.mts#L2986) | Returns an array of matching descendants, or an empty array. | +| [`uninstall()`](../src/nwsapi.mts#L3000) | Restores the native methods saved by `install()`. |
Configuration @@ -58,22 +58,22 @@ These exports support extensions and debugging. Prefer query methods and `config | Member | Purpose | | --- | --- | -| [`CFG`](../src/nwsapi.mts#L2958) | Contains the compiler syntax settings. | -| [`Config`](../src/nwsapi.mts#L2984) | Contains the active options. Use `configure()` to change them. | -| [`M_BODY`](../src/nwsapi.mts#L2961) | Contains the matching resolver body template. | -| [`M_TEST`](../src/nwsapi.mts#L2965) | Contains the matching resolver test template. | -| [`matchLambdas`](../src/nwsapi.mts#L2950) | Caches compiled matching functions, not DOM results. | -| [`matchResolvers`](../src/nwsapi.mts#L2953) | Caches matching plans, not DOM results. | -| [`N_BODY`](../src/nwsapi.mts#L2962) | Exposes the matching resolver body template. | -| [`N_TEST`](../src/nwsapi.mts#L2966) | Contains the alternate resolver test template. | -| [`Operators`](../src/nwsapi.mts#L2992) | Contains registered attribute operators. | -| [`S_BODY`](../src/nwsapi.mts#L2960) | Contains the selection resolver body template. | -| [`S_TEST`](../src/nwsapi.mts#L2964) | Contains the selection resolver test template. | -| [`selectLambdas`](../src/nwsapi.mts#L2951) | Caches compiled selection functions, not DOM results. | -| [`Selectors`](../src/nwsapi.mts#L2993) | Contains registered selector extensions. | -| [`selectResolvers`](../src/nwsapi.mts#L2954) | Caches selection plans, not DOM results. | -| [`Snapshot`](../src/nwsapi.mts#L2985) | Contains the document state and helpers used by compiled selectors. | -| [`Version`](../src/nwsapi.mts#L2987) | Contains the engine version string. | +| [`CFG`](../src/nwsapi.mts#L2968) | Contains the compiler syntax settings. | +| [`Config`](../src/nwsapi.mts#L2994) | Contains the active options. Use `configure()` to change them. | +| [`M_BODY`](../src/nwsapi.mts#L2971) | Contains the matching resolver body template. | +| [`M_TEST`](../src/nwsapi.mts#L2975) | Contains the matching resolver test template. | +| [`matchLambdas`](../src/nwsapi.mts#L2960) | Caches compiled matching functions, not DOM results. | +| [`matchResolvers`](../src/nwsapi.mts#L2963) | Caches matching plans, not DOM results. | +| [`N_BODY`](../src/nwsapi.mts#L2972) | Exposes the matching resolver body template. | +| [`N_TEST`](../src/nwsapi.mts#L2976) | Contains the alternate resolver test template. | +| [`Operators`](../src/nwsapi.mts#L3002) | Contains registered attribute operators. | +| [`S_BODY`](../src/nwsapi.mts#L2970) | Contains the selection resolver body template. | +| [`S_TEST`](../src/nwsapi.mts#L2974) | Contains the selection resolver test template. | +| [`selectLambdas`](../src/nwsapi.mts#L2961) | Caches compiled selection functions, not DOM results. | +| [`Selectors`](../src/nwsapi.mts#L3003) | Contains registered selector extensions. | +| [`selectResolvers`](../src/nwsapi.mts#L2964) | Caches selection plans, not DOM results. | +| [`Snapshot`](../src/nwsapi.mts#L2995) | Contains the document state and helpers used by compiled selectors. | +| [`Version`](../src/nwsapi.mts#L2997) | Contains the engine version string. |
diff --git a/package.json b/package.json index 24e76b6..979b3ab 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "upstream:verify": "node scripts/run.mts scripts/git-partial-submodule.mts verify", "test:attributes": "node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts test/attribute-parse-error.test.mts", "test:attributes:browser": "NWSAPI_BROWSER=1 node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts test/attribute-strings-browser.test.mts", - "test:browser": "NWSAPI_BROWSER=1 node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts" + "test:browser": "NWSAPI_BROWSER=1 node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts", + "test:optimizer": "node scripts/run.mts node_modules/vitest/vitest.mjs run --config .config/vitest.config.mts test/optimizer-nesting.test.mts" } } diff --git a/src/nwsapi.mts b/src/nwsapi.mts index da9a1bb..270b5ef 100644 --- a/src/nwsapi.mts +++ b/src/nwsapi.mts @@ -1212,7 +1212,8 @@ // pairs, coloring breakage and other editors highlightning problems. // - var // non-ascii chars + var parenthesized, + // non-ascii chars noascii = '[^\\x00-\\x9f]', // unicode chars unicode = '\\\\[0-9a-fA-F]{1,6}', @@ -1348,6 +1349,15 @@ // deepest localName in selector strings and then // use it to retrieve all possible matching nodes // that will be filtered by compiled resolvers + // The parenthesized part has to tolerate nesting. Written as + // '\x28[^\x29]+' it stops at the first ')', so a final compound + // holding a nested functional pseudo-class matches nothing at all, and + // a selector the optimizer cannot read is answered by testing every + // element in the context instead of the elements of one tag or class. + parenthesized = '\\x28[^\\x28\\x29]*(?:\\x29|$)' + parenthesized = '\\x28(?:[^\\x28\\x29]|' + parenthesized + ')*(?:\\x29|$)' + parenthesized = '\\x28(?:[^\\x28\\x29]|' + parenthesized + ')*(?:\\x29|$)' + reOptimizer = RegExp( '(?:([.:#*]?)' + '(' + @@ -1356,7 +1366,7 @@ '(?:' + ':[-\\w]+|' + '\\[[^\\]]+(?:\\]|$)|' + - '\\x28[^\\x29]+(?:\\x29|$)' + + parenthesized + ')*)$', ) diff --git a/test/optimizer-nesting.md b/test/optimizer-nesting.md new file mode 100644 index 0000000..ec86d48 --- /dev/null +++ b/test/optimizer-nesting.md @@ -0,0 +1,11 @@ +# Nested optimizer regression + +Run with Node.js ≥ 22: + +```sh +pnpm install +pnpm run test:optimizer +``` + +The tests check strict factory initialization and nested selector results, +including cached queries. jsdom supplies an independent reference engine. diff --git a/test/optimizer-nesting.test.mts b/test/optimizer-nesting.test.mts new file mode 100644 index 0000000..ef8a2fa --- /dev/null +++ b/test/optimizer-nesting.test.mts @@ -0,0 +1,47 @@ +const __dirname = import.meta.dirname +import { createRequire } from 'node:module' +const require = createRequire(import.meta.url) +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +import { test } from 'vitest' +const vm = require('node:vm') +const { JSDOM } = require('jsdom') +const source = readFileSync(join(__dirname, '../src/nwsapi.js'), 'utf8') + +test('strict factory initialization does not leak parser variables', () => { + const context = { + module: { exports: {} as (host: unknown) => unknown }, + exports: {}, + } + vm.runInNewContext('"use strict";\n' + source, context) + const { window } = new JSDOM('
') + try { + assert.doesNotThrow(() => context.module.exports(window)) + assert.equal(Object.hasOwn(context, 'parenthesized'), false) + } finally { + window.close() + } +}) + +for (const selector of [ + 'div:not(:nth-of-type(2n))', + 'div:not(:nth-child(3))', + 'div:is(.a):not(:where(.b))', + 'div:not(:not(:not(span)))', + 'div:has(:is(.a .b))', +]) { + test(selector, () => { + const { window } = new JSDOM( + '
', + ) + try { + const nw = require('../src/nwsapi')(window) + const expected = [...window.document.querySelectorAll(selector)] + assert.deepEqual(nw.select(selector), expected) + assert.deepEqual(nw.select(selector), expected, 'cached selection') + } finally { + window.close() + } + }) +}