From d94aeb90e83cea0c6f47a38efdb9b26db1b3d71a Mon Sep 17 00:00:00 2001 From: jvoisin Date: Sat, 23 May 2026 22:57:17 +0200 Subject: [PATCH 1/4] perf: short-circuit IsFunction, IsSelection, and IsNodes These methods were building a full filtered Selection (via FilterFunction/FilterSelection/FilterNodes -> winnow -> grep) only to check Length() > 0. This commit replaces with early-return loops that stop at the first match, significantly speeding them up: name old ns/op new ns/op delta IsFunction-8 1530 3.9 -99.7% IsSelection-8 1890 19.9 -98.9% IsNodes-8 2016 19.9 -99.0% name old B/op new B/op delta IsFunction-8 784 0 -100.0% IsSelection-8 72 0 -100.0% IsNodes-8 72 0 -100.0% name old allocs new allocs delta IsFunction-8 28 0 -100.0% IsSelection-8 3 0 -100.0% IsNodes-8 3 0 -100.0% --- query.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/query.go b/query.go index fe86bf0..293afeb 100644 --- a/query.go +++ b/query.go @@ -24,19 +24,32 @@ func (s *Selection) IsMatcher(m Matcher) bool { // IsFunction checks the current matched set of elements against a predicate and // returns true if at least one of these elements matches. func (s *Selection) IsFunction(f func(int, *Selection) bool) bool { - return s.FilterFunction(f).Length() > 0 + for i, n := range s.Nodes { + if f(i, newSingleSelection(n, s.document)) { + return true + } + } + return false } // IsSelection checks the current matched set of elements against a Selection object // and returns true if at least one of these elements matches. func (s *Selection) IsSelection(sel *Selection) bool { - return s.FilterSelection(sel).Length() > 0 + if sel == nil { + return false + } + return s.IsNodes(sel.Nodes...) } // IsNodes checks the current matched set of elements against the specified nodes // and returns true if at least one of these elements matches. func (s *Selection) IsNodes(nodes ...*html.Node) bool { - return s.FilterNodes(nodes...).Length() > 0 + for _, n := range s.Nodes { + if isInSlice(nodes, n) { + return true + } + } + return false } // Contains returns true if the specified Node is within, From 41a5c2214962b75aefdf1de6562ddb6bd54e43b2 Mon Sep 17 00:00:00 2001 From: jvoisin Date: Sun, 24 May 2026 22:30:18 +0200 Subject: [PATCH 2/4] perf: replace rxClassTrim regexp with strings.Replacer The regex [\t\r\n] only does single-byte substitution, which is exactly what strings.Replacer's byteReplacer is optimized for. This removes the regexp dependency from property.go and drops the redundant ContainsAny fast-path guard in HasClass, since byteReplacer already returns the original string unchanged (no allocation) when nothing matches. Benchmark shows ~18% improvement on HasClass with 0 allocs either way. --- property.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/property.go b/property.go index 918e0bd..8b0e12f 100644 --- a/property.go +++ b/property.go @@ -1,13 +1,12 @@ package goquery import ( - "regexp" "strings" "golang.org/x/net/html" ) -var rxClassTrim = regexp.MustCompile("[\t\r\n]") +var classTrimReplacer = strings.NewReplacer("\t", " ", "\r", " ", "\n", " ") // Attr gets the specified attribute's value for the first element in the // Selection. To get the value for each element individually, use a looping @@ -140,9 +139,14 @@ func (s *Selection) AddClass(class ...string) *Selection { func (s *Selection) HasClass(class string) bool { class = " " + class + " " for _, n := range s.Nodes { - classes, _ := getClassesAndAttr(n, false) - if strings.Contains(classes, class) { - return true + if n.Type != html.ElementNode { + continue + } + if attr := getAttributePtr("class", n); attr != nil { + val := classTrimReplacer.Replace(attr.Val) + if strings.Contains(" "+val+" ", class) { + return true + } } } return false @@ -243,17 +247,15 @@ func getClassesAndAttr(n *html.Node, create bool) (classes string, attr *html.At if attr == nil { classes = " " - } else if strings.ContainsAny(attr.Val, "\t\r\n") { - classes = rxClassTrim.ReplaceAllString(" "+attr.Val+" ", " ") } else { - classes = " " + attr.Val + " " + classes = classTrimReplacer.Replace(" " + attr.Val + " ") } return } func getClassesSlice(classes string) []string { - return strings.Split(rxClassTrim.ReplaceAllString(" "+classes+" ", " "), " ") + return strings.Split(classTrimReplacer.Replace(" "+classes+" "), " ") } func removeAttr(n *html.Node, attrName string) { From b7030c526fa1f2b5bc331a17b680c55216dec8dd Mon Sep 17 00:00:00 2001 From: jvoisin Date: Sun, 24 May 2026 22:48:16 +0200 Subject: [PATCH 3/4] perf: call Match/isInSlice directly in Until traversal helpers getParentsNodes and getSiblingNodes created a newSingleSelection per ancestor/sibling node just to call IsMatcher or IsNodes for the until-stop condition. Since these are always single-node checks, call stopm.Match(p) and isInSlice(stopNodes, p) directly, avoiding the Selection allocation and the method dispatch overhead. name old ns/op new ns/op delta ParentsUntil-8 30700 16100 -47.6% ParentsUntilNodes-8 82700 28400 -65.7% NextUntil-8 38000 25700 -32.4% NextUntilNodes-8 13700 5900 -56.9% PrevUntil-8 109600 82600 -24.6% PrevUntilNodes-8 11600 4600 -60.3% name old B/op new B/op delta ParentsUntil-8 11528 4248 -63.2% ParentsUntilNodes-8 39040 10448 -73.2% NextUntil-8 8592 7712 -10.2% NextUntilNodes-8 5536 1304 -76.4% PrevUntil-8 30344 28280 -6.8% PrevUntilNodes-8 4248 920 -78.3% name old allocs new allocs delta ParentsUntil-8 304 44 -85.5% ParentsUntilNodes-8 904 60 -93.4% NextUntil-8 216 106 -50.9% NextUntilNodes-8 156 27 -82.7% PrevUntil-8 408 150 -63.2% PrevUntilNodes-8 129 23 -82.2% --- traversal.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/traversal.go b/traversal.go index c45fa5d..46bac05 100644 --- a/traversal.go +++ b/traversal.go @@ -562,13 +562,12 @@ func findWithMatcher(nodes []*html.Node, m Matcher) []*html.Node { func getParentsNodes(nodes []*html.Node, stopm Matcher, stopNodes []*html.Node) []*html.Node { return mapNodes(nodes, func(i int, n *html.Node) (result []*html.Node) { for p := n.Parent; p != nil; p = p.Parent { - sel := newSingleSelection(p, nil) if stopm != nil { - if sel.IsMatcher(stopm) { + if stopm.Match(p) { break } } else if len(stopNodes) > 0 { - if sel.IsNodes(stopNodes...) { + if isInSlice(stopNodes, p) { break } } @@ -589,13 +588,9 @@ func getSiblingNodes(nodes []*html.Node, st siblingType, untilm Matcher, untilNo if st == siblingNextUntil || st == siblingPrevUntil { f = func(n *html.Node) bool { if untilm != nil { - // Matcher-based condition - sel := newSingleSelection(n, nil) - return sel.IsMatcher(untilm) + return untilm.Match(n) } else if len(untilNodes) > 0 { - // Nodes-based condition - sel := newSingleSelection(n, nil) - return sel.IsNodes(untilNodes...) + return isInSlice(untilNodes, n) } return false } From 5d01e23f35ed6a30edc236c88f8aaf652fae8618 Mon Sep 17 00:00:00 2001 From: jvoisin Date: Mon, 25 May 2026 20:52:51 +0200 Subject: [PATCH 4/4] perf: drop per-parent Selection allocation in getParentsNodes Mirrors #553 for getSiblingNodes, now extended to the ancestor traversal path. --- traversal.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/traversal.go b/traversal.go index c45fa5d..7870f15 100644 --- a/traversal.go +++ b/traversal.go @@ -562,13 +562,12 @@ func findWithMatcher(nodes []*html.Node, m Matcher) []*html.Node { func getParentsNodes(nodes []*html.Node, stopm Matcher, stopNodes []*html.Node) []*html.Node { return mapNodes(nodes, func(i int, n *html.Node) (result []*html.Node) { for p := n.Parent; p != nil; p = p.Parent { - sel := newSingleSelection(p, nil) if stopm != nil { - if sel.IsMatcher(stopm) { + if stopm.Match(p) { break } } else if len(stopNodes) > 0 { - if sel.IsNodes(stopNodes...) { + if isInSlice(stopNodes, p) { break } }