Skip to content

Add method-based TagLib syntax with legacy compatibility, benchmarks, and docs#15465

Open
davydotcom wants to merge 14 commits into8.0.xfrom
feature/taglib-method-actions
Open

Add method-based TagLib syntax with legacy compatibility, benchmarks, and docs#15465
davydotcom wants to merge 14 commits into8.0.xfrom
feature/taglib-method-actions

Conversation

@davydotcom
Copy link
Copy Markdown
Contributor

Summary

This PR introduces method-based TagLib handlers as the recommended syntax, while preserving full backward compatibility with closure-based handlers and legacy invocation paths.

Rebased onto 8.0.x from the original PR #15459.

What's included

  • Add method-defined TagLib support in core dispatch/invocation paths
  • Preserve implicit method context (attrs and body) for method handlers
  • Support binding named attributes directly to method signature arguments (for example def greeting(String name) binds from name="...")
  • Add compatibility shims so legacy property/direct invocations continue to work for method-defined tags
  • Preserve namespaced dispatch behavior and collision handling with method-backed tags
  • Add compile-time warning for closure-defined tag fields in user TagLibs
  • Convert shipped Grails web/GSP taglibs to method-based handlers
  • Add coverage for method-defined tags and legacy compatibility behavior
  • Add benchmark spec for method vs closure invocation
  • Update guides/docs to present method syntax first, with closure syntax as legacy-compatible

Performance

The method-vs-closure benchmark added in this change set shows an approximately 7–10% improvement for method-based invocation in the covered scenarios.

TagLib syntax examples

Recommended (method-based)

class DemoTagLib {
    static namespace = 'demo'

    def greet() {
        out << "Hello, ${attrs.name}!"
    }

    def greeting(String name) {
        out << "Hello, ${name}!"
    }

    def repeat() {
        attrs.times?.toInteger()?.times { n ->
            out << body(n)
        }
    }
}

Usage:

<demo:greet name="Ada" />
<demo:greeting name="Ada" />

Legacy-compatible (closure field)

class DemoTagLib {
    static namespace = 'demo'

    def greet = { attrs, body ->
        out << "Hello, ${attrs.name}!"
    }
}

Validation performed

  • Focused regressions:
    • FormTagLib2Tests
    • FormTagLib3Tests
    • SelectTagTests
    • NamespacedTagLibMethodTests
    • TagLibMethodMissingSpec
    • MethodDefinedTagLibSpec
  • Full suite:
    • :grails-gsp:test
  • Functional validation:
    • :grails-test-examples-app1:integrationTest --tests functionaltests.MiscFunctionalSpec

Co-Authored-By: Oz oz-agent@warp.dev

davydotcom and others added 8 commits February 26, 2026 07:55
…pdate

- implement method-defined tag handler support and invocation context
- preserve closure-style behavior across property/direct and namespaced paths
- convert built-in web/GSP taglibs to method syntax
- add compile-time warning for closure-defined tag fields
- add coverage and benchmark for method vs closure invocation
- update guides and demo taglib samples to method syntax

Co-Authored-By: Oz <oz-agent@warp.dev>
- treat only Map parameter named attrs as full tag attributes map
- allow other Map-typed parameters to bind from attribute key by parameter name
- add regression tests for map-valued attribute binding and reserved attrs behavior

Co-Authored-By: Oz <oz-agent@warp.dev>
- use private implementation helpers to avoid recursive dispatch in typed overloads
- keep Map-based handlers for validation-safe fallback behavior
- add regression test ensuring private/protected methods are not exposed as tag methods
- document overload pattern for typed signatures with existing validation paths

Co-Authored-By: Oz <oz-agent@warp.dev>
…gnment

- keep typed overloads delegating to private implementation helpers
- remove unnecessary attrs.name writes since typed args are sourced from attrs
- preserve behavior validated by focused FormTagLib and method-tag test suites

Co-Authored-By: Oz <oz-agent@warp.dev>
- add thread-safe ClassValue cache for invokable public tag methods by name
- remove per-invocation getMethods scans in hasInvokableTagMethod/invokeTagMethod
- optimize TagLibrary.propertyMissing by caching method fallback closures in non-dev mode
- use resolved namespace for default-namespace fallback closures

Co-Authored-By: Oz <oz-agent@warp.dev>
- restore attrs-reserved binding for paginate
- route namespaced method tag calls via tag output capture
- add fieldValue(Map) compatibility overload
- harden form fields rendering/raw handling with method dispatch

Co-Authored-By: Oz <oz-agent@warp.dev>
@jamesfredley
Copy link
Copy Markdown
Contributor

Doc Examples

Files: namespaces.adoc, tagReturnValue.adoc
In namespaces.adoc, the new method-based tag was added but the old closure-based tag was also left in place, :

class SimpleTagLib {
    static namespace = "my"
    def example() {        // ← new method added
    def example = { attrs ->  // ← old closure NOT removed
        //...
    }

Same issue in tagReturnValue.adoc - def content() was inserted but def content = { attrs, body -> remains on the next line. Both are incomplete edits that will produce broken examples in the published docs.

isTagMethodCandidate Is Broad

Any public, non-static, non-getter/setter method on a TagLib class is treated as a tag candidate. The exclusion list is minimal:

  • afterPropertiesSet
  • get*() (zero-arg) / set*(x) (one-arg)
  • invokeMethod, methodMissing, propertyMissing
  • Methods declared on Object or GroovyObject
    This means toString(), hashCode(), equals(), other Spring lifecycle methods (e.g. destroy(), onApplicationEvent()), and any custom utility methods will all be registered as invokable tags. A TagLib with a helper method like def formatDate(Date d) would silently become a <g:formatDate> tag.

Namespace Property Guard Removed

registerNamespaceMetaProperty previously had a guard:

if (!metaClass.hasProperty(namespace) && !doesMethodExist(metaClass, getGetterName(namespace), ...)) {
    registerPropertyMissingForTag(...)
}

That guard was removed - namespace dispatchers now always overwrite. This could shadow real properties on TagLibs that happen to share a name with a registered namespace (e.g. a TagLib with a String my property when "my" is also a namespace).

registerTagMetaMethods Now Defaults overrideMethods = true

The signature changed to:

static void registerTagMetaMethods(MetaClass emc, TagLibraryLookup lookup, String namespace, boolean overrideMethods = true)

For the taglib's own namespace, tags now always override existing metaclass methods, whereas previously overrideMethods was false. This could break TagLibs that intentionally define a method sharing a name with a default-namespace tag (the local method would get silently overwritten by the tag dispatcher).

ThreadLocal Push Outside try Block

In GroovyPage.invokeTagLibMethod():

TagMethodContext.push(attrs, actualBody);          // ← outside try
Object tagResult = TagMethodInvoker.invokeTagMethod(tagLib, tagName, attrs, actualBody);
outputTagResult(returnsObject, tagResult);
} finally {
    TagMethodContext.pop();                         // ← inside finally

If an exception occurs between push() and the try block (or if invokeTagMethod throws before entering the try), the push has already happened but pop may not execute in the right scope. The push should be moved inside the try to guarantee the finally always cleans up exactly what was pushed.

Single-Parameter Fallback Heuristic in toMethodArguments

When a tag method has a single parameter and attrs has a single entry, the code uses the first value from the map regardless of whether the parameter name matches:

if (value == null && parameters.length == 1 && attrs != null && attrs.size() == 1) {
    value = attrs.values().iterator().next();
}

This is a magic heuristic that could silently bind the wrong attribute. For example, <g:myTag foo="bar"/> calling def myTag(String name) would bind "bar" to name even though the attribute is foo, not name. This makes debugging attribute-binding issues difficult.

davydotcom and others added 6 commits February 26, 2026 13:56
…egistered as tag methods

Make helper methods private across all affected TagLib files to prevent
TagMethodInvoker.isTagMethodCandidate() from matching them as tag methods.
Remove convenience overloads (e.g. textField(String,Map)) entirely where
Groovy 4's multimethod restriction forbids mixing private/public methods
of the same name.

Changes:
- ApplicationTagLib: make renderResourceLink, doCreateLink private
- FormatTagLib: make messageHelper private
- UrlMappingTagLib: make appendClass private
- ValidationTagLib: remove fieldValue(Map) overload, make formatValue
  private, remove formatValue from returnObjectForTags
- FormTagLib: remove 5 typed convenience overloads, make
  renderNoSelectionOption private
- FormFieldsTagLib: make 9 protected helper methods private
- TagMethodInvoker: sort methods by descending param count to prefer
  (Map,Closure) over (Map) signatures
- Checkstyle/CodeNarc fixes: alphabetical imports, blank lines before
  constructors, single-quoted strings
# Conflicts:
#	grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy
#	grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy
#	grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy
@testlens-app
Copy link
Copy Markdown

testlens-app bot commented Mar 23, 2026

🚨 TestLens detected 16 failed tests 🚨

Here is what you can do:

  1. Inspect the test failures carefully.
  2. If you are convinced that some of the tests are flaky, you can mute them below.
  3. Finally, trigger a rerun by checking the rerun checkbox.

Test Summary

Check Project/Task Test Runs
CI - Groovy Joint Validation Build / build_grails :grails-gsp:test ReverseUrlMappingTests > testLinkTagRendering
CI - Groovy Joint Validation Build / build_grails :grails-gsp:test ReverseUrlMappingTests > testPaginateWithNamedUrlMapping
CI - Groovy Joint Validation Build / build_grails :grails-gsp:test ReverseUrlMappingTests > testSortableColumnWithNamedUrlMapping
CI - Groovy Joint Validation Build / build_grails :grails-gsp:test ReverseUrlMappingTests > testSortableColumnWithNamespaceAttribute
CI - Groovy Joint Validation Build / build_grails :grails-gsp:test RootUrlMappingTests > testMappingToController
CI - Groovy Joint Validation Build / build_grails :grails-test-examples-scaffolding:integrationTest UserControllerSpec > User list
CI / Build Grails-Core (macos-latest, 21) :grails-gsp:test ReverseUrlMappingTests > testLinkTagRendering
CI / Build Grails-Core (macos-latest, 21) :grails-gsp:test ReverseUrlMappingTests > testPaginateWithNamedUrlMapping
CI / Build Grails-Core (macos-latest, 21) :grails-gsp:test ReverseUrlMappingTests > testSortableColumnWithNamedUrlMapping
CI / Build Grails-Core (macos-latest, 21) :grails-gsp:test ReverseUrlMappingTests > testSortableColumnWithNamespaceAttribute
CI / Build Grails-Core (macos-latest, 21) :grails-gsp:test RootUrlMappingTests > testMappingToController
CI / Build Grails-Core (ubuntu-latest, 17) :grails-gsp:test ReverseUrlMappingToDefaultActionTests > testLinkTagRendering
CI / Build Grails-Core (ubuntu-latest, 21) :grails-gsp:test ReverseUrlMappingToDefaultActionTests > testLinkTagRendering
CI / Build Grails-Core (ubuntu-latest, 25) :grails-gsp:test ReverseUrlMappingToDefaultActionTests > testLinkTagRendering
CI / Build Grails-Core (windows-latest, 25) :grails-gsp:test ReverseUrlMappingToDefaultActionTests > testLinkTagRendering
CI / Build Grails-Core Rerunning all Tasks (ubuntu-latest, 17) :grails-gsp:test ReverseUrlMappingToDefaultActionTests > testLinkTagRendering

🏷️ Commit: c1db2b3
▶️ Tests: 43491 executed
⚪️ Checks: 35/35 completed

Test Failures (first 5 of 16)

ReverseUrlMappingTests > testLinkTagRendering (:grails-gsp:test in CI / Build Grails-Core (macos-latest, 21))
Condition not satisfied:

output == '<a href="/acme/product/create">New Product</a>'
|      |
|      false
|      16 differences (69% similarity)
|      <a href="/(-----)product/create(?mslug=acme)">New Product</a>
|      <a href="/(acme/)product/create(-----------)">New Product</a>
<a href="/product/create?mslug=acme">New Product</a>

	at org.grails.web.mapping.ReverseUrlMappingTests.testLinkTagRendering(ReverseUrlMappingTests.groovy:39)
expected actual
<a href="/acme/product/create">New Product</a> <a href="/product/create?mslug=acme">New Product</a>
ReverseUrlMappingTests > testPaginateWithNamedUrlMapping (:grails-gsp:test in CI / Build Grails-Core (macos-latest, 21))
Condition not satisfied:

output == '<span class="currentStep">1</span><a href="/showSomeBooks?offset=5&amp;max=5" class="step">2</a><a href="/showSomeBooks?offset=10&amp;max=5" class="step">3</a><a href="/showSomeBooks?offset=5&amp;max=5" class="nextLink">Next</a>'
|      |
|      false
|      42 differences (81% similarity)
|      <span class="currentStep">1</span><a href="(--------------)?offset=5&amp;max=5" class="step">2</a><a href="(--------------)?offset=10&amp;max=5" class="step">3</a><a href="(--------------)?offset=5&amp;max=5" class="nextLink">Next</a>
|      <span class="currentStep">1</span><a href="(/showSomeBooks)?offset=5&amp;max=5" class="step">2</a><a href="(/showSomeBooks)?offset=10&amp;max=5" class="step">3</a><a href="(/showSomeBooks)?offset=5&amp;max=5" class="nextLink">Next</a>
<span class="currentStep">1</span><a href="?offset=5&amp;max=5" class="step">2</a><a href="?offset=10&amp;max=5" class="step">3</a><a href="?offset=5&amp;max=5" class="nextLink">Next</a>

	at org.grails.web.mapping.ReverseUrlMappingTests.testPaginateWithNamedUrlMapping(ReverseUrlMappingTests.groovy:111)
expected actual
<span class="currentStep">1</span><a href="/showSomeBooks?offset=5&max=5" class="step">2</a><a href="/showSomeBooks?offset=10&max=5" class="step">3</a><a href="/showSomeBooks?offset=5&max=5" class="nextLink">Next</a> <span class="currentStep">1</span><a href="?offset=5&max=5" class="step">2</a><a href="?offset=10&max=5" class="step">3</a><a href="?offset=5&max=5" class="nextLink">Next</a>
ReverseUrlMappingTests > testSortableColumnWithNamedUrlMapping (:grails-gsp:test in CI / Build Grails-Core (macos-latest, 21))
Condition not satisfied:

output == '<th class="sortable" ><a href="/showSomeOtherBooks?sort=releaseDate&amp;order=asc">Release Date</a></th>'
|      |
|      false
|      15 differences (85% similarity)
|      <th class="sortable" ><a href="/(b-)o(--)o(k/ind)e(x-----)?sort=releaseDate&amp;order=asc">Release Date</a></th>
|      <th class="sortable" ><a href="/(sh)o(wS)o(meOth)e(rBooks)?sort=releaseDate&amp;order=asc">Release Date</a></th>
<th class="sortable" ><a href="/book/index?sort=releaseDate&amp;order=asc">Release Date</a></th>

	at org.grails.web.mapping.ReverseUrlMappingTests.testSortableColumnWithNamedUrlMapping(ReverseUrlMappingTests.groovy:122)
expected actual
<th class="sortable" ><a href="/showSomeOtherBooks?sort=releaseDate&order=asc">Release Date</a></th> <th class="sortable" ><a href="/book/index?sort=releaseDate&order=asc">Release Date</a></th>
ReverseUrlMappingTests > testSortableColumnWithNamespaceAttribute (:grails-gsp:test in CI / Build Grails-Core (macos-latest, 21))
Condition not satisfied:

output == '<th class="sortable" ><a href="/grails/book/index?sort=id&amp;order=asc">ID</a></th>'
|      |
|      false
|      7 differences (91% similarity)
|      <th class="sortable" ><a href="/(-------)book/index?sort=id&amp;order=asc">ID</a></th>
|      <th class="sortable" ><a href="/(grails/)book/index?sort=id&amp;order=asc">ID</a></th>
<th class="sortable" ><a href="/book/index?sort=id&amp;order=asc">ID</a></th>

	at org.grails.web.mapping.ReverseUrlMappingTests.testSortableColumnWithNamespaceAttribute(ReverseUrlMappingTests.groovy:139)
expected actual
<th class="sortable" ><a href="/grails/book/index?sort=id&order=asc">ID</a></th> <th class="sortable" ><a href="/book/index?sort=id&order=asc">ID</a></th>
RootUrlMappingTests > testMappingToController (:grails-gsp:test in CI / Build Grails-Core (macos-latest, 21))
Condition not satisfied:

output == '<a href="/">Show the time !</a>'
|      |
|      false
|      1 difference (96% similarity)
|      <a href="(-)">Show the time !</a>
|      <a href="(/)">Show the time !</a>
<a href="">Show the time !</a>

	at org.grails.web.mapping.RootUrlMappingTests.testMappingToController(RootUrlMappingTests.groovy:34)
expected actual
<a href="/">Show the time !</a> <a href="">Show the time !</a>

Muted Tests

Select tests to mute in this pull request:

  • ReverseUrlMappingTests > testLinkTagRendering
  • ReverseUrlMappingTests > testPaginateWithNamedUrlMapping
  • ReverseUrlMappingTests > testSortableColumnWithNamedUrlMapping
  • ReverseUrlMappingTests > testSortableColumnWithNamespaceAttribute
  • ReverseUrlMappingToDefaultActionTests > testLinkTagRendering
  • RootUrlMappingTests > testMappingToController
  • UserControllerSpec > User list

Reuse successful test results:

  • ♻️ Only rerun the tests that failed or were muted before

Click the checkbox to trigger a rerun:

  • Rerun jobs

Learn more about TestLens at testlens.app.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants