diff --git a/docs/api.md b/docs/api.md index 19f1942..2c1f934 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#L3855) | Returns elements with the class name. | -| [`byId(id, context)`](../src/nwsapi.mts#L3853) | Returns elements with the ID. Duplicate IDs are allowed by default. | -| [`byTag(tag, context)`](../src/nwsapi.mts#L3854) | Returns elements with the tag name. Use `*` for all elements. | -| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3861) | Returns the nearest match, starting with the element, or `null`. | -| [`compile(selector, mode, callback, relative)`](../src/nwsapi.mts#L3863) | Compiles a selector into a resolver function. This is an advanced API. | -| [`configure(option, clear)`](../src/nwsapi.mts#L3864) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. | -| [`emit(message, proto)`](../src/nwsapi.mts#L3866) | Reports an error using the configured error policy. | -| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3857) | Returns the first matching descendant, or `null`. | -| [`install(all)`](../src/nwsapi.mts#L3872) | Replaces native selector methods. Pass `true` to also replace collection methods. | -| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3858) | Returns whether the element matches. | -| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3879) | Adds a relationship between elements using trusted resolver code. | -| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3904) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. | -| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3926) | Adds a selector pattern and a compiler callback that returns `source` and `status`. | -| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3859) | Returns an array of matching descendants, or an empty array. | -| [`uninstall()`](../src/nwsapi.mts#L3873) | Restores the native methods saved by `install()`. | +| [`byClass(cls, context)`](../src/nwsapi.mts#L3859) | Returns elements with the class name. | +| [`byId(id, context)`](../src/nwsapi.mts#L3857) | Returns elements with the ID. Duplicate IDs are allowed by default. | +| [`byTag(tag, context)`](../src/nwsapi.mts#L3858) | Returns elements with the tag name. Use `*` for all elements. | +| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3865) | Returns the nearest match, starting with the element, or `null`. | +| [`compile(selector, mode, callback, relative)`](../src/nwsapi.mts#L3867) | Compiles a selector into a resolver function. This is an advanced API. | +| [`configure(option, clear)`](../src/nwsapi.mts#L3868) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. | +| [`emit(message, proto)`](../src/nwsapi.mts#L3870) | Reports an error using the configured error policy. | +| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3861) | Returns the first matching descendant, or `null`. | +| [`install(all)`](../src/nwsapi.mts#L3876) | Replaces native selector methods. Pass `true` to also replace collection methods. | +| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3862) | Returns whether the element matches. | +| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3883) | Adds a relationship between elements using trusted resolver code. | +| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3908) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. | +| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3930) | Adds a selector pattern and a compiler callback that returns `source` and `status`. | +| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3863) | Returns an array of matching descendants, or an empty array. | +| [`uninstall()`](../src/nwsapi.mts#L3877) | 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#L3841) | Contains the compiler syntax settings. | -| [`Config`](../src/nwsapi.mts#L3867) | Contains the active options. Use `configure()` to change them. | -| [`M_BODY`](../src/nwsapi.mts#L3844) | Contains the matching resolver body template. | -| [`M_TEST`](../src/nwsapi.mts#L3848) | Contains the matching resolver test template. | -| [`matchLambdas`](../src/nwsapi.mts#L3833) | Caches compiled matching functions, not DOM results. | -| [`matchResolvers`](../src/nwsapi.mts#L3836) | Caches matching plans, not DOM results. | -| [`N_BODY`](../src/nwsapi.mts#L3845) | Exposes the matching resolver body template. | -| [`N_TEST`](../src/nwsapi.mts#L3849) | Contains the alternate resolver test template. | -| [`Operators`](../src/nwsapi.mts#L3875) | Contains registered attribute operators. | -| [`S_BODY`](../src/nwsapi.mts#L3843) | Contains the selection resolver body template. | -| [`S_TEST`](../src/nwsapi.mts#L3847) | Contains the selection resolver test template. | -| [`selectLambdas`](../src/nwsapi.mts#L3834) | Caches compiled selection functions, not DOM results. | -| [`Selectors`](../src/nwsapi.mts#L3876) | Contains registered selector extensions. | -| [`selectResolvers`](../src/nwsapi.mts#L3837) | Caches selection plans, not DOM results. | -| [`Snapshot`](../src/nwsapi.mts#L3868) | Contains the document state and helpers used by compiled selectors. | -| [`Version`](../src/nwsapi.mts#L3870) | Contains the engine version string. | +| [`CFG`](../src/nwsapi.mts#L3845) | Contains the compiler syntax settings. | +| [`Config`](../src/nwsapi.mts#L3871) | Contains the active options. Use `configure()` to change them. | +| [`M_BODY`](../src/nwsapi.mts#L3848) | Contains the matching resolver body template. | +| [`M_TEST`](../src/nwsapi.mts#L3852) | Contains the matching resolver test template. | +| [`matchLambdas`](../src/nwsapi.mts#L3837) | Caches compiled matching functions, not DOM results. | +| [`matchResolvers`](../src/nwsapi.mts#L3840) | Caches matching plans, not DOM results. | +| [`N_BODY`](../src/nwsapi.mts#L3849) | Exposes the matching resolver body template. | +| [`N_TEST`](../src/nwsapi.mts#L3853) | Contains the alternate resolver test template. | +| [`Operators`](../src/nwsapi.mts#L3879) | Contains registered attribute operators. | +| [`S_BODY`](../src/nwsapi.mts#L3847) | Contains the selection resolver body template. | +| [`S_TEST`](../src/nwsapi.mts#L3851) | Contains the selection resolver test template. | +| [`selectLambdas`](../src/nwsapi.mts#L3838) | Caches compiled selection functions, not DOM results. | +| [`Selectors`](../src/nwsapi.mts#L3880) | Contains registered selector extensions. | +| [`selectResolvers`](../src/nwsapi.mts#L3841) | Caches selection plans, not DOM results. | +| [`Snapshot`](../src/nwsapi.mts#L3872) | Contains the document state and helpers used by compiled selectors. | +| [`Version`](../src/nwsapi.mts#L3874) | Contains the engine version string. |
diff --git a/src/nwsapi.mts b/src/nwsapi.mts index 709a826..39dba9e 100644 --- a/src/nwsapi.mts +++ b/src/nwsapi.mts @@ -3378,6 +3378,10 @@ } }, // equivalent of w3c 'querySelector' method + // Reuse the stop callback when first() has no user callback. + firstMatch = function firstMatch() { + return false + }, first = function _querySelector(selectors, context, callback) { var element, match @@ -3408,13 +3412,11 @@ selectors, context, typeof callback == 'function' - ? function firstMatch(element) { + ? function firstMatchCallback(element) { callback(element) return false } - : function firstMatch() { - return false - }, + : firstMatch, )[0] || null ) }, @@ -3430,52 +3432,52 @@ if (selectors) { if ((resolver = selectResolvers.get(selectors))) { - if (resolver.context === context && resolver.callback === callback) { - var i, - l, - list, - f = resolver.factory, - n = resolver.nodeset - if (n.length > 1) { - for (i = 0, l = n.length; l > i; ++i) { - list = fetch[n[i][0]](n[i].slice(1), context) - if (f[i] !== null) { - f[i](list, callback, context, nodes) - } else { - nodes = nodes.concat(list) - } - } - if (l > 1 && nodes.length > 1) { - nodes.sort(documentOrder) - hasDupes && (nodes = unique(nodes)) - } - } else { - list = fetch[n[0][0]](n[0].slice(1), context) - if (f[0]) { - nodes = f[0](list, callback, context, nodes) + var i, + l, + list, + f = resolver.factory, + n = resolver.nodeset + if (n.length > 1) { + for (i = 0, l = n.length; l > i; ++i) { + list = fetch[n[i][0]](n[i].slice(1), context) + if (f[i] !== null) { + f[i](list, callback, context, nodes) } else { - nodes = list + nodes = nodes.concat(list) } } - if (typeof callback == 'function') { - nodes = concatCall(nodes, callback) + if (l > 1 && nodes.length > 1) { + nodes.sort(documentOrder) + hasDupes && (nodes = unique(nodes)) } - return !Config.NODE_LIST - ? nodes - : isInstanceOf(nodes) - ? nodes - : toNodeList(nodes) + } else if (n.length) { + list = fetch[n[0][0]](n[0].slice(1), context) + nodes = f[0] ? f[0](list, callback, context, nodes) : list + } + if (typeof callback == 'function') { + nodes = concatCall(nodes, callback) } + return !Config.NODE_LIST + ? nodes + : isInstanceOf(nodes) + ? nodes + : toNodeList(nodes) } } - // save/reuse factory and closure collection - selectResolvers.set( - selectors, - collect(parse(selectors, true), context, callback), - ) + resolver = collect(parse(selectors, true), context, callback) + nodes = resolver.results - nodes = selectResolvers.get(selectors).results + // Cache the query plan, never the answer. 'results' is a live list of + // matched elements and 'htmlset' closes over the context, so caching + // the whole collection kept a removed subtree alive for as long as its + // selector stayed in the cache. What is kept here is context-free, + // which also lets a plan be reused across contexts instead of only for + // the one it was built against. + selectResolvers.set(selectors, { + factory: resolver.factory, + nodeset: resolver.nodeset, + }) if (typeof callback == 'function') { nodes = concatCall(nodes, callback) @@ -3524,6 +3526,8 @@ } } + // unescape before recording the token: 'nodeset' is what a later + // run rebuilds its candidate list from, so the two must agree token[2] = unescapeIdentifier(token[2]) nodeset[i] = token[1] + token[2] // An escaped space cannot be part of a class token. diff --git a/test/repo/e2e/upstream/fixtures/plan-cache.html b/test/repo/e2e/upstream/fixtures/plan-cache.html new file mode 100644 index 0000000..0ef3ec8 --- /dev/null +++ b/test/repo/e2e/upstream/fixtures/plan-cache.html @@ -0,0 +1,40 @@ + + +Context-free query plans + + +

+ diff --git a/test/repo/e2e/upstream/manifest.mts b/test/repo/e2e/upstream/manifest.mts index d768611..9947e52 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/plan-cache.html', + note: 'local WPT regression: context-free plans, escaped tokens, callbacks, and DOM mutation', + install: false, + }, { path: '/_repo/test/repo/e2e/upstream/fixtures/wrapper-arguments.html', note: 'local WPT regression: installed wrapper arities and callbacks', diff --git a/test/repo/unit/plan-cache.test.mts b/test/repo/unit/plan-cache.test.mts new file mode 100644 index 0000000..fc64961 --- /dev/null +++ b/test/repo/unit/plan-cache.test.mts @@ -0,0 +1,34 @@ +import { JSDOM } from 'jsdom' +import { expect, test } from 'vitest' +import factory from '../../../src/nwsapi.js' + +test('cached plans retain no query results, context, or callback', t => { + const { window } = new JSDOM('

', { + url: 'https://example.test/', + }) + t.onTestFinished(() => window.close()) + const engine = factory(window) + const doc = window.document + const main = doc.querySelector('main')! + const selector = 'p.a\\.b' + const seen: Element[] = [] + expect( + engine.select(selector, main, element => { + seen.push(element) + }), + ).toEqual([main.firstElementChild]) + const cache = Reflect.get(engine, 'selectResolvers') + const plan = cache.get(selector) + expect(Object.keys(plan).toSorted()).toEqual(['factory', 'nodeset']) + expect(seen).toEqual([main.firstElementChild]) + const other = doc.createDocumentFragment() + other.append(main.cloneNode(true)) + expect(engine.select(selector, other)).toEqual([other.firstChild!.firstChild]) + expect(cache.get(selector)).toBe(plan) + main.firstElementChild!.remove() + expect(engine.select(selector, main)).toEqual([]) + expect(engine.first(selector, main)).toBeNull() + expect(cache.get(selector)).toBe(plan) + expect(engine.select(selector, other)).toEqual([other.firstChild!.firstChild]) + expect(cache.get(selector)).toBe(plan) +})