diff --git a/docs/api.md b/docs/api.md index 7e87a53..73095f4 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#L3747) | Returns elements with the class name. | -| [`byId(id, context)`](../src/nwsapi.mts#L3745) | Returns elements with the ID. Duplicate IDs are allowed by default. | -| [`byTag(tag, context)`](../src/nwsapi.mts#L3746) | Returns elements with the tag name. Use `*` for all elements. | -| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3753) | Returns the nearest match, starting with the element, or `null`. | -| [`compile(selector, mode, callback, relative)`](../src/nwsapi.mts#L3755) | Compiles a selector into a resolver function. This is an advanced API. | -| [`configure(option, clear)`](../src/nwsapi.mts#L3756) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. | -| [`emit(message, proto)`](../src/nwsapi.mts#L3758) | Reports an error using the configured error policy. | -| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3749) | Returns the first matching descendant, or `null`. | -| [`install(all)`](../src/nwsapi.mts#L3764) | Replaces native selector methods. Pass `true` to also replace collection methods. | -| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3750) | Returns whether the element matches. | -| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3771) | Adds a relationship between elements using trusted resolver code. | -| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3796) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. | -| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3818) | Adds a selector pattern and a compiler callback that returns `source` and `status`. | -| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3751) | Returns an array of matching descendants, or an empty array. | -| [`uninstall()`](../src/nwsapi.mts#L3765) | Restores the native methods saved by `install()`. | +| [`byClass(cls, context)`](../src/nwsapi.mts#L3774) | Returns elements with the class name. | +| [`byId(id, context)`](../src/nwsapi.mts#L3772) | Returns elements with the ID. Duplicate IDs are allowed by default. | +| [`byTag(tag, context)`](../src/nwsapi.mts#L3773) | Returns elements with the tag name. Use `*` for all elements. | +| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3780) | Returns the nearest match, starting with the element, or `null`. | +| [`compile(selector, mode, callback, relative)`](../src/nwsapi.mts#L3782) | Compiles a selector into a resolver function. This is an advanced API. | +| [`configure(option, clear)`](../src/nwsapi.mts#L3783) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. | +| [`emit(message, proto)`](../src/nwsapi.mts#L3785) | Reports an error using the configured error policy. | +| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3776) | Returns the first matching descendant, or `null`. | +| [`install(all)`](../src/nwsapi.mts#L3791) | Replaces native selector methods. Pass `true` to also replace collection methods. | +| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3777) | Returns whether the element matches. | +| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3798) | Adds a relationship between elements using trusted resolver code. | +| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3823) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. | +| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3845) | Adds a selector pattern and a compiler callback that returns `source` and `status`. | +| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3778) | Returns an array of matching descendants, or an empty array. | +| [`uninstall()`](../src/nwsapi.mts#L3792) | Restores the native methods saved by `install()`. |
Configuration @@ -60,22 +60,22 @@ These exports support extensions and debugging. Prefer query methods and `config | Member | Purpose | | --- | --- | -| [`CFG`](../src/nwsapi.mts#L3733) | Contains the compiler syntax settings. | -| [`Config`](../src/nwsapi.mts#L3759) | Contains the active options. Use `configure()` to change them. | -| [`M_BODY`](../src/nwsapi.mts#L3736) | Contains the matching resolver body template. | -| [`M_TEST`](../src/nwsapi.mts#L3740) | Contains the matching resolver test template. | -| [`matchLambdas`](../src/nwsapi.mts#L3725) | Caches compiled matching functions, not DOM results. | -| [`matchResolvers`](../src/nwsapi.mts#L3728) | Caches matching plans, not DOM results. | -| [`N_BODY`](../src/nwsapi.mts#L3737) | Exposes the matching resolver body template. | -| [`N_TEST`](../src/nwsapi.mts#L3741) | Contains the alternate resolver test template. | -| [`Operators`](../src/nwsapi.mts#L3767) | Contains registered attribute operators. | -| [`S_BODY`](../src/nwsapi.mts#L3735) | Contains the selection resolver body template. | -| [`S_TEST`](../src/nwsapi.mts#L3739) | Contains the selection resolver test template. | -| [`selectLambdas`](../src/nwsapi.mts#L3726) | Caches compiled selection functions, not DOM results. | -| [`Selectors`](../src/nwsapi.mts#L3768) | Contains registered selector extensions. | -| [`selectResolvers`](../src/nwsapi.mts#L3729) | Caches selection plans, not DOM results. | -| [`Snapshot`](../src/nwsapi.mts#L3760) | Contains the document state and helpers used by compiled selectors. | -| [`Version`](../src/nwsapi.mts#L3762) | Contains the engine version string. | +| [`CFG`](../src/nwsapi.mts#L3760) | Contains the compiler syntax settings. | +| [`Config`](../src/nwsapi.mts#L3786) | Contains the active options. Use `configure()` to change them. | +| [`M_BODY`](../src/nwsapi.mts#L3763) | Contains the matching resolver body template. | +| [`M_TEST`](../src/nwsapi.mts#L3767) | Contains the matching resolver test template. | +| [`matchLambdas`](../src/nwsapi.mts#L3752) | Caches compiled matching functions, not DOM results. | +| [`matchResolvers`](../src/nwsapi.mts#L3755) | Caches matching plans, not DOM results. | +| [`N_BODY`](../src/nwsapi.mts#L3764) | Exposes the matching resolver body template. | +| [`N_TEST`](../src/nwsapi.mts#L3768) | Contains the alternate resolver test template. | +| [`Operators`](../src/nwsapi.mts#L3794) | Contains registered attribute operators. | +| [`S_BODY`](../src/nwsapi.mts#L3762) | Contains the selection resolver body template. | +| [`S_TEST`](../src/nwsapi.mts#L3766) | Contains the selection resolver test template. | +| [`selectLambdas`](../src/nwsapi.mts#L3753) | Caches compiled selection functions, not DOM results. | +| [`Selectors`](../src/nwsapi.mts#L3795) | Contains registered selector extensions. | +| [`selectResolvers`](../src/nwsapi.mts#L3756) | Caches selection plans, not DOM results. | +| [`Snapshot`](../src/nwsapi.mts#L3787) | Contains the document state and helpers used by compiled selectors. | +| [`Version`](../src/nwsapi.mts#L3789) | Contains the engine version string. |
diff --git a/src/nwsapi.mts b/src/nwsapi.mts index d66f364..2d4efbc 100644 --- a/src/nwsapi.mts +++ b/src/nwsapi.mts @@ -2179,7 +2179,7 @@ loop += mode || mode === null ? '{' + source + '}' : source - if (mode || (mode === null && selector.includes(':nth'))) { + if (mode || selector.includes(':nth')) { loop += reNthElem.test(selector) ? 's.nthElement(null, 2);' : '' loop += reNthType.test(selector) ? 's.nthOfType(null, 2);' : '' } @@ -2587,6 +2587,33 @@ : 'n>' + (b - 1) : 'false' } + // A constant index needs no index. nth(Element|OfType) + // builds the sibling list of the parent to number the + // element within it, which is the right trade for an an+b + // form that has to know where the element sits, and pure + // overhead for ':nth-child(3)', which only has to know + // whether three steps back runs out of siblings. + // + // Only for the -child forms: of-type has to compare the + // name of every sibling it steps over, and reading + // localName through the host on each one costs more than + // the list it avoids. + if (test == 'n==' + a && a >= 1 && !expr) { + test = type ? 'next' : 'previous' + source = + 'n=1,o=e;' + + 'while(n<=' + + a + + '&&(o=o.' + + test + + 'ElementSibling))++n;' + + 'if(n==' + + a + + '){' + + source + + '}' + break + } expr = expr ? 'OfType' : 'Element' type = type ? 'true' : 'false' source = diff --git a/test/repo/e2e/upstream/fixtures/nth-constant.html b/test/repo/e2e/upstream/fixtures/nth-constant.html new file mode 100644 index 0000000..3e3876a --- /dev/null +++ b/test/repo/e2e/upstream/fixtures/nth-constant.html @@ -0,0 +1,35 @@ + + +Constant sibling indexes + + + + diff --git a/test/repo/e2e/upstream/manifest.mts b/test/repo/e2e/upstream/manifest.mts index 9318151..2ad6639 100644 --- a/test/repo/e2e/upstream/manifest.mts +++ b/test/repo/e2e/upstream/manifest.mts @@ -36,6 +36,11 @@ export const manifest: Array<{ install?: boolean legacyMap?: boolean }> = [ + { + path: '/_repo/test/repo/e2e/upstream/fixtures/nth-constant.html', + note: 'local WPT regression: constant sibling indexes agree with native queries after mutation', + install: false, + }, { path: '/_repo/test/repo/e2e/upstream/fixtures/legacy-dom.html', note: 'local WPT regression: legacy host reads agree with native selectors and refresh after mutations', diff --git a/test/repo/unit/nth-constant.test.mts b/test/repo/unit/nth-constant.test.mts new file mode 100644 index 0000000..2395673 --- /dev/null +++ b/test/repo/unit/nth-constant.test.mts @@ -0,0 +1,71 @@ +import { JSDOM } from 'jsdom' +import { expect, test } from 'vitest' +import factory from '../../../src/nwsapi.js' + +for (const pseudo of [ + 'nth-child', + 'nth-last-child', + 'nth-of-type', + 'nth-last-of-type', +]) { + test(`${pseudo} preserves constant and formula results after mutations`, t => { + const { window } = new JSDOM( + '
text
', + { + url: 'https://example.test/', + }, + ) + t.onTestFinished(() => window.close()) + const { document } = window + const engine = factory(window) + const parent = document.querySelector('main')! + for (let round = 0; round < 3; round++) { + for (const index of [ + '0', + '-1', + '1', + '2', + '3', + '4', + '5', + '0n+3', + '2n', + 'n+3', + ]) { + const selector = `:${pseudo}(${index})` + const elements = [...parent.children] + const expected = elements.filter(element => { + const siblings = pseudo.includes('of-type') + ? elements.filter( + sibling => sibling.localName === element.localName, + ) + : elements + const position = pseudo.includes('last') + ? siblings.length - siblings.indexOf(element) + : siblings.indexOf(element) + 1 + return index === '2n' + ? position % 2 === 0 + : index === 'n+3' + ? position >= 3 + : position === (index === '0n+3' ? 3 : Number(index)) + }) + expect( + engine.select(selector, parent), + `${selector}, round ${round}`, + ).toEqual(expected) + expect(engine.first(selector, parent)).toBe(expected[0] ?? null) + for (const element of parent.children) { + expect(engine.match(selector, element)).toBe( + expected.includes(element), + ) + } + } + parent.prepend(document.createElement('i')) + parent.lastElementChild!.remove() + parent.remove() + } + const orphan = document.createElement('i') + expect(engine.match(`:${pseudo}(1)`, orphan)).toBe(true) + expect(engine.match(`:${pseudo}(2)`, orphan)).toBe(false) + }) +}