Skip to content

Selector :contains within :has seems broken on Chrome #5098

Closed
jquery/sizzle
#486

Description

@Rinzwind

Using the following example, the console shows 1,1,1,1 on Safari (15.6.1) and Firefox (104.0.1), but 1,1,0,0 on Chrome (105.0.5195.52). I would expect the result to also be 1,1,1,1 on Chrome. Based on our usage in Selenium, I think the result actually was also 1,1,1,1 on previous versions of Chrome (≤ 104).

<html>
<head>
	<script src="https://code.jquery.com/jquery-3.6.1.min.js"></script>
</head>
<body>
	<ul>
		<li>Item</li>
	</ul>
	<script>
		console.log("" + [
			$("li:contains('Item')").length,
			$("ul:has(li)").length,
			$("ul:has(li:contains('Item'))").length,
			$("ul:has(> li:contains('Item'))").length ]);
	</script>
</body>
</html>

Activity

  1. mgol commented on Aug 31, 2022

    @mgol
    Member

    Thanks for the report!

    The way the jQuery selector engine works, the selector is tried against querySelectorAll with minor modifications and if it fails, it goes through the internal selector engine. It's either-or.

    Chrome 105 doesn't support :contains as it's a non-native selector but it does support :has. I'd expect that to still fail the internal check but, surprisingly, document.querySelectorAll("ul:has(li:contains('Item'))") no longer throws an error in Chrome 105. It looks like it must be doing some lax internal parsing?

    This, indeed, sounds problematic. I wonder if we should detect :contains presence in the selector and default to the internal jQuery engine in such a case? An interesting issue.

    @miketaylr @paulirish could you have a look? This change in Chrome 105 has potential compatibility issues.

  2. added
    Discuss in MeetingReserved for Issues and PRs that anyone would like to discuss in the weekly meeting.
    on Aug 31, 2022
  3. mgol commented on Aug 31, 2022

    @mgol
    Member

    cc @gibson042 for ideas on how to potentially handle it on jQuery side. That won't resolve compatibility issues in jQuery versions in the wild, though.

  4. mgol commented on Aug 31, 2022

    @mgol
    Member

    Worth noting is that this change makes Chrome fail both the jQuery test suite on main:
    Screen Shot 2022-08-31 at 11 48 42

    and the Sizzle one:
    Screen Shot 2022-08-31 at 11 48 52

    It'd help if Chrome was running its builds against the jQuery test suite periodically...

  5. mgol commented on Aug 31, 2022

    @mgol
    Member

    For reference, the jQuery failure can be reporduced just by opening the following URL in Chrome 105:
    https://swarm.jquery.org/builds/jquery/016872ffe03ab9107b1bc62fae674a4809c3b23f/test/index.html?module=selector

  6. mgol commented on Aug 31, 2022

    @mgol
    Member

    I guess we should just be able to run exactly the failing tests above as support tests to account for the difference in the selector engine. Worth noting is that it doesn't only affect :has with :contains but also more complex :has - here, nested with another :has & :not. But we still need to detect :has in a selector passed.

    But, of course, older jQuery versions will remain with the broken behavior.

  7. Rinzwind commented on Aug 31, 2022

    @Rinzwind
    Author

    Thanks for the explanation! Particularly the point about the use of querySelectorAll versus the internal selector engine: could I, as a temporary workaround, force jQuery to always use the internal one?

  8. mgol commented on Aug 31, 2022

    @mgol
    Member

    The primitives keeping buggy selectors are not exposed so there's no official way to defer to the internal engine just for problematic :has selectors. We're not keen on exposing those primitives as we plan to change how the selector engine works to always depend on qSA and just mix it with jQuery-specific pseudos so in the future there wouldn't be a way to opt out of the qSA path - but, at the same time, it's more likely that mixed native+jQuery selectors will then work.

    For now, the best hack that comes to my mind is to null qSA before running the query and then restore it:

    let origQSA = document.querySelectorAll;
    document.querySelectorAll = null;
    let result = $("ul:has(li:contains('Item'))");
    document.querySelectorAll = origQSA;

    If you want, you can patch $.find to always do this if you're not afraid of worse performance or skipping qSA in jQuery via something like:

    let origDocumentQSA = document.querySelectorAll;
    let origElementQSA = Element.prototype.querySelectorAll;
    let origFind = $.find;
    $.find = function () {
        document.querySelectorAll = null;
        Element.prototype.querySelectorAll = null;
        let result = origFind.apply(this, arguments);
        document.querySelectorAll = origDocumentQSA;
        Element.prototype.querySelectorAll = origElementQSA;
        return result;
    };

    $.find is used by jQuery internally for selection so this will also handle your use case of $("ul:has(li:contains('Item'))") or similar. The API is undocumented but it's been assigned to Sizzle for ages (and now, in the 4.x line, its fork) so it will at least work in jQuery 3.x & 4.x.

    EDIT: updated the workaround to account for element qSA

  9. mgol commented on Aug 31, 2022

    @mgol
    Member

    Remember that the Sizzle doesn't implement the CSS Selector level 4 APIs (see jquery/sizzle#237) so this workaround may break some of your selectors if you depend on them.

  10. Rinzwind commented on Aug 31, 2022

    @Rinzwind
    Author

    Ok thanks for the tip! I tried nulling querySelectorAll, but I’m a bit confused at the moment about the result in the following example being 0,1,1 in Chrome (it’s 1,1,1 in Safari).

    <html>
    <head>
    	<script src="https://code.jquery.com/jquery-3.6.1.min.js"></script>
    </head>
    <body>
    	<ul>
    		<li>Item</li>
    	</ul>
    	<script>
    		document.querySelectorAll = null;
    		var selector = "ul:has(> li:contains('Item'))";
    		console.log("" + [
    			$("body").find(selector).length,
    			$(selector).length,
    			$("body").find(selector).length ]);
    	</script>
    </body>
    </html>
  11. mgol commented on Aug 31, 2022

    @mgol
    Member

    Right, that's what happens when running untested code. ;) qSA is run on the context, so in this case you also need to null the Element version of qSA:

    document.querySelectorAll = null;
    Element.prototype.querySelectorAll = null;
  12. miketaylr commented on Sep 1, 2022

    @miketaylr

    Based on our usage in Selenium, I think the result actually was also 1,1,1,1 on previous versions of Chrome (≤ 104).

    The difference would be shipping a native :has implementation, right? https://chromestatus.com/feature/5794378545102848

    @Rinzwind have you filed a bug at crbug.com?

  13. Rinzwind commented on Sep 1, 2022

    @Rinzwind
    Author

    @miketaylr no I haven’t also filed a bug there.

    @mgol Thanks again! Didn’t think of the fact there’s more than one querySelectorAll. We use Selenium (through Parasol) for our tests. Various tests use a variation of #findElementByCSSSelector: which allows passing a jQuery selector. I’ve applied the temporary nulling of querySelectorAll to that method, making those tests pass again, while also giving some confidence that only the jQuery selectors used in the tests are affected by this issue but not those in the application itself.

  14. mgol commented on Sep 1, 2022

    @mgol
    Member

    @miketaylr Based on this discussion, I filed https://crbug.com/1358953.

  15. 63 remaining items

  16. locked as resolved and limited conversation to collaborators on Sep 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions