Skip to content

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

Open
davydotcom wants to merge 43 commits into
8.0.xfrom
feature/taglib-method-actions
Open

Add method-based TagLib syntax with legacy compatibility, benchmarks, and docs#15465
davydotcom wants to merge 43 commits into
8.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 8 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
@jdaugherty
Copy link
Copy Markdown
Contributor

@davydotcom I made some attempts at fixing the test pollution, but I think there is still more work required here.

@jdaugherty
Copy link
Copy Markdown
Contributor

From my AI Results:

  1. Generic test-isolation fix at the Spock extension level                                                                                                                                                       
                                                                                                                                                                                                                     
    Tests using UrlMappingsUnitTest no longer need per-test cleanup boilerplate. The fix lives in three places:                                                                                                      
                                                                                                                                                                                                                     
    - grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy — mockArtefact() now clears artefactInfo and destroys the cached grailsUrlMappingsHolder singleton before
    re-registering. Added resetUrlMappingsForFeature() and cleanupUrlMappingsAfterFeature() helpers.                                                                                                                 
    - grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingSetupSpecInterceptor.groovy — now handles both setupSpec (mocking controllers) and setup (re-registering URL mappings before each
     feature method).                                                                                                                                                                                                
    - grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingCleanupInterceptor.groovy (new) — clears the URL mapping artefact registry after each feature method so non-UrlMappingsUnitTest
    specs running later in the same JVM don't inherit foreign mappings.                                                                                                                                              
    - grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebTestingSupportExtension.groovy — wires the new setup/cleanup interceptors.
    - grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy — removed the now-redundant per-test setup/cleanup workaround.                                                 

@jdaugherty
Copy link
Copy Markdown
Contributor

Concerning the continued failures:

Original CI failures (5 tests at maxTestParallel=3, 1 test at maxTestParallel=4) are reduced to occasional flakes on ReverseUrlMappingToDefaultActionTests.testLinkTagRendering. That remaining flake is a
pre-existing URL-mapping selection issue in DefaultUrlMappingsHolder — the holder contains the right mappings (verified via debug) but the lookup intermittently picks /$id? over /$dir/$id?. It's unrelated to
the test-isolation problem the PR's CI was hitting.

@jdaugherty
Copy link
Copy Markdown
Contributor

jdaugherty commented Apr 27, 2026

Update on this, from the AI:

          ⏺ The smoking gun! ReverseUrlMappingTests' setup updated linkGen=1573001442 but the test is using linkGen=1931205834 (from RestfulReverseUrlRenderingTests). Tag library state is shared across specs. 

So the tag library state isn't being cleaned up correctly.

Here's the code it used:
image

# Conflicts:
#	DEVELOPMENT.md
#	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
@jdaugherty
Copy link
Copy Markdown
Contributor

I decided to move the taglib cleanup to be by feature instead of by spec. The biggest reason was the flakiness of the grails-gsp test run was really due to partial clean-ups. By cleaning up after each feature, each test is isolated.

jdaugherty added 2 commits May 3, 2026 20:49
# Conflicts:
#	build-logic/docs-core/build.gradle
#	build.gradle
#	grails-forge/build.gradle
#	grails-gradle/build.gradle
Copy link
Copy Markdown
Contributor

@codeconsole codeconsole left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

Considerations?:

PR #15465 Review — Method-based TagLib Handlers

Substantial, well-thought refactor (+1401/-244, ~60 files). The ClassValue cache, push/pop discipline in
GroovyPage.invokeTagLibMethod, and the UrlMappingsUnitTest.mockArtefact fix are good. Highest-impact
issues below.

Blockers

B1 — TagMethodInvoker.invokeTagMethod swallows Error and rewraps it as RuntimeException. The
catch only handles RuntimeException; an Error (StackOverflowError, AssertionError) gets boxed, hiding
the true cause. Add if (target instanceof Error err) throw err; and prefer GrailsTagException
(consistent with the closure path) for non-runtime targets.

B2 — Closure-tag dispatch regresses. TagMethodInvoker.getClosureTagProperty walks the superclass
chain via getDeclaredField on every closure tag invocation, replacing what was previously a single
metaclass getProperty(tagName). Tag-heavy GSPs likely take a hit on the closure path that the benchmark
doesn't measure. Cache resolved fields in a ClassValue<Map<String, Optional<Field>>> mirroring
INVOKABLE_METHODS_BY_NAME.

B3 — TagLibArtefactTypeAstTransformation deprecation warning is unconditional. Third-party plugins
that ship closure-defined tag fields will spam warnings during user compilation, with no opt-out. Verify
whether GrailsASTUtils.warning interacts with Groovy's Werror flag — if so this can break user builds.
Gate behind a system property (grails.taglib.warnDeprecatedClosures) and/or skip when the source is from
a plugin jar, not the user's app.

Major

M1 — ValidationTagLib.formatValue silently dropped from returnObjectForTags and made private.
<g:formatValue> was a public tag; this is a breaking behavior change not called out in
upgrading80x.adoc. Either restore it as a public method tag, or document the removal.

M2 — toMethodArguments returns null (rejecting the overload) when any non-attrs/non-Closure arg
is null.
Closures pass null through; typed-parameter tags now silently fall through to another
overload or throw MissingMethodException. Only reject null when the parameter is primitive; allow it
for reference types.

M3 — Method resolution depends on JVM-defined getDeclaredMethods() order for same-arity overloads.
Add a deterministic tiebreaker (e.g., parameter-type names) so behavior is identical on HotSpot vs.
Graal/J9.

M4 — is*() no-arg exclusion is heuristic-fragile. Targeted at JavaBean accessors, but a tag
legitimately written boolean isAvailable(...) works only because it has args. Consider a @Tag
annotation override for explicit opt-in.

Minor / DRY

  • N1FormTagLib private *Impl helpers (textFieldImpl, passwordFieldImpl, submitButtonImpl,
    etc.) are ~30 lines of mechanical boilerplate caused by the Groovy 4 multimethod restriction. A single
    private void renderField(Map attrs, String type) setting both attrs.type and attrs.tagName collapses
    them.
  • N2UrlMappingTagLib.paginate: attrs = (TypeConvertingMap) attrs re-casting the parameter to
    itself is non-obvious; introduce a typed local.
  • N3JavascriptTagLib.javascript/escapeJavascript not converted while everything around them is —
    introduce a typed local.
  • N3JavascriptTagLib.javascript/escapeJavascript not converted while everything around them is — either convert or
    add a one-line comment about why.
  • N4 — Doc examples in simpleTags.adoc and MethodDefinedTagLibSpec reference
    propertyMissing('attrs')/propertyMissing('body'). CLAUDE.md says don't expose internal mechanisms in docs — add a public
    currentAttrs()/currentBody() helper on the TagLibrary trait and document that.
  • N5TagMethodContext.ThreadLocal is only remove()d when the stack empties on pop. If any dispatch path ever skips
    pop, a later request on the same container thread sees stale attrs/body. Add a request-lifecycle interceptor that calls
    clearAll() defensively.
  • N6FRAMEWORK_METHOD_NAMES and NON_TAG_METHOD_NAMES overlap conceptually; consolidate to avoid drift.
  • N7 — Test deletions (RestfulReverseUrlRenderingTests -22, etc.) are not coverage losses — they're redundant after
    UrlMappingCleanupInterceptor and the new mockArtefact cleanup. Good DRY.

Positives

  • ClassValue is the right primitive for class-keyed caches (auto-invalidates on unload).
  • try/finally push/pop in GroovyPage.invokeTagLibMethod is correct.
  • Benchmark spec is honest — prints, doesn't assertion-gate the percentage.
  • mockArtefact destroys the cached singleton before re-registering — fixes a real test-isolation hazard.

Recommendation

Fix B1, B2, B3, M1, M2 before merge; the rest are post-merge improvements. The formatValue removal (M1) is the most likely to
bite real users silently

Resolve blockers and major comments from review of PR #15465:

- B1: rethrow Error from TagMethodInvoker.invokeTagMethod instead of
  wrapping in RuntimeException; matches closure-path semantics so
  StackOverflowError/AssertionError surface unchanged.

- B2: cache resolved Closure tag fields per TagLib class via ClassValue.
  Eliminates per-invocation getDeclaredField walk and exception-driven
  control flow on the closure dispatch hot path.

- M1: restore ValidationTagLib.formatValue as a public method tag and
  back into returnObjectForTags. Now safe because overload resolution
  no longer relies on the magic single-attribute fallback.

- M2 + jf#6: replace the magic single-param attribute fallback in
  toMethodArguments with strict containsKey-based binding. Absent
  attribute rejects the overload; null is now a legal binding for
  reference-typed parameters and only rejected for primitives.

- M3: deterministic same-arity tiebreaker (signature compareTo) so
  overload resolution is stable across HotSpot/Graal/J9.

- jf#1: fix broken doc examples in namespaces.adoc and tagReturnValue.adoc
  that mixed method and closure syntax in the same snippet.

- jf#2: introduce @grails.gsp.NotATag annotation as an explicit method
  opt-out, plus signature-based filtering for overrides of Object and
  GroovyObject methods (toString/hashCode/equals declared on the user
  class). Matches the controllers-style "public methods are tags;
  helpers are private" convention without forcing annotations.

- B3: gate the closure deprecation AST warning behind the system
  property grails.taglib.warnDeprecatedClosures (default true). Set
  false to silence at compile time.

- Document method-based TagLib handlers, @NotATag, the suppression
  property, and the tag method registration behavior change in
  upgrading70x.adoc (new section 16).

New specs:
- org.grails.taglib.TagMethodInvokerSpec - covers Error/RuntimeException
  propagation, Object/GroovyObject override exclusion, Spring lifecycle
  exclusion, @NotATag, closure-field cache including subclass shadowing,
  null/missing attribute binding semantics, and same-arity overload
  ordering.
- grails.gsp.taglib.compiler.TagLibArtefactTypeAstTransformationSpec -
  covers default warning emission and suppression via system property.
- ValidationTagLibSpec - new specs for <g:formatValue> tag-syntax and
  function-syntax invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@davydotcom
Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 7c89b78. Status of every concern raised:

Resolved

ID Concern Resolution
B1 Error swallowed/rewrapped in TagMethodInvoker.invokeTagMethod Error rethrown directly so StackOverflowError/AssertionError surface unchanged; matches closure-path semantics
B2 Closure dispatch reflection regression in getClosureTagProperty Added ClassValue<Map<String,Field>> cache mirroring INVOKABLE_METHODS_BY_NAME; eliminates per-call getDeclaredField walk and exception-driven control flow
M1 ValidationTagLib.formatValue made private Restored as public + re-added to returnObjectForTags; safe now that overload resolution no longer uses the magic single-attribute fallback
M2 + jf#6 null rejected for reference params; magic single-param fallback toMethodArguments now uses containsKey so an absent attribute rejects the overload, null binds to reference-typed parameters, primitives still reject null
M3 getDeclaredMethods() ordering non-deterministic Same-arity tiebreaker on signature string; stable across HotSpot/Graal/J9
jf#1 Broken doc examples in namespaces.adoc / tagReturnValue.adoc Cleaned up
jf#2 isTagMethodCandidate too broad Signature-based exclusion catches Object/GroovyObject overrides (toString/hashCode/equals declared on user class), plus Spring lifecycle names (destroy, onApplicationEvent); new @grails.gsp.NotATag annotation gives explicit per-method opt-out without forcing annotations on every helper
B3 (residual) Unconditional deprecation warning Gated behind -Dgrails.taglib.warnDeprecatedClosures=false; default behavior unchanged

Documented (not reverted)

jf#4registerTagMetaMethods default overrideMethods=true. I tried reverting to match 8.0.x but it broke 17 FormTagLib tests; with method-based tags now being real methods on the class, the metaclass dispatcher must override for function-style invocations like tagLib.actionSubmit(map) to return captured output instead of writing to out. Documented as an intentional behavior change in upgrading70x.adoc §16.1, including the workaround (rename or move to a different namespace).

Verified invalid

  • jf#5 ("ThreadLocal push outside try block") — push is at GroovyPage.java:497 inside the try { at line 490; pop() is also no-op-safe on empty stacks.
  • B3 (Werror claim)GrailsASTUtils.warning is just System.err.println; never went through Groovy's WarningMessage system, so -Werror doesn't apply.

Test coverage

New focused specs:

  • org.grails.taglib.TagMethodInvokerSpec — Error/RuntimeException propagation, Object/GroovyObject override exclusion, Spring lifecycle exclusion, @NotATag, closure-field cache with subclass shadowing, null/missing attribute binding, deterministic overload ordering.
  • grails.gsp.taglib.compiler.TagLibArtefactTypeAstTransformationSpec — default warning emission and system-property suppression.
  • ValidationTagLibSpec<g:formatValue> tag-syntax and function-syntax invocation.

:grails-taglib:test, :grails-web-taglib:test, and :grails-gsp:test all green on the merged base.

Open follow-ups (post-merge polish)

The N1–N7 cosmetic items from @codeconsole's review are intentionally deferred — happy to file follow-up issues if useful. Most impactful would be N4 (replace propertyMissing('attrs')/propertyMissing('body') in docs with public currentAttrs()/currentBody() helpers on the TagLibrary trait).

@jdaugherty jdaugherty requested a review from codeconsole May 8, 2026 20:16
@jamesfredley
Copy link
Copy Markdown
Contributor

Following up on my earlier review with empirical reproducers for the issues that are introduced by this PR (i.e. behaviors that did not exist on 8.0.x before these changes). I kept the scope tight — pre-existing surprises (like closure tags also being unable to use names like raw because raw() is a trait method) are excluded.

A single Spock spec exercises each issue against the public TagMethodInvoker API on the PR's HEAD (7c89b78). All six tests pass on this branch, demonstrating the behaviors are real and observable from user code.

Reproducer: Pr15465ReproducerSpec.groovy on branch pr15465-issue-reproducers.

Run with:

./gradlew :grails-taglib:test --tests org.grails.taglib.Pr15465ReproducerSpec

C1 — -parameters compile flag is silently required for the headline feature

The PR's marquee feature — def greeting(String name) binding from a name="..." attribute — depends on Parameter.getName() returning the real source name at runtime. That requires -parameters for JavaCompile and groovyOptions.parameters = true for GroovyCompile.

The framework's own build-logic/plugins/.../CompilePlugin.groovy sets both. The user-facing grails-gradle/.../GrailsGradlePlugin.groovy sets neither. Spring Boot's plugin enables -parameters for JavaCompile since 3.2 but never for GroovyCompile. A user app inheriting Grails' Gradle plugin defaults will see Parameter.getName() return arg0, attrs.containsKey("arg0") returns false, and the call surfaces as MissingMethodException with no hint about the missing flag.

Reproducer test: C1: tag method binding silently fails when parameter names are not preserved — compiles the same TagLib twice via GroovyClassLoader (once with CompilerConfiguration.parameters = true, once with the default false) and shows only the first invocation succeeds.

Suggestion: have GrailsGradlePlugin apply both flags to user-app builds; or detect synthetic names (arg0, arg1) at TagLib registration and emit a startup error with remediation steps.


H1 — convention-based discovery is opt-out, exposing helpers as tags

TagMethodInvoker.isTagMethodCandidate accepts every public, non-static, non-getter/setter method on a TagLib class minus Object/GroovyObject overrides, eight hard-coded framework names, and @NotATag-annotated methods. That leaves any user helper method silently registered as a tag.

This is a behavior change. Before this PR you opted IN by writing Closure x = { ... }. After, every public method is opt-in by default and the user opts OUT with @NotATag. Existing TagLibs recompiled against 8.0.x silently gain new tags they never declared.

The inheritance walk in getCandidateMethods makes this worse — methods on an abstract base TagLib are exposed on every subclass.

Reproducer tests:

Suggestion: flip polarity to opt-in (@Tag annotation), restrict candidate discovery to directly-declared methods (skip the hierarchy walk), or emit an INFO-log enumeration of discovered tag methods at startup so users can audit silent registrations.


H2 — registerTagMetaMethods default for overrideMethods flipped from false to true

Diff confirms the change in TagLibraryMetaUtils.groovy:

- registerMethodMissingForTags(emc, lookup, namespace, tagName, addAll, false)
+ registerMethodMissingForTags(emc, lookup, namespace, tagName, addAll, overrideMethods)

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

Documented in upgrading70x.adoc §16.1, but the runtime failure mode is invisible: a user TagLib defining def actionSubmit(Map x) (a name shared with FormTagLib's default-namespace tag) silently has its method shadowed when the tag dispatcher is registered. No log, no exception, the user's method just stops being called.

Reproducer test: H2: registerTagMetaMethods default for overrideMethods is now 'true' (was 'false' in 7.x) — verifies the new method signature exists and documents the call-site change.

Suggestion: keep the new default (it's load-bearing for the 17 FormTagLib tests the author flagged), but log at DEBUG when registerTagMetaMethods overrides a user-declared method, so users have a fighting chance of diagnosing this when their helper "stops working."


M1 — Map-parameter binding requires the literal name attrs; Closure-parameter binding does not

TagMethodInvoker.toMethodArguments lines 252-259:

if (Map.class.isAssignableFrom(parameterType) && "attrs".equals(parameterName)) { ... }   // name-required
if (Closure.class.isAssignableFrom(parameterType)) { ... }   // name-agnostic

The two rules are inconsistent. def myTag(Map params) does NOT receive the attrs map (it falls through to containsKey("params") and rejects the overload). def myTag(String name, Closure renderer) DOES bind renderer to body, even if the user intended renderer to be passed as an attribute.

Reproducer tests:

Suggestion: make both rules name-required (canonical names attrs / body), or both name-agnostic (first Map is attrs, first Closure is body). Document the chosen semantic.


Summary

ID Severity Reproducer Status
C1 Blocking for users C1: …silently fails when parameter names are not preserved Confirmed
H1 High H1, H1b Confirmed
H2 Medium-High H2: …overrideMethods is now 'true' Confirmed via diff + signature assertion
M1 Medium M1, M1b Confirmed

C1 is the one I'd really like to see addressed before merge — the headline feature silently does nothing in a fresh user app, and the diagnostic gives no clue why. H1 is the design polarity question: opt-out vs opt-in for tag-method discovery.

The reproducer is on a branch on my fork — feel free to pull it down or leave it where it is.

@jamesfredley
Copy link
Copy Markdown
Contributor

I updated the reproducer branch with a stronger H2 runtime check:

  • Branch: https://github.com/jamesfredley/grails-core/tree/pr15465-issue-reproducers
  • Commit: jamesfredley@d818f4ade1
  • Change: H2 no longer relies on source-inspection comments. It now creates an ExpandoMetaClass, calls TagLibraryMetaUtils.registerTagMetaMethods(...) with a default-namespace actionSubmit tag, and asserts the real user method actionSubmit(Map) is shadowed by the generated ClosureMetaMethod dispatcher.
  • Verification: ./gradlew --quiet :grails-taglib:test --tests org.grails.taglib.Pr15465ReproducerSpec --rerun-tasks passes with 6 tests, 0 failures.

typed method tag arguments bind reliably in user applications.
Narrow method tag discovery to avoid exposing public helper methods as
tags by default. Conventional attrs/body signatures remain supported,
while zero-arg and typed-parameter method tags require @tag. Preserve
@NotATag opt-out behavior and add debug logging when tag dispatchers
override existing methods.
Expand unit, Gradle plugin, docs, and grails-test-examples coverage for
the reviewed reproducer cases.
@jdaugherty
Copy link
Copy Markdown
Contributor

@jamesfredley I think i've addressed all of the issues you raised.

@bito-code-review
Copy link
Copy Markdown

This question isn’t related to the pull request. I can only help with questions about the PR’s code or comments.

@jdaugherty jdaugherty force-pushed the feature/taglib-method-actions branch from 91be40c to ddeb082 Compare May 17, 2026 20:03
@jdaugherty jdaugherty added this to the grails:8.0.0-M2 milestone May 18, 2026
@testlens-app
Copy link
Copy Markdown

testlens-app Bot commented May 18, 2026

🚨 TestLens detected 2 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-data-hibernate5-dbmigration:test GroovyChangeLogSpec > outputs a warning message by calling the warn method
CI - Groovy Joint Validation Build / build_grails :grails-data-hibernate5-dbmigration:test GroovyChangeLogSpec > updates a database with Groovy Change

🏷️ Commit: 11cf62f
▶️ Tests: 9115 executed
⚪️ Checks: 35/35 completed

Test Failures

GroovyChangeLogSpec > outputs a warning message by calling the warn method (:grails-data-hibernate5-dbmigration:test in CI - Groovy Joint Validation Build / build_grails)
Condition not satisfied:

output.toString().contains('warn message')
|      |          |
|      |          false
|      03:15:39.709 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
|      Running Changeset: changelog.groovy::2::John Smith
|       
|      UPDATE SUMMARY
|      Run:                          1
|      Previously run:               0
|      Filtered out:                 0
|      -------------------------------
|      Total change sets:            1
|       
|      Liquibase: Update has been successful. Rows affected: 1
03:15:39.709 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
Running Changeset: changelog.groovy::2::John Smith
 
UPDATE SUMMARY
Run:                          1
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            1
 
Liquibase: Update has been successful. Rows affected: 1

	at org.grails.plugins.databasemigration.liquibase.GroovyChangeLogSpec.outputs a warning message by calling the warn method(GroovyChangeLogSpec.groovy:87)
GroovyChangeLogSpec > updates a database with Groovy Change (:grails-data-hibernate5-dbmigration:test in CI - Groovy Joint Validation Build / build_grails)
Condition not satisfied:

output.toString().contains('confirmation message')
|      |          |
|      |          false
|      03:15:39.129 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
|      Running Changeset: changelog.groovy::1::John Smith
|       
|      UPDATE SUMMARY
|      Run:                          1
|      Previously run:               0
|      Filtered out:                 0
|      -------------------------------
|      Total change sets:            1
|       
|      Liquibase: Update has been successful. Rows affected: 1
03:15:39.129 [Test worker] INFO org.hibernate.dialect.Dialect -- HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
Running Changeset: changelog.groovy::1::John Smith
 
UPDATE SUMMARY
Run:                          1
Previously run:               0
Filtered out:                 0
-------------------------------
Total change sets:            1
 
Liquibase: Update has been successful. Rows affected: 1

	at org.grails.plugins.databasemigration.liquibase.GroovyChangeLogSpec.updates a database with Groovy Change(GroovyChangeLogSpec.groovy:61)

Muted Tests

Select tests to mute in this pull request:

  • GroovyChangeLogSpec > outputs a warning message by calling the warn method
  • GroovyChangeLogSpec > updates a database with Groovy Change

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.

4 participants