From 9a3ce312621ee0d43c32c236440a0e1c1ac3c21e Mon Sep 17 00:00:00 2001 From: David Estes Date: Wed, 25 Feb 2026 21:40:12 -0500 Subject: [PATCH 01/29] Add method-based TagLib handlers with legacy compatibility and docs update - 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 --- .../unitTesting/unitTestingTagLibraries.adoc | 2 +- .../src/en/guide/theWebLayer/gsp/taglibs.adoc | 17 +- .../gsp/taglibs/iterativeTags.adoc | 4 +- .../theWebLayer/gsp/taglibs/logicalTags.adoc | 2 +- .../theWebLayer/gsp/taglibs/namespaces.adoc | 2 +- .../theWebLayer/gsp/taglibs/simpleTags.adoc | 6 +- .../gsp/taglibs/tagReturnValue.adoc | 6 +- .../src/en/guide/theWebLayer/taglibs.adoc | 17 +- .../groovy/org/grails/gsp/GroovyPage.java | 29 +++- .../core/gsp/DefaultGrailsTagLibClass.java | 2 + .../grails/taglib/TagLibraryMetaUtils.groovy | 49 ++++-- .../org/grails/taglib/TagMethodContext.java | 58 +++++++ .../org/grails/taglib/TagMethodInvoker.java | 150 ++++++++++++++++++ .../groovy/org/grails/taglib/TagOutput.java | 25 ++- .../groovy/grails/artefact/TagLibrary.groovy | 35 +++- .../TagLibArtefactTypeAstTransformation.java | 20 +++ .../web/taglib/ApplicationTagLib.groovy | 26 +-- .../plugins/web/taglib/CountryTagLib.groovy | 4 +- .../plugins/web/taglib/FormTagLib.groovy | 60 +++---- .../plugins/web/taglib/FormatTagLib.groovy | 8 +- .../web/taglib/JavascriptTagLib.groovy | 4 +- .../plugins/web/taglib/PluginTagLib.groovy | 6 +- .../web/taglib/UrlMappingTagLib.groovy | 18 +-- .../web/taglib/ValidationTagLib.groovy | 20 +-- .../web/taglib/MethodDefinedTagLibSpec.groovy | 128 +++++++++++++++ ...VsClosureTagInvocationBenchmarkSpec.groovy | 80 ++++++++++ .../functionaltests/MiscController.groovy | 4 + .../functionaltests/MethodTagLib.groovy | 46 ++++++ .../SharedNsClosureTagLib.groovy | 27 ++++ .../SharedNsMethodTagLib.groovy | 27 ++++ .../app1/grails-app/views/misc/tagMethods.gsp | 7 + .../functionaltests/MiscFunctionalSpec.groovy | 14 ++ .../grails-app/taglib/demo/FirstTagLib.groovy | 2 +- .../taglib/demo/SampleTagLib.groovy | 8 +- .../taglib/demo/SecondTagLib.groovy | 2 +- .../plugins/loadafter/build.gradle | 2 +- 36 files changed, 795 insertions(+), 122 deletions(-) create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodContext.java create mode 100644 grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy create mode 100644 grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodVsClosureTagInvocationBenchmarkSpec.groovy create mode 100644 grails-test-examples/app1/grails-app/taglib/functionaltests/MethodTagLib.groovy create mode 100644 grails-test-examples/app1/grails-app/taglib/functionaltests/SharedNsClosureTagLib.groovy create mode 100644 grails-test-examples/app1/grails-app/taglib/functionaltests/SharedNsMethodTagLib.groovy create mode 100644 grails-test-examples/app1/grails-app/views/misc/tagMethods.gsp diff --git a/grails-doc/src/en/guide/testing/unitTesting/unitTestingTagLibraries.adoc b/grails-doc/src/en/guide/testing/unitTesting/unitTestingTagLibraries.adoc index f4d7d23ee2f..595fdeb5323 100644 --- a/grails-doc/src/en/guide/testing/unitTesting/unitTestingTagLibraries.adoc +++ b/grails-doc/src/en/guide/testing/unitTesting/unitTestingTagLibraries.adoc @@ -39,7 +39,7 @@ Adding the `TagLibUnitTest` trait to a test causes a new `tagLib` field to be automatically created for the TagLib class under test. The `tagLib` property can be used to test calling tags as function calls. The return value of a function call is either a `org.grails.buffer,StreamCharBuffer` -instance or the object returned from the tag closure when +instance or the object returned from the tag handler when `returnObjectForTags` feature is used. To test a tag which accepts parameters, specify the parameter values as named diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc index e6bfc66ed13..54b5a5d71a4 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc @@ -28,23 +28,26 @@ class SimpleTagLib { } ---- -Now to create a tag create a Closure property that takes two arguments: the tag attributes and the body content: +Now create tags using methods. You can access tag attributes through the implicit `attrs` map and body through the implicit `body` closure: [source,groovy] ---- class SimpleTagLib { - def simple = { attrs, body -> + def simple() { + // ... } } ---- -The `attrs` argument is a Map of the attributes of the tag, whilst the `body` argument is a Closure that returns the body content when invoked: +Closure field-style tags are still supported for backward compatibility, but method-based tags are the recommended syntax. + +The implicit `attrs` property is a `Map` of the tag attributes, while `body()` returns the tag body content when invoked: [source,groovy] ---- class SimpleTagLib { - def emoticon = { attrs, body -> + def emoticon() { out << body() << (attrs.happy == 'true' ? " :-)" : " :-(") } } @@ -57,7 +60,7 @@ As demonstrated above there is an implicit `out` variable that refers to the out Hi John ---- -NOTE: To help IDEs like Spring Tool Suite (STS) and others autocomplete tag attributes, you should add Javadoc comments to your tag closures with `@attr` descriptions. Since taglibs use Groovy code it can be difficult to reliably detect all usable attributes. +NOTE: To help IDEs like Spring Tool Suite (STS) and others autocomplete tag attributes, add Javadoc comments with `@attr` descriptions to your tag methods. Since taglibs use Groovy code it can be difficult to reliably detect all usable attributes. For example: @@ -71,7 +74,7 @@ class SimpleTagLib { * @attr happy whether to show a happy emoticon ('true') or * a sad emoticon ('false') */ - def emoticon = { attrs, body -> + def emoticon() { out << body() << (attrs.happy == 'true' ? " :-)" : " :-(") } } @@ -89,7 +92,7 @@ class SimpleTagLib { * @attr name REQUIRED the field name * @attr value the field value */ - def passwordField = { attrs -> + def passwordField() { attrs.type = "password" attrs.tagName = "passwordField" fieldImpl(out, attrs) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/iterativeTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/iterativeTags.adoc index 26497339d72..45871599324 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/iterativeTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/iterativeTags.adoc @@ -21,7 +21,7 @@ Iterative tags are easy too, since you can invoke the body multiple times: [source,groovy] ---- -def repeat = { attrs, body -> +def repeat() { attrs.times?.toInteger()?.times { num -> out << body(num) } @@ -48,7 +48,7 @@ That value is then passed as the default variable `it` to the tag. However, if y [source,groovy] ---- -def repeat = { attrs, body -> +def repeat() { def var = attrs.var ?: "num" attrs.times?.toInteger()?.times { num -> out << body((var):num) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/logicalTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/logicalTags.adoc index b6e7b442fef..fce648d10b5 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/logicalTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/logicalTags.adoc @@ -21,7 +21,7 @@ You can also create logical tags where the body of the tag is only output once a [source,groovy] ---- -def isAdmin = { attrs, body -> +def isAdmin() { def user = attrs.user if (user && checkUserPrivs(user)) { out << body() diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/namespaces.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/namespaces.adoc index d87d01223e5..91d25b83359 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/namespaces.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/namespaces.adoc @@ -23,7 +23,7 @@ By default, tags are added to the default Grails namespace and are used with the ---- class SimpleTagLib { static namespace = "my" - + def example() { def example = { attrs -> //... } diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc index f564df62eb3..330422a336e 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc @@ -21,7 +21,7 @@ As demonstrated in the previous example it is easy to write simple tags that hav [source,groovy] ---- -def dateFormat = { attrs, body -> +def dateFormat() { out << new java.text.SimpleDateFormat(attrs.format).format(attrs.date) } ---- @@ -37,7 +37,7 @@ With simple tags sometimes you need to write HTML mark-up to the response. One a [source,groovy] ---- -def formatBook = { attrs, body -> +def formatBook() { out << "
" out << "Title : ${attrs.book.title}" out << "
" @@ -48,7 +48,7 @@ Although this approach may be tempting it is not very clean. A better approach w [source,groovy] ---- -def formatBook = { attrs, body -> +def formatBook() { out << render(template: "bookTemplate", model: [book: attrs.book]) } ---- diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/tagReturnValue.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/tagReturnValue.adoc index 65b8a434925..6027e9653e9 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/tagReturnValue.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/tagReturnValue.adoc @@ -19,13 +19,13 @@ under the License. A taglib can be used in a GSP as an ordinary tag, or it might be used as a function in other taglibs or GSP expressions. -Internally Grails intercepts calls to taglib closures. +Internally Grails intercepts calls to tag handlers (method-based or closure-based). The "out" that is available in a taglib is mapped to a `java.io.Writer` implementation that writes to a buffer that "captures" the output of the taglib call. This buffer is the return value of a tag library call when it's used as a function. If the tag is listed in the library's static `returnObjectForTags` array, then its return value will be written to -the output when it's used as a normal tag. The return value of the tag lib closure will be returned as-is +the output when it's used as a normal tag. The return value of the tag method/closure will be returned as-is if it's used as a function in GSP expressions or other taglibs. If the tag is not included in the returnObjectForTags array, then its return value will be discarded. @@ -37,7 +37,7 @@ Example: class ObjectReturningTagLib { static namespace = "cms" static returnObjectForTags = ['content'] - + def content() { def content = { attrs, body -> CmsContent.findByCode(attrs.code)?.content } diff --git a/grails-doc/src/en/guide/theWebLayer/taglibs.adoc b/grails-doc/src/en/guide/theWebLayer/taglibs.adoc index e7066068354..692fde9aca9 100644 --- a/grails-doc/src/en/guide/theWebLayer/taglibs.adoc +++ b/grails-doc/src/en/guide/theWebLayer/taglibs.adoc @@ -28,23 +28,26 @@ class SimpleTagLib { } ---- -Now to create a tag create a Closure property that takes two arguments: the tag attributes and the body content: +Now create tags using methods. You can access tag attributes through the implicit `attrs` map and body through the implicit `body` closure: [source,groovy] ---- class SimpleTagLib { - def simple = { attrs, body -> + def simple() { + // ... } } ---- -The `attrs` argument is a Map of the attributes of the tag, whilst the `body` argument is a Closure that returns the body content when invoked: +Closure field-style tags are still supported for backward compatibility, but method-based tags are the recommended syntax. + +The implicit `attrs` property is a `Map` of the tag attributes, while `body()` returns the tag body content when invoked: [source,groovy] ---- class SimpleTagLib { - def emoticon = { attrs, body -> + def emoticon() { out << body() << (attrs.happy == 'true' ? " :-)" : " :-(") } } @@ -57,7 +60,7 @@ As demonstrated above there is an implicit `out` variable that refers to the out Hi John ---- -NOTE: To help IDEs autocomplete tag attributes, you should add Javadoc comments to your tag closures with `@attr` descriptions. Since taglibs use Groovy code it can be difficult to reliably detect all usable attributes. +NOTE: To help IDEs autocomplete tag attributes, add Javadoc comments with `@attr` descriptions to your tag methods. Since taglibs use Groovy code it can be difficult to reliably detect all usable attributes. For example: @@ -71,7 +74,7 @@ class SimpleTagLib { * @attr happy whether to show a happy emoticon ('true') or * a sad emoticon ('false') */ - def emoticon = { attrs, body -> + def emoticon() { out << body() << (attrs.happy == 'true' ? " :-)" : " :-(") } } @@ -89,7 +92,7 @@ class SimpleTagLib { * @attr name REQUIRED the field name * @attr value the field value */ - def passwordField = { attrs -> + def passwordField() { attrs.type = "password" attrs.tagName = "passwordField" fieldImpl(out, attrs) diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java index 095f5a098c3..0abec8524cf 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java @@ -47,6 +47,8 @@ import org.grails.taglib.AbstractTemplateVariableBinding; import org.grails.taglib.GrailsTagException; import org.grails.taglib.GroovyPageAttributes; +import org.grails.taglib.TagMethodContext; +import org.grails.taglib.TagMethodInvoker; import org.grails.taglib.TagBodyClosure; import org.grails.taglib.TagLibraryLookup; import org.grails.taglib.TagOutput; @@ -381,10 +383,13 @@ public final void invokeTag(String tagName, String tagNamespace, int lineNumber, if (tagLib != null || (gspTagLibraryLookup != null && gspTagLibraryLookup.hasNamespace(tagNamespace))) { if (tagLib != null) { boolean returnsObject = gspTagLibraryLookup.doesTagReturnObject(tagNamespace, tagName); - Object tagLibClosure = tagLib.getProperty(tagName); + Object tagLibClosure = TagMethodInvoker.getClosureTagProperty(tagLib, tagName); if (tagLibClosure instanceof Closure) { Map encodeAsForTag = gspTagLibraryLookup.getEncodeAsForTag(tagNamespace, tagName); invokeTagLibClosure(tagName, tagNamespace, (Closure) tagLibClosure, attrs, body, returnsObject, encodeAsForTag); + } else if (TagMethodInvoker.hasInvokableTagMethod(tagLib, tagName)) { + Map encodeAsForTag = gspTagLibraryLookup.getEncodeAsForTag(tagNamespace, tagName); + invokeTagLibMethod(tagName, tagNamespace, tagLib, attrs, body, returnsObject, encodeAsForTag); } else { throw new GrailsTagException("Tag [" + tagName + "] does not exist in tag library [" + tagLib.getClass().getName() + "]", getGroovyPageFileName(), lineNumber); } @@ -475,6 +480,28 @@ private void invokeTagLibClosure(String tagName, String tagNamespace, Closure } } + private void invokeTagLibMethod(String tagName, String tagNamespace, GroovyObject tagLib, Map attrs, Closure body, + boolean returnsObject, Map defaultEncodeAs) { + if (!(attrs instanceof GroovyPageAttributes)) { + attrs = new GroovyPageAttributes(attrs); + } + ((GroovyPageAttributes) attrs).setGspTagSyntaxCall(true); + boolean encodeAsPushedToStack = false; + try { + Map codecSettings = TagOutput.createCodecSettings(tagNamespace, tagName, attrs, defaultEncodeAs); + if (codecSettings != null) { + outputStack.push(WithCodecHelper.createOutputStackAttributesBuilder(codecSettings, outputContext.getGrailsApplication()).build()); + encodeAsPushedToStack = true; + } + Closure actualBody = body != null ? body : TagOutput.EMPTY_BODY_CLOSURE; + TagMethodContext.push(attrs, actualBody); + Object tagResult = TagMethodInvoker.invokeTagMethod(tagLib, tagName, attrs, actualBody); + outputTagResult(returnsObject, tagResult); + } finally { + TagMethodContext.pop(); + if (encodeAsPushedToStack) outputStack.pop(); + } + } private void outputTagResult(boolean returnsObject, Object tagresult) { if (returnsObject && tagresult != null && !(tagresult instanceof Writer)) { if (tagresult instanceof String && isHtmlPart((String) tagresult)) { diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/core/gsp/DefaultGrailsTagLibClass.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/core/gsp/DefaultGrailsTagLibClass.java index 9719da62d93..68a42f2c5c1 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/core/gsp/DefaultGrailsTagLibClass.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/core/gsp/DefaultGrailsTagLibClass.java @@ -33,6 +33,7 @@ import grails.core.gsp.GrailsTagLibClass; import org.grails.core.AbstractInjectableGrailsClass; import org.grails.core.artefact.gsp.TagLibArtefactHandler; +import org.grails.taglib.TagMethodInvoker; /** * Default implementation of a tag lib class. @@ -69,6 +70,7 @@ public DefaultGrailsTagLibClass(Class clazz) { tags.add(prop.getName()); } } + tags.addAll(TagMethodInvoker.getInvokableTagMethodNames(clazz)); String ns = getStaticPropertyValue(NAMESPACE_FIELD_NAME, String.class); if (ns != null && !"".equals(ns.trim())) { diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy index 5937aac2e7d..bad77a60702 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy @@ -29,6 +29,7 @@ import org.springframework.context.ApplicationContext import grails.core.gsp.GrailsTagLibClass import grails.util.GrailsClassUtils import org.grails.taglib.encoder.OutputContextLookupHelper +import org.grails.taglib.encoder.OutputEncodingStack class TagLibraryMetaUtils { @@ -44,10 +45,26 @@ class TagLibraryMetaUtils { @CompileStatic static void enhanceTagLibMetaClass(MetaClass mc, TagLibraryLookup gspTagLibraryLookup, String namespace) { + registerTagMethodContextMetaProperties(mc) registerTagMetaMethods(mc, gspTagLibraryLookup, namespace) registerNamespaceMetaProperties(mc, gspTagLibraryLookup) } + @CompileStatic + static void registerTagMethodContextMetaProperties(MetaClass metaClass) { + GroovyObject mc = (GroovyObject) metaClass + if (!metaClass.hasProperty("attrs") && !doesMethodExist(metaClass, "getAttrs", [] as Class[])) { + mc.setProperty("getAttrs") { -> + TagMethodContext.currentAttrs() + } + } + if (!metaClass.hasProperty("body") && !doesMethodExist(metaClass, "getBody", [] as Class[])) { + mc.setProperty("getBody") { -> + TagMethodContext.currentBody() + } + } + } + @CompileStatic static void registerNamespaceMetaProperties(MetaClass mc, TagLibraryLookup gspTagLibraryLookup) { for (String ns : gspTagLibraryLookup.getAvailableNamespaces()) { @@ -57,9 +74,7 @@ class TagLibraryMetaUtils { @CompileStatic static void registerNamespaceMetaProperty(MetaClass metaClass, TagLibraryLookup gspTagLibraryLookup, String namespace) { - if (!metaClass.hasProperty(namespace) && !doesMethodExist(metaClass, GrailsClassUtils.getGetterName(namespace), [] as Class[])) { - registerPropertyMissingForTag(metaClass, namespace, gspTagLibraryLookup.lookupNamespaceDispatcher(namespace)) - } + registerPropertyMissingForTag(metaClass, namespace, gspTagLibraryLookup.lookupNamespaceDispatcher(namespace)) } @CompileStatic @@ -68,33 +83,45 @@ class TagLibraryMetaUtils { if (overrideMethods || !doesMethodExist(metaClass, name, [Map, Closure] as Class[])) { mc.setProperty(name) { Map attrs, Closure body -> - TagOutput.captureTagOutput(gspTagLibraryLookup, namespace, name, attrs, body, OutputContextLookupHelper.lookupOutputContext()) + captureTagOutputForMethodCall(gspTagLibraryLookup, namespace, name, attrs, body) } } if (overrideMethods || !doesMethodExist(metaClass, name, [Map, CharSequence] as Class[])) { mc.setProperty(name) { Map attrs, CharSequence body -> - TagOutput.captureTagOutput(gspTagLibraryLookup, namespace, name, attrs, new TagOutput.ConstantClosure(body), OutputContextLookupHelper.lookupOutputContext()) + captureTagOutputForMethodCall(gspTagLibraryLookup, namespace, name, attrs, new TagOutput.ConstantClosure(body)) } } if (overrideMethods || !doesMethodExist(metaClass, name, [Map] as Class[])) { mc.setProperty(name) { Map attrs -> - TagOutput.captureTagOutput(gspTagLibraryLookup, namespace, name, attrs, null, OutputContextLookupHelper.lookupOutputContext()) + captureTagOutputForMethodCall(gspTagLibraryLookup, namespace, name, attrs, null) } } if (addAll) { if (overrideMethods || !doesMethodExist(metaClass, name, [Closure] as Class[])) { mc.setProperty(name) { Closure body -> - TagOutput.captureTagOutput(gspTagLibraryLookup, namespace, name, [:], body, OutputContextLookupHelper.lookupOutputContext()) + captureTagOutputForMethodCall(gspTagLibraryLookup, namespace, name, [:], body) } } if (overrideMethods || !doesMethodExist(metaClass, name, [] as Class[])) { mc.setProperty(name) { -> - TagOutput.captureTagOutput(gspTagLibraryLookup, namespace, name, [:], null, OutputContextLookupHelper.lookupOutputContext()) + captureTagOutputForMethodCall(gspTagLibraryLookup, namespace, name, [:], null) } } } } + @CompileStatic + private static Object captureTagOutputForMethodCall(TagLibraryLookup gspTagLibraryLookup, String namespace, String name, Map attrs, Object body) { + Object output = TagOutput.captureTagOutput(gspTagLibraryLookup, namespace, name, attrs, body, OutputContextLookupHelper.lookupOutputContext()) + boolean returnsObject = gspTagLibraryLookup.doesTagReturnObject(namespace, name) + boolean gspTagSyntaxCall = attrs instanceof GroovyPageAttributes && ((GroovyPageAttributes) attrs).isGspTagSyntaxCall() + if (gspTagSyntaxCall && !returnsObject && output != null) { + OutputEncodingStack.currentStack().taglibWriter.print(output) + return null + } + return output + } + static registerMethodMissingForTags(MetaClass mc, ApplicationContext ctx, GrailsTagLibClass tagLibraryClass, String name) { TagLibraryLookup gspTagLibraryLookup = ctx.getBean('gspTagLibraryLookup') @@ -109,13 +136,13 @@ class TagLibraryMetaUtils { } @CompileStatic - static void registerTagMetaMethods(MetaClass emc, TagLibraryLookup lookup, String namespace) { + static void registerTagMetaMethods(MetaClass emc, TagLibraryLookup lookup, String namespace, boolean overrideMethods = true) { for (String tagName : lookup.getAvailableTags(namespace)) { boolean addAll = !(namespace == TagOutput.DEFAULT_NAMESPACE && tagName == 'hasErrors') - registerMethodMissingForTags(emc, lookup, namespace, tagName, addAll, false) + registerMethodMissingForTags(emc, lookup, namespace, tagName, addAll, overrideMethods) } if (namespace != TagOutput.DEFAULT_NAMESPACE) { - registerTagMetaMethods(emc, lookup, TagOutput.DEFAULT_NAMESPACE) + registerTagMetaMethods(emc, lookup, TagOutput.DEFAULT_NAMESPACE, false) } } diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodContext.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodContext.java new file mode 100644 index 00000000000..9b17cafc494 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodContext.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.taglib; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; + +import groovy.lang.Closure; + +public final class TagMethodContext { + + private static final ThreadLocal> CONTEXT_STACK = ThreadLocal.withInitial(ArrayDeque::new); + private TagMethodContext() { + } + + public static void push(Map attrs, Closure body) { + CONTEXT_STACK.get().push(new TagMethodContextEntry(attrs, body)); + } + + public static void pop() { + Deque stack = CONTEXT_STACK.get(); + if (!stack.isEmpty()) { + stack.pop(); + } + if (stack.isEmpty()) { + CONTEXT_STACK.remove(); + } + } + + public static Map currentAttrs() { + Deque stack = CONTEXT_STACK.get(); + return stack.isEmpty() ? null : stack.peek().attrs(); + } + + public static Closure currentBody() { + Deque stack = CONTEXT_STACK.get(); + return stack.isEmpty() ? null : stack.peek().body(); + } + + private record TagMethodContextEntry(Map attrs, Closure body) { } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java new file mode 100644 index 00000000000..5669a775127 --- /dev/null +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.taglib; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Parameter; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import groovy.lang.Closure; +import groovy.lang.GroovyObject; +import groovy.lang.MissingMethodException; + +public final class TagMethodInvoker { + private TagMethodInvoker() { + } + + public static Object getClosureTagProperty(GroovyObject tagLib, String tagName) { + Class type = tagLib.getClass(); + while (type != null && type != Object.class) { + try { + Field field = type.getDeclaredField(tagName); + if (!Modifier.isStatic(field.getModifiers()) && Closure.class.isAssignableFrom(field.getType())) { + field.setAccessible(true); + return field.get(tagLib); + } + return null; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + return null; + } + + public static Collection getInvokableTagMethodNames(Class tagLibClass) { + if (tagLibClass == null) { + return Collections.emptyList(); + } + List names = new ArrayList<>(); + for (Method method : tagLibClass.getDeclaredMethods()) { + if (isTagMethodCandidate(method)) { + names.add(method.getName()); + } + } + return names; + } + + public static boolean hasInvokableTagMethod(GroovyObject tagLib, String tagName) { + for (Method method : tagLib.getClass().getMethods()) { + if (isTagMethodCandidate(method) && method.getName().equals(tagName)) { + return true; + } + } + return false; + } + + public static Object invokeTagMethod(GroovyObject tagLib, String tagName, Map attrs, Closure body) { + for (Method method : tagLib.getClass().getMethods()) { + if (!isTagMethodCandidate(method) || !method.getName().equals(tagName)) { + continue; + } + Object[] args = toMethodArguments(method, attrs, body); + if (args != null) { + try { + return method.invoke(tagLib, args); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + Throwable targetException = e.getTargetException(); + if (targetException instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new RuntimeException(targetException); + } + } + } + throw new MissingMethodException(tagName, tagLib.getClass(), new Object[] { attrs, body }); + } + + public static boolean isTagMethodCandidate(Method method) { + int modifiers = method.getModifiers(); + if (!Modifier.isPublic(modifiers) || Modifier.isStatic(modifiers) || method.isBridge() || method.isSynthetic()) { + return false; + } + String name = method.getName(); + if ("afterPropertiesSet".equals(name)) { + return false; + } + if (name.startsWith("get") && method.getParameterCount() == 0) { + return false; + } + if (name.startsWith("set") && method.getParameterCount() == 1) { + return false; + } + if ("invokeMethod".equals(name) || "methodMissing".equals(name) || "propertyMissing".equals(name)) { + return false; + } + return method.getDeclaringClass() != Object.class && method.getDeclaringClass() != GroovyObject.class; + } + + private static Object[] toMethodArguments(Method method, Map attrs, Closure body) { + Parameter[] parameters = method.getParameters(); + Object[] args = new Object[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + Class parameterType = parameters[i].getType(); + if (Map.class.isAssignableFrom(parameterType)) { + args[i] = attrs; + continue; + } + if (Closure.class.isAssignableFrom(parameterType)) { + args[i] = body != null ? body : TagOutput.EMPTY_BODY_CLOSURE; + continue; + } + String parameterName = parameters[i].getName(); + Object value = attrs != null ? attrs.get(parameterName) : null; + if (value == null && parameters.length == 1 && attrs != null && attrs.size() == 1) { + value = attrs.values().iterator().next(); + } + if (value == null) { + return null; + } + args[i] = value; + } + return args; + } +} diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagOutput.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagOutput.java index bad1f1c5cdd..d44f394690a 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagOutput.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagOutput.java @@ -54,10 +54,11 @@ public final static Object captureTagOutput(TagLibraryLookup gspTagLibraryLookup throw new GrailsTagException("Tag [" + tagName + "] does not exist. No corresponding tag library found."); } + boolean gspTagSyntaxCall = attrs instanceof GroovyPageAttributes && ((GroovyPageAttributes) attrs).isGspTagSyntaxCall(); if (!(attrs instanceof GroovyPageAttributes)) { attrs = new GroovyPageAttributes(attrs, false); } - ((GroovyPageAttributes) attrs).setGspTagSyntaxCall(false); + ((GroovyPageAttributes) attrs).setGspTagSyntaxCall(gspTagSyntaxCall); Closure actualBody = createOutputCapturingClosure(tagLib, body, outputContext); final GroovyPageTagWriter tagOutput = new GroovyPageTagWriter(); @@ -74,7 +75,7 @@ public final static Object captureTagOutput(TagLibraryLookup gspTagLibraryLookup builder.topWriter(tagOutput); outputStack.push(builder.build()); - Object tagLibProp = tagLib.getProperty(tagName); // retrieve tag lib and create wrapper writer + Object tagLibProp = TagMethodInvoker.getClosureTagProperty(tagLib, tagName); // retrieve tag closure field if (tagLibProp instanceof Closure) { Closure tag = (Closure) ((Closure) tagLibProp).clone(); Object bodyResult; @@ -122,6 +123,26 @@ public final static Object captureTagOutput(TagLibraryLookup gspTagLibraryLookup return tagOutput.getBuffer(); } } + if (TagMethodInvoker.hasInvokableTagMethod(tagLib, tagName)) { + try { + TagMethodContext.push(attrs, actualBody); + Object bodyResult = TagMethodInvoker.invokeTagMethod(tagLib, tagName, attrs, actualBody); + Encoder taglibEncoder = outputStack.getTaglibEncoder(); + boolean returnsObject = gspTagLibraryLookup.doesTagReturnObject(namespace, tagName); + if (returnsObject && bodyResult != null && !(bodyResult instanceof Writer)) { + if (taglibEncoder != null) { + bodyResult = taglibEncoder.encode(bodyResult); + } + return bodyResult; + } + if (taglibEncoder != null) { + return taglibEncoder.encode(tagOutput.getBuffer()); + } + return tagOutput.getBuffer(); + } finally { + TagMethodContext.pop(); + } + } throw new GrailsTagException("Tag [" + tagName + "] does not exist in tag library [" + tagLib.getClass().getName() + "]"); diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy index 0a1c680b66a..74a02b72c3e 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy @@ -34,10 +34,14 @@ import grails.web.api.WebAttributes import org.grails.buffer.GrailsPrintWriter import org.grails.encoder.Encoder import org.grails.taglib.GrailsTagException +import org.grails.taglib.GroovyPageAttributes import org.grails.taglib.TagLibraryLookup import org.grails.taglib.TagLibraryMetaUtils +import org.grails.taglib.TagMethodContext +import org.grails.taglib.TagMethodInvoker import org.grails.taglib.TagOutput import org.grails.taglib.TemplateVariableBinding +import org.grails.taglib.encoder.OutputContextLookupHelper import org.grails.taglib.encoder.OutputEncodingStack import org.grails.taglib.encoder.WithCodecHelper import org.grails.web.servlet.mvc.GrailsWebRequest @@ -131,8 +135,21 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker * @throws MissingPropertyException When no tag namespace or tag is found */ Object propertyMissing(String name) { + if (name == 'attrs') { + def contextAttrs = TagMethodContext.currentAttrs() + if (contextAttrs != null) { + return contextAttrs + } + } + if (name == 'body') { + def contextBody = TagMethodContext.currentBody() + if (contextBody != null) { + return contextBody + } + } TagLibraryLookup gspTagLibraryLookup = getTagLibraryLookup() if (gspTagLibraryLookup != null) { + boolean methodTagFallback = false Object result = gspTagLibraryLookup.lookupNamespaceDispatcher(name) if (result == null) { @@ -143,14 +160,26 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker } if (tagLibrary != null) { - Object tagProperty = tagLibrary.getProperty(name) + Object tagProperty = TagMethodInvoker.getClosureTagProperty(tagLibrary, name) if (tagProperty instanceof Closure) { result = ((Closure) tagProperty).clone() + } else if (TagMethodInvoker.hasInvokableTagMethod(tagLibrary, name)) { + methodTagFallback = true + final String currentNamespace = namespace + result = { Map attrs = [:], Closure body = null -> + Object output = TagOutput.captureTagOutput(gspTagLibraryLookup, currentNamespace, name, attrs, body, OutputContextLookupHelper.lookupOutputContext()) + boolean gspTagSyntaxCall = attrs instanceof GroovyPageAttributes && ((GroovyPageAttributes) attrs).isGspTagSyntaxCall() + boolean returnsObject = gspTagLibraryLookup.doesTagReturnObject(currentNamespace, name) + if (gspTagSyntaxCall && !returnsObject && output != null) { + OutputEncodingStack.currentStack().taglibWriter.print(output) + return null + } + output + } } } } - - if (result != null && !Environment.isDevelopmentMode()) { + if (result != null && !Environment.isDevelopmentMode() && !methodTagFallback) { MetaClass mc = GrailsMetaClassUtils.getExpandoMetaClass(getClass()) // Register the property for the already-existing singleton instance of the taglib diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java index 0983d2aed3a..0df4fda9be7 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java @@ -18,9 +18,11 @@ */ package grails.gsp.taglib.compiler; +import groovy.lang.Closure; import org.codehaus.groovy.ast.AnnotationNode; import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.FieldNode; import org.codehaus.groovy.control.CompilePhase; import org.codehaus.groovy.control.SourceUnit; import org.codehaus.groovy.transform.GroovyASTTransformation; @@ -31,9 +33,11 @@ @GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION) public class TagLibArtefactTypeAstTransformation extends ArtefactTypeAstTransformation { private static final ClassNode MY_TYPE = new ClassNode(TagLib.class); + private static final ClassNode CLOSURE_TYPE = new ClassNode(Closure.class); @Override protected String resolveArtefactType(SourceUnit sourceUnit, AnnotationNode annotationNode, ClassNode classNode) { + addClosureTagDeprecationWarnings(sourceUnit, classNode); return "TagLibrary"; } @@ -41,4 +45,20 @@ protected String resolveArtefactType(SourceUnit sourceUnit, AnnotationNode annot protected ClassNode getAnnotationType() { return MY_TYPE; } + + protected void addClosureTagDeprecationWarnings(SourceUnit sourceUnit, ClassNode classNode) { + if (classNode.getPackageName() != null && classNode.getPackageName().startsWith("org.grails.plugins.web.taglib")) { + return; + } + for (FieldNode field : classNode.getFields()) { + if (field.isStatic()) { + continue; + } + if (field.getType() != null && CLOSURE_TYPE.equals(field.getType())) { + String message = "Closure-based tag definition [" + field.getName() + "] in TagLib [" + classNode.getName() + "] is deprecated. " + + "Define tag handlers as methods instead."; + org.grails.compiler.injection.GrailsASTUtils.warning(sourceUnit, field, message); + } + } + } } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy index 96844c286e2..fa00d0ec6b9 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy @@ -91,7 +91,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * * @attr name REQUIRED the cookie name */ - Closure cookie = { attrs -> + def cookie(Map attrs) { request.cookies.find { it.name == attrs.name }?.value } @@ -102,7 +102,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * * @attr name REQUIRED the header name */ - Closure header = { attrs -> + def header(Map attrs) { attrs.name ? request.getHeader(attrs.name) : null } @@ -115,7 +115,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * @attr bean the name or the type of a bean in the applicationContext; the type can be an interface or superclass * @attr scope the scope name; defaults to pageScope */ - Closure set = { attrs, body -> + def set(Map attrs, Closure body) { def var = attrs.var if (!var) throw new IllegalArgumentException('[var] attribute must be specified to for !') @@ -142,7 +142,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * * @emptyTag */ - Closure createLinkTo = { attrs -> + def createLinkTo(Map attrs) { GrailsUtil.deprecated('Tag [createLinkTo] is deprecated please use [resource] instead') return resource(attrs) } @@ -161,7 +161,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * @attr absolute If set to "true" will prefix the link target address with the value of the grails.serverURL property from Config, or http://localhost:<port> if no value in Config and not running in production. * @attr plugin The plugin to look for the resource in */ - Closure resource = { attrs -> + def resource(Map attrs) { if (!attrs.pluginContextPath && pageScope.pluginContextPath) { attrs.pluginContextPath = pageScope.pluginContextPath } @@ -179,7 +179,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * @attr plugin Optional the name of the grails plugin if the resource is not part of the application * @attr uri Optional app-relative URI path of the resource if not using dir/file attributes - only if Resources plugin is in use */ - Closure img = { attrs -> + def img(Map attrs) { if (!attrs.uri && !attrs.dir) { attrs.dir = 'images' } @@ -308,7 +308,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * @attr plugin * @attr type */ - Closure external = { attrs -> + def external(Map attrs) { if (!attrs.uri) { attrs.uri = resource(attrs).toString() } @@ -363,7 +363,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * @attr mapping The named URL mapping to use to rewrite the link * @attr event Webflow _eventId parameter */ - Closure createLink = { attrs -> + def createLink(Map attrs) { return doCreateLink(attrs instanceof Map ? (Map) attrs : Collections.emptyMap()) } @@ -403,7 +403,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * @attr name REQUIRED the tag name * @attr attrs tag attributes */ - Closure withTag = { attrs, body -> + def withTag(Map attrs, Closure body) { def writer = out writer << "<${attrs.name}" attrs.attrs?.each { k, v -> @@ -431,7 +431,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * @attr REQUIRED in The collection to iterate over * @attr delimiter The value of the delimiter to use during the join. If no delimiter is specified then ", " (a comma followed by a space) will be used as the delimiter. */ - Closure join = { attrs -> + def join(Map attrs) { def collection = attrs.'in' if (collection == null) { throwTagError('Tag ["join"] missing required attribute ["in"]') @@ -448,7 +448,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * * @attr name REQUIRED the metadata key */ - Closure meta = { attrs -> + def meta(Map attrs) { if (!attrs.name) { throwTagError('Tag ["meta"] missing required attribute ["name"]') } @@ -458,7 +458,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr /** * Filters the url through the RequestDataValueProcessor bean if it is registered. */ - String processedUrl(String link, request) { + private String processedUrl(String link, request) { if (requestDataValueProcessor == null) { return link } @@ -466,7 +466,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr return requestDataValueProcessor.processUrl(request, link) } - Closure applyCodec = { Map attrs, Closure body -> + def applyCodec(Map attrs, Closure body) { // encoding is handled in GroovyPage.invokeTag and GroovyPage.captureTagOutput body() } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/CountryTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/CountryTagLib.groovy index 793d1583726..cc218d8ede9 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/CountryTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/CountryTagLib.groovy @@ -307,7 +307,7 @@ class CountryTagLib implements TagLibrary { * @attr noSelection A single-entry map detailing the key and value to use for the "no selection made" choice in the select box. If there is no current selection this will be shown as it is first in the list, and if submitted with this selected, the key that you provide will be submitted. Typically this will be blank - but you can also use 'null' in the case that you're passing the ID of an object * @attr disabled boolean value indicating whether the select is disabled or enabled (defaults to false - enabled) */ - Closure countrySelect = { attrs -> + def countrySelect(Map attrs) { if (!attrs.from) { attrs.from = COUNTRY_CODES_BY_NAME_ORDER } @@ -328,7 +328,7 @@ class CountryTagLib implements TagLibrary { * * @attr code REQUIRED the ISO3166_3 country code */ - Closure country = { attrs -> + def country(Map attrs) { if (!attrs.code) { throwTagError('[country] requires [code] attribute to specify the country code') } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy index c7acf239e1c..c4e084c9bb7 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy @@ -84,7 +84,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar configureCsrf() } - void configureCsrf() { + private void configureCsrf() { try { var filterChainProxy = applicationContext.getBean( Class.forName('org.springframework.security.web.FilterChainProxy')) @@ -105,7 +105,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr name REQUIRED the field name * @attr value the field value */ - Closure textField = { attrs -> + def textField(Map attrs) { attrs.type = 'text' attrs.tagName = 'textField' fieldImpl(out, attrs) @@ -119,7 +119,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr name REQUIRED the field name * @attr value the field value */ - Closure passwordField = { attrs -> + def passwordField(Map attrs) { attrs.type = 'password' attrs.tagName = 'passwordField' fieldImpl(out, attrs) @@ -131,11 +131,11 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr name REQUIRED the field name * @attr value the field value */ - Closure hiddenField = { attrs -> + def hiddenField(Map attrs) { hiddenFieldImpl(out, attrs) } - def hiddenFieldImpl(out, attrs) { + private def hiddenFieldImpl(out, attrs) { attrs.type = 'hidden' attrs.tagName = 'hiddenField' fieldImpl(out, attrs) @@ -151,7 +151,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr type input type; defaults to 'submit' * @attr event the webflow event id */ - Closure submitButton = { attrs -> + def submitButton(Map attrs) { attrs.type = attrs.type ?: 'submit' attrs.tagName = 'submitButton' if (request.flowExecutionKey) { @@ -168,13 +168,13 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * * @attr type REQUIRED the input type */ - Closure field = { attrs -> + def field(Map attrs) { attrs.tagName = 'field' fieldImpl(out, attrs) } @CompileStatic - def fieldImpl(GrailsPrintWriter out, Map attrs) { + private def fieldImpl(GrailsPrintWriter out, Map attrs) { resolveAttributes(attrs) attrs.value = processFormFieldValueIfNecessary(attrs.name, attrs.value, attrs.type) @@ -206,7 +206,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr readonly if evaluates to true, sets to checkbox to read only * @attr id DOM element id; defaults to name */ - Closure checkBox = { attrs -> + def checkBox(Map attrs) { def value = attrs.remove('value') def name = attrs.remove('name') def formName = attrs.get('form') @@ -288,7 +288,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr escapeHtml if true escapes the text as HTML * @attr id DOM element id; defaults to name */ - Closure textArea = { attrs, body -> + def textArea(Map attrs, Closure body) { resolveAttributes(attrs) // Pull out the value to use as content not attrib def value = attrs.remove('value') @@ -338,7 +338,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar /** * Check required attributes, set the id to name if no id supplied, extract bean values etc. */ - void resolveAttributes(Map attrs) { + private void resolveAttributes(Map attrs) { if (!attrs.name && !attrs.field) { throwTagError("Tag [${attrs.tagName}] is missing required attribute [name] or [field]") } @@ -366,7 +366,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * Dump out attributes in HTML compliant fashion. */ @CompileStatic - void outputAttributes(Map attrs, GrailsPrintWriter writer, boolean useNameAsIdIfIdDoesNotExist = false) { + private void outputAttributes(Map attrs, GrailsPrintWriter writer, boolean useNameAsIdIfIdDoesNotExist = false) { attrs.remove('tagName') // Just in case one is left Encoder htmlEncoder = codecLookup?.lookupEncoder('HTML') attrs.each { k, v -> @@ -393,9 +393,9 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr useToken Set whether to send a token in the request to handle duplicate form submissions. See Handling Duplicate Form Submissions * @attr method the form method to use, either 'POST' or 'GET'; defaults to 'POST' */ - Closure uploadForm = { attrs, body -> + def uploadForm(Map attrs, Closure body) { attrs.enctype = 'multipart/form-data' - out << form(attrs, body) + form(attrs, body) } /** @@ -412,7 +412,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr useToken Set whether to send a token in the request to handle duplicate form submissions. See Handling Duplicate Form Submissions * @attr method the form method to use, either 'POST' or 'GET'; defaults to 'POST' */ - Closure form = { attrs, body -> + def form(Map attrs, Closure body) { boolean useToken = false if (attrs.containsKey('useToken')) { @@ -526,7 +526,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * */ @Deprecated(since = '7.0.0') - Closure actionSubmit = { attrs -> + def actionSubmit(Map attrs) { if (!attrs.value) { throwTagError('Tag [actionSubmit] is missing required attribute [value]') } @@ -580,7 +580,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr base Sets the prefix to be added to the link target address, typically an absolute server URL. This overrides the behaviour of the absolute property, if both are specified. * @attr event Webflow _eventId parameter */ - def formActionSubmit = { Map attrs -> + def formActionSubmit(Map attrs) { if (!attrs.value) { throwTagError('Tag [formActionSubmit] is missing required attribute [value]') } @@ -634,7 +634,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr src The source of the image to use * @attr disabled Makes the button to be disabled. Will be interpreted as a Groovy Truth */ - Closure actionSubmitImage = { attrs -> + def actionSubmitImage(Map attrs) { attrs.tagName = 'actionSubmitImage' if (!attrs.value) { @@ -683,7 +683,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr locale The locale to use for display formatting. Defaults to the current request locale and then the system default locale if not specified. * @attr selectDateClass css class added to each select tag */ - Closure datePicker = { attrs -> + def datePicker(Map attrs) { def out = out // let x = x ? def xdefault = attrs['default'] if (xdefault == null) { @@ -939,11 +939,11 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar } } - Closure renderNoSelectionOption = { noSelectionKey, noSelectionValue, value -> + def renderNoSelectionOption(noSelectionKey, noSelectionValue, value) { renderNoSelectionOptionImpl(out, noSelectionKey, noSelectionValue, value) } - def renderNoSelectionOptionImpl(out, noSelectionKey, noSelectionValue, value) { + private def renderNoSelectionOptionImpl(out, noSelectionKey, noSelectionValue, value) { // If a label for the '--Please choose--' first item is supplied, write it out out << "" } @@ -958,7 +958,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr value An instance of java.util.TimeZone. Defaults to the time zone for the current Locale if not specified * @attr locale The locale to use for formatting the time zone names. Defaults to the current request locale and then system default locale if not specified */ - Closure timeZoneSelect = { attrs -> + def timeZoneSelect(Map attrs) { attrs.from = TimeZone.getAvailableIDs() attrs.value = (attrs.value ? attrs.value.ID : TimeZone.getDefault().ID) def date = new Date() @@ -978,7 +978,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar } // use generic select - out << select(attrs) + select(attrs) } /** @@ -992,7 +992,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr value The set locale, defaults to the current request locale if not specified * @attr locale The locale to use for formatting the locale names. Defaults to the current request locale and then the system default locale if not specified */ - Closure localeSelect = { attrs -> + def localeSelect(Map attrs) { attrs.from = Locale.getAvailableLocales() attrs.value = (attrs.value ?: RCU.getLocale(request))?.toString() // set the key as a closure that formats the locale @@ -1014,7 +1014,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr from The currency symbols to select from, defaults to the major ones if not specified * @attr value The currency value as the currency code. Defaults to the currency for the current Locale if not specified */ - Closure currencySelect = { attrs, body -> + def currencySelect(Map attrs, Closure body) { if (!attrs.from) { attrs.from = DEFAULT_CURRENCY_CODES } @@ -1053,7 +1053,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr dataAttrs a Map that adds data-* attributes to the <option> elements. Map's keys will be used as names of the data-* attributes like so: data-${key} (i.e. with a "data-" prefix). The object belonging to a Map's key determines the value of the data-* attribute. It can be a string referring to a property of beans in {@code from}, a Closure that accepts an item from {@code from} and returns the value or a List that contains a value for each of the <option>s. * @attr locale The locale to use for formatting. Defaults to the current request locale and then the system default locale if not specified */ - Closure select = { attrs -> + def select(Map attrs) { if (!attrs.name) { throwTagError('Tag [select] is missing required attribute [name]') } @@ -1257,7 +1257,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr readonly boolean to indicate that the radio button should not be editable * @attr id the DOM element id */ - Closure radio = { attrs -> + def radio(Map attrs) { def value = attrs.remove('value') def name = attrs.remove('name') booleanToAttribute(attrs, 'disabled') @@ -1286,7 +1286,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr disabled Disables the resulting radio buttons. * @attr readonly Makes the resulting radio buttons to not be editable */ - Closure radioGroup = { attrs, body -> + def radioGroup(Map attrs, Closure body) { def value = attrs.remove('value') def values = attrs.remove('values') def labels = attrs.remove('labels') @@ -1318,7 +1318,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar } } - private processFormFieldValueIfNecessary(name, value, type) { + private def processFormFieldValueIfNecessary(name, value, type) { if (requestDataValueProcessor != null) { return requestDataValueProcessor.processFormFieldValue(request, name, "${value}", type) } @@ -1328,7 +1328,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar /** * Filters the url through the RequestDataValueProcessor bean if it is registered. */ - String processedUrl(String link, request) { + private String processedUrl(String link, request) { if (requestDataValueProcessor == null) { return link } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormatTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormatTagLib.groovy index cbdbc072bfa..2825d7ac772 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormatTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormatTagLib.groovy @@ -94,7 +94,7 @@ class FormatTagLib implements TagLibrary { * @attr false text label for boolean false value * @attr locale Force the locale for formatting. */ - Closure formatBoolean = { attrs -> + def formatBoolean(Map attrs) { if (!attrs.containsKey('boolean')) { throwTagError('Tag [formatBoolean] is missing required attribute [boolean]') } @@ -144,7 +144,7 @@ class FormatTagLib implements TagLibrary { * @attr dateStyle Set separate style for the date part. * @attr timeStyle Set separate style for the time part. */ - Closure formatDate = { attrs -> + def formatDate(Map attrs) { def date if (attrs.containsKey('date')) { @@ -230,7 +230,7 @@ class FormatTagLib implements TagLibrary { * @attr roundingMode Sets the RoundingMode used in this DecimalFormat. Usual values: HALF_UP, HALF_DOWN. If roundingMode is UNNECESSARY and ArithemeticException raises, the original number formatted with default number formatting will be returned. * @attr nan String to be used for display if numberic value is NaN */ - Closure formatNumber = { attrs -> + def formatNumber(Map attrs) { if (!attrs.containsKey('number')) { throwTagError('Tag [formatNumber] is missing required attribute [number]') } @@ -360,7 +360,7 @@ class FormatTagLib implements TagLibrary { * * @attr codec REQUIRED the codec name */ - Closure encodeAs = { attrs, body -> + def encodeAs(Map attrs, Closure body) { if (!attrs.codec) { throwTagError('Tag [encodeAs] requires a codec name in the [codec] attribute') } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/JavascriptTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/JavascriptTagLib.groovy index f9e541bb394..db20ab98eef 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/JavascriptTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/JavascriptTagLib.groovy @@ -71,7 +71,7 @@ class JavascriptTagLib implements ApplicationContextAware, TagLibrary { * @attr contextPath the context path to use (relative to the application context path). Defaults to "" or path to the plugin for a plugin view or template. * @attr base specifies the full base url to prepend to the library name */ - Closure javascript = { attrs, body -> + Closure javascript = { Map attrs, body -> if (attrs.src) { javascriptInclude(attrs) } else { @@ -121,7 +121,7 @@ class JavascriptTagLib implements ApplicationContextAware, TagLibrary { * * <g:escapeJavascript>This is some "text" to be escaped</g:escapeJavascript> */ - Closure escapeJavascript = { attrs, body -> + Closure escapeJavascript = { Map attrs, body -> if (body) { out << body() } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/PluginTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/PluginTagLib.groovy index 99757f550f2..f86e80b93d1 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/PluginTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/PluginTagLib.groovy @@ -42,7 +42,7 @@ class PluginTagLib implements TagLibrary { * * @attr name REQUIRED the plugin name */ - Closure path = { attrs, body -> + def path(Map attrs, Closure body) { out << pluginManager.getPluginPath(attrs.name) } @@ -54,7 +54,7 @@ class PluginTagLib implements TagLibrary { * @attr name REQUIRED the plugin name * @attr version the plugin version */ - Closure isAvailable = { attrs, body -> + def isAvailable(Map attrs, Closure body) { if (checkPluginExists(attrs.version, attrs.name)) { out << body() } @@ -68,7 +68,7 @@ class PluginTagLib implements TagLibrary { * @attr name REQUIRED the plugin name * @attr version the plugin version */ - Closure isNotAvailable = { attrs, body -> + def isNotAvailable(Map attrs, Closure body) { if (!checkPluginExists(attrs.version, attrs.name)) { out << body() } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy index 09d8c961ace..2d695e987ca 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy @@ -66,7 +66,7 @@ class UrlMappingTagLib implements TagLibrary { * @attr view The name of the view. Cannot be specified in combination with controller/action/id * @attr model A model to pass onto the included controller in the request */ - Closure include = { Map attrs, body -> + def include(Map attrs, Closure body) { if (attrs.action && !attrs.controller) { def controller = request?.getAttribute(GrailsApplicationAttributes.CONTROLLER) def controllerName = ((GroovyObject) controller)?.getProperty('controllerName') @@ -119,7 +119,7 @@ class UrlMappingTagLib implements TagLibrary { * @attr mapping The named URL mapping to use to rewrite the link * @attr fragment The link fragment (often called anchor tag) to use */ - Closure paginate = { Map attrsMap -> + def paginate(Map attrsMap) { TypeConvertingMap attrs = (TypeConvertingMap) attrsMap def writer = out if (attrs.total == null) { @@ -184,14 +184,14 @@ class UrlMappingTagLib implements TagLibrary { // display previous link when not on firststep unless omitPrev is true if (currentstep > firststep && !attrs.boolean('omitPrev')) { linkParams.offset = offset - max - writer << callLink(appendClass((Map) linkTagAttrs.clone(), 'prevLink')) { + writer << callLink(appendClass(new LinkedHashMap(linkTagAttrs), 'prevLink')) { (attrs.prev ?: messageSource.getMessage('paginate.prev', null, messageSource.getMessage('default.paginate.prev', null, 'Previous', locale), locale)) } } // display steps when steps are enabled and laststep is not firststep if (steps && laststep > firststep) { - Map stepAttrs = appendClass((Map) linkTagAttrs.clone(), 'step') + Map stepAttrs = appendClass(new LinkedHashMap(linkTagAttrs), 'step') // determine begin and endstep paging variables int beginstep = currentstep - (Math.round(maxsteps / 2.0d) as int) + (maxsteps % 2) @@ -212,7 +212,7 @@ class UrlMappingTagLib implements TagLibrary { // display firststep link when beginstep is not firststep if (beginstep > firststep && !attrs.boolean('omitFirst')) { linkParams.offset = 0 - writer << callLink((Map) stepAttrs.clone()) { firststep.toString() } + writer << callLink(new LinkedHashMap(stepAttrs)) { firststep.toString() } } //show a gap if beginstep isn't immediately after firststep, and if were not omitting first or rev if (beginstep > firststep + 1 && (!attrs.boolean('omitFirst') || !attrs.boolean('omitPrev'))) { @@ -226,7 +226,7 @@ class UrlMappingTagLib implements TagLibrary { } else { linkParams.offset = (i - 1) * max - writer << callLink((Map) stepAttrs.clone()) { i.toString() } + writer << callLink(new LinkedHashMap(stepAttrs)) { i.toString() } } } @@ -237,14 +237,14 @@ class UrlMappingTagLib implements TagLibrary { // display laststep link when endstep is not laststep if (endstep < laststep && !attrs.boolean('omitLast')) { linkParams.offset = (laststep - 1) * max - writer << callLink((Map) stepAttrs.clone()) { laststep.toString() } + writer << callLink(new LinkedHashMap(stepAttrs)) { laststep.toString() } } } // display next link when not on laststep unless omitNext is true if (currentstep < laststep && !attrs.boolean('omitNext')) { linkParams.offset = offset + max - writer << callLink(appendClass((Map) linkTagAttrs.clone(), 'nextLink')) { + writer << callLink(appendClass(new LinkedHashMap(linkTagAttrs), 'nextLink')) { (attrs.next ? attrs.next : messageSource.getMessage('paginate.next', null, messageSource.getMessage('default.paginate.next', null, 'Next', locale), locale)) } } @@ -277,7 +277,7 @@ class UrlMappingTagLib implements TagLibrary { * @attr params A map containing URL query parameters * @attr class CSS class name */ - Closure sortableColumn = { Map attrs -> + def sortableColumn(Map attrs) { def writer = out if (!attrs.property) { throwTagError('Tag [sortableColumn] is missing required attribute [property]') diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy index c8a3f376b97..7037c317f89 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy @@ -65,7 +65,7 @@ class ValidationTagLib implements TagLibrary { * @attr encodeAs The name of a codec to apply, i.e. HTML, JavaScript, URL etc * @attr locale override locale to use instead of the one detected */ - Closure fieldError = { attrs, body -> + def fieldError(Map attrs, Closure body) { def bean = attrs.bean def field = attrs.field def encodeAs = attrs.encodeAs @@ -91,7 +91,7 @@ class ValidationTagLib implements TagLibrary { * @attr valueMessagePrefix Setting this allows the value to be resolved from the I18n messages. * */ - Closure fieldValue = { attrs, body -> + def fieldValue(Map attrs, Closure body) { def bean = attrs.bean String field = attrs.field?.toString() if (!bean || !field) { @@ -130,7 +130,7 @@ class ValidationTagLib implements TagLibrary { return rejectedValue } - def extractErrors(attrs) { + private def extractErrors(Map attrs) { def model = attrs.model def checkList = [] if (attrs.containsKey('bean')) { @@ -186,7 +186,7 @@ class ValidationTagLib implements TagLibrary { * @attr field The field of the bean or model reference to check * @attr model The model reference to check for errors */ - Closure hasErrors = { attrs, body -> + def hasErrors(Map attrs, Closure body) { def errorsList = extractErrors(attrs) if (errorsList) { out << body() @@ -201,16 +201,16 @@ class ValidationTagLib implements TagLibrary { * @attr field The field of the bean or model reference to check * @attr model The model reference to check for errors */ - Closure eachError = { attrs, body -> + def eachError(Map attrs, Closure body) { eachErrorInternal(attrs, body, true) } - def eachErrorInternal(attrs, body, boolean outputResult = false) { + private def eachErrorInternal(Map attrs, Closure body, boolean outputResult = false) { def errorsList = extractErrors(attrs) eachErrorInternalForList(attrs, errorsList, body, outputResult) } - def eachErrorInternalForList(attrs, errorsList, body, boolean outputResult = false) { + private def eachErrorInternalForList(Map attrs, errorsList, Closure body, boolean outputResult = false) { def var = attrs.var def field = attrs.field @@ -290,12 +290,12 @@ class ValidationTagLib implements TagLibrary { * @attr encodeAs The name of a codec to apply, i.e. HTML, JavaScript, URL etc * @attr locale override locale to use instead of the one detected */ - Closure message = { attrs -> + def message(Map attrs) { messageImpl(attrs) } @CompileStatic - def messageImpl(Map attrs) { + private def messageImpl(Map attrs) { Locale locale = FormatTagLib.resolveLocale(attrs.locale) def tagSyntaxCall = (attrs instanceof GroovyPageAttributes) ? attrs.isGspTagSyntaxCall() : false @@ -383,7 +383,7 @@ class ValidationTagLib implements TagLibrary { * @attr form REQUIRED the HTML form name * @attr againstClass REQUIRED the domain class name */ - Closure validate = { attrs, body -> + def validate(Map attrs, Closure body) { def form = attrs.form if (!form) { throwTagError('Tag [validate] is missing required attribute [form]') diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy new file mode 100644 index 00000000000..ae636b4996b --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.artefact.Artefact +import grails.compiler.GrailsCompileStatic +import grails.testing.web.taglib.TagLibUnitTest +import spock.lang.Specification + +class MethodDefinedTagLibSpec extends Specification implements TagLibUnitTest { + + void setupSpec() { + mockTagLibs(MethodTagLib, SharedNsMethodTagLib, SharedNsClosureTagLib, StaticMethodTagLib) + } + + void "method tag can use implicit attrs"() { + expect: + applyTemplate('') == 'duh - is this' + } + + void "method tag can bind named attribute to typed argument"() { + expect: + applyTemplate('') == 'duh - typed' + } + void "method tag can bind multiple named attributes to multiple typed arguments"() { + expect: + applyTemplate('') == 'hello-world' + } + + void "method tag can use implicit body closure"() { + expect: + applyTemplate('abc') == 'before-abc-after' + } + + void "closure tag remains supported"() { + expect: + applyTemplate('') == 'legacy-duh' + } + + void "multiple taglibs sharing the same namespace resolve independently"() { + expect: + applyTemplate(' ') == 'method-1 closure-2' + } + + void "statically compiled method tag can use implicit attrs and typed args"() { + expect: + applyTemplate(' ') == 'duh - static implicit duh2 - static typed' + } + + void "statically compiled method tag can render body"() { + expect: + applyTemplate('abc') == 'before-abc-after' + } +} + +@GrailsCompileStatic +@Artefact('TagLib') +class StaticMethodTagLib { + def staticImplicitTag() { + Map tagAttrs = (Map) propertyMissing('attrs') + out << "${tagAttrs.blah} - static implicit" + } + + def staticTypedTag(String blah) { + out << "${blah} - static typed" + } + + def staticBodyTag() { + Closure tagBody = (Closure) propertyMissing('body') + out << "before-${tagBody?.call()}-after" + } +} + +@Artefact('TagLib') +class MethodTagLib { + def methodTag() { + out << "${attrs.blah} - is this" + } + + def typedTag(String blah) { + out << "${blah} - typed" + } + def multiTypedTag(String first, String second) { + out << "${first}-${second}" + } + + def bodyTag() { + out << "before-${body()}-after" + } + + Closure legacyTag = { attrs, body -> + out << "legacy-${attrs.blah}" + } +} + +@Artefact('TagLib') +class SharedNsMethodTagLib { + static namespace = 'shared' + + def fromMethod(String one) { + out << "method-${one}" + } +} + +@Artefact('TagLib') +class SharedNsClosureTagLib { + static namespace = 'shared' + + Closure fromClosure = { attrs -> + out << "closure-${attrs.two}" + } +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodVsClosureTagInvocationBenchmarkSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodVsClosureTagInvocationBenchmarkSpec.groovy new file mode 100644 index 00000000000..49fcea19243 --- /dev/null +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodVsClosureTagInvocationBenchmarkSpec.groovy @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.web.taglib + +import grails.artefact.Artefact +import grails.testing.web.taglib.TagLibUnitTest +import spock.lang.Specification + +class MethodVsClosureTagInvocationBenchmarkSpec extends Specification implements TagLibUnitTest { + + void 'benchmark method invocation versus closure invocation for taglibs'() { + given: + int warmupIterations = 50 + int measureIterations = 300 + String closureTemplate = '' + String methodTemplate = '' + + expect: + applyTemplate(closureTemplate) == '123' + applyTemplate(methodTemplate) == '123' + + when: + warmupIterations.times { + applyTemplate(closureTemplate) + applyTemplate(methodTemplate) + } + + long closureNanos = measureNanos(measureIterations) { + applyTemplate(closureTemplate) + } + long methodNanos = measureNanos(measureIterations) { + applyTemplate(methodTemplate) + } + + double closurePerOpMicros = (closureNanos / (double) measureIterations) / 1_000d + double methodPerOpMicros = (methodNanos / (double) measureIterations) / 1_000d + double ratio = methodPerOpMicros / closurePerOpMicros + + println "BENCHMARK taglib invocation: closure=${String.format('%.3f', closurePerOpMicros)}us/op, method=${String.format('%.3f', methodPerOpMicros)}us/op, method/closure=${String.format('%.3f', ratio)}" + + then: + closurePerOpMicros > 0d + methodPerOpMicros > 0d + } + + private static long measureNanos(int iterations, Closure work) { + long start = System.nanoTime() + iterations.times { + work.call() + } + System.nanoTime() - start + } +} + +@Artefact('TagLib') +class MethodVsClosureBenchmarkTagLib { + Closure closureTag = { attrs -> + out << attrs.value + } + + def methodTag(String value) { + out << value + } +} diff --git a/grails-test-examples/app1/grails-app/controllers/functionaltests/MiscController.groovy b/grails-test-examples/app1/grails-app/controllers/functionaltests/MiscController.groovy index 5afd2cb8a61..a70f511f4c6 100644 --- a/grails-test-examples/app1/grails-app/controllers/functionaltests/MiscController.groovy +++ b/grails-test-examples/app1/grails-app/controllers/functionaltests/MiscController.groovy @@ -43,6 +43,10 @@ class MiscController { [:] } + def tagMethods() { + render(view: 'tagMethods') + } + def interceptedByInterceptor() { // no op } diff --git a/grails-test-examples/app1/grails-app/taglib/functionaltests/MethodTagLib.groovy b/grails-test-examples/app1/grails-app/taglib/functionaltests/MethodTagLib.groovy new file mode 100644 index 00000000000..3c6ee4fd9e7 --- /dev/null +++ b/grails-test-examples/app1/grails-app/taglib/functionaltests/MethodTagLib.groovy @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package functionaltests +import grails.compiler.GrailsCompileStatic + +@GrailsCompileStatic +class MethodTagLib { + + def implicitTag() { + Map tagAttrs = (Map) propertyMissing('attrs') + out << "${tagAttrs.blah} - implicit" + } + + def typedTag(String blah) { + out << "${blah} - typed" + } + + def multiTypedTag(String first, String second) { + out << "${first}-${second}" + } + + def bodyTag() { + Closure tagBody = (Closure) propertyMissing('body') + out << "before-${tagBody?.call()}-after" + } + + Closure legacyTag = { Map attrs -> + out << "legacy-${attrs.blah}" + } +} diff --git a/grails-test-examples/app1/grails-app/taglib/functionaltests/SharedNsClosureTagLib.groovy b/grails-test-examples/app1/grails-app/taglib/functionaltests/SharedNsClosureTagLib.groovy new file mode 100644 index 00000000000..ee2fca5b390 --- /dev/null +++ b/grails-test-examples/app1/grails-app/taglib/functionaltests/SharedNsClosureTagLib.groovy @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package functionaltests + +class SharedNsClosureTagLib { + static namespace = 'shared' + + Closure fromClosure = { attrs -> + out << "closure-${attrs.two}" + } +} diff --git a/grails-test-examples/app1/grails-app/taglib/functionaltests/SharedNsMethodTagLib.groovy b/grails-test-examples/app1/grails-app/taglib/functionaltests/SharedNsMethodTagLib.groovy new file mode 100644 index 00000000000..81eb5583cf0 --- /dev/null +++ b/grails-test-examples/app1/grails-app/taglib/functionaltests/SharedNsMethodTagLib.groovy @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package functionaltests + +class SharedNsMethodTagLib { + static namespace = 'shared' + + def fromMethod(String one) { + out << "method-${one}" + } +} diff --git a/grails-test-examples/app1/grails-app/views/misc/tagMethods.gsp b/grails-test-examples/app1/grails-app/views/misc/tagMethods.gsp new file mode 100644 index 00000000000..93bd9f87e0a --- /dev/null +++ b/grails-test-examples/app1/grails-app/views/misc/tagMethods.gsp @@ -0,0 +1,7 @@ + + + +abc + + + diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/MiscFunctionalSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/MiscFunctionalSpec.groovy index ac24114827b..7315cacc77d 100644 --- a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/MiscFunctionalSpec.groovy +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/MiscFunctionalSpec.groovy @@ -45,4 +45,18 @@ class MiscFunctionalSpec extends ContainerGebSpec { expect: to(PlaceHolderConfigPage) } + + void 'Test method-defined taglibs render expected output end to end'() { + when: + go('/misc/tagMethods') + + then: + pageSource.contains('duh - implicit') + pageSource.contains('duh2 - typed') + pageSource.contains('hello-world') + pageSource.contains('before-abc-after') + pageSource.contains('legacy-legacy') + pageSource.contains('method-1') + pageSource.contains('closure-2') + } } diff --git a/grails-test-examples/demo33/grails-app/taglib/demo/FirstTagLib.groovy b/grails-test-examples/demo33/grails-app/taglib/demo/FirstTagLib.groovy index 01019daa967..0f653bf6abe 100644 --- a/grails-test-examples/demo33/grails-app/taglib/demo/FirstTagLib.groovy +++ b/grails-test-examples/demo33/grails-app/taglib/demo/FirstTagLib.groovy @@ -25,7 +25,7 @@ class FirstTagLib { static namespace = 'one' - def sayHello = { attrs -> + def sayHello() { out << 'BEFORE ' // this is invoking a tag from another tag library diff --git a/grails-test-examples/demo33/grails-app/taglib/demo/SampleTagLib.groovy b/grails-test-examples/demo33/grails-app/taglib/demo/SampleTagLib.groovy index 2cb34d391a0..22f70e5d142 100644 --- a/grails-test-examples/demo33/grails-app/taglib/demo/SampleTagLib.groovy +++ b/grails-test-examples/demo33/grails-app/taglib/demo/SampleTagLib.groovy @@ -28,23 +28,23 @@ class SampleTagLib { // end::basic_declaration[] // tag::hello_world[] - def helloWorld = { attrs -> + def helloWorld() { out << 'Hello, World!' } // end::hello_world[] // tag::say_hello[] - def sayHello = { attrs -> + def sayHello() { out << "Hello, ${attrs.name}!" } // end::say_hello[] // tag::render_some_number[] - def renderSomeNumber = { attrs -> + def renderSomeNumber() { int number = attrs.int('value', -1) out << "The Number Is ${number}" } // end::render_some_number[] - def renderMessage = { + def renderMessage() { out << message(code: 'some.custom.message', locale: request.locale) } // tag::basic_declaration[] diff --git a/grails-test-examples/demo33/grails-app/taglib/demo/SecondTagLib.groovy b/grails-test-examples/demo33/grails-app/taglib/demo/SecondTagLib.groovy index 8436d2a9953..7e97149060a 100644 --- a/grails-test-examples/demo33/grails-app/taglib/demo/SecondTagLib.groovy +++ b/grails-test-examples/demo33/grails-app/taglib/demo/SecondTagLib.groovy @@ -25,7 +25,7 @@ class SecondTagLib { static namespace = 'two' - def sayHello = { attrs -> + def sayHello() { out << 'Hello From SecondTagLib' } } diff --git a/grails-test-examples/plugins/loadafter/build.gradle b/grails-test-examples/plugins/loadafter/build.gradle index 65151fdeff9..6120dca94b1 100644 --- a/grails-test-examples/plugins/loadafter/build.gradle +++ b/grails-test-examples/plugins/loadafter/build.gradle @@ -40,7 +40,7 @@ dependencies { api 'com.h2database:h2' api 'jakarta.servlet:jakarta.servlet-api' - implementation "org.apache.grails:grails-spring-security:$grailsSpringSecurityVersion" + implementation 'org.apache.grails:grails-spring-security:7.0.1' console 'org.apache.grails:grails-console' } From 106c92c8418272fbbd5fbd00ff4817d5773e8c20 Mon Sep 17 00:00:00 2001 From: David Estes Date: Wed, 25 Feb 2026 21:42:25 -0500 Subject: [PATCH 02/29] Document typed attribute-to-argument binding for method TagLib syntax Co-Authored-By: Oz --- .../src/en/guide/theWebLayer/gsp/taglibs.adoc | 18 ++++++++++++++++++ .../src/en/guide/theWebLayer/taglibs.adoc | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc index 54b5a5d71a4..e9d6e2ad4cf 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc @@ -53,6 +53,24 @@ class SimpleTagLib { } ---- +For method-based tags, named attributes can also bind directly to method signature arguments: + +[source,groovy] +---- +class SimpleTagLib { + def greeting(String name) { + out << "Hello, ${name}!" + } +} +---- + +Used as: + +[source,xml] +---- + +---- + As demonstrated above there is an implicit `out` variable that refers to the output `Writer` which you can use to append content to the response. Then you can reference the tag inside your GSP; no imports are necessary: [source,xml] diff --git a/grails-doc/src/en/guide/theWebLayer/taglibs.adoc b/grails-doc/src/en/guide/theWebLayer/taglibs.adoc index 692fde9aca9..e3d14b35ef5 100644 --- a/grails-doc/src/en/guide/theWebLayer/taglibs.adoc +++ b/grails-doc/src/en/guide/theWebLayer/taglibs.adoc @@ -53,6 +53,24 @@ class SimpleTagLib { } ---- +For method-based tags, named attributes can also bind directly to method signature arguments: + +[source,groovy] +---- +class SimpleTagLib { + def greeting(String name) { + out << "Hello, ${name}!" + } +} +---- + +Used as: + +[source,xml] +---- + +---- + As demonstrated above there is an implicit `out` variable that refers to the output `Writer` which you can use to append content to the response. Then you can reference the tag inside your GSP; no imports are necessary: [source,xml] From 1d708b15683d9a762dc735e7be389d8d210e85f4 Mon Sep 17 00:00:00 2001 From: David Estes Date: Wed, 25 Feb 2026 21:43:08 -0500 Subject: [PATCH 03/29] Add typed method-argument TagLib example to simple tags guide Co-Authored-By: Oz --- .../src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc index 330422a336e..cc1e37b9458 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/simpleTags.adoc @@ -33,6 +33,15 @@ The above uses Java's `SimpleDateFormat` class to format a date and then write i ---- +With method-based tags, attributes may also bind directly to method parameters by name: + +[source,groovy] +---- +def dateFormat(String format, Date date) { + out << new java.text.SimpleDateFormat(format).format(date) +} +---- + With simple tags sometimes you need to write HTML mark-up to the response. One approach would be to embed the content directly: [source,groovy] From f42e058c6edd0fe67119e5b45b059beaeefd7b8e Mon Sep 17 00:00:00 2001 From: David Estes Date: Wed, 25 Feb 2026 21:59:21 -0500 Subject: [PATCH 04/29] Bind Map tag method args by name except reserved attrs map - 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 --- .../org/grails/taglib/TagMethodInvoker.java | 4 ++-- .../web/taglib/MethodDefinedTagLibSpec.groovy | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index 5669a775127..cf19e2fb4b0 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -126,8 +126,9 @@ private static Object[] toMethodArguments(Method method, Map attrs, Closur Parameter[] parameters = method.getParameters(); Object[] args = new Object[parameters.length]; for (int i = 0; i < parameters.length; i++) { + String parameterName = parameters[i].getName(); Class parameterType = parameters[i].getType(); - if (Map.class.isAssignableFrom(parameterType)) { + if (Map.class.isAssignableFrom(parameterType) && "attrs".equals(parameterName)) { args[i] = attrs; continue; } @@ -135,7 +136,6 @@ private static Object[] toMethodArguments(Method method, Map attrs, Closur args[i] = body != null ? body : TagOutput.EMPTY_BODY_CLOSURE; continue; } - String parameterName = parameters[i].getName(); Object value = attrs != null ? attrs.get(parameterName) : null; if (value == null && parameters.length == 1 && attrs != null && attrs.size() == 1) { value = attrs.values().iterator().next(); diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy index ae636b4996b..3deeb6171c9 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy @@ -43,6 +43,15 @@ class MethodDefinedTagLibSpec extends Specification implements TagLibUnitTest') == 'hello-world' } + void "method tag can bind map-valued attribute to map-typed argument by parameter name"() { + expect: + applyTemplate('') == 'v' + } + void "method tag still supports reserved attrs map parameter"() { + expect: + applyTemplate('') == 'duh' + } + void "method tag can use implicit body closure"() { expect: applyTemplate('abc') == 'before-abc-after' @@ -100,6 +109,14 @@ class MethodTagLib { out << "${first}-${second}" } + def mapValueTag(Map config) { + out << "${config.k}" + } + + def attrsMapTag(Map attrs) { + out << "${attrs.blah}" + } + def bodyTag() { out << "before-${body()}-after" } From 0eb22dd0039c564338fed33125249a8b57a8303a Mon Sep 17 00:00:00 2001 From: David Estes Date: Wed, 25 Feb 2026 22:20:47 -0500 Subject: [PATCH 05/29] Refine FormTagLib typed overloads and guard non-public tag methods - 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 --- .../src/en/guide/theWebLayer/gsp/taglibs.adoc | 14 +++++ .../src/en/guide/theWebLayer/taglibs.adoc | 14 +++++ .../plugins/web/taglib/FormTagLib.groovy | 55 +++++++++++++++++++ .../web/taglib/MethodDefinedTagLibSpec.groovy | 21 +++++++ 4 files changed, 104 insertions(+) diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc index e9d6e2ad4cf..cfe5b738cd5 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs.adoc @@ -71,6 +71,20 @@ Used as: ---- +For tags with strict validation/error handling, you can keep a `Map attrs` handler and add typed overloads that delegate to it: + +[source,groovy] +---- +def field(Map attrs) { + // existing validation + rendering path +} + +def field(String type, Map attrs) { + attrs.type = type + field(attrs) +} +---- + As demonstrated above there is an implicit `out` variable that refers to the output `Writer` which you can use to append content to the response. Then you can reference the tag inside your GSP; no imports are necessary: [source,xml] diff --git a/grails-doc/src/en/guide/theWebLayer/taglibs.adoc b/grails-doc/src/en/guide/theWebLayer/taglibs.adoc index e3d14b35ef5..fa63946ca70 100644 --- a/grails-doc/src/en/guide/theWebLayer/taglibs.adoc +++ b/grails-doc/src/en/guide/theWebLayer/taglibs.adoc @@ -71,6 +71,20 @@ Used as: ---- +For tags with strict validation/error handling, keep a `Map attrs` handler and add typed overloads that delegate to it: + +[source,groovy] +---- +def field(Map attrs) { + // existing validation + rendering path +} + +def field(String type, Map attrs) { + attrs.type = type + field(attrs) +} +---- + As demonstrated above there is an implicit `out` variable that refers to the output `Writer` which you can use to append content to the response. Then you can reference the tag inside your GSP; no imports are necessary: [source,xml] diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy index c4e084c9bb7..5b357f107f2 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy @@ -104,8 +104,19 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * * @attr name REQUIRED the field name * @attr value the field value + * @param name required field name + * @param attrs optional tag attributes including value, id, class and other HTML attributes */ def textField(Map attrs) { + textFieldImpl(attrs) + } + + def textField(String name, Map attrs) { + attrs.name = name + textFieldImpl(attrs) + } + + private void textFieldImpl(Map attrs) { attrs.type = 'text' attrs.tagName = 'textField' fieldImpl(out, attrs) @@ -118,8 +129,19 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * * @attr name REQUIRED the field name * @attr value the field value + * @param name required field name + * @param attrs optional tag attributes including value, id, class and other HTML attributes */ def passwordField(Map attrs) { + passwordFieldImpl(attrs) + } + + def passwordField(String name, Map attrs) { + attrs.name = name + passwordFieldImpl(attrs) + } + + private void passwordFieldImpl(Map attrs) { attrs.type = 'password' attrs.tagName = 'passwordField' fieldImpl(out, attrs) @@ -130,8 +152,19 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * * @attr name REQUIRED the field name * @attr value the field value + * @param name required field name + * @param attrs optional tag attributes including value and additional HTML attributes */ def hiddenField(Map attrs) { + hiddenFieldTagImpl(attrs) + } + + def hiddenField(String name, Map attrs) { + attrs.name = name + hiddenFieldTagImpl(attrs) + } + + private void hiddenFieldTagImpl(Map attrs) { hiddenFieldImpl(out, attrs) } @@ -150,8 +183,19 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar * @attr value the button text * @attr type input type; defaults to 'submit' * @attr event the webflow event id + * @param name required field name + * @param attrs optional tag attributes including value, type, event and additional HTML attributes */ def submitButton(Map attrs) { + submitButtonImpl(attrs) + } + + def submitButton(String name, Map attrs) { + attrs.name = name + submitButtonImpl(attrs) + } + + private void submitButtonImpl(Map attrs) { attrs.type = attrs.type ?: 'submit' attrs.tagName = 'submitButton' if (request.flowExecutionKey) { @@ -173,6 +217,17 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar fieldImpl(out, attrs) } + /** + * A general tag for creating fields with method-argument binding for required type. + * + * @param type required input type + * @param attrs tag attributes, including required name/field and optional value/id/class/etc + */ + def field(String type, Map attrs) { + attrs.type = type + field(attrs) + } + @CompileStatic private def fieldImpl(GrailsPrintWriter out, Map attrs) { resolveAttributes(attrs) diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy index 3deeb6171c9..4c9e3d6dfb2 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy @@ -21,6 +21,7 @@ package org.grails.web.taglib import grails.artefact.Artefact import grails.compiler.GrailsCompileStatic import grails.testing.web.taglib.TagLibUnitTest +import org.grails.taglib.GrailsTagException import spock.lang.Specification class MethodDefinedTagLibSpec extends Specification implements TagLibUnitTest { @@ -76,6 +77,18 @@ class MethodDefinedTagLibSpec extends Specification implements TagLibUnitTestabc') == 'before-abc-after' } + + void "private and protected methods are not exposed as tags"() { + when: + applyTemplate('') + then: + thrown(GrailsTagException) + + when: + applyTemplate('') + then: + thrown(GrailsTagException) + } } @GrailsCompileStatic @@ -117,6 +130,14 @@ class MethodTagLib { out << "${attrs.blah}" } + private def privateOnlyTag() { + out << 'private' + } + + protected def protectedOnlyTag() { + out << 'protected' + } + def bodyTag() { out << "before-${body()}-after" } From 3854084fd3f61bb327e1cf75a2e5590e295eaa06 Mon Sep 17 00:00:00 2001 From: David Estes Date: Wed, 25 Feb 2026 22:27:01 -0500 Subject: [PATCH 06/29] Simplify FormTagLib typed overloads by removing redundant name reassignment - 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 --- .../groovy/org/grails/plugins/web/taglib/FormTagLib.groovy | 4 ---- 1 file changed, 4 deletions(-) diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy index 5b357f107f2..7d4f84b8a8e 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy @@ -112,7 +112,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar } def textField(String name, Map attrs) { - attrs.name = name textFieldImpl(attrs) } @@ -137,7 +136,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar } def passwordField(String name, Map attrs) { - attrs.name = name passwordFieldImpl(attrs) } @@ -160,7 +158,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar } def hiddenField(String name, Map attrs) { - attrs.name = name hiddenFieldTagImpl(attrs) } @@ -191,7 +188,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar } def submitButton(String name, Map attrs) { - attrs.name = name submitButtonImpl(attrs) } From 78c0d84a3b5bf75b9e66fcb09f78d38fba7dacc4 Mon Sep 17 00:00:00 2001 From: David Estes Date: Wed, 25 Feb 2026 22:43:28 -0500 Subject: [PATCH 07/29] Optimize tag invocation dispatch with concurrent-safe method caching - 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 --- .../org/grails/taglib/TagMethodInvoker.java | 34 +++++++++++++------ .../groovy/grails/artefact/TagLibrary.groovy | 11 +++--- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index cf19e2fb4b0..4644417709c 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,6 +35,22 @@ import groovy.lang.MissingMethodException; public final class TagMethodInvoker { + private static final ClassValue>> INVOKABLE_METHODS_BY_NAME = new ClassValue<>() { + @Override + protected Map> computeValue(Class type) { + Map> methodsByName = new HashMap<>(); + for (Method method : type.getMethods()) { + if (isTagMethodCandidate(method)) { + methodsByName.computeIfAbsent(method.getName(), ignored -> new ArrayList<>()).add(method); + } + } + Map> immutableMethodsByName = new HashMap<>(methodsByName.size()); + for (Map.Entry> entry : methodsByName.entrySet()) { + immutableMethodsByName.put(entry.getKey(), Collections.unmodifiableList(entry.getValue())); + } + return Collections.unmodifiableMap(immutableMethodsByName); + } + }; private TagMethodInvoker() { } @@ -70,19 +87,16 @@ public static Collection getInvokableTagMethodNames(Class tagLibClass } public static boolean hasInvokableTagMethod(GroovyObject tagLib, String tagName) { - for (Method method : tagLib.getClass().getMethods()) { - if (isTagMethodCandidate(method) && method.getName().equals(tagName)) { - return true; - } - } - return false; + List methods = INVOKABLE_METHODS_BY_NAME.get(tagLib.getClass()).get(tagName); + return methods != null && !methods.isEmpty(); } public static Object invokeTagMethod(GroovyObject tagLib, String tagName, Map attrs, Closure body) { - for (Method method : tagLib.getClass().getMethods()) { - if (!isTagMethodCandidate(method) || !method.getName().equals(tagName)) { - continue; - } + List methods = INVOKABLE_METHODS_BY_NAME.get(tagLib.getClass()).get(tagName); + if (methods == null) { + throw new MissingMethodException(tagName, tagLib.getClass(), new Object[] { attrs, body }); + } + for (Method method : methods) { Object[] args = toMethodArguments(method, attrs, body); if (args != null) { try { diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy index 74a02b72c3e..ffe068ff0d8 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy @@ -149,13 +149,13 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker } TagLibraryLookup gspTagLibraryLookup = getTagLibraryLookup() if (gspTagLibraryLookup != null) { - boolean methodTagFallback = false Object result = gspTagLibraryLookup.lookupNamespaceDispatcher(name) if (result == null) { - String namespace = getTaglibNamespace() - GroovyObject tagLibrary = gspTagLibraryLookup.lookupTagLibrary(namespace, name) + String resolvedNamespace = getTaglibNamespace() + GroovyObject tagLibrary = gspTagLibraryLookup.lookupTagLibrary(resolvedNamespace, name) if (tagLibrary == null) { + resolvedNamespace = TagOutput.DEFAULT_NAMESPACE tagLibrary = gspTagLibraryLookup.lookupTagLibrary(TagOutput.DEFAULT_NAMESPACE, name) } @@ -164,8 +164,7 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker if (tagProperty instanceof Closure) { result = ((Closure) tagProperty).clone() } else if (TagMethodInvoker.hasInvokableTagMethod(tagLibrary, name)) { - methodTagFallback = true - final String currentNamespace = namespace + final String currentNamespace = resolvedNamespace result = { Map attrs = [:], Closure body = null -> Object output = TagOutput.captureTagOutput(gspTagLibraryLookup, currentNamespace, name, attrs, body, OutputContextLookupHelper.lookupOutputContext()) boolean gspTagSyntaxCall = attrs instanceof GroovyPageAttributes && ((GroovyPageAttributes) attrs).isGspTagSyntaxCall() @@ -179,7 +178,7 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker } } } - if (result != null && !Environment.isDevelopmentMode() && !methodTagFallback) { + if (result != null && !Environment.isDevelopmentMode()) { MetaClass mc = GrailsMetaClassUtils.getExpandoMetaClass(getClass()) // Register the property for the already-existing singleton instance of the taglib From 4d8238ca016482b483e171ebf9cbfc563a2019c8 Mon Sep 17 00:00:00 2001 From: David Estes Date: Thu, 26 Feb 2026 07:52:03 -0500 Subject: [PATCH 08/29] Fix method taglib compatibility regressions across fields and gsp - 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 --- .../plugin/formfields/FormFieldsTagLib.groovy | 20 ++++++------- .../grails/taglib/TagLibraryMetaUtils.groovy | 28 +++++++++++++++++++ .../web/taglib/UrlMappingTagLib.groovy | 4 +-- .../web/taglib/ValidationTagLib.groovy | 4 +++ 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy b/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy index 3631c820012..a790ddea8fa 100644 --- a/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy +++ b/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy @@ -247,7 +247,7 @@ class FormFieldsTagLib { widgetAttrs.remove('class') } if (hasBody(body)) { - model.widget = raw(body(model + [attrs: widgetAttrs] + widgetAttrs)) + model.widget = body(model + [attrs: widgetAttrs] + widgetAttrs)?.encodeAsRaw() } else { model.widget = renderWidget(propertyAccessor, model, widgetAttrs, widgetFolder ?: templatesFolder, theme) } @@ -389,7 +389,7 @@ class FormFieldsTagLib { out << render(template: "/templates/_fields/$template", model: attrs + [domainClass: domainClass, domainProperties: properties]) { prop -> BeanPropertyAccessor propertyAccessor = resolveProperty(bean, prop.name) Map model = buildModel(propertyAccessor, attrs, 'HTML') - out << raw(renderDisplayWidget(propertyAccessor, model, attrs, templatesFolder, theme)) + out << renderDisplayWidget(propertyAccessor, model, attrs, templatesFolder, theme)?.encodeAsRaw() } } } else { @@ -415,7 +415,7 @@ class FormFieldsTagLib { String widgetsFolderToUse = widgetFolder ?: templatesFolder if (hasBody(body)) { - model.widget = raw(body(model + [attrs: widgetAttrs] + widgetAttrs)) + model.widget = body(model + [attrs: widgetAttrs] + widgetAttrs)?.encodeAsRaw() model.value = body(model) } else { model.widget = renderDisplayWidget(propertyAccessor, model, widgetAttrs, widgetsFolderToUse, theme) @@ -426,7 +426,7 @@ class FormFieldsTagLib { if (template) { out << render(template: template.path, plugin: template.plugin, model: model + [attrs: wrapperAttrs] + wrapperAttrs) } else { - out << raw(renderDisplayWidget(propertyAccessor, model, attrs, widgetsFolderToUse, theme)) + out << renderDisplayWidget(propertyAccessor, model, attrs, widgetsFolderToUse, theme)?.encodeAsRaw() } } @@ -715,11 +715,11 @@ class FormFieldsTagLib { } } - CharSequence renderDefaultInput(Map model, Map attrs = [:]) { + protected CharSequence renderDefaultInput(Map model, Map attrs = [:]) { renderDefaultInput(null, model, attrs) } - CharSequence renderDefaultInput(BeanPropertyAccessor propertyAccessor, Map model, Map attrs = [:]) { + protected CharSequence renderDefaultInput(BeanPropertyAccessor propertyAccessor, Map model, Map attrs = [:]) { Constrained constrained = (Constrained) model.constraints attrs.name = (model.prefix ?: '') + model.property attrs.value = model.value @@ -778,7 +778,7 @@ class FormFieldsTagLib { } } - CharSequence renderDateTimeInput(Map model, Map attrs) { + protected CharSequence renderDateTimeInput(Map model, Map attrs) { attrs.precision = model.type in [java.sql.Time, LocalDateTime] ? 'minute' : 'day' if (!model.required) { attrs.noSelection = ['': ''] @@ -787,7 +787,7 @@ class FormFieldsTagLib { return g.datePicker(attrs) } - CharSequence renderStringInput(Map model, Map attrs) { + protected CharSequence renderStringInput(Map model, Map attrs) { Constrained constrained = (Constrained) model.constraints if (!attrs.type) { @@ -819,7 +819,7 @@ class FormFieldsTagLib { return g.field(attrs) } - CharSequence renderNumericInput(BeanPropertyAccessor propertyAccessor, Map model, Map attrs) { + protected CharSequence renderNumericInput(BeanPropertyAccessor propertyAccessor, Map model, Map attrs) { Constrained constrained = (Constrained) model.constraints if (!attrs.type && constrained?.inList) { @@ -992,7 +992,7 @@ class FormFieldsTagLib { buffer << render(template: '/templates/_fields/list', model: [domainClass: domainClass, domainProperties: properties]) { prop -> def propertyAccessor = resolveProperty(bean, prop.name) def model = buildModel(propertyAccessor, attrs) - out << raw(renderDisplayWidget(propertyAccessor, model, attrs, templatesFolder, theme)) + out << renderDisplayWidget(propertyAccessor, model, attrs, templatesFolder, theme)?.encodeAsRaw() } buffer.buffer } diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy index bad77a60702..dd9adf4378c 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy @@ -176,6 +176,34 @@ class TagLibraryMetaUtils { Object[] args = makeObjectArray(argsParam) final GroovyObject tagBean = gspTagLibraryLookup.lookupTagLibrary(namespace, name) if (tagBean != null) { + Object tagLibProp = TagMethodInvoker.getClosureTagProperty(tagBean, name) + if (tagLibProp instanceof Closure || TagMethodInvoker.hasInvokableTagMethod(tagBean, name)) { + Map attrs = [:] + Object body = null + switch (args.length) { + case 0: + break + case 1: + if (args[0] instanceof Map) { + attrs = (Map) args[0] + } else if (args[0] instanceof Closure || args[0] instanceof CharSequence) { + body = args[0] + } else { + attrs = [(name): args[0]] + } + break + case 2: + if (args[0] instanceof Map) { + attrs = (Map) args[0] + body = args[1] + } + break + } + if (addMethodsToMetaClass) { + registerMethodMissingForTags(mc, gspTagLibraryLookup, namespace, name) + } + return captureTagOutputForMethodCall(gspTagLibraryLookup, namespace, name, attrs, body) + } MetaClass tagBeanMc = tagBean.getMetaClass() final MetaMethod method = tagBeanMc.respondsTo(tagBean, name, args).find { it } if (method != null) { diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy index 2d695e987ca..181667313af 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy @@ -119,8 +119,8 @@ class UrlMappingTagLib implements TagLibrary { * @attr mapping The named URL mapping to use to rewrite the link * @attr fragment The link fragment (often called anchor tag) to use */ - def paginate(Map attrsMap) { - TypeConvertingMap attrs = (TypeConvertingMap) attrsMap + def paginate(Map attrs) { + attrs = (TypeConvertingMap) attrs def writer = out if (attrs.total == null) { throwTagError('Tag [paginate] is missing required attribute [total]') diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy index 7037c317f89..6e961c48e37 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy @@ -122,6 +122,10 @@ class ValidationTagLib implements TagLibrary { } } + def fieldValue(Map attrs) { + fieldValue(attrs, null) + } + private Object parseForRejectedValue(bean, field) { def rejectedValue = bean for (String fieldPart in field.split('\\.')) { From 40d2458855cc74775c43220aeb1ea29e686ea14e Mon Sep 17 00:00:00 2001 From: David Estes Date: Thu, 26 Feb 2026 13:56:16 -0500 Subject: [PATCH 09/29] Fix method-based TagLib dispatch: prevent helper methods from being registered 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 --- .../plugin/formfields/FormFieldsTagLib.groovy | 18 ++++++------ .../groovy/org/grails/gsp/GroovyPage.java | 5 ++-- .../grails/taglib/TagLibraryMetaUtils.groovy | 8 ++--- .../org/grails/taglib/TagMethodContext.java | 1 + .../org/grails/taglib/TagMethodInvoker.java | 12 ++++++-- .../web/taglib/ApplicationTagLib.groovy | 4 +-- .../plugins/web/taglib/FormTagLib.groovy | 29 +------------------ .../plugins/web/taglib/FormatTagLib.groovy | 2 +- .../web/taglib/UrlMappingTagLib.groovy | 2 +- .../web/taglib/ValidationTagLib.groovy | 8 ++--- 10 files changed, 33 insertions(+), 56 deletions(-) diff --git a/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy b/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy index a790ddea8fa..fb5eebe4abd 100644 --- a/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy +++ b/grails-fields/grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy @@ -682,7 +682,7 @@ class FormFieldsTagLib { message ?: defaultMessage } - protected CharSequence renderDefaultField(Map model, Map attrs = [:]) { + private CharSequence renderDefaultField(Map model, Map attrs = [:]) { List classes = [attrs['class'] ?: 'fieldcontain'] if (model.invalid) classes << (attrs.remove('invalidClass') ?: 'error') if (model.required) classes << (attrs.remove('requiredClass') ?: 'required') @@ -715,11 +715,11 @@ class FormFieldsTagLib { } } - protected CharSequence renderDefaultInput(Map model, Map attrs = [:]) { + private CharSequence renderDefaultInput(Map model, Map attrs = [:]) { renderDefaultInput(null, model, attrs) } - protected CharSequence renderDefaultInput(BeanPropertyAccessor propertyAccessor, Map model, Map attrs = [:]) { + private CharSequence renderDefaultInput(BeanPropertyAccessor propertyAccessor, Map model, Map attrs = [:]) { Constrained constrained = (Constrained) model.constraints attrs.name = (model.prefix ?: '') + model.property attrs.value = model.value @@ -778,7 +778,7 @@ class FormFieldsTagLib { } } - protected CharSequence renderDateTimeInput(Map model, Map attrs) { + private CharSequence renderDateTimeInput(Map model, Map attrs) { attrs.precision = model.type in [java.sql.Time, LocalDateTime] ? 'minute' : 'day' if (!model.required) { attrs.noSelection = ['': ''] @@ -787,7 +787,7 @@ class FormFieldsTagLib { return g.datePicker(attrs) } - protected CharSequence renderStringInput(Map model, Map attrs) { + private CharSequence renderStringInput(Map model, Map attrs) { Constrained constrained = (Constrained) model.constraints if (!attrs.type) { @@ -819,7 +819,7 @@ class FormFieldsTagLib { return g.field(attrs) } - protected CharSequence renderNumericInput(BeanPropertyAccessor propertyAccessor, Map model, Map attrs) { + private CharSequence renderNumericInput(BeanPropertyAccessor propertyAccessor, Map model, Map attrs) { Constrained constrained = (Constrained) model.constraints if (!attrs.type && constrained?.inList) { @@ -845,12 +845,12 @@ class FormFieldsTagLib { } @CompileStatic - protected NumberFormat getNumberFormatter() { + private NumberFormat getNumberFormatter() { NumberFormat.getInstance(getLocale()) } @CompileStatic - protected Locale getLocale() { + private Locale getLocale() { def locale def request = GrailsWebRequest.lookup()?.currentRequest if (request instanceof HttpServletRequest) { @@ -863,7 +863,7 @@ class FormFieldsTagLib { } @CompileStatic - protected String getDefaultNumberType(Map model) { + private String getDefaultNumberType(Map model) { Class modelType = (Class) model.type def typeName = modelType.simpleName.toLowerCase() diff --git a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java index 0abec8524cf..49f3068538e 100644 --- a/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java +++ b/grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java @@ -47,10 +47,10 @@ import org.grails.taglib.AbstractTemplateVariableBinding; import org.grails.taglib.GrailsTagException; import org.grails.taglib.GroovyPageAttributes; -import org.grails.taglib.TagMethodContext; -import org.grails.taglib.TagMethodInvoker; import org.grails.taglib.TagBodyClosure; import org.grails.taglib.TagLibraryLookup; +import org.grails.taglib.TagMethodContext; +import org.grails.taglib.TagMethodInvoker; import org.grails.taglib.TagOutput; import org.grails.taglib.encoder.OutputContext; import org.grails.taglib.encoder.OutputEncodingStack; @@ -502,6 +502,7 @@ private void invokeTagLibMethod(String tagName, String tagNamespace, GroovyObjec if (encodeAsPushedToStack) outputStack.pop(); } } + private void outputTagResult(boolean returnsObject, Object tagresult) { if (returnsObject && tagresult != null && !(tagresult instanceof Writer)) { if (tagresult instanceof String && isHtmlPart((String) tagresult)) { diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy index dd9adf4378c..f4c16ac6954 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy @@ -53,13 +53,13 @@ class TagLibraryMetaUtils { @CompileStatic static void registerTagMethodContextMetaProperties(MetaClass metaClass) { GroovyObject mc = (GroovyObject) metaClass - if (!metaClass.hasProperty("attrs") && !doesMethodExist(metaClass, "getAttrs", [] as Class[])) { - mc.setProperty("getAttrs") { -> + if (!metaClass.hasProperty('attrs') && !doesMethodExist(metaClass, 'getAttrs', [] as Class[])) { + mc.setProperty('getAttrs') { -> TagMethodContext.currentAttrs() } } - if (!metaClass.hasProperty("body") && !doesMethodExist(metaClass, "getBody", [] as Class[])) { - mc.setProperty("getBody") { -> + if (!metaClass.hasProperty('body') && !doesMethodExist(metaClass, 'getBody', [] as Class[])) { + mc.setProperty('getBody') { -> TagMethodContext.currentBody() } } diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodContext.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodContext.java index 9b17cafc494..f521c56d2d6 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodContext.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodContext.java @@ -27,6 +27,7 @@ public final class TagMethodContext { private static final ThreadLocal> CONTEXT_STACK = ThreadLocal.withInitial(ArrayDeque::new); + private TagMethodContext() { } diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index 4644417709c..fa308d88438 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -18,11 +18,11 @@ */ package org.grails.taglib; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Parameter; -import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -46,11 +46,17 @@ protected Map> computeValue(Class type) { } Map> immutableMethodsByName = new HashMap<>(methodsByName.size()); for (Map.Entry> entry : methodsByName.entrySet()) { - immutableMethodsByName.put(entry.getKey(), Collections.unmodifiableList(entry.getValue())); + // Sort methods by descending parameter count so that (Map, Closure) signatures + // are tried before (Map) signatures, preventing infinite recursion when a + // 1-arg convenience overload delegates to the 2-arg variant. + List sorted = new ArrayList<>(entry.getValue()); + sorted.sort((a, b) -> Integer.compare(b.getParameterCount(), a.getParameterCount())); + immutableMethodsByName.put(entry.getKey(), Collections.unmodifiableList(sorted)); } return Collections.unmodifiableMap(immutableMethodsByName); } }; + private TagMethodInvoker() { } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy index fa00d0ec6b9..ab4ec75b45d 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy @@ -320,7 +320,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr * @attr uri * @attr type */ - protected renderResourceLink(attrs) { + private renderResourceLink(attrs) { def uri = attrs.remove('uri') def type = attrs.remove('type') if (!type) { @@ -368,7 +368,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr } @CompileStatic - protected String doCreateLink(Map attrs) { + private String doCreateLink(Map attrs) { Map urlAttrs = attrs if (attrs.url instanceof Map) { urlAttrs = (Map) attrs.url diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy index 7d4f84b8a8e..6f66a47bc3b 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormTagLib.groovy @@ -111,10 +111,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar textFieldImpl(attrs) } - def textField(String name, Map attrs) { - textFieldImpl(attrs) - } - private void textFieldImpl(Map attrs) { attrs.type = 'text' attrs.tagName = 'textField' @@ -135,10 +131,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar passwordFieldImpl(attrs) } - def passwordField(String name, Map attrs) { - passwordFieldImpl(attrs) - } - private void passwordFieldImpl(Map attrs) { attrs.type = 'password' attrs.tagName = 'passwordField' @@ -157,10 +149,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar hiddenFieldTagImpl(attrs) } - def hiddenField(String name, Map attrs) { - hiddenFieldTagImpl(attrs) - } - private void hiddenFieldTagImpl(Map attrs) { hiddenFieldImpl(out, attrs) } @@ -187,10 +175,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar submitButtonImpl(attrs) } - def submitButton(String name, Map attrs) { - submitButtonImpl(attrs) - } - private void submitButtonImpl(Map attrs) { attrs.type = attrs.type ?: 'submit' attrs.tagName = 'submitButton' @@ -213,17 +197,6 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar fieldImpl(out, attrs) } - /** - * A general tag for creating fields with method-argument binding for required type. - * - * @param type required input type - * @param attrs tag attributes, including required name/field and optional value/id/class/etc - */ - def field(String type, Map attrs) { - attrs.type = type - field(attrs) - } - @CompileStatic private def fieldImpl(GrailsPrintWriter out, Map attrs) { resolveAttributes(attrs) @@ -990,7 +963,7 @@ class FormTagLib implements ApplicationContextAware, InitializingBean, TagLibrar } } - def renderNoSelectionOption(noSelectionKey, noSelectionValue, value) { + private def renderNoSelectionOption(noSelectionKey, noSelectionValue, value) { renderNoSelectionOptionImpl(out, noSelectionKey, noSelectionValue, value) } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormatTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormatTagLib.groovy index 2825d7ac772..d02ca324b02 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormatTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/FormatTagLib.groovy @@ -56,7 +56,7 @@ class FormatTagLib implements TagLibrary { GrailsTagDateHelper grailsTagDateHelper @CompileStatic - String messageHelper(String code, Object defaultMessage = null, List args = null, Locale locale = null) { + private String messageHelper(String code, Object defaultMessage = null, List args = null, Locale locale = null) { if (locale == null) { locale = GrailsWebRequest.lookup().getLocale() } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy index 181667313af..f683e4719a3 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy @@ -90,7 +90,7 @@ class UrlMappingTagLib implements TagLibrary { } } - Map appendClass(Map attrs, String cssClass) { + private Map appendClass(Map attrs, String cssClass) { attrs['class'] = [attrs['class'] ?: '', cssClass].join(' ').trim() attrs } diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy index 6e961c48e37..e52f100d12c 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ValidationTagLib.groovy @@ -49,7 +49,7 @@ import org.grails.web.servlet.mvc.GrailsWebRequest @TagLib class ValidationTagLib implements TagLibrary { - static returnObjectForTags = ['message', 'fieldError', 'formatValue'] + static returnObjectForTags = ['message', 'fieldError'] MessageSource messageSource CodecLookup codecLookup @@ -122,10 +122,6 @@ class ValidationTagLib implements TagLibrary { } } - def fieldValue(Map attrs) { - fieldValue(attrs, null) - } - private Object parseForRejectedValue(bean, field) { def rejectedValue = bean for (String fieldPart in field.split('\\.')) { @@ -469,7 +465,7 @@ class ValidationTagLib implements TagLibrary { * formatted according to the current user's locale during the * conversion to a string. */ - def formatValue(value, String propertyPath = null, Boolean tagSyntaxCall = false) { + private def formatValue(value, String propertyPath = null, Boolean tagSyntaxCall = false) { def webRequest = GrailsWebRequest.lookup() PropertyEditorRegistry registry = webRequest.getPropertyEditorRegistry() PropertyEditor editor = registry.findCustomEditor(value.getClass(), propertyPath) From 6683ca60c3278e607f9366f9b7070acd6ff8183b Mon Sep 17 00:00:00 2001 From: David Estes Date: Thu, 26 Feb 2026 18:23:54 -0500 Subject: [PATCH 10/29] fixing more issues with testing --- .../org/grails/taglib/TagMethodInvoker.java | 21 ++++++++++++++++++- .../TagLibArtefactTypeAstTransformation.java | 2 +- .../app1/grails-app/views/misc/tagMethods.gsp | 18 ++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index fa308d88438..993ac704727 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -29,17 +29,33 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import groovy.lang.Closure; import groovy.lang.GroovyObject; import groovy.lang.MissingMethodException; public final class TagMethodInvoker { + + /** + * Method names inherited from framework traits and interfaces that must never + * be treated as tag methods. These come from {@code TagLibrary}, + * {@code TagLibraryInvoker}, {@code WebAttributes}, {@code ServletAttributes}, + * and related Spring interfaces. + */ + private static final Set FRAMEWORK_METHOD_NAMES = Set.of( + "initializeTagLibrary", + "raw", + "throwTagError", + "withCodec", + "currentRequestAttributes" + ); + private static final ClassValue>> INVOKABLE_METHODS_BY_NAME = new ClassValue<>() { @Override protected Map> computeValue(Class type) { Map> methodsByName = new HashMap<>(); - for (Method method : type.getMethods()) { + for (Method method : type.getDeclaredMethods()) { if (isTagMethodCandidate(method)) { methodsByName.computeIfAbsent(method.getName(), ignored -> new ArrayList<>()).add(method); } @@ -139,6 +155,9 @@ public static boolean isTagMethodCandidate(Method method) { if ("invokeMethod".equals(name) || "methodMissing".equals(name) || "propertyMissing".equals(name)) { return false; } + if (FRAMEWORK_METHOD_NAMES.contains(name)) { + return false; + } return method.getDeclaringClass() != Object.class && method.getDeclaringClass() != GroovyObject.class; } diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java index 0df4fda9be7..fe9e022a67c 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/gsp/taglib/compiler/TagLibArtefactTypeAstTransformation.java @@ -18,8 +18,8 @@ */ package grails.gsp.taglib.compiler; -import groovy.lang.Closure; +import groovy.lang.Closure; import org.codehaus.groovy.ast.AnnotationNode; import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.FieldNode; diff --git a/grails-test-examples/app1/grails-app/views/misc/tagMethods.gsp b/grails-test-examples/app1/grails-app/views/misc/tagMethods.gsp index 93bd9f87e0a..7cf17fa3f4e 100644 --- a/grails-test-examples/app1/grails-app/views/misc/tagMethods.gsp +++ b/grails-test-examples/app1/grails-app/views/misc/tagMethods.gsp @@ -1,3 +1,21 @@ +<%-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--%> From e53430a155f9e8e1d29dd0575bc0631c6146d6b2 Mon Sep 17 00:00:00 2001 From: David Estes Date: Tue, 3 Mar 2026 10:51:06 -0500 Subject: [PATCH 11/29] missing some methods --- .../grails/taglib/TagLibraryMetaUtils.groovy | 7 ---- .../org/grails/taglib/TagMethodInvoker.java | 33 +++++++++++++++++-- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy index f4c16ac6954..bbcb4db8d47 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy @@ -29,7 +29,6 @@ import org.springframework.context.ApplicationContext import grails.core.gsp.GrailsTagLibClass import grails.util.GrailsClassUtils import org.grails.taglib.encoder.OutputContextLookupHelper -import org.grails.taglib.encoder.OutputEncodingStack class TagLibraryMetaUtils { @@ -113,12 +112,6 @@ class TagLibraryMetaUtils { @CompileStatic private static Object captureTagOutputForMethodCall(TagLibraryLookup gspTagLibraryLookup, String namespace, String name, Map attrs, Object body) { Object output = TagOutput.captureTagOutput(gspTagLibraryLookup, namespace, name, attrs, body, OutputContextLookupHelper.lookupOutputContext()) - boolean returnsObject = gspTagLibraryLookup.doesTagReturnObject(namespace, name) - boolean gspTagSyntaxCall = attrs instanceof GroovyPageAttributes && ((GroovyPageAttributes) attrs).isGspTagSyntaxCall() - if (gspTagSyntaxCall && !returnsObject && output != null) { - OutputEncodingStack.currentStack().taglibWriter.print(output) - return null - } return output } diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index 993ac704727..27b5720faef 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -55,7 +56,7 @@ public final class TagMethodInvoker { @Override protected Map> computeValue(Class type) { Map> methodsByName = new HashMap<>(); - for (Method method : type.getDeclaredMethods()) { + for (Method method : getCandidateMethods(type)) { if (isTagMethodCandidate(method)) { methodsByName.computeIfAbsent(method.getName(), ignored -> new ArrayList<>()).add(method); } @@ -100,7 +101,7 @@ public static Collection getInvokableTagMethodNames(Class tagLibClass return Collections.emptyList(); } List names = new ArrayList<>(); - for (Method method : tagLibClass.getDeclaredMethods()) { + for (Method method : getCandidateMethods(tagLibClass)) { if (isTagMethodCandidate(method)) { names.add(method.getName()); } @@ -161,6 +162,34 @@ public static boolean isTagMethodCandidate(Method method) { return method.getDeclaringClass() != Object.class && method.getDeclaringClass() != GroovyObject.class; } + private static Collection getCandidateMethods(Class type) { + List methods = new ArrayList<>(); + Set seenSignatures = new HashSet<>(); + Class current = type; + while (current != null && current != Object.class && current != GroovyObject.class) { + for (Method method : current.getDeclaredMethods()) { + String signature = signature(method); + if (seenSignatures.add(signature)) { + methods.add(method); + } + } + current = current.getSuperclass(); + } + return methods; + } + + private static String signature(Method method) { + StringBuilder builder = new StringBuilder(method.getName()).append('('); + Class[] parameterTypes = method.getParameterTypes(); + for (int i = 0; i < parameterTypes.length; i++) { + if (i > 0) { + builder.append(','); + } + builder.append(parameterTypes[i].getName()); + } + return builder.append(')').toString(); + } + private static Object[] toMethodArguments(Method method, Map attrs, Closure body) { Parameter[] parameters = method.getParameters(); Object[] args = new Object[parameters.length]; From c3b4cf038df20898c3cc169848bf7d97406ec7d5 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Mon, 23 Mar 2026 14:39:22 -0400 Subject: [PATCH 12/29] styling: fix import order --- .../src/main/groovy/org/grails/taglib/TagMethodInvoker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index 27b5720faef..ed6dd986267 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -26,8 +26,8 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashSet; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; From c1db2b3fa8e17175f8961d2f880fe10ea647c4c1 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Mon, 23 Mar 2026 16:11:27 -0400 Subject: [PATCH 13/29] Fix raw lookup to be CompileStatic --- .../main/groovy/grails/artefact/TagLibrary.groovy | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy index ffe068ff0d8..e4989aceca5 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/TagLibrary.groovy @@ -18,7 +18,6 @@ */ package grails.artefact -import groovy.transform.CompileDynamic import groovy.transform.CompileStatic import org.codehaus.groovy.runtime.InvokerHelper @@ -67,14 +66,12 @@ trait TagLibrary implements WebAttributes, ServletAttributes, TagLibraryInvoker } } - @CompileDynamic - def raw(Object value) { - if (rawEncoder == null) { - rawEncoder = WithCodecHelper.lookupEncoder(grailsApplication, 'Raw') - if (rawEncoder == null) - return InvokerHelper.invokeMethod(value, 'encodeAsRaw', null) + Object raw(Object value) { + Encoder encoder = WithCodecHelper.lookupEncoder(getGrailsApplication(), 'Raw') + if (encoder == null) { + return InvokerHelper.invokeMethod(value, 'encodeAsRaw', null) } - return rawEncoder.encode(value) + return encoder.encode(value) } /** From 5103ee38bce46c1b8e60ea59f14288b497aa24e4 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sat, 25 Apr 2026 12:29:04 -0400 Subject: [PATCH 14/29] feature - ability to rerun tests without having to rerun all tasks --- DEVELOPMENT.md | 12 ++++++++++++ build-logic/docs-core/build.gradle | 6 ++++++ build.gradle | 4 ++++ gradle/functional-test-config.gradle | 5 +++++ gradle/grails-data-tck-config.gradle | 6 ++++++ gradle/hibernate5-test-config.gradle | 5 +++++ gradle/mongodb-forked-test-config.gradle | 5 +++++ gradle/mongodb-test-config.gradle | 5 +++++ gradle/test-config.gradle | 7 +++++-- grails-forge/build.gradle | 9 +++++++++ grails-forge/gradle/test-config.gradle | 5 +++++ grails-gradle/build.gradle | 4 ++++ grails-gradle/gradle/test-config.gradle | 5 +++++ grails-test-suite-uber/build.gradle | 5 +++++ 14 files changed, 81 insertions(+), 2 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 43541a0ce45..09d9c1862e9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,5 +44,17 @@ These can be set on the command line like so: * `skipMongodbTests` - does not run mongo related tests * `skipTests` - no tests will run +## Environment variables + +* `DO_NOT_CACHE_TESTS` - set to `1` (or any truthy value) to force every `Test` task to run + every invocation, without needing `--rerun-tasks`. This skips both Gradle's build cache + and the `up-to-date` check for tests, while leaving the rest of the build (compilation, + resource processing, etc.) cacheable. Useful when chasing flaky tests that depend on + test execution order across runs. + + ``` + DO_NOT_CACHE_TESTS=1 ./gradlew :grails-gsp:test + ``` + ## Start a mongo docker container (containers will start by default) `docker run -d --name mongo-on-docker -p 27017:27017 mongo` \ No newline at end of file diff --git a/build-logic/docs-core/build.gradle b/build-logic/docs-core/build.gradle index 856409ccdf8..5e9aa649056 100644 --- a/build-logic/docs-core/build.gradle +++ b/build-logic/docs-core/build.gradle @@ -88,6 +88,12 @@ def java17moduleReflectionCompatibilityArguments = [ '--add-opens=java.base/java.util=ALL-UNNAMED' ] tasks.withType(Test).configureEach { + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + boolean doNotCacheTests = System.getenv().get('DO_NOT_CACHE_TESTS') as Boolean + outputs.cacheIf { !doNotCacheTests } + outputs.upToDateWhen { !doNotCacheTests } + onlyIf { ![ 'onlyFunctionalTests', diff --git a/build.gradle b/build.gradle index eb7b9989196..36371cc2df9 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,10 @@ ext { DateTimeFormatter.ISO_DATE.format(LocalDate.ofInstant(buildInstant as Instant, ZoneOffset.UTC)) buildDate = (buildInstant as Instant).atZone(ZoneOffset.UTC) // for reproducible builds isCiBuild = System.getenv().get('CI') as Boolean + // Set DO_NOT_CACHE_TESTS=1 to force test tasks to re-run on every invocation without + // needing --rerun-tasks. Useful for repeatedly running the same test command while + // chasing flaky tests across runs. + doNotCacheTests = System.getenv().get('DO_NOT_CACHE_TESTS') as Boolean configuredTestParallel = findProperty('maxTestParallel') as Integer ?: (isCiBuild ? 4 : Runtime.runtime.availableProcessors() * 3 / 4 as int ?: 1) excludeUnusedTransDeps = findProperty('excludeUnusedTransDeps') diff --git a/gradle/functional-test-config.gradle b/gradle/functional-test-config.gradle index 55e9798284d..0157ba7b172 100644 --- a/gradle/functional-test-config.gradle +++ b/gradle/functional-test-config.gradle @@ -62,6 +62,11 @@ tasks.withType(Test).configureEach { Test task -> boolean isHibernate5 = !project.name.startsWith('grails-test-examples-hibernate5') boolean isMongo = !project.name.startsWith('grails-test-examples-mongodb') + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + task.outputs.cacheIf { !doNotCacheTests } + task.outputs.upToDateWhen { !doNotCacheTests } + onlyIf { if (project.hasProperty('skipFunctionalTests')) { return false diff --git a/gradle/grails-data-tck-config.gradle b/gradle/grails-data-tck-config.gradle index 91f301cef7d..63f417cc762 100644 --- a/gradle/grails-data-tck-config.gradle +++ b/gradle/grails-data-tck-config.gradle @@ -59,6 +59,12 @@ cleanupTask.configure { Task it -> tasks.withType(Test).configureEach { Test it -> it.dependsOn(extractTck) + + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + it.outputs.cacheIf { !doNotCacheTests } + it.outputs.upToDateWhen { !doNotCacheTests } + it.onlyIf { if (project.hasProperty('onlyFunctionalTests')) { return false diff --git a/gradle/hibernate5-test-config.gradle b/gradle/hibernate5-test-config.gradle index 36f5841a48a..2b5882b0361 100644 --- a/gradle/hibernate5-test-config.gradle +++ b/gradle/hibernate5-test-config.gradle @@ -23,6 +23,11 @@ dependencies { } tasks.withType(Test).configureEach { + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + outputs.cacheIf { !doNotCacheTests } + outputs.upToDateWhen { !doNotCacheTests } + onlyIf { ![ 'onlyFunctionalTests', diff --git a/gradle/mongodb-forked-test-config.gradle b/gradle/mongodb-forked-test-config.gradle index f2f2215914e..609f38e6c53 100644 --- a/gradle/mongodb-forked-test-config.gradle +++ b/gradle/mongodb-forked-test-config.gradle @@ -27,6 +27,11 @@ tasks.named('compileTestGroovy', GroovyCompile) { } tasks.withType(Test).configureEach { + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + outputs.cacheIf { !doNotCacheTests } + outputs.upToDateWhen { !doNotCacheTests } + onlyIf { ![ 'onlyFunctionalTests', diff --git a/gradle/mongodb-test-config.gradle b/gradle/mongodb-test-config.gradle index 09fcbf9c30c..2e877182220 100644 --- a/gradle/mongodb-test-config.gradle +++ b/gradle/mongodb-test-config.gradle @@ -27,6 +27,11 @@ tasks.named('compileTestGroovy', GroovyCompile) { } tasks.withType(Test).configureEach { + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + outputs.cacheIf { !doNotCacheTests } + outputs.upToDateWhen { !doNotCacheTests } + onlyIf { ![ 'onlyFunctionalTests', diff --git a/gradle/test-config.gradle b/gradle/test-config.gradle index 3e6c5f1c83e..ae1a2b407be 100644 --- a/gradle/test-config.gradle +++ b/gradle/test-config.gradle @@ -43,8 +43,11 @@ tasks.withType(GroovyCompile).configureEach { } tasks.withType(Test).configureEach { - // Disable build cache for tests in CI to ensure they always run - outputs.cacheIf { !isCiBuild } + // Disable build cache for tests in CI to ensure they always run. + // Also honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test + // command without --rerun-tasks (and without recompiling everything else). + outputs.cacheIf { !isCiBuild && !doNotCacheTests } + outputs.upToDateWhen { !doNotCacheTests } onlyIf { ![ diff --git a/grails-forge/build.gradle b/grails-forge/build.gradle index bd7eadc9e1b..1f6bcfe4169 100644 --- a/grails-forge/build.gradle +++ b/grails-forge/build.gradle @@ -37,6 +37,10 @@ ext { DateTimeFormatter.ISO_DATE.format(LocalDate.ofInstant(buildInstant as Instant, ZoneOffset.UTC)) buildDate = (buildInstant as Instant).atZone(ZoneOffset.UTC) // for reproducible builds isCiBuild = System.getenv().containsKey('CI') + // Set DO_NOT_CACHE_TESTS=1 to force test tasks to re-run on every invocation without + // needing --rerun-tasks. Useful for repeatedly running the same test command while + // chasing flaky tests across runs. + doNotCacheTests = System.getenv().get('DO_NOT_CACHE_TESTS') as Boolean } allprojects { @@ -45,6 +49,11 @@ allprojects { gradle.includedBuild('grails-core').task(':publishAllPublicationsToTestCaseMavenRepoRepository'), gradle.includedBuild('grails-gradle').task(':publishAllPublicationsToTestCaseMavenRepoRepository') ) + + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + testTask.outputs.cacheIf { !doNotCacheTests } + testTask.outputs.upToDateWhen { !doNotCacheTests } } } diff --git a/grails-forge/gradle/test-config.gradle b/grails-forge/gradle/test-config.gradle index 2f840abf40f..40557e445c9 100644 --- a/grails-forge/gradle/test-config.gradle +++ b/grails-forge/gradle/test-config.gradle @@ -18,6 +18,11 @@ */ tasks.withType(Test).configureEach { + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + outputs.cacheIf { !doNotCacheTests } + outputs.upToDateWhen { !doNotCacheTests } + onlyIf { ![ 'skipTests' diff --git a/grails-gradle/build.gradle b/grails-gradle/build.gradle index e7cb03479e1..b63d39f913e 100644 --- a/grails-gradle/build.gradle +++ b/grails-gradle/build.gradle @@ -41,6 +41,10 @@ ext { isReleaseVersion = Boolean.parseBoolean(System.getenv('GRAILS_PUBLISH_RELEASE')) isCiBuild = System.getenv().containsKey('CI') + // Set DO_NOT_CACHE_TESTS=1 to force test tasks to re-run on every invocation without + // needing --rerun-tasks. Useful for repeatedly running the same test command while + // chasing flaky tests across runs. + doNotCacheTests = System.getenv().get('DO_NOT_CACHE_TESTS') as Boolean configuredTestParallel = findProperty('maxTestParallel') as Integer ?: (isCiBuild ? 3 : Runtime.runtime.availableProcessors() * 3/4 as int ?: 1) } diff --git a/grails-gradle/gradle/test-config.gradle b/grails-gradle/gradle/test-config.gradle index 8f166e00978..03c03c5a494 100644 --- a/grails-gradle/gradle/test-config.gradle +++ b/grails-gradle/gradle/test-config.gradle @@ -27,6 +27,11 @@ def java17moduleReflectionCompatibilityArguments = [ ] tasks.withType(Test).configureEach { + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + outputs.cacheIf { !doNotCacheTests } + outputs.upToDateWhen { !doNotCacheTests } + onlyIf { ![ 'onlyFunctionalTests', diff --git a/grails-test-suite-uber/build.gradle b/grails-test-suite-uber/build.gradle index aa7b5efeb82..9169f2a6d21 100644 --- a/grails-test-suite-uber/build.gradle +++ b/grails-test-suite-uber/build.gradle @@ -131,6 +131,11 @@ tasks.named('isolatedTestsTwo', Test) { } tasks.withType(Test).configureEach { + // Honor DO_NOT_CACHE_TESTS=1 so developers can repeatedly invoke the same test command + // without --rerun-tasks (and without recompiling everything else). + outputs.cacheIf { !doNotCacheTests } + outputs.upToDateWhen { !doNotCacheTests } + onlyIf { !testSkippingProperties.any {project.hasProperty(it) } } useJUnitPlatform() maxParallelForks = configuredTestParallel From c1d07b0019c4b98019f9ceabaab4070a4c01440a Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sat, 25 Apr 2026 12:29:28 -0400 Subject: [PATCH 15/29] Test pollution fixes & cleanup --- .../RestfulReverseUrlRenderingTests.groovy | 22 --------- .../testing/web/UrlMappingsUnitTest.groovy | 41 +++++++++++++++++ .../spock/UrlMappingCleanupInterceptor.groovy | 45 +++++++++++++++++++ .../UrlMappingSetupSpecInterceptor.groovy | 16 ++++++- .../spock/WebTestingSupportExtension.groovy | 3 ++ 5 files changed, 104 insertions(+), 23 deletions(-) create mode 100644 grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingCleanupInterceptor.groovy diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy index a2b787f7115..63bcc2a05bf 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy @@ -20,7 +20,6 @@ package org.grails.web.mapping import grails.artefact.Artefact import grails.testing.web.UrlMappingsUnitTest -import org.grails.core.artefact.UrlMappingsArtefactHandler import spock.lang.Specification /** @@ -30,27 +29,6 @@ import spock.lang.Specification */ class RestfulReverseUrlRenderingTests extends Specification implements UrlMappingsUnitTest { - def setup() { - // Access config to ensure grailsApplication is initialized and Holders is populated. - // This is necessary before accessing grailsApplication properties. - assert config != null - - // Reset URL mappings to prevent test environment pollution - clear any mappings from previous tests - // and re-register this test's URL mappings with a fresh holder bean - if (grailsApplication instanceof grails.core.DefaultGrailsApplication) { - grailsApplication.@artefactInfo.remove(UrlMappingsArtefactHandler.TYPE) - } - // Re-register this test's URL mappings and recreate the holder bean - mockArtefact(RestfulReverseUrlMappings) - } - - def cleanup() { - // Clear URL mappings artefacts added by this test to prevent test environment pollution - if (grailsApplication instanceof grails.core.DefaultGrailsApplication) { - grailsApplication.@artefactInfo.remove(UrlMappingsArtefactHandler.TYPE) - } - } - def testLinkTagRendering() { when: def template = 'create' diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy index 2bb5a433290..d4b3c07bd66 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy @@ -58,6 +58,33 @@ trait UrlMappingsUnitTest implements ParameterizedGrailsUnitTest, GrailsWe getArtefactInstance() } + /** + * Re-registers this spec's URL mappings and rebuilds the {@code grailsUrlMappingsHolder} + * bean so each feature method starts with only this spec's mappings active. Other specs + * running in the same JVM may register their own mappings or clear the registry; calling + * this restores the state expected by this spec. + */ + void resetUrlMappingsForFeature() { + Class typeUnderTest = getTypeUnderTest() + if (typeUnderTest != null) { + mockArtefact(typeUnderTest) + } + } + + /** + * Clears any URL mapping artefacts registered by this spec so that subsequent specs in + * the same JVM start from a clean registry. The holder bean itself is left intact — + * destroying it would leave {@code linkGenerator} (held by tag libraries) pointing at a + * destroyed bean for any non-URL-mapping spec that runs next. The next + * {@link UrlMappingsUnitTest}-based spec rebuilds the holder via {@link #mockArtefact}. + */ + @CompileDynamic + void cleanupUrlMappingsAfterFeature() { + if (grailsApplication instanceof grails.core.DefaultGrailsApplication) { + grailsApplication.@artefactInfo.remove(UrlMappingsArtefactHandler.TYPE) + } + } + /** * @return The {@link UrlMappingsHolder} bean */ @@ -424,8 +451,22 @@ trait UrlMappingsUnitTest implements ParameterizedGrailsUnitTest, GrailsWe @CompileDynamic void mockArtefact(Class urlMappingsClass) { + // Clear any URL mapping artefacts registered by another spec so that this spec's + // reverse-mapping lookups don't see foreign mappings. addArtefact() only appends. + if (grailsApplication instanceof grails.core.DefaultGrailsApplication) { + grailsApplication.@artefactInfo.remove(UrlMappingsArtefactHandler.TYPE) + } grailsApplication.addArtefact(UrlMappingsArtefactHandler.TYPE, urlMappingsClass) + // Destroy the cached holder so that defineBeans rebuilds it from the freshly registered + // artefacts. Re-registering the bean definition alone does not evict the cached singleton + // from the bean factory, so without this another spec's holder may stay live after we + // redefine the bean definition. + def beanFactory = applicationContext.beanFactory + if (beanFactory.containsSingleton('grailsUrlMappingsHolder')) { + beanFactory.destroySingleton('grailsUrlMappingsHolder') + } + defineBeans { grailsUrlMappingsHolder(UrlMappingsHolderFactoryBean) { delegate.grailsApplication = grailsApplication diff --git a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingCleanupInterceptor.groovy b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingCleanupInterceptor.groovy new file mode 100644 index 00000000000..b13d6b6df21 --- /dev/null +++ b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingCleanupInterceptor.groovy @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.grails.testing.spock + +import groovy.transform.CompileStatic + +import org.spockframework.runtime.extension.IMethodInterceptor +import org.spockframework.runtime.extension.IMethodInvocation + +import grails.testing.web.UrlMappingsUnitTest + +/** + * Clears URL mapping artefacts and the cached holder bean after each feature method so that + * other specs running later in the same JVM (including specs that don't implement + * {@link UrlMappingsUnitTest}) start from a clean URL mappings registry. + */ +@CompileStatic +class UrlMappingCleanupInterceptor implements IMethodInterceptor { + + @Override + void intercept(IMethodInvocation invocation) throws Throwable { + try { + invocation.proceed() + } finally { + ((UrlMappingsUnitTest) invocation.instance).cleanupUrlMappingsAfterFeature() + } + } +} diff --git a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingSetupSpecInterceptor.groovy b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingSetupSpecInterceptor.groovy index 0958a68fa8f..10290ef2c35 100644 --- a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingSetupSpecInterceptor.groovy +++ b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/UrlMappingSetupSpecInterceptor.groovy @@ -26,12 +26,26 @@ import org.spockframework.runtime.extension.IMethodInvocation import grails.testing.web.UrlMappingsUnitTest +/** + * Wires up controllers and URL mappings for {@link UrlMappingsUnitTest}-based specs. + * + * Acts as both a {@code setupSpec} interceptor (mocking declared controllers once per spec + * class) and a {@code setup} interceptor (re-registering this spec's URL mappings before + * every feature method). The per-feature pass guarantees that a spec running after another + * spec in the same JVM sees only its own URL mappings, regardless of what the previous spec + * did to the artefact registry or the {@code grailsUrlMappingsHolder} bean. + */ @CompileStatic class UrlMappingSetupSpecInterceptor implements IMethodInterceptor { @Override void intercept(IMethodInvocation invocation) throws Throwable { - ((UrlMappingsUnitTest) invocation.instance).configuredMockedControllers() + UrlMappingsUnitTest spec = (UrlMappingsUnitTest) invocation.instance + if (invocation.method.kind.isSpecScopedFixtureMethod()) { + spec.configuredMockedControllers() + } else { + spec.resetUrlMappingsForFeature() + } invocation.proceed() } } diff --git a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebTestingSupportExtension.groovy b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebTestingSupportExtension.groovy index 9df90f7211e..fd651c79dd9 100644 --- a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebTestingSupportExtension.groovy +++ b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebTestingSupportExtension.groovy @@ -36,6 +36,7 @@ class WebTestingSupportExtension extends AbstractGlobalExtension { WebCleanupInterceptor webCleanupInterceptor = new WebCleanupInterceptor() WebCleanupSpecInterceptor webCleanupSpecInterceptor = new WebCleanupSpecInterceptor() UrlMappingSetupSpecInterceptor urlMappingSetupSpecInterceptor = new UrlMappingSetupSpecInterceptor() + UrlMappingCleanupInterceptor urlMappingCleanupInterceptor = new UrlMappingCleanupInterceptor() InterceptorSetupSpecInterceptor interceptorSetupSpecInterceptor = new InterceptorSetupSpecInterceptor() void visitSpec(SpecInfo spec) { @@ -48,6 +49,8 @@ class WebTestingSupportExtension extends AbstractGlobalExtension { if (UrlMappingsUnitTest.isAssignableFrom(spec.reflection)) { spec.addSetupSpecInterceptor(urlMappingSetupSpecInterceptor) + spec.addSetupInterceptor(urlMappingSetupSpecInterceptor) + spec.addCleanupInterceptor(urlMappingCleanupInterceptor) } if (InterceptorUnitTest.isAssignableFrom(spec.reflection)) { From b7a7a5f8fd5dd9939978334f3fd6127b8cd84f84 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sun, 26 Apr 2026 20:47:07 -0400 Subject: [PATCH 16/29] Attempting to fix test pollution --- .../groovy/org/grails/taglib/TagLibraryLookup.java | 12 ++++++++++++ .../testing/spock/WebCleanupSpecInterceptor.groovy | 1 + 2 files changed, 13 insertions(+) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java index fb3e6ec3fd0..0558601c209 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryLookup.java @@ -201,6 +201,18 @@ public Set getAvailableNamespaces() { return namespaceDispatchers.keySet(); } + /** + * Clears all internal tag library caches. This should be called during + * test cleanup to prevent stale tag library references from leaking + * between test contexts. + */ + public void clear() { + tagNamespaces.clear(); + namespaceDispatchers.clear(); + tagsThatReturnObjectForNamespace.clear(); + encodeAsForTagNamespaces.clear(); + } + public Set getAvailableTags(String namespace) { Map tags = tagNamespaces.get(namespace); if (tags == null) { diff --git a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupSpecInterceptor.groovy b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupSpecInterceptor.groovy index 1bb7c86f4e2..9f4e8b03609 100644 --- a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupSpecInterceptor.groovy +++ b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupSpecInterceptor.groovy @@ -44,6 +44,7 @@ class WebCleanupSpecInterceptor implements IMethodInterceptor { try { def lookup = test.grailsApplication.mainContext.getBean(LazyTagLibraryLookup) if (lookup) { + lookup.clear() lookup.cleanTagLibsMetaClass() } } From 567d14d77da8aecee779838ee1e0b9abc582a8f3 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Mon, 27 Apr 2026 00:25:31 -0400 Subject: [PATCH 17/29] Prevent test pollution --- .../grails/web/layout/RenderTagLibTests.groovy | 3 +++ .../mapping/RestfulReverseUrlRenderingTests.groovy | 2 ++ .../web/mapping/ReverseUrlMappingTests.groovy | 1 + .../ReverseUrlMappingToDefaultActionTests.groovy | 2 ++ .../grails/web/mapping/RootUrlMappingTests.groovy | 1 + .../web/taglib/FormTagLibResourceTests.groovy | 1 + .../web/taglib/LinkRenderingTagLib2Tests.groovy | 1 + .../web/taglib/LinkRenderingTagLibTests.groovy | 1 + .../taglib/NamespacedNamedUrlMappingTests.groovy | 2 ++ .../taglib/OverlappingReverseMappedLinkTests.groovy | 2 ++ .../org/grails/web/taglib/RenderTagLibTests.groovy | 3 +++ .../grails/testing/web/UrlMappingsUnitTest.groovy | 13 +++++++++++++ 12 files changed, 32 insertions(+) diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderTagLibTests.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderTagLibTests.groovy index 5bd86b2b508..af94406d87d 100644 --- a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderTagLibTests.groovy +++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderTagLibTests.groovy @@ -30,6 +30,9 @@ import org.grails.core.io.MockStringResourceLoader import spock.lang.Specification class RenderTagLibTests extends Specification implements UrlMappingsUnitTest { + + boolean purgeTagLibMetaClass = true + void testPageProperty() { def template = '' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy index 63bcc2a05bf..0acfd933794 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy @@ -29,6 +29,8 @@ import spock.lang.Specification */ class RestfulReverseUrlRenderingTests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true + def testLinkTagRendering() { when: def template = 'create' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingTests.groovy index 367996d2b5c..6dc085a3b27 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingTests.groovy @@ -29,6 +29,7 @@ import spock.lang.Specification */ class ReverseUrlMappingTests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true def testLinkTagRendering() { when: diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingToDefaultActionTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingToDefaultActionTests.groovy index c5e10de490f..4f06e3c9dc0 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingToDefaultActionTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingToDefaultActionTests.groovy @@ -30,6 +30,8 @@ import spock.lang.Specification */ class ReverseUrlMappingToDefaultActionTests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true + @OnceBefore void mockControllers() { mockController(ReverseUrlMappingContentController) diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RootUrlMappingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RootUrlMappingTests.groovy index d8e57bc77d8..7bc8c5cb0c0 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RootUrlMappingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RootUrlMappingTests.groovy @@ -24,6 +24,7 @@ import spock.lang.Specification class RootUrlMappingTests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true def testMappingToController() { when: diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/FormTagLibResourceTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/FormTagLibResourceTests.groovy index 8a0f33509ce..416436a43dc 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/FormTagLibResourceTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/FormTagLibResourceTests.groovy @@ -24,6 +24,7 @@ import spock.lang.* class FormTagLibResourceTests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true def testResourceSave() { when: diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLib2Tests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLib2Tests.groovy index 282a6817dc7..abb3f70d490 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLib2Tests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLib2Tests.groovy @@ -24,6 +24,7 @@ import spock.lang.Specification class LinkRenderingTagLib2Tests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true def testLinkWithOnlyId() { when: diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLibTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLibTests.groovy index 0f915d38977..4ff021eb4a9 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLibTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLibTests.groovy @@ -24,6 +24,7 @@ import spock.lang.Specification class LinkRenderingTagLibTests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true def testMappingsWhichSpecifyAPlugin() { when: diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespacedNamedUrlMappingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespacedNamedUrlMappingTests.groovy index ddb38d8d037..20bc908dee1 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespacedNamedUrlMappingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespacedNamedUrlMappingTests.groovy @@ -24,6 +24,8 @@ import spock.lang.Specification class NamespacedNamedUrlMappingTests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true + def testLinkAttributes() { when: def template = 'Strawberry Licorice' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/OverlappingReverseMappedLinkTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/OverlappingReverseMappedLinkTests.groovy index e1df2464fea..170058813b4 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/OverlappingReverseMappedLinkTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/OverlappingReverseMappedLinkTests.groovy @@ -30,6 +30,8 @@ import spock.lang.Specification */ class OverlappingReverseMappedLinkTests extends Specification implements UrlMappingsUnitTest { + boolean purgeTagLibMetaClass = true + def testSimpleLink() { when: def template = 'link1' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RenderTagLibTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RenderTagLibTests.groovy index fe1b7bcd149..dea0b027a85 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RenderTagLibTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RenderTagLibTests.groovy @@ -35,6 +35,9 @@ import spock.lang.Specification * @author Marcel Overdijk */ class RenderTagLibTests extends Specification implements UrlMappingsUnitTest { + + boolean purgeTagLibMetaClass = true + // test for GRAILS-5376 void testPaginateTag() { when: diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy index d4b3c07bd66..bb5902724cf 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy @@ -32,6 +32,7 @@ import org.grails.core.artefact.ControllerArtefactHandler import org.grails.core.artefact.UrlMappingsArtefactHandler import org.grails.gsp.GroovyPagesTemplateEngine import org.grails.testing.ParameterizedGrailsUnitTest +import org.grails.testing.runtime.support.LazyTagLibraryLookup import org.grails.web.mapping.UrlMappingsHolderFactoryBean import org.grails.web.mapping.mvc.GrailsControllerUrlMappingInfo @@ -63,8 +64,20 @@ trait UrlMappingsUnitTest implements ParameterizedGrailsUnitTest, GrailsWe * bean so each feature method starts with only this spec's mappings active. Other specs * running in the same JVM may register their own mappings or clear the registry; calling * this restores the state expected by this spec. + * + * Also purges stale tag lib metaclass entries from any previous spec (regardless of + * whether that spec had {@code purgeTagLibMetaClass} enabled) so the first + * {@code applyTemplate()} call in this feature re-registers tag methods against this + * spec's own {@code TagLibraryLookup}, {@code linkGenerator}, and + * {@code urlMappingsHolder}. */ void resetUrlMappingsForFeature() { + try { + LazyTagLibraryLookup lookup = applicationContext.getBean(LazyTagLibraryLookup) + if (lookup) { + lookup.cleanTagLibsMetaClass() + } + } catch (ignored) {} Class typeUnderTest = getTypeUnderTest() if (typeUnderTest != null) { mockArtefact(typeUnderTest) From 1ce43221aeb229d3a9b8a2fe39e55cb7aeb13a86 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Mon, 27 Apr 2026 18:20:28 -0400 Subject: [PATCH 18/29] Fix is* invocations --- .../src/main/groovy/org/grails/taglib/TagMethodInvoker.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index ed6dd986267..e5c637361e8 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -150,6 +150,10 @@ public static boolean isTagMethodCandidate(Method method) { if (name.startsWith("get") && method.getParameterCount() == 0) { return false; } + if (name.startsWith("is") && method.getParameterCount() == 0 + && (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) { + return false; + } if (name.startsWith("set") && method.getParameterCount() == 1) { return false; } From 9d1c76b197fbe2bfee81a30ba3f31139d1ddc377 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Mon, 27 Apr 2026 18:37:58 -0400 Subject: [PATCH 19/29] Adjust code style --- .../src/main/groovy/org/grails/taglib/TagMethodInvoker.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index e5c637361e8..9f6446b5576 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -150,8 +150,8 @@ public static boolean isTagMethodCandidate(Method method) { if (name.startsWith("get") && method.getParameterCount() == 0) { return false; } - if (name.startsWith("is") && method.getParameterCount() == 0 - && (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) { + if (name.startsWith("is") && method.getParameterCount() == 0 && + (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class)) { return false; } if (name.startsWith("set") && method.getParameterCount() == 1) { From 531150c3102d2afec0243845ee37469305b3a645 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 28 Apr 2026 19:46:08 -0400 Subject: [PATCH 20/29] Always clear taglib metaclasses in web test cleanup --- .../grails/web/layout/RenderTagLibTests.groovy | 2 -- .../RestfulReverseUrlRenderingTests.groovy | 2 -- .../web/mapping/ReverseUrlMappingTests.groovy | 2 -- .../ReverseUrlMappingToDefaultActionTests.groovy | 2 -- .../web/mapping/RootUrlMappingTests.groovy | 2 -- .../web/taglib/FormTagLibResourceTests.groovy | 3 --- .../web/taglib/LinkRenderingTagLib2Tests.groovy | 2 -- .../web/taglib/LinkRenderingTagLibTests.groovy | 4 +--- .../taglib/NamespacedNamedUrlMappingTests.groovy | 4 +--- .../OverlappingReverseMappedLinkTests.groovy | 2 -- .../grails/web/taglib/RenderTagLibTests.groovy | 2 -- .../grails/testing/web/GrailsWebUnitTest.groovy | 7 ------- .../testing/web/UrlMappingsUnitTest.groovy | 13 ------------- .../testing/spock/WebCleanupInterceptor.groovy | 6 ++++++ .../spock/WebCleanupSpecInterceptor.groovy | 16 ---------------- 15 files changed, 8 insertions(+), 61 deletions(-) diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderTagLibTests.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderTagLibTests.groovy index af94406d87d..6c23096077e 100644 --- a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderTagLibTests.groovy +++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderTagLibTests.groovy @@ -31,8 +31,6 @@ import spock.lang.Specification class RenderTagLibTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - void testPageProperty() { def template = '' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy index 0acfd933794..63bcc2a05bf 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RestfulReverseUrlRenderingTests.groovy @@ -29,8 +29,6 @@ import spock.lang.Specification */ class RestfulReverseUrlRenderingTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - def testLinkTagRendering() { when: def template = 'create' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingTests.groovy index 6dc085a3b27..a28b799187c 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingTests.groovy @@ -29,8 +29,6 @@ import spock.lang.Specification */ class ReverseUrlMappingTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - def testLinkTagRendering() { when: def template1 = 'New Product' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingToDefaultActionTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingToDefaultActionTests.groovy index 4f06e3c9dc0..c5e10de490f 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingToDefaultActionTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/ReverseUrlMappingToDefaultActionTests.groovy @@ -30,8 +30,6 @@ import spock.lang.Specification */ class ReverseUrlMappingToDefaultActionTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - @OnceBefore void mockControllers() { mockController(ReverseUrlMappingContentController) diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RootUrlMappingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RootUrlMappingTests.groovy index 7bc8c5cb0c0..3a856a286bf 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RootUrlMappingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/mapping/RootUrlMappingTests.groovy @@ -24,8 +24,6 @@ import spock.lang.Specification class RootUrlMappingTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - def testMappingToController() { when: def template = 'Show the time !' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/FormTagLibResourceTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/FormTagLibResourceTests.groovy index 416436a43dc..8dc898f2be7 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/FormTagLibResourceTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/FormTagLibResourceTests.groovy @@ -24,8 +24,6 @@ import spock.lang.* class FormTagLibResourceTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - def testResourceSave() { when: def template = '' @@ -133,4 +131,3 @@ class TestFormTagUrlMappings { } - diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLib2Tests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLib2Tests.groovy index abb3f70d490..5771b481c87 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLib2Tests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLib2Tests.groovy @@ -24,8 +24,6 @@ import spock.lang.Specification class LinkRenderingTagLib2Tests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - def testLinkWithOnlyId() { when: def template = 'Enter' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLibTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLibTests.groovy index 4ff021eb4a9..773dc72aae2 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLibTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/LinkRenderingTagLibTests.groovy @@ -24,8 +24,6 @@ import spock.lang.Specification class LinkRenderingTagLibTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - def testMappingsWhichSpecifyAPlugin() { when: def template = 'click' @@ -358,4 +356,4 @@ class LinkRenderingTestUrlMappings { "/pluginThreeFirstController"(controller: 'first', action: 'index', plugin: 'thirdUtil') } -} \ No newline at end of file +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespacedNamedUrlMappingTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespacedNamedUrlMappingTests.groovy index 20bc908dee1..59e169b1a27 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespacedNamedUrlMappingTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespacedNamedUrlMappingTests.groovy @@ -24,8 +24,6 @@ import spock.lang.Specification class NamespacedNamedUrlMappingTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - def testLinkAttributes() { when: def template = 'Strawberry Licorice' @@ -92,4 +90,4 @@ class TestUrlMappings { action = "show" } } -} \ No newline at end of file +} diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/OverlappingReverseMappedLinkTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/OverlappingReverseMappedLinkTests.groovy index 170058813b4..e1df2464fea 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/OverlappingReverseMappedLinkTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/OverlappingReverseMappedLinkTests.groovy @@ -30,8 +30,6 @@ import spock.lang.Specification */ class OverlappingReverseMappedLinkTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - def testSimpleLink() { when: def template = 'link1' diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RenderTagLibTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RenderTagLibTests.groovy index dea0b027a85..3f19c7e8cbe 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RenderTagLibTests.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/RenderTagLibTests.groovy @@ -36,8 +36,6 @@ import spock.lang.Specification */ class RenderTagLibTests extends Specification implements UrlMappingsUnitTest { - boolean purgeTagLibMetaClass = true - // test for GRAILS-5376 void testPaginateTag() { when: diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy index 68449652eba..82b0d93592d 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy @@ -53,13 +53,6 @@ trait GrailsWebUnitTest implements GrailsUnitTest { static Map groovyPages = [:] GrailsWebRequest webRequest - /** - * When mocking tag libs, the LazyTagLibraryLookup will not be cleared by default. True forces it to be cleared. - */ - boolean getPurgeTagLibMetaClass() { - false - } - GrailsMockHttpServletRequest getRequest() { return (GrailsMockHttpServletRequest) getWebRequest().getCurrentRequest() } diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy index bb5902724cf..d4b3c07bd66 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/UrlMappingsUnitTest.groovy @@ -32,7 +32,6 @@ import org.grails.core.artefact.ControllerArtefactHandler import org.grails.core.artefact.UrlMappingsArtefactHandler import org.grails.gsp.GroovyPagesTemplateEngine import org.grails.testing.ParameterizedGrailsUnitTest -import org.grails.testing.runtime.support.LazyTagLibraryLookup import org.grails.web.mapping.UrlMappingsHolderFactoryBean import org.grails.web.mapping.mvc.GrailsControllerUrlMappingInfo @@ -64,20 +63,8 @@ trait UrlMappingsUnitTest implements ParameterizedGrailsUnitTest, GrailsWe * bean so each feature method starts with only this spec's mappings active. Other specs * running in the same JVM may register their own mappings or clear the registry; calling * this restores the state expected by this spec. - * - * Also purges stale tag lib metaclass entries from any previous spec (regardless of - * whether that spec had {@code purgeTagLibMetaClass} enabled) so the first - * {@code applyTemplate()} call in this feature re-registers tag methods against this - * spec's own {@code TagLibraryLookup}, {@code linkGenerator}, and - * {@code urlMappingsHolder}. */ void resetUrlMappingsForFeature() { - try { - LazyTagLibraryLookup lookup = applicationContext.getBean(LazyTagLibraryLookup) - if (lookup) { - lookup.cleanTagLibsMetaClass() - } - } catch (ignored) {} Class typeUnderTest = getTypeUnderTest() if (typeUnderTest != null) { mockArtefact(typeUnderTest) diff --git a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupInterceptor.groovy b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupInterceptor.groovy index 1efba63fe27..86c64bcfb9d 100644 --- a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupInterceptor.groovy +++ b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupInterceptor.groovy @@ -28,6 +28,7 @@ import org.springframework.web.context.request.RequestContextHolder import grails.testing.web.GrailsWebUnitTest import org.grails.gsp.GroovyPagesTemplateEngine +import org.grails.testing.runtime.support.LazyTagLibraryLookup import org.grails.web.gsp.GroovyPagesTemplateRenderer import org.grails.web.servlet.mvc.GrailsWebRequest @@ -55,6 +56,11 @@ class WebCleanupInterceptor implements IMethodInterceptor { if (ctx?.containsBean(GROOVY_PAGES_TEMPLATE_RENDERER)) { ctx.getBean(GROOVY_PAGES_TEMPLATE_RENDERER, GroovyPagesTemplateRenderer).clearCache() } + if (ctx?.containsBean('gspTagLibraryLookup')) { + LazyTagLibraryLookup lookup = ctx.getBean(LazyTagLibraryLookup) + lookup.clear() + lookup.cleanTagLibsMetaClass() + } test.webRequest = null } } diff --git a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupSpecInterceptor.groovy b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupSpecInterceptor.groovy index 9f4e8b03609..0aa45bfcf60 100644 --- a/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupSpecInterceptor.groovy +++ b/grails-testing-support-web/src/main/groovy/org/grails/testing/spock/WebCleanupSpecInterceptor.groovy @@ -24,8 +24,6 @@ import groovy.transform.CompileStatic import org.spockframework.runtime.extension.IMethodInterceptor import org.spockframework.runtime.extension.IMethodInvocation -import grails.testing.web.GrailsWebUnitTest -import org.grails.testing.runtime.support.LazyTagLibraryLookup import org.grails.web.converters.configuration.ConvertersConfigurationHolder @CompileStatic @@ -38,20 +36,6 @@ class WebCleanupSpecInterceptor implements IMethodInterceptor { } finally { ConvertersConfigurationHolder.clear() - - GrailsWebUnitTest test = (GrailsWebUnitTest) invocation.instance - if (test.purgeTagLibMetaClass) { - try { - def lookup = test.grailsApplication.mainContext.getBean(LazyTagLibraryLookup) - if (lookup) { - lookup.clear() - lookup.cleanTagLibsMetaClass() - } - } - catch (ignored) { - // may not exist - } - } } } } From 68a404980f705ba34d87298d6dc64818e2fe0c68 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 28 Apr 2026 20:01:24 -0400 Subject: [PATCH 21/29] Tighten method tag dispatch and preserve taglib remocking --- .../grails/taglib/TagLibraryMetaUtils.groovy | 23 ++++++++- .../org/grails/taglib/TagMethodInvoker.java | 19 +++++-- .../artefact/gsp/TagLibraryInvoker.groovy | 8 +++ .../web/taglib/MethodDefinedTagLibSpec.groovy | 37 ++++++++++++++ .../testing/web/GrailsWebUnitTest.groovy | 8 +-- .../testing/web/taglib/TagLibUnitTest.groovy | 49 ++++++++++++++++--- 6 files changed, 129 insertions(+), 15 deletions(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy index bbcb4db8d47..7816f1879e2 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy @@ -23,6 +23,7 @@ import groovy.transform.CompileStatic import groovy.transform.TypeCheckingMode import org.codehaus.groovy.reflection.CachedMethod import org.codehaus.groovy.runtime.metaclass.MethodSelectionException +import org.codehaus.groovy.runtime.InvokerHelper import org.springframework.context.ApplicationContext @@ -62,6 +63,24 @@ class TagLibraryMetaUtils { TagMethodContext.currentBody() } } + if (!doesMethodExist(metaClass, 'body', [] as Class[])) { + mc.setProperty('body') { -> + Closure currentBody = (Closure) TagMethodContext.currentBody() + currentBody.call() + } + } + if (!doesMethodExist(metaClass, 'body', [Map] as Class[])) { + mc.setProperty('body') { Map arguments -> + Closure currentBody = (Closure) TagMethodContext.currentBody() + currentBody.call(arguments) + } + } + if (!doesMethodExist(metaClass, 'body', [Object] as Class[])) { + mc.setProperty('body') { Object argument -> + Closure currentBody = (Closure) TagMethodContext.currentBody() + currentBody.call(argument) + } + } } @CompileStatic @@ -73,7 +92,9 @@ class TagLibraryMetaUtils { @CompileStatic static void registerNamespaceMetaProperty(MetaClass metaClass, TagLibraryLookup gspTagLibraryLookup, String namespace) { - registerPropertyMissingForTag(metaClass, namespace, gspTagLibraryLookup.lookupNamespaceDispatcher(namespace)) + if (!metaClass.hasProperty(namespace) && !doesMethodExist(metaClass, GrailsClassUtils.getGetterName(namespace), [] as Class[])) { + registerPropertyMissingForTag(metaClass, namespace, gspTagLibraryLookup.lookupNamespaceDispatcher(namespace)) + } } @CompileStatic diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java index 9f6446b5576..ef77a3d3a9f 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagMethodInvoker.java @@ -52,6 +52,15 @@ public final class TagMethodInvoker { "currentRequestAttributes" ); + private static final Set NON_TAG_METHOD_NAMES = Set.of( + "afterPropertiesSet", + "destroy", + "equals", + "hashCode", + "onApplicationEvent", + "toString" + ); + private static final ClassValue>> INVOKABLE_METHODS_BY_NAME = new ClassValue<>() { @Override protected Map> computeValue(Class type) { @@ -143,8 +152,11 @@ public static boolean isTagMethodCandidate(Method method) { if (!Modifier.isPublic(modifiers) || Modifier.isStatic(modifiers) || method.isBridge() || method.isSynthetic()) { return false; } + if (method.getDeclaringClass() == Object.class || method.getDeclaringClass() == GroovyObject.class) { + return false; + } String name = method.getName(); - if ("afterPropertiesSet".equals(name)) { + if (NON_TAG_METHOD_NAMES.contains(name)) { return false; } if (name.startsWith("get") && method.getParameterCount() == 0) { @@ -163,7 +175,7 @@ public static boolean isTagMethodCandidate(Method method) { if (FRAMEWORK_METHOD_NAMES.contains(name)) { return false; } - return method.getDeclaringClass() != Object.class && method.getDeclaringClass() != GroovyObject.class; + return true; } private static Collection getCandidateMethods(Class type) { @@ -209,9 +221,6 @@ private static Object[] toMethodArguments(Method method, Map attrs, Closur continue; } Object value = attrs != null ? attrs.get(parameterName) : null; - if (value == null && parameters.length == 1 && attrs != null && attrs.size() == 1) { - value = attrs.values().iterator().next(); - } if (value == null) { return null; } diff --git a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/TagLibraryInvoker.groovy b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/TagLibraryInvoker.groovy index 24de3d8a2bf..0b1c6bfcd8f 100644 --- a/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/TagLibraryInvoker.groovy +++ b/grails-gsp/grails-web-taglib/src/main/groovy/grails/artefact/gsp/TagLibraryInvoker.groovy @@ -28,8 +28,10 @@ import grails.web.api.WebAttributes import org.grails.taglib.NamespacedTagDispatcher import org.grails.taglib.TagLibraryLookup import org.grails.taglib.TagLibraryMetaUtils +import org.grails.taglib.TagMethodContext import org.grails.taglib.TagOutput import org.grails.taglib.encoder.WithCodecHelper +import org.codehaus.groovy.runtime.InvokerHelper /** * A trait that adds the ability invoke tags to any class @@ -74,6 +76,12 @@ trait TagLibraryInvoker extends WebAttributes { */ Object methodMissing(String methodName, Object argsObject) { Object[] args = argsObject instanceof Object[] ? (Object[]) argsObject : [argsObject] as Object[] + if ('body' == methodName) { + Closure body = (Closure) TagMethodContext.currentBody() + if (body != null) { + return InvokerHelper.invokeMethod(body, 'call', args) + } + } if (shouldHandleMethodMissing(methodName, args)) { TagLibraryLookup lookup = getTagLibraryLookup() if (lookup) { diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy index 4c9e3d6dfb2..fe8a5e06b77 100644 --- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy +++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/MethodDefinedTagLibSpec.groovy @@ -44,6 +44,14 @@ class MethodDefinedTagLibSpec extends Specification implements TagLibUnitTest') == 'hello-world' } + void "method tag does not bind a single mismatched attribute by position"() { + when: + applyTemplate('') + + then: + thrown(GrailsTagException) + } + void "method tag can bind map-valued attribute to map-typed argument by parameter name"() { expect: applyTemplate('') == 'v' @@ -89,6 +97,19 @@ class MethodDefinedTagLibSpec extends Specification implements TagLibUnitTest') + + then: + thrown(GrailsTagException) + } + + void "namespace metaproperty does not shadow a real getter"() { + expect: + applyTemplate('') == 'real-shared' + } } @GrailsCompileStatic @@ -111,6 +132,10 @@ class StaticMethodTagLib { @Artefact('TagLib') class MethodTagLib { + String getShared() { + 'real-shared' + } + def methodTag() { out << "${attrs.blah} - is this" } @@ -122,6 +147,10 @@ class MethodTagLib { out << "${first}-${second}" } + def mismatchedArgTag(String name) { + out << name + } + def mapValueTag(Map config) { out << "${config.k}" } @@ -142,6 +171,14 @@ class MethodTagLib { out << "before-${body()}-after" } + def readSharedProperty() { + out << shared + } + + String toString() { + 'object-method' + } + Closure legacyTag = { attrs, body -> out << "legacy-${attrs.blah}" } diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy index 82b0d93592d..51d55805895 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy @@ -103,9 +103,11 @@ trait GrailsWebUnitTest implements GrailsUnitTest { GrailsTagLibClass tagLib = grailsApplication.addArtefact(TagLibArtefactHandler.TYPE, tagLibClass) final tagLookup = applicationContext.getBean(TagLibraryLookup) - defineBeans { - "${tagLib.fullName}"(tagLibClass) { bean -> - bean.autowire = true + if (!applicationContext.containsBean(tagLib.fullName)) { + defineBeans { + "${tagLib.fullName}"(tagLibClass) { bean -> + bean.autowire = true + } } } diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/taglib/TagLibUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/taglib/TagLibUnitTest.groovy index e2e856d2e03..3330f19a54b 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/taglib/TagLibUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/taglib/TagLibUnitTest.groovy @@ -22,18 +22,17 @@ import java.lang.reflect.ParameterizedType import groovy.transform.CompileStatic +import grails.core.gsp.GrailsTagLibClass import grails.testing.web.GrailsWebUnitTest import org.grails.testing.ParameterizedGrailsUnitTest +import org.grails.taglib.TagLibraryLookup @CompileStatic trait TagLibUnitTest implements ParameterizedGrailsUnitTest, GrailsWebUnitTest { + private static final Map, Set>> MOCKED_TAG_LIB_CLASSES_BY_SPEC = [:].withDefault { [] as LinkedHashSet> } private boolean hasBeenMocked = false - boolean getPurgeTagLibMetaClass() { - true - } - /** * Renders a template for the given contents and model * @@ -62,6 +61,17 @@ trait TagLibUnitTest implements ParameterizedGrailsUnitTest, GrailsWebUnit mockTagLib(tagLibClass) } + Object mockTagLib(Class tagLibClass) { + getMockedTagLibClasses().add(tagLibClass) + GrailsWebUnitTest.super.mockTagLib(tagLibClass) + } + + void mockTagLibs(Class... tagLibClasses) { + for (Class tagLibClass in tagLibClasses) { + mockTagLib(tagLibClass) + } + } + String getBeanName(Class tagLibClass) { tagLibClass.name } @@ -81,9 +91,36 @@ trait TagLibUnitTest implements ParameterizedGrailsUnitTest, GrailsWebUnit } private void ensureTaglibHasBeenMocked() { - if (!hasBeenMocked) { - mockTagLib(getTagLibTypeUnderTest()) + if (!hasBeenMocked || !areMockedTagLibsRegistered()) { + Set> mockedTagLibClasses = getMockedTagLibClasses() + if (mockedTagLibClasses.isEmpty()) { + mockedTagLibClasses.add(getTagLibTypeUnderTest()) + } + for (Class tagLibClass in mockedTagLibClasses) { + GrailsWebUnitTest.super.mockTagLib(tagLibClass) + } hasBeenMocked = true } } + + private boolean areMockedTagLibsRegistered() { + TagLibraryLookup tagLibraryLookup = applicationContext.getBean(TagLibraryLookup) + for (Class tagLibClass in getMockedTagLibClasses()) { + GrailsTagLibClass grailsTagLibClass = (GrailsTagLibClass) grailsApplication.getArtefact('TagLib', tagLibClass.name) + if (grailsTagLibClass == null) { + return false + } + String namespace = grailsTagLibClass.namespace + if (!grailsTagLibClass.tagNames.every { String tagName -> + tagLibraryLookup.lookupTagLibrary(namespace, tagName) != null + }) { + return false + } + } + return true + } + + private Set> getMockedTagLibClasses() { + MOCKED_TAG_LIB_CLASSES_BY_SPEC.get(getClass()) + } } From 06c93bf1f0c7b7f48cf1dcbc382bb2b1b43a3b15 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 28 Apr 2026 20:02:48 -0400 Subject: [PATCH 22/29] Document Grails 8 taglib updates --- .../src/en/guide/introduction/whatsNew.adoc | 8 ++++-- .../theWebLayer/gsp/taglibs/namespaces.adoc | 2 +- .../gsp/taglibs/tagReturnValue.adoc | 6 ++-- .../src/en/guide/upgrading/upgrading80x.adoc | 28 +++++++++++++++++-- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index d2191138a89..37b46a59804 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -27,6 +27,10 @@ This release focuses on enhancing the developer experience, improving performanc For detailed information on how to upgrade to Grails 8, including major dependency changes, please see the xref:upgrading#upgrading80x[Upgrading from Grails 7 to Grails 8] section. Notable new features are included below. -==== No New Features at this time +==== GSP Tag Library Improvements -No new features at this time +Grails 8 continues the move toward method-based TagLib handlers while preserving compatibility with existing closure-based tags. +Method-defined tags now bind named attributes more predictably, exclude inherited framework and `Object` methods from tag dispatch, and preserve real namespace property getters. + +Tag library unit tests also clean up and rebuild TagLib metadata automatically between features. +Tests that use `TagLibUnitTest` no longer need to manage `purgeTagLibMetaClass`, and specs that mock additional tag libraries continue to work across feature methods. diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/namespaces.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/namespaces.adoc index 91d25b83359..853a4549a14 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/namespaces.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/namespaces.adoc @@ -23,8 +23,8 @@ By default, tags are added to the default Grails namespace and are used with the ---- class SimpleTagLib { static namespace = "my" + def example() { - def example = { attrs -> //... } } diff --git a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/tagReturnValue.adoc b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/tagReturnValue.adoc index 6027e9653e9..f3b7956c604 100644 --- a/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/tagReturnValue.adoc +++ b/grails-doc/src/en/guide/theWebLayer/gsp/taglibs/tagReturnValue.adoc @@ -36,10 +36,10 @@ Example: ---- class ObjectReturningTagLib { static namespace = "cms" - static returnObjectForTags = ['content'] + static returnObjectForTags = ['content'] + def content() { - def content = { attrs, body -> - CmsContent.findByCode(attrs.code)?.content + CmsContent.findByCode(attrs.code)?.content } } ---- diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index ef5dd4e1ede..ec9ab8cb3c0 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -651,7 +651,29 @@ If your application contributed `HttpMessageConverter` beans through this class, Spring Framework 7 deprecated `org.springframework.lang.Nullable` and `org.springframework.lang.NonNull` in favor of the JSpecify annotations (`org.jspecify.annotations.Nullable`, `org.jspecify.annotations.NonNull`). The Spring annotations still work, so this is non-blocking, but new code should prefer the JSpecify equivalents. -==== 20. Known Plugin Incompatibilities +==== 20. Tag Library Test Cleanup Changes + +Grails 8 removes the `purgeTagLibMetaClass` test hook used by some web and TagLib unit tests. +TagLib metaclass cleanup now happens automatically as part of the web test infrastructure, so test specs should delete any custom `purgeTagLibMetaClass` property or getter. + +If your tests use `TagLibUnitTest` and mock additional tag libraries with `mockTagLib(...)` or `mockTagLibs(...)`, those mocked tag libraries are rebuilt automatically between feature methods. +No replacement flag is required. + +[source,groovy] +---- +// Before +class MyTagLibSpec extends Specification implements TagLibUnitTest { + boolean getPurgeTagLibMetaClass() { + true + } +} + +// After +class MyTagLibSpec extends Specification implements TagLibUnitTest { +} +---- + +==== 21. Known Plugin Incompatibilities Some third-party plugins have not yet been updated for Spring Boot 4 / Spring Framework 7 compatibility. The following are known blockers at this time: @@ -664,7 +686,7 @@ Applications using `grails-sitemesh3` should remain on the `grails-layout` plugi Check the https://github.com/apache/grails-core/issues[Grails issue tracker] for the latest status of plugin compatibility. -===== 21. Custom JSON View Converters +===== 22. Custom JSON View Converters JSON views now use Groovy's `groovy.json.JsonGenerator` implementation instead of the previous Grails-specific JSON generator infrastructure. @@ -682,7 +704,7 @@ To migrate your custom converters: `src/main/resources/META-INF/services/groovy.json.JsonGenerator$Converter` (was previously `src/main/resources/META-INF/services/grails.plugin.json.builder.JsonGenerator$Converter`) -===== 22 Rendering Enum values as JSON +===== 23 Rendering Enum values as JSON It is no longer possible to render a single enum value as JSON using the `render(Number.ONE as JSON)` syntax. Previously, rendering an enum value would produce a JSON string with the type and name of the enum value like: From 9ef3130e6861ca5a319813ee4d7a2ad1e120cd56 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 28 Apr 2026 20:05:18 -0400 Subject: [PATCH 23/29] Codestyle fixes --- .../web/taglib/ApplicationTagLib.groovy | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy index adfb2ec037c..1922579b7f2 100644 --- a/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy +++ b/grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/ApplicationTagLib.groovy @@ -63,11 +63,11 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr RequestDataValueProcessor requestDataValueProcessor - static final SCOPES = [page : 'pageScope', + static final SCOPES = [page: 'pageScope', application: 'servletContext', - request : 'request', - session : 'session', - flash : 'flash'] + request: 'request', + session: 'session', + flash: 'flash'] boolean useJsessionId = false boolean hasResourceProcessor = false @@ -270,7 +270,7 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr } static LINK_WRITERS = [ - js : { url, constants, attrs -> + js: { url, constants, attrs -> return "" }, @@ -291,13 +291,13 @@ class ApplicationTagLib implements ApplicationContextAware, InitializingBean, Gr } static SUPPORTED_TYPES = [ - css : [type: 'text/css', rel: 'stylesheet', media: 'screen, projection'], - js : [type: 'text/javascript', writer: 'js'], + css: [type: 'text/css', rel: 'stylesheet', media: 'screen, projection'], + js: [type: 'text/javascript', writer: 'js'], - gif : [rel: 'shortcut icon'], - jpg : [rel: 'shortcut icon'], - png : [rel: 'shortcut icon'], - ico : [rel: 'shortcut icon'], + gif: [rel: 'shortcut icon'], + jpg: [rel: 'shortcut icon'], + png: [rel: 'shortcut icon'], + ico: [rel: 'shortcut icon'], appleicon: [rel: 'apple-touch-icon'] // @todo add feed link types here too From bad5577df9779355c1dcb15de574b54bcd202233 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 28 Apr 2026 20:28:13 -0400 Subject: [PATCH 24/29] Restore taglib namespace lookups after web cleanup --- .../groovy/org/grails/taglib/TagLibraryMetaUtils.groovy | 2 +- .../grails/testing/web/taglib/TagLibUnitTest.groovy | 5 +++-- .../testing/runtime/support/LazyTagLibraryLookup.java | 9 +++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy index 7816f1879e2..7164bd3aa45 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy @@ -92,7 +92,7 @@ class TagLibraryMetaUtils { @CompileStatic static void registerNamespaceMetaProperty(MetaClass metaClass, TagLibraryLookup gspTagLibraryLookup, String namespace) { - if (!metaClass.hasProperty(namespace) && !doesMethodExist(metaClass, GrailsClassUtils.getGetterName(namespace), [] as Class[])) { + if (!doesMethodExist(metaClass, GrailsClassUtils.getGetterName(namespace), [] as Class[], false, true)) { registerPropertyMissingForTag(metaClass, namespace, gspTagLibraryLookup.lookupNamespaceDispatcher(namespace)) } } diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/taglib/TagLibUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/taglib/TagLibUnitTest.groovy index 3330f19a54b..799b13211e2 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/taglib/TagLibUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/taglib/TagLibUnitTest.groovy @@ -93,8 +93,9 @@ trait TagLibUnitTest implements ParameterizedGrailsUnitTest, GrailsWebUnit private void ensureTaglibHasBeenMocked() { if (!hasBeenMocked || !areMockedTagLibsRegistered()) { Set> mockedTagLibClasses = getMockedTagLibClasses() - if (mockedTagLibClasses.isEmpty()) { - mockedTagLibClasses.add(getTagLibTypeUnderTest()) + Class tagLibTypeUnderTest = getTagLibTypeUnderTest() + if (tagLibTypeUnderTest != null) { + mockedTagLibClasses.add(tagLibTypeUnderTest) } for (Class tagLibClass in mockedTagLibClasses) { GrailsWebUnitTest.super.mockTagLib(tagLibClass) diff --git a/grails-testing-support-web/src/main/groovy/org/grails/testing/runtime/support/LazyTagLibraryLookup.java b/grails-testing-support-web/src/main/groovy/org/grails/testing/runtime/support/LazyTagLibraryLookup.java index 6f87c6264fd..c4a38c78dea 100644 --- a/grails-testing-support-web/src/main/groovy/org/grails/testing/runtime/support/LazyTagLibraryLookup.java +++ b/grails-testing-support-web/src/main/groovy/org/grails/testing/runtime/support/LazyTagLibraryLookup.java @@ -83,6 +83,15 @@ public void cleanTagLibsMetaClass() { } } + @Override + public void clear() { + super.clear(); + registerTemplateNamespace(); + for (Class providedArtefact : tagLibClasses) { + registerLazyLoadableTagLibClass(providedArtefact); + } + } + public void registerLazyLoadableTagLibClass(Class tagLibClass) { Class defaultTagLibClass = null; GrailsTagLibClass grailsTagLibClass = null; From 5f439ecd85e138a9f0aad74cd85a88ebc0b42ceb Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 28 Apr 2026 21:42:26 -0400 Subject: [PATCH 25/29] Fix calling a taglib directly --- .../main/groovy/grails/testing/web/GrailsWebUnitTest.groovy | 3 +++ 1 file changed, 3 insertions(+) diff --git a/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy b/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy index 51d55805895..a0adf4b88f1 100644 --- a/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy +++ b/grails-testing-support-web/src/main/groovy/grails/testing/web/GrailsWebUnitTest.groovy @@ -42,6 +42,7 @@ import org.grails.plugins.codecs.DefaultCodecLookup import org.grails.plugins.testing.GrailsMockHttpServletRequest import org.grails.plugins.testing.GrailsMockHttpServletResponse import org.grails.taglib.TagLibraryLookup +import org.grails.taglib.TagLibraryMetaUtils import org.grails.testing.GrailsUnitTest import org.grails.web.servlet.mvc.GrailsWebRequest import org.grails.web.util.GrailsApplicationAttributes @@ -114,6 +115,8 @@ trait GrailsWebUnitTest implements GrailsUnitTest { tagLookup.registerTagLib(tagLib) def taglibObject = applicationContext.getBean(tagLib.fullName) + TagLibraryMetaUtils.enhanceTagLibMetaClass(tagLib, tagLookup) + TagLibraryMetaUtils.enhanceTagLibMetaClass(taglibObject.metaClass, tagLookup, tagLib.namespace) if (taglibObject instanceof TagLibrary) { ((TagLibrary) taglibObject).setTagLibraryLookup(tagLookup) } From 2e8ab1f7ef864e18790fdf49e4b4f94e21653d46 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 28 Apr 2026 22:00:33 -0400 Subject: [PATCH 26/29] Remove unused import --- .../src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy index 7164bd3aa45..42312207fe1 100644 --- a/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy +++ b/grails-gsp/grails-taglib/src/main/groovy/org/grails/taglib/TagLibraryMetaUtils.groovy @@ -23,7 +23,6 @@ import groovy.transform.CompileStatic import groovy.transform.TypeCheckingMode import org.codehaus.groovy.reflection.CachedMethod import org.codehaus.groovy.runtime.metaclass.MethodSelectionException -import org.codehaus.groovy.runtime.InvokerHelper import org.springframework.context.ApplicationContext From 9ab3547e0db2a2679f852b5528c528e581cb529a Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Tue, 28 Apr 2026 23:15:36 -0400 Subject: [PATCH 27/29] Move taglib mocks to setup() instead of setupSpec() --- .../guide/testing/unitTesting/unitTestingTagLibraries.adoc | 6 ++++++ grails-doc/src/en/guide/upgrading/upgrading80x.adoc | 4 ++++ .../demo33/src/test/groovy/demo/FirstTagLibSpec.groovy | 2 +- .../grails/test/mixin/GroovyPageUnitTestMixinTests.groovy | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/grails-doc/src/en/guide/testing/unitTesting/unitTestingTagLibraries.adoc b/grails-doc/src/en/guide/testing/unitTesting/unitTestingTagLibraries.adoc index 595fdeb5323..4b94bfb50fe 100644 --- a/grails-doc/src/en/guide/testing/unitTesting/unitTestingTagLibraries.adoc +++ b/grails-doc/src/en/guide/testing/unitTesting/unitTestingTagLibraries.adoc @@ -93,6 +93,12 @@ In order to test a tag library which invokes tags from another tag library, the second tag library needs to be explicitly mocked by invoking the `mockTagLib` method. +NOTE: Mocked tag libraries are cleared after each feature method as part of the +web test cleanup lifecycle. Call `mockTagLib` in `setup()` rather than +`setupSpec()` to ensure the mock is active for every feature. Tests that +implement `TagLibUnitTest` handle this automatically, but tests that use +`GrailsWebUnitTest` directly must re-mock in each feature or in `setup()`. + [source,groovy] .grails-app/taglib/demo/FirstTagLib.groovy ---- diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index ec9ab8cb3c0..b19765202e4 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -659,6 +659,10 @@ TagLib metaclass cleanup now happens automatically as part of the web test infra If your tests use `TagLibUnitTest` and mock additional tag libraries with `mockTagLib(...)` or `mockTagLibs(...)`, those mocked tag libraries are rebuilt automatically between feature methods. No replacement flag is required. +IMPORTANT: Mocked tag libraries are cleared after each feature method. +If your tests call `mockTagLib` directly (for example, when implementing `GrailsWebUnitTest` instead of `TagLibUnitTest`), move the call from `setupSpec()` to `setup()` so the mock is re-applied before every feature. +`TagLibUnitTest` handles this automatically, but `GrailsWebUnitTest` does not. + [source,groovy] ---- // Before diff --git a/grails-test-examples/demo33/src/test/groovy/demo/FirstTagLibSpec.groovy b/grails-test-examples/demo33/src/test/groovy/demo/FirstTagLibSpec.groovy index c368aefa435..cff692f24f1 100644 --- a/grails-test-examples/demo33/src/test/groovy/demo/FirstTagLibSpec.groovy +++ b/grails-test-examples/demo33/src/test/groovy/demo/FirstTagLibSpec.groovy @@ -24,7 +24,7 @@ import spock.lang.Specification class FirstTagLibSpec extends Specification implements TagLibUnitTest { - void setupSpec() { + void setup() { mockTagLib SecondTagLib } diff --git a/grails-test-suite-uber/src/test/groovy/grails/test/mixin/GroovyPageUnitTestMixinTests.groovy b/grails-test-suite-uber/src/test/groovy/grails/test/mixin/GroovyPageUnitTestMixinTests.groovy index ef4e8bc151a..4ca0e4fc1f1 100644 --- a/grails-test-suite-uber/src/test/groovy/grails/test/mixin/GroovyPageUnitTestMixinTests.groovy +++ b/grails-test-suite-uber/src/test/groovy/grails/test/mixin/GroovyPageUnitTestMixinTests.groovy @@ -28,7 +28,7 @@ import spock.lang.Specification */ class GroovyPageUnitTestMixinTests extends Specification implements GrailsWebUnitTest { - void setupSpec() { + void setup() { mockTagLib(FooTagLib) } From 20e515d12a8707fda0f58d9351e1b25f17dc5e59 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Sun, 3 May 2026 20:52:54 -0400 Subject: [PATCH 28/29] Update rat-config for generated files --- gradle/rat-root-config.gradle | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gradle/rat-root-config.gradle b/gradle/rat-root-config.gradle index 34e12fe51c8..d7b26f73895 100644 --- a/gradle/rat-root-config.gradle +++ b/gradle/rat-root-config.gradle @@ -43,6 +43,9 @@ tasks.named('rat') { '.github/**', // github configuration isn't shipped in the source distro '**/.gitignore', // git configuration isn't code '**/.gitkeep', // git configuration isn't code + '**/.classpath', // Eclipse generated files + '**/.project', // Eclipse generated files + '**/.settings', // Eclipse generated files 'etc/bin/results/**', // exclude build directories '**/*.png', '**/*.svg', '**/*.ico', '**/*.eps', '**/*.icns', '**/*.jpg', '**/*.jpeg', '**/*.gif', // Image files '**/*.db', // H2 database test files @@ -61,6 +64,10 @@ tasks.named('rat') { 'bin/**', // scripts generated during build for grails-shell-cli 'build-logic/plugins/build/**', // exclude build artifacts 'build-logic/docs-core/build/**', // exclude build artifacts + 'grails-test-examples/*/build/**', // build directories + 'grails-gsp/*/build/**', // build directories + 'grails-test-examples/gsp-spring-boot/app/build', // build directories + '*/build', // root build directories 'build-logic/docs-core/src/main/template/**', // template files that people are expected to use in the end application 'grails-common/src/main/groovy/org/apache/grails/common/compiler/asm/Attribute.java', // See license file, BSD licensed 'grails-common/src/main/groovy/org/apache/grails/common/compiler/asm/ByteVector.java', // See license file, BSD licensed From 38d41c8f6170e0e6ec6ca3c248480647581effd5 Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Mon, 4 May 2026 00:22:06 -0400 Subject: [PATCH 29/29] Fix bad merge --- grails-test-examples/plugins/loadafter/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-test-examples/plugins/loadafter/build.gradle b/grails-test-examples/plugins/loadafter/build.gradle index 8c9a6c6c6f6..f9ef09f690d 100644 --- a/grails-test-examples/plugins/loadafter/build.gradle +++ b/grails-test-examples/plugins/loadafter/build.gradle @@ -41,7 +41,7 @@ dependencies { api 'com.h2database:h2' api 'jakarta.servlet:jakarta.servlet-api' - implementation 'org.apache.grails:grails-spring-security:7.0.1' + implementation "org.apache.grails:grails-spring-security:$grailsSpringSecurityVersion" console 'org.apache.grails:grails-console' }