Skip to content

Resolve tag calls at compile time instead of through the metaclass - #16134

Open
codeconsole wants to merge 63 commits into
apache:8.0.xfrom
codeconsole:feat/taglib-compile-time-index-8.0.x
Open

Resolve tag calls at compile time instead of through the metaclass#16134
codeconsole wants to merge 63 commits into
apache:8.0.xfrom
codeconsole:feat/taglib-compile-time-index-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

Tag libraries are described as they are compiled, and that description resolves tag calls in code compiled afterwards. A call whose namespace and tag are known becomes a direct invocation instead of being dispatched through the metaclass, and nothing is installed onto a metaclass to make dispatch work.

Defining tags

class GreetingTagLib {
    static namespace = 'greet'

    def hello(Map attrs) {
        out << "Hello ${attrs.name}"
    }

    def wrapped(Map attrs, Closure body) {
        out << '<div>' << body() << '</div>'
    }
}

Calling tags

In a tag library or a controller:

class BookController {
    def index() {
        String markup = g.createLink(controller: 'book')   // compiled into a direct invocation
        String other  = greet.hello(name: 'Grails')        // likewise
        String third  = createLink(controller: 'book')     // likewise, when nothing else answers to the name
    }
}

A call that names its namespace is compiled the same way inside a closure — a tag body, a withFormat block, anything taking a block — as outside one, as is one in a constructor or a field initialiser. A call written without a namespace inside a closure is not: a closure is handed a delegate when it runs, and a name the delegate answers to is the delegate's rather than a tag's. request.withFormat { form multipartForm { } } is the case that settles it — form there is a format in a DSL, not the g:form tag.

The tag is still selected by name when the call runs, through the same lookup dynamic dispatch uses. A tag library that overrides another, one registered while the application is running, and the order tag libraries are registered in all decide the outcome exactly as before. Nothing is bound to a particular tag library class, so a tag declared by more than one of them, and a tag declared as a Closure field, are compiled the same way.

The attributes and body are passed straight through where the call says what they are; where it does not — a map held in a variable, a single value the tag reads under its own name — the arguments are forwarded as written and adapted by the same rules dynamic dispatch applies.

Tags in pages

A page resolves a name against the model it was rendered with before it reaches a tag library, and that model is not known when the page compiles. A page therefore keeps resolving its tags as it always has, unless it declares compileStatic:

<%@ page compileStatic="true" %>
${g.createLink(controller: 'book')}   <%-- compiled into a direct invocation --%>

Declaring it reserves the namespace names for tag libraries there. grails.views.gsp.compileStatic applies it to every page. A tag written as markup, <g:createLink controller="book"/>, already compiles into a direct call and is unchanged.

Checking tags

By default nothing is reported: a tag no compiled tag library declares is left to resolve at runtime, because a namespace can legitimately hold tag libraries carrying no description. An application whose tag libraries are all described can ask for an error instead:

grails {
    compileStatic {
        strictTags = true
        dynamicTagNamespaces = ['legacy']   // namespaces filled in while the application runs
    }
}

dynamicTagNamespaces turns compile-time resolution off for a namespace completely — calls into it are never rewritten, never reported, and dispatched exactly as before.

Strict checking applies where the source says a call is a tag: one naming its namespace, and one written as markup. A call written without a namespace is never checked, and a namespaced expression in a page is checked only where the page declares compileStatic.

Deprecation

Defining a tag as a Closure field warns at compile time. It still works and is called the same way; the form is deprecated because a closure carries no signature, so nothing about the call can be checked:

// Deprecated
Closure hello = { Map attrs -> out << "Hello ${attrs.name}" }

// Preferred
def hello(Map attrs) { out << "Hello ${attrs.name}" }

Why

Profiling a running application attributed roughly 25% of samples on a tag-heavy page to reflective and metaclass tag dispatch, and about 10% to ExpandoMetaClass read-lock contention.

Every caller used to mutate its own ExpandoMetaClass the first time it used a tag; every namespace dispatcher was built with a metaclass carrying a method per tag; and plugin bootstrap installed every tag onto every tag library. None of that remains.

Measured on a page performing 400 tag invocations, 8 concurrent, 105k warmup requests, same publish flow both sides:

ms/req
8.0.x 0.5213
this branch 0.4699

Compiling the calls is worth this much again on top, measured with metaclass removal present on both sides and only the rewriting varying:

per tag call
expression in a compile-static page −66%
call written inside a tag library −33%

Where the description comes from

Under the Grails Gradle plugin the index is written twice, because the two things reading it need different guarantees.

generateTagLibraryIndex runs before compilation, so a call to a tag the project itself declares resolves as it compiles. Reading from source it cannot describe everything — a tag library referring to a type written in Java, or generated by the build, is left out — so what it missed is recorded, and nothing in an incompletely described namespace is ever reported. It is never packaged.

packageTagLibraryIndex runs afterwards with the project's own classes on the classpath, where every tag library resolves. That index is the one pages compile against, the one packaged, and the one a project depending on this one reads. Each run replaces it, so a renamed or deleted tag library cannot survive.

A build that does not write the index — a plain groovyc, or a build without the Grails Gradle plugin — has each tag library annotated @TagLib describe itself as it compiles. That fallback does not reach a tag library declared by convention: an unannotated class under grails-app/taglib is recognised as an artefact later in the compilation than the descriptor is written, so without the Gradle plugin it contributes none. A tag with no description is dispatched dynamically, so nothing breaks; it simply does not take the faster path.

What is not rewritten

  • a namespace no compiled tag library declares, which is what keeps a tag library registered at runtime working
  • a namespace the build declared in dynamicTagNamespaces
  • a name something else in scope answers to — a local, parameter, field or getter called g is that thing
  • an unqualified call in a page, and any expression in a page that has not declared compileStatic
  • a name a page puts into its own binding with <g:set>
  • an unqualified call inside a closure, which a delegate given to the closure at runtime may answer to
  • an unqualified call to a name Groovy already answers to — with, each, print and the rest of DefaultGroovyMethods, plus any extension module on the compiling classpath. Those are real methods on every receiver, so a tag of the same name must not capture the call
  • a controller declared with @Artefact('Controller') outside grails-app/controllers, which gains the ability to call tags later in the compilation than the rewriting runs

Limitations

  • A model attribute named after a namespace stops winning in a compileStatic page. That is what declaring it means there. A page that has not declared it is unaffected.
  • A method added to a controller or tag library at runtime, through doWithDynamicMethods, loses to a tag of the same name when the call is written without a namespace. Declare the method on the class, name the namespace in dynamicTagNamespaces, or call the tag with its namespace.
  • Unit testing support still installs tag methods onto metaclasses, deliberately: tests call tag methods directly, and the installed methods substitute an empty body for a missing one, so tagLib.someTag(attrs, null) works. A running application does not depend on this.
  • The end-to-end figure comes from one machine that showed thermal variance during the run; the per-call figures are in-process renders excluding the HTTP stack. Treat both as indicative of direction, not precise.
  • Scope within a method body is not tracked when deciding whether an unqualified name is claimed by a local. A name declared anywhere in the body counts throughout it, which can leave a call dispatched dynamically but never sends one somewhere else.
  • Extracting the discovery rules also changes runtime tag discovery, which is what DefaultGrailsTagLibClass is built from. Three differences from the code it replaces: equals/hashCode/toString and the GroovyObject members are excluded by name rather than by full signature; a zero-argument is* method is an accessor regardless of return type; and a name containing $ is excluded. None is reachable by a tag that would otherwise have been discovered — the shape check rejects all three anyway — and each is pinned in TagDiscoveryRulesSpec through both the tree and the compiled class.
  • A resolved call reports an unregistered tag as GrailsTagException rather than MissingMethodException. This arises where the index knows a tag but the running application has not registered it — a plugin excluded, a tag library in nonEnhancedTagLibClasses, a unit test mocking only some. Code catching MissingMethodException around a tag call, or probing with respondsTo, is affected. A call into an undescribed namespace still reports MissingMethodException.
  • A self-written descriptor is never removed. Where no build writes the index, renaming or deleting a tag library leaves its description behind until the build directory is cleaned. Builds using the Gradle plugin rewrite the index each run and are unaffected.

The closure form is deprecated and carries no callable signature, so a
tag defined that way cannot be resolved when a page is compiled. This
was the last closure-based tag remaining in the repository.
Discovering which tags exist required loading every tag library and
reflecting over it, which is only possible once the application is
running. A GSP therefore had no way to know at compile time whether a
tag call would resolve.

The TagLib AST transformation now records each tag library's namespace
and tag names as it is compiled, writing one descriptor per class under
META-INF/grails/taglibs along with a manifest naming them. Descriptors
are per class so that tag libraries packaged in separate jars merge on
the classpath with no build step combining them, in the manner of
META-INF/services entries.

Deriving tag names from the AST has to agree exactly with the runtime
rules in TagMethodInvoker, since a tag recorded in the index but
rejected at runtime would resolve when a page is compiled and then fail
when it renders. The framework method exclusions are shared rather than
duplicated, and TagLibraryIndexAgreementSpec asserts the two views
match for every framework tag library.

Two cases the AST view has to account for: trait application generates
super-accessor bridges that are synthetic at runtime but not marked so
at canonicalization, and parameters with default values expand into
overloads that reflection sees but the declaration does not show.
The type checking extension answered every unresolved tag call with
makeDynamic, so compileStatic on a GSP verified model fields and left
tag calls exactly as dynamic as they were without it.

Tag calls are now checked against the tag library index. A call into a
namespace backed by a compiled tag library must name a tag that library
declares, and a misspelling is reported when the page is compiled
rather than surfacing as a missing method when it renders. Namespaces
the index does not know, as a tag library registered at runtime or
supplied by a separately compiled plugin would be, keep resolving
dynamically.

Namespaces contributed by compiled tag libraries no longer have to be
declared through the taglibs directive, because the index already
states which tags they hold.
Dispatching a tag read Method.getParameters() on every invocation to
work out which parameter takes the attribute map, which takes the body,
and which are bound from named attributes. That allocates a fresh
Parameter array and materialises reflection metadata each time, and it
showed up directly in profiles of tag-heavy pages, yet the answer is
fixed for a given method.

The classification is now computed once, when the tag library class is
first seen, and held alongside the method. Invocation walks the
precomputed plan instead of re-reading reflection metadata, and the
access check is suppressed once rather than paid per call.

Also corrects two disagreements between the compile-time index and
runtime dispatch that the framework tag libraries did not exercise:
@tag and @NotATag override the conventional signature rule at runtime
and now do so when scanning the AST, and an attributes parameter has to
be assignable to Map, so an untyped parameter is not a dispatchable tag
and is no longer recorded as one. IndexEdgeCaseTagLib covers both
directions.
The index is written per tag library class specifically so that
libraries packaged in separate jars combine on the classpath without a
build step merging them. That is the central claim of the format and
was previously only exercised indirectly, through tag libraries that
all happened to live in one module.

Builds classpaths out of temporary jars and asserts that two jars
contributing to one namespace merge, that distinct namespaces stay
distinct, that an empty classpath yields an empty index rather than
failing, and that a malformed descriptor leaves its tags unknown so
they fall back to dynamic resolution.
A design review found the compile-time index and runtime dispatch
disagreeing in ways the framework tag libraries never exercise. Each
would let a page compile and then fail as it renders.

An attributes parameter is only recognised at runtime when it is named
"attrs", unless the class was compiled without parameter names, in
which case any name is accepted. The scanner checked only the type, so
a tag written as foo(Map options) was recorded but is not dispatchable.
Whether names are retained is read from the compiler configuration and
the same rule applied, with the body parameter treated the same way.

TagMethodInvoker scans declared methods, so a tag inherited from a base
class is not dispatchable. The scanner walked inherited methods too and
is now restricted to declarations on the tag library itself. Trait
methods are woven as declarations and remain visible.

A namespace is read at runtime through the class hierarchy and after
its initialiser has run. The scanner looked only at the class itself
and treated anything other than a constant as the default namespace,
filing those tags under "g". It now walks the hierarchy, and when the
namespace cannot be known without running the code the tag library is
left out of the index rather than filed under a guess.

An unrecognised tag is now a warning rather than a compilation error.
The index describes the tag libraries compiled before a page, so a tag
added without rebuilding its library, or a library registered at
runtime, would otherwise fail a build whose pages are correct. Setting
grails.views.gsp.strictTagChecking restores the error.
When more than one tag library declares the same namespace and tag, the
one registered last wins, and registration order comes from artefact
scanning rather than from the classpath. TagPrecedenceSpec pins that
down: the winner flips purely with registration order and carries no
inherent ranking, and returnObjectForTags follows the winner rather
than accumulating.

The index cannot reproduce that ordering, so it no longer tries. A tag
declared by two tag libraries is recorded as ambiguous and is not
resolved, which leaves the choice where it is actually made. Resolving
it here would risk compiling against one implementation and dispatching
to another. The same tag library reaching the classpath twice, as a
duplicated dependency does, names one implementation and stays
resolvable.

Descriptors also carry the format version they were written with, and
one written by a different version is ignored rather than read under
rules that may since have changed.
Whether a method is a tag was decided in two places: by reflection when
an application registers its tag libraries, and over the syntax tree
when the tag library index is written. Keeping the two in step was left
to a test, and they had already drifted apart three times.

The rules now live in TagDiscoveryRules, over a TagMethodView that a
compiled method and a method being compiled each adapt to. The two
sources differ in only two respects, both confined to their adapters:
parameter defaults have already become overloads by the time a class is
reflected on, and whether parameter names survive into the class file
is a property of the compilation rather than of the method.

TagDiscoveryRulesSpec compiles one matrix of method shapes and
classifies each of them twice, from the tree and from the resulting
class, asserting the two agree as well as asserting the expected
answer. It covers the shapes that caused the earlier drift: a Map
parameter not named attrs, an untyped parameter, @tag and @NotATag, a
framework trait name, and a defaulted trailing parameter.
The index was written as each tag library compiled, which left it
unable to describe the source set as a whole. A renamed or deleted tag
library kept its descriptor, and the manifest naming it, until the
build directory was cleaned, so the index went on describing tags that
no longer existed.

TagLibraryIndexGenerator now writes it for a whole source directory at
once, and clears what was there first, so what it describes is what
exists. Sources are parsed only as far as the syntax tree, never
loaded or executed, which is covered by a tag library whose static
initialiser would throw if it ran. Regenerating unchanged sources
produces a byte-identical index.

The generateTagLibraryIndex Gradle task runs it, before page
compilation and ahead of the artifact being packaged, so a project
depending on this one can resolve its tags. The generator reads source
rather than classes, so its classpath is the compile classpath alone:
including this project's own output made it wait for the compilation it
exists to precede, which showed up as a circular dependency through
compileAstGroovy. Two tests hold that ordering in place.

The AST transformation keeps writing descriptors, which covers tag
libraries compiled outside this task.
Registering a tag library asked the class what tags it declares, which
walks its metaclass properties, reflects over its declared methods and
scans its fields. That happens for every tag library as an application
starts, and the answer was already worked out when the tag library was
compiled.

Registration now prefers the tags recorded in the index, and discovers
them from the class only when there is no record. That keeps working
unchanged for a plugin built before the index existed, for a tag
library registered while an application is being developed, and for
one registered by a test.

A tag declared by more than one tag library is deliberately absent from
the index, so a tag library holding such a tag falls back to discovery
rather than registering an incomplete set.
Resolving a tag installed it onto the caller's metaclass so that later
calls bypassed methodMissing, and every namespace dispatcher was built
with its own ExpandoMetaClass carrying a method for each tag in the
namespace. Tag dispatch was therefore a read of an initialised
ExpandoMetaClass, guarded by a read-write lock that profiles of
concurrent rendering showed to be the largest single contended cost,
and every caller mutated its own metaclass the first time it used a
tag.

Both now dispatch through the tag library lookup, which is a map read.

Removing the installed methods is not simply removing a cache: they
carried overloads that adapted a CharSequence body into a closure and
routed the call through the output capture protocol. Dispatching
straight at the tag library skipped that and broke a tag called with a
string body. The dynamic path therefore goes through
methodMissingForTagLib, which already does both, with the flag that
installs the metaclass methods turned off.

NoMetaClassMutationSpec holds the property that resolving a tag writes
to no metaclass.
Now that the index is generated from source before anything resolving
tag calls is compiled, it describes the tag libraries of this project
as well as those of its dependencies, so a tag it cannot find in a
namespace it knows is a misspelling rather than a gap in what it has
seen. Those are reported as compilation errors.

A namespace with no compiled tag library is still left to runtime
resolution, as a tag library registered while developing or supplied by
a plugin built before the index existed would be, and a tag declared by
two tag libraries stays ambiguous and unresolved. Setting
grails.views.gsp.strictTagChecking to false turns the error back into a
warning.

Generating the index no longer fails when one tag library cannot be
resolved ahead of compilation. FormFieldsTagLib refers to services in
its own project, which by design are not on the classpath the generator
runs against, and that took the whole index down with it. Sources that
fail are parsed individually and those that still fail are named and
skipped, leaving them to be described by the compiler as they are
built.
Calling a tag reaches the tag library through invokeMethod, which
leaves a dynamic call site in the caller's bytecode even when that
caller is statically compiled. Once a tag has been resolved against the
index there is nothing left to decide beyond which bean holds it, so
the call can be an ordinary method call.

CompiledTagInvocation is that call. It takes the namespace and name as
arguments and ends at TagOutput.captureTagOutput, which is where the
dynamic path ends too, so attribute and body handling, output capture,
encoding and return-object behaviour are the same either way.
TagLibNamespaceMethodDispatcher, which is how a statically compiled
page reaches a tag, now goes through it.

This is the target a rewritten call site needs. Rewriting the call
sites themselves is not part of this commit.
Every tag library had every tag in every namespace installed onto its
metaclass as it was constructed, and again for the whole application at
plugin bootstrap, so that a tag library calling another tag found a
method rather than falling through to methodMissing. A namespace
resolved through propertyMissing was installed as a property too.

None of that is needed now that tags are resolved through the tag
library lookup and invoked through CompiledTagInvocation, so it is
gone. Registering a tag library with the lookup is all bootstrap does.

TagLibraryMetaUtils is deprecated. What remains of it is the dynamic
dispatch a tag library registered at runtime still relies on, reached
with metaclass installation switched off.

The compile-time warning for a closure-based tag now says what the
consequence is, that calls to it stay dynamic because it cannot be
resolved when a page is compiled, and shows the method form to use
instead.
Writing g.link(controller: 'book') reaches the tag library through
propertyMissing to find the namespace and invokeMethod to find the tag,
which leaves a dynamic call site in the bytecode of a tag library even
when it is statically compiled. Both names are fixed in the source and
the index says whether that tag exists, so the call is replaced with a
call to CompiledTagInvocation.

Only calls whose shape is evident from the source are rewritten: a tag
takes attributes, a body, both or neither, written as literals. A call
whose attributes are assembled at runtime, a namespace no compiled tag
library declares, a tag declared by more than one of them, and a
namespace shadowed by a field of the same name are all left to resolve
as they did before.

CompiledTagCallRewriterSpec renders through each of those shapes, since
a rewrite that changed behaviour is the failure that matters.
Behaviour alone cannot show that anything was rewritten, because the
dynamic route produces the same output, so CompiledTagCallBytecodeSpec
compiles a tag library and looks for the invocation in the class file,
and for its absence where nothing should have been rewritten.
A review of the stack found the strict check and the explicit
invocation path each breaking cases the dynamic path handled.

An unrecognised tag is a warning again rather than an error. Knowing
that a namespace holds some compiled tag libraries is not knowing that
it holds all of them: a plugin built before the index existed
contributes tags to g without a descriptor, a tag library registered
while an application runs contributes more, and the index generator
skips a source it cannot resolve ahead of compilation. In each case the
namespace is known but incomplete, so a tag missing from it is not
necessarily a misspelling. Failing the build needs a namespace able to
state that it is complete, which the descriptors cannot yet do.
grails.views.gsp.strictTagChecking opts in to the error.

A tag declared by more than one tag library was reported as no such
tag. The index deliberately leaves it unresolved so that runtime
precedence decides, which the checker read as absent. It now asks
whether the tag is ambiguous before reporting it.

A tag body given as text threw a GroovyCastException. The dynamic path
accepted text through overloads that wrapped it in a closure, and the
explicit API narrowed the body to Closure, which a namespaced
dispatcher call with a string body could not satisfy. The API takes the
body as it is given and wraps text, as before.

Registering a tag library after startup, as reloading a changed class
during development and registering one from a test both do, uses the
descriptor supplied rather than the one recorded when the class was
compiled, which no longer describes what is being registered.
A tag library rewrote its own tag calls as it compiled, but a
controller can call tags as well. It gains that from the tag library
invoker trait rather than from being a tag library, so nothing rewrote
its calls and they stayed dynamic.

A global transformation now rewrites tag calls in any class carrying
that trait, which covers controllers without naming them and without a
second copy of the rules. It runs after trait injection, since whether
a class can call tags is only settled once its traits are applied, and
it does nothing at all when no compiled tag library is on the
classpath.

ControllerTagCallRewriteSpec compiles a class with the trait and one
without, and looks in the class files for the invocation, since a
rewritten call and a dynamic one produce the same output.
Describes how tag libraries are described when compiled and how that
resolves tag calls, what is compiled into a direct invocation and what
stays dynamic, how an unrecognised tag is reported and how to turn that
into an error, why a closure-based tag cannot be resolved, and where
the description is written and packaged.

Adds the corresponding what's new entry and an upgrade note covering
the two things an existing application notices: the warning for an
unrecognised tag, and the warning for a closure-based tag with the
method form to replace it.
The pre-compilation task scanned only grails-app/taglib, so a project
keeping tag libraries elsewhere had them described as they compiled
rather than beforehand, which is later than anything resolving them in
the same compilation needs.

The task now takes a collection of directories, defaulting to the one
it scanned before, and the generator can add to an index rather than
always replacing it, so several directories contribute to one index
instead of each erasing the last.
Three places were still installing methods onto metaclasses, so the
earlier claim that dispatching a tag writes to none of them was wider
than what had actually been done.

A page had methodMissing installed onto its metaclass as it compiled,
along with a method for every tag and a property for every namespace.
GroovyPage declares methodMissing itself now and already resolved a
namespace through getProperty, so a page reaches the same tags without
any of those writes.

The template namespace installed a method for each template name the
first time it was used. Rendering goes through the render tag either
way, so the name is resolved rather than installed.

The unit testing support keeps installing tag methods, deliberately.
Tests call tag methods directly, and the installed methods substitute
an empty body for a missing one, so tagLib.someTag(attrs, null) works.
Removing it broke twelve FormTagLibTests cases that rely on that
calling convention. A running application does not depend on it.

NoMetaClassMutationSpec now covers the template namespace and the page,
alongside the namespace dispatcher it already covered.
The index said only that a tag existed, which is enough to tell a
misspelling from a real tag but not enough to decide whether a call to
it can be bound. A tag defined as a Closure field carries no signature,
so a call to it cannot become a direct invocation, and nothing in the
index said which tags those were.

Each tag is now recorded with its kind, and a call is only compiled
into a direct invocation when the tag is a method. A closure-based tag
stays known, so it is never reported as a misspelling, and stays
dynamically dispatched. This showed up immediately: g.link is a Closure
field, so calls to it are correctly left alone.

The descriptor format is version 2 as a result. A descriptor written by
another version is ignored rather than read under the wrong rules, and
a kind that cannot be recognised is treated as the dynamic one so that
a newer descriptor can never cause a call to be bound wrongly.
A namespace is not declared anywhere: it is reached because nothing
else answers to the name. The rewriter took any receiver that was not
this or super as a namespace, checking only for a field of that name on
the class itself, so a local variable, a parameter, an inherited field
or a getter-only property called g had calls on it rewritten into tag
invocations. The object the author wrote was then never called, and the
code still compiled, which is the worst way for this to go wrong.

A receiver that resolves to anything - a local, a parameter, a field, a
property - is that thing, and the field and property checks now walk
the hierarchy and consider getters.

Rewriting is also confined to methods declared by the class being
transformed. getMethods() reaches inherited methods, whose bodies
belong to the class that declared them, so a subclass able to call tags
could otherwise change a superclass that cannot.

TagCallShadowingSpec covers a local, a parameter, a typed local, a
field, an inherited method, and the unshadowed case that must still be
rewritten.
The index a project generates from its own sources reached page
compilation and the packaged artifact, but not the compilation of its
own controllers and tag libraries. Those could resolve tags from
dependencies while a call to a tag declared in the same project stayed
dynamic, which is not what the documentation described.

The generated directory now joins the compile classpath, and
compileGroovy waits for it. It goes onto the classpath rather than into
the source set output, which would make the index wait for the
compilation it exists to precede.

The documentation is also narrowed to what is actually rewritten.
Expressions in a GSP page are checked against the descriptions but are
not rewritten: a page selects the tag by name as it renders, through
the namespace dispatcher, which no longer touches a metaclass but is
still a runtime choice. An unqualified call such as message(code: 'x')
is likewise left alone, since whether that name is a tag or a method of
the calling class is decided where it is called. The examples now use a
method-based tag, since the closure-based g.link they used is one of
the calls that is deliberately not rewritten.
Groovy reads a property from getX() and, when the return type is
boolean, from isX() as well. Only the first was checked, so a class
declaring boolean isG() had this.g treated as a tag library namespace
and calls on it rewritten, sending them to a tag library instead of the
property the author wrote.

Both forms now claim the name, with the isX form requiring a boolean
return type as Groovy does. TagCallShadowingSpec covers each getter
form and an inherited getter.

Also proves the resolution the previous commit exists to enable.
Generating an index from a tag library source, putting it on a compile
classpath and compiling a controller that calls that namespace shows
the call becoming an invocation, and shows it staying dynamic without
the index. The build wiring is asserted separately; what was missing
was evidence that the wiring is sufficient for the compiler to resolve
the call.
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.49491% with 221 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.6726%. Comparing base (a6f4846) to head (30cac71).
⚠️ Report is 19 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...s/gsp/taglib/compiler/CompiledTagCallRewriter.java 77.3869% 16 Missing and 29 partials ⚠️
...roovy/org/grails/taglib/index/TagLibraryIndex.java 73.9726% 22 Missing and 16 partials ⚠️
.../grails/taglib/index/TagLibraryIndexGenerator.java 77.6978% 20 Missing and 11 partials ⚠️
...lugin/views/gsp/GenerateTagLibraryIndexTask.groovy 44.8276% 14 Missing and 2 partials ⚠️
...ls/gradle/plugin/views/gsp/GroovyPagePlugin.groovy 80.0000% 9 Missing and 4 partials ⚠️
...org/grails/taglib/index/TagLibraryIndexWriter.java 83.1169% 5 Missing and 8 partials ⚠️
...grails/gsp/taglib/compiler/LocalNameCollector.java 71.0526% 9 Missing and 2 partials ⚠️
.../compiler/TagLibArtefactTypeAstTransformation.java 67.7419% 6 Missing and 4 partials ⚠️
...rails/taglib/discovery/TagLibraryAstDiscovery.java 62.5000% 3 Missing and 6 partials ⚠️
...ails/gsp/taglib/compiler/PageBindingCollector.java 73.0769% 0 Missing and 7 partials ⚠️
... and 8 more
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16134        +/-   ##
==================================================
+ Coverage     52.3242%   52.6726%   +0.3484%     
- Complexity      18537      18860       +323     
==================================================
  Files            2039       2054        +15     
  Lines           97498      98389       +891     
  Branches        17138      17338       +200     
==================================================
+ Hits            51015      51824       +809     
+ Misses          38998      38984        -14     
- Partials         7485       7581        +96     
Files with missing lines Coverage Δ
...iler/TagLibraryInvokerTypeCheckingExtension.groovy 59.4595% <ø> (ø)
...adle/plugin/core/GrailsCompileStaticOptions.groovy 100.0000% <100.0000%> (ø)
.../groovy/org/grails/gsp/GroovyPagesMetaUtils.groovy 100.0000% <ø> (+9.0909%) ⬆️
...sp/compiler/GroovyPageTypeCheckingExtension.groovy 65.0794% <100.0000%> (+2.7843%) ⬆️
...y/org/grails/taglib/NamespacedTagDispatcher.groovy 100.0000% <100.0000%> (+12.5000%) ⬆️
...ails/taglib/TagLibNamespaceMethodDispatcher.groovy 76.4706% <100.0000%> (+5.8824%) ⬆️
...roovy/org/grails/taglib/TagLibraryMetaUtils.groovy 54.6053% <ø> (ø)
...ails/taglib/TemplateNamespacedTagDispatcher.groovy 9.0909% <ø> (-6.2937%) ⬇️
.../org/grails/taglib/index/TagLibraryIndexEntry.java 100.0000% <100.0000%> (ø)
.../src/main/groovy/grails/artefact/TagLibrary.groovy 71.4286% <ø> (ø)
... and 20 more

... and 21 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Reading walks every jar on the classpath. A compiler that consulted the
index for each source file walked it once per file, while the one caller
that did cache it held the result in a static field, which carried one
project's tag libraries into the next compilation in the same Gradle
daemon and read them from the wrong class loader. It is read once per
class loader instead, which is once per compilation and no longer.

Asking what a single tag library declares is answered from its own
descriptor. It used to be answered by scanning every namespace and then
discarding the answer entirely if any namespace anywhere held a tag two
libraries declared, so one overridden tag left every tag library
undescribed.

A tag two libraries declare is now reported as known even though it
cannot say which of them will answer to it, so that it is never mistaken
for a misspelling, and the settings a build states about its tag
libraries are read alongside the descriptors.
Registration preferred the tags recorded when a tag library was compiled
over the tags the class has, to save discovering them by reflection as
the application starts. It saved nothing: the tag library class is
constructed before it is registered, and constructing it already reads
every tag by reflection and through the metaclass.

What it did add was a way for a descriptor left behind by an earlier
build to decide what a running application believes a tag library
declares. Reflection is authoritative at runtime; the index describes
what was true when the tag library was compiled and is used where that
is the question being asked.
processResources no longer carries the index, so the import went with
the configuration block that did.
@codeconsole
codeconsole requested review from borinquenkid, jdaugherty, matrei and sbglasius and removed request for jdaugherty August 11, 2026 22:54
The index written after compilation reads this project's classes so that
a tag library referring to a service, base class or trait of the same
project can be described. It took the whole source set output to do
that, which also waits for anything else writing into it.

A view compiler registers its own output directory into that output and
runs after the classes task, so it cannot name the classes task as its
producer without a cycle - which leaves anything reading the whole
output consuming a directory nothing declares it produces, and Gradle
rejects that. It failed every project that compiles both pages and JSON
or markup views.

The class directories are taken instead, with a dependency on the
classes task so that everything writing into those - the ast classes are
copied in after compiling, for one - is still waited for. Compiled views
are no use in resolving what a tag library declares.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review

I checked the branch out and built/ran :grails-gsp, :grails-taglib, :grails-web-taglib, :grails-test-suite-web, :grails-test-suite-uber, :grails-fields, :grails-cache, the Gradle-plugin specs, and codeStyle / check. Everything passes. The findings below are things the test suite doesn't cover; the three blocking ones I reproduced by compiling real sources against this branch.

What's good

The TagDiscoveryRules / TagMethodView split, so AST discovery and reflective discovery cannot drift, is the right shape for this. TagMethodBinding pre-classifying parameters is a clean win over re-reading Method.getParameters() per invocation. TagLibraryIndexGenerator's SourceRootClassNodeResolver — reading a collaborator's source rather than substituting a placeholder — and the refusal to guess a namespace that isn't a constant are both correctly conservative. compiledTags.adoc is unusually thorough, and the two-index rationale in GroovyPagePlugin is well argued. The Limitations section in the description is honest; it just needs the three items below added to it.


Blocking

1. Unqualified rewriting ignores DefaultGroovyMethods and extension modules. Reproduced. Given @TagLib class DgmTagLib { static namespace = 'g'; def sleep(Map attrs) { out << 'slept' } }, a controller's sleep(100) compiles to CompiledTagInvocation.invokeArguments(lookup, "g", "sleep", 100). Before this PR that call reached DefaultGroovyMethods.sleep(Object, long)methodMissing was never involved, because DGM methods are on the metaclass. declaresMember only inspects the ClassNode. Details inline on CompiledTagCallRewriter.java.

2. The self-describing path never fires for conventional tag libraries. Reproduced. TagLibArtefactTypeAstTransformation is a local transform bound to @TagLib; a conventional grails-app/taglib/FooTagLib.groovy carries no annotation and gets @Artefact added by GlobalGrailsClassInjectorTransformation far too late for the local transform to be collected. Compiling an unannotated tag library emits no descriptor at all. That makes the claim in compiledTags.adoc — "each tag library describes itself as it is compiled instead" — wrong for the normal case. Details inline.

3. @Artefact('Controller') classes outside grails-app/controllers are never rewritten. Reproduced: the class file shows implements grails.artefact.gsp.TagLibraryInvoker but contains no CompiledTagInvocation reference, while the same class under grails-app/controllers does. CompiledTagCallTransformation has no TransformWithPriority, and local transforms run after every global in the same phase. Details inline.


High

4. TagLibraryInvoker.methodMissing changed dispatch semantics (inline).

5. Error type changed. A tag the index knows but the runtime hasn't registered — plugin excluded, unit test, nonEnhancedTagLibClasses — now throws GrailsTagException from TagOutput.captureTagOutput:54 rather than MissingMethodException. Anything catching MissingMethodException, or probing with respondsTo, behaves differently.

6. GroovyPage.methodMissing can NPE on a null lookup (inline).

7. strictTags has no cross-jar completeness signal (inline).


Medium

  1. Shared AST singletons (VariableExpression.THIS_EXPRESSION, ConstantExpression.NULL) reused as generated nodes — inline.
  2. Dead API added: TagLibraryIndex.lookup, getAmbiguousTagNames, getTagNamesForClass, isClassDescribed, getIncompleteNamespaces; TagLibraryAstDiscovery.findTagNames (a near-duplicate of findTags); TagLibraryIndexWriter.write(…, Collection<String>). See also the Kind comment inline.
  3. FORMAT_VERSION = 2 for a format introduced by this PR.
  4. TagLibraryIndexFiles duplicates the on-disk format as string literals across a module boundary — inline.
  5. Gradle configuration-cache risk — inline.
  6. The packaged index never reaches the test runtime classpath — inline.
  7. sourceEncoding is never populated from the project's compile encoding — inline.
  8. index.properties manifest read-modify-write and stale entries — inline.
  9. TagLibraryIndex.listDescriptors performs one full classpath scan per described tag library — inline.

Low / hygiene

  1. Orphaned duplicate javadoc in TagMethodInvoker — inline.
  2. Duplicate javadoc block on TagLibraryAstDiscovery.findTags — inline.
  3. TagLibraryInvoker.developmentMode and NamespacedTagDispatcher.developmentMode are now written and never read. The trait field is materialised into every controller and tag library (grails_artefact_gsp_TagLibraryInvoker__developmentMode is visible in the bytecode), plus an Environment.isDevelopmentMode() call per construction.
  4. TagLibrary.initializeTagLibrary() is now an empty @PostConstruct method.
  5. @Deprecated on the whole of TagLibraryMetaUtils — inline.
  6. API removals with no upgrade note: NamespacedTagDispatcher.initializeMetaClass(), NamespacedTagDispatcher.registerTagMetaMethods(ExpandoMetaClass), TemplateNamespacedTagDispatcher.registerTagMetaMethods; and GroovyPagesMetaUtils.registerMethodMissingForGSP silently became a no-op. Neither those nor the broader "tags and namespaces are no longer installed onto metaclasses" change — which breaks tagLib.metaClass.respondsTo('someTag') and similar — appear in upgrading80x.adoc.
    23/24. LocalNameCollector: deprecated Groovy API, and missed catch parameters — inline.
  7. The docs deprecate Closure hello = { … }, but essentially every real tag library in this monorepo writes def hello = { attrs -> } (grails-fields, grails-cache, grails-redis, grails-spring-security). The warning does fire for that form once TagLibraryTransformer has forced the property type, so please show it in the upgrade guide — that is the form people have to change.
  8. TagDispatchBenchmarkSpec — inline.
  9. grails-mail drive-by — inline.
  10. TagDiscoveryRules also changes runtime discovery — inline.
  11. ControllerTagCallRewriteSpec covers neither real controller shape — inline.

Scope

69 files, +7.8k, mixing metaclass removal, an index format, a compiler rewrite, a Gradle task, docs and an e2e project. The metaclass removal (0.5213 → 0.4699 ms/req) stands on its own, is independently measurable and carries almost none of the risk above. Splitting it out would make the compile-time rewrite — which carries all of it — far easier to assess and to revert independently if a regression like #1 turns up in the field.

* Whether the class, or anything it inherits from, already answers to a name at all. An
* unqualified call reaches a tag only when nothing else does, so here a method counts too.
*/
private boolean declaresMember(String name) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — unqualified rewriting ignores DefaultGroovyMethods and extension modules.

declaresMember only inspects the ClassNode's own and inherited methods/fields/properties. It cannot see anything the metaclass supplies, so DGM and extension-module methods never claim their name.

Reproduced on this branch. Tag library:

@TagLib
class DgmTagLib {
    static namespace = 'g'
    def sleep(Map attrs) { out << 'slept' }
}

Controller, compiled with that descriptor on the classpath:

class DgmController {
    def index() { sleep(100); 'done' }
}

The class file contains CompiledTagInvocation.invokeArguments(lookup, "g", "sleep", 100). Before this PR the call reached DefaultGroovyMethods.sleep(Object, long)methodMissing was never involved, because DGM methods are registered on the metaclass. So this is a silent behaviour change, not a missed optimisation.

RESERVED_NAMES is {"body", "render"}. The exposed surface is every DGM name that applies to Object: print, println, printf, sprintf, sleep, with, tap, use, dump, inspect, is, identity, each, find, every, any, collect, grep, sort, sum, min, max, getAt, putAt, asType, respondsTo, hasProperty, addShutdownHook — plus anything an extension module a project or plugin registers.

It is worse than the g namespace alone suggests: unqualifiedNamespaceOf tries callerNamespace before g, so a tag library whose own namespace declares a tag called each breaks every unqualified each { } in every other tag library of that namespace.

The upgrade guide covers doWithDynamicMethods but not this, and this one is far easier to hit by accident because it needs no dynamic registration at all — just a name collision.

Options, roughly in order of preference:

  1. seed RESERVED_NAMES from DefaultGroovyMethods / DefaultGroovyStaticMethods plus ExtensionModuleScanner, so any name the metaclass would answer is left alone;
  2. consult GroovySystem.getMetaClassRegistry().getMetaClass(Object) for the receiver's meta-methods;
  3. make unqualified rewriting opt-in, and rewrite only namespaced calls by default.

Whichever way it goes, this needs a test: a tag whose name collides with a DGM method, asserting the DGM method still wins.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. RESERVED_NAMES is now seeded from GroovySystem.getMetaClassRegistry().getMetaClass(Object), so every name the metaclass answers to for an arbitrary receiver — DGM plus any extension module on the compiling classpath — is left alone. Only unqualified calls are affected; a call naming its namespace is unchanged.

Not hypothetical, as you say: grails-fields already declares f:with. GroovyMethodNameCollisionSpec covers it, and I checked it bites — with the old two-name set both with and each fail, with the fix both pass, and the guard cases (namespaced call, non-colliding tag) pass either way.

return null;
}
ArgumentListExpression invocationArgs = new ArgumentListExpression();
invocationArgs.addExpression(new MethodCallExpression(VariableExpression.THIS_EXPRESSION,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

VariableExpression.THIS_EXPRESSION (here and at outputContext()) and ConstantExpression.NULL (in attributesAndBody) are process-wide singletons, and this reuses them as generated nodes at every rewritten call site in every class in the compilation.

StaticTypeCheckingVisitor.storeType writes INFERRED_TYPE node metadata onto the expression it visits, so a @CompileStatic page or tag library will stamp inferred types onto the shared instances and the last write wins. I didn't manage to make it misbehave in a quick test, but it's a latent hazard rather than a safe idiom — note that TagLibraryTransformer, doing the same job a few files over, deliberately uses new VariableExpression("this") for exactly this reason.

Suggest new VariableExpression("this") and new ConstantExpression(null) per call site.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — new VariableExpression("this") and new ConstantExpression(null) per call site, matching TagLibraryTransformer.

*/
@CompileStatic
@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
class CompiledTagCallTransformation implements ASTTransformation {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — @Artefact('Controller') classes outside grails-app/controllers are never rewritten.

Reproduced on this branch:

// src/main/groovy/pkg/AnnotatedController.groovy
@Artefact('Controller')
class AnnotatedController {
    def index() { g.createLink(controller: 'book') }
}

javap shows implements grails.artefact.gsp.TagLibraryInvoker — the trait is there — but the class file contains no CompiledTagInvocation reference. The same class under grails-app/controllers is rewritten.

The reason is transform ordering. In ASTTransformationVisitor.addPhaseOperations, global transforms are registered as phase operations before the local-transform visitor, so every global at CANONICALIZATION runs before every local one. The convention path works because GlobalGrailsClassInjectorTransformation is itself global and has a high TransformWithPriority, whereas this transform doesn't implement TransformWithPriority at all and so defaults to 0 — last among globals. That is accidentally correct for the convention path and unavoidably wrong for locally-annotated artefacts, where trait injection happens in ArtefactTypeAstTransformation after all globals have finished.

So the class javadoc

Runs after trait injection, since whether a class can call tags is only settled once its traits have been applied.

isn't guaranteed — it holds for one of the two ways to declare a controller.

Two things to fix:

  • implement TransformWithPriority and add an entry to org.apache.grails.common.compiler.GroovyTransformOrder, as every other Grails global transform does. Relying on an undeclared default of 0 is exactly the fragility that registry exists to prevent.
  • decide what to do about @Artefact-annotated artefacts. Either hook the rewrite into ArtefactTypeAstTransformation the way TagLibArtefactTypeAstTransformation already does for tag libraries, or document that the fast path is convention-only.

Either way the behaviour is currently a silent, undetectable difference between two source layouts that are meant to be equivalent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Half fixed, half documented.

TransformWithPriority is implemented with a COMPILED_TAG_CALL_ORDER slot in GroovyTransformOrder, and CompiledTagCallTransformationOrderSpec pins the relationship to artefact trait injection rather than the number.

The @Artefact('Controller') gap I documented rather than fixed. Hooking into ArtefactTypeAstTransformation would mean grails-core referencing the GSP rewriter, and rewriting after type checking risks @CompileStatic. It degrades to dynamic dispatch, so behaviour is correct — it just misses the faster path. ControllerTagCallRewriteSpec pins the annotated case so the difference is explicit, and the guide says so.

* <p>Failure to write is never fatal: the index is an optimisation, and a missing descriptor
* degrades to the runtime resolution that applies when a tag library is registered dynamically.
*/
protected void writeIndexEntry(SourceUnit sourceUnit, ClassNode classNode) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — this never runs for conventional tag libraries.

TagLibArtefactTypeAstTransformation is a local transform, bound to @TagLib via @GroovyASTTransformationClass on grails.gsp.TagLib. A conventional grails-app/taglib/FooTagLib.groovy carries no annotation in source: GlobalGrailsClassInjectorTransformation adds @Artefact('TagLib') during CANONICALIZATION, long after ASTTransformationCollectorCodeVisitor ran at SEMANTIC_ANALYSIS, so this transform is never collected and writeIndexEntry is never called.

Reproduced. Compiling grails-app/taglib/pkg/DgmTagLib.groovy (no annotation) with a plain CompilationUnit and a target directory emits no META-INF/grails/taglibs/ output. Adding @grails.gsp.TagLib to the same class emits the descriptor. Grails' own tag libraries all carry @TagLib, which is why the framework's own build looks like it works.

Consequences:

  • Every plugin that declares tag libraries by convention and doesn't apply the Grails GSP Gradle plugin publishes no descriptors — grails-fields, grails-cache, grails-redis and grails-spring-security in this repo are all in that position.
  • buildOwnsIndex() is designed around a fallback that mostly doesn't exist.
  • The claim in compiledTags.adoc that a tag library "describes itself as it is compiled" when no build writes the index is wrong for the normal case (separate comment there).

It degrades safely — a missing descriptor just means dynamic dispatch — so this isn't a correctness bug. But the design and the docs both assume a fallback that only covers annotated tag libraries, and that assumption should either be fixed (have GlobalGrailsClassInjectorTransformation drive the index write for artefacts it recognises) or stated plainly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed, and reworded rather than fixed. The guide now says the fallback reaches @TagLib-annotated tag libraries only, that a convention-declared one contributes no descriptor without the Gradle plugin, and that this degrades to dynamic dispatch rather than breaking. Also noted that a self-written descriptor is never removed, which you raised separately.

exist is left out, as it would be by the compiler.

Where no build writes the index — a plain Groovy compilation, or one that does not apply the Grails
Gradle plugin — each tag library describes itself as it is compiled instead, which makes it resolvable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This claim doesn't hold for the normal case.

The self-describing path is TagLibArtefactTypeAstTransformation, a local AST transform bound to @TagLib. A conventional grails-app/taglib/FooTagLib.groovy has no annotation in source — @Artefact is added by GlobalGrailsClassInjectorTransformation at CANONICALIZATION, after local transforms were collected — so nothing describes it. I verified this: compiling an unannotated tag library emits no descriptor; adding @grails.gsp.TagLib to the same class emits one.

So "a plain Groovy compilation, or one that does not apply the Grails Gradle plugin" describes only tag libraries that were annotated by hand. Please either make the fallback cover convention-based tag libraries or reword this paragraph to say what it actually does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reworded — it now says what it actually does, including that the fallback misses convention-declared tag libraries and that the result is dynamic dispatch rather than a failure.


@Override
public void visitForLoop(ForStatement forLoop) {
if (forLoop.getVariable() != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ForStatement.getVariable() is deprecated in Groovy 5 — javac reports it on every build of this module:

Note: .../LocalNameCollector.java uses or overrides a deprecated API.

The replacement is getIndexVariable() / getValueVariable() (and the classic form now carries both), so this also currently misses the index variable of a classic for (int i = 0; ...) loop.

Two other gaps in the same collector, both in the "leaves a call dynamic" direction so neither is unsound, but both cost the optimisation and are cheap to close:

  • catch parameters. CodeVisitorSupport.visitTryCatchFinally visits the catch body but not its Parameter, so a catch (SomeException message) doesn't claim message.
  • an implicit-it closure. visitClosureExpression only adds parameters when isParameterSpecified(), so it is never collected.

Worth a visitCatchStatement override and adding "it" for the implicit case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

All three fixed — getIndexVariable()/getValueVariable() in place of the deprecated getVariable() (which also picks up the classic loop's index), a visitCatchStatement override for catch parameters, and it for an implicit-parameter closure. The javac deprecation note this module was printing is gone.

* the environment, where this build bridges only a few named properties into it.
*/
@Requires({ System.getenv('GRAILS_TAGLIB_BENCH') })
class TagDispatchBenchmarkSpec extends Specification implements TagLibUnitTest<ApplicationTagLib> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

241 lines of hand-rolled timing harness in src/test, @Requires-gated on an environment variable so it never runs and nothing keeps it compiling-and-correct beyond compileTestGroovy.

This repo already has JMH benchmark infrastructure. A hand-rolled loop with a warmup count and a median can't control for JIT state, GC, or dead-code elimination the way JMH's blackholes and forks do — and the spec's own comment ("reported rather than asserted: a timing is evidence, not a contract") concedes that it produces no verdict.

Suggest moving it to the JMH module, where the numbers in the PR description could then be reproduced by anyone, or dropping it and keeping the measurements in the description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Deleted. Porting it properly would mean giving grails-benchmarks the taglib and Spring dependencies it doesn't have, which is more scope rather than less. The measurements stay in the description.

when: 'a class carrying the tag library invoker trait, as a controller does'
byte[] compiled = compile('''
import grails.artefact.gsp.TagLibraryInvoker
class TagCallingController implements TagLibraryInvoker {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This hand-writes implements TagLibraryInvoker, which is the one shape a controller never has in real code. It therefore can't catch the ordering problem described on CompiledTagCallTransformation: a real @Artefact('Controller') class gets the trait from a local transform that runs after this global one, and is not rewritten (I verified this).

Please add two cases:

  • a class under a grails-app/controllers/... path, which exercises the GlobalGrailsClassInjectorTransformation route that works today;
  • a class carrying @Artefact('Controller') outside that directory, which currently does not get rewritten.

The second one should fail as written today, which is the point.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both cases added, and then one removed again — worth explaining.

The @Artefact('Controller') case is there and passes, confirming what you found: the trait is present, the call is not rewritten.

The grails-app/controllers case I removed. It compiled a file into a temporary grails-app/controllers directory and relied on the artefact injector recognising it by location, which turns on where the compilation happens rather than what is compiled. It passed on macOS and failed on Ubuntu and Windows in every CI run and again on a rerun, and I couldn't reproduce it locally in any configuration. The convention path is exercised for real by every application under grails-test-examples; the spec now records the gap rather than leaving it silent.

static namespace = 'text'

def newLine = {
def newLine(Map attrs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is this here? It's a change to a different module's published tag signature, in a PR about compile-time tag resolution, with no test and no mention in the description.

def newLine = { out << '\n' } is an Object-typed field, so it isn't picked up by TagLibraryAstDiscovery's Closure-typed field scan, and this file isn't under grails-app/taglib so TagLibraryTransformer never forced the type or generated wrappers for it. As far as I can tell it therefore wasn't in the index either before or after, and nothing in this PR requires the change.

If it's an unrelated fix for a text:newLine tag that was already broken, it deserves its own commit and a spec. If this PR does require it, that implies something about zero-parameter closure tags that should be explained — and tested — because other plugins will have the same shape.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair — it had no test and no explanation. Both now.

PlainTextMailTagLibSpec covers text:newLine. I checked, and you were right that nothing required the change: the closure form dispatches fine either way. It was converted because declaring a tag as a closure is deprecated as of this PR and now warns, and the framework's own tag libraries shouldn't trip a warning the framework introduces. That reasoning is on the spec.

/**
* Names that are Groovy or Object plumbing on any class.
*/
private static final Set<String> LANGUAGE_METHOD_NAMES = Set.of(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth calling out explicitly in the description: extracting these rules also changes runtime tag discovery, which is what DefaultGrailsTagLibClass and TagMethodInvoker.INVOKABLE_METHODS_BY_NAME are built from. Three differences from the code this replaces:

  1. equals / hashCode / toString / getProperty / setProperty / getMetaClass / setMetaClass are now excluded by name, where before Object and GroovyObject members were excluded by full signature. A tag called equals(Map attrs) was previously discoverable and no longer is.
  2. isPropertyAccessor drops the boolean-return-type check on is*, so any zero-arg is* is now an accessor.
  3. Names containing $ are newly excluded.

I worked through all three and couldn't construct a case that behaves differently in practice — (1) and (2) are unreachable because the shape check rejects them anyway, and (3) only removes synthetic trait accessors that were never tags. So I think this is safe. But it is a change to runtime behaviour riding along in a compile-time feature, and reviewers shouldn't have to derive that themselves.

Please note it in the description, and add a spec pinning the rules directly (TagDiscoveryRulesSpec covers the AST view — a reflective equivalent asserting the same verdicts for the same shapes would make the "the two cannot drift apart" claim in the class javadoc actually enforced).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Noted in the description, and the three differences are now pinned — an Object member name, a non-boolean is* accessor, and a name containing $ — as rows in TagDiscoveryRulesSpec.

One correction: that spec isn't AST-only. Each row runs classifyFromTree (AstTagMethodView) and classifyFromClass (ReflectedTagMethodView) over the same source and asserts fromTree == fromClass before asserting the verdict, so the "cannot drift apart" claim is already enforced across the matrix — the new rows just extend it to these three.

@jdaugherty

Copy link
Copy Markdown
Contributor

I posted the AI review, I'm not sure I agree with splitting it. I think it found enough real issues that we can use this to iterate on the review. @codeconsole

An unqualified call to a DefaultGroovyMethods method - with, each, print
and the rest - reached that method directly and never went near
methodMissing. Rewriting it into a tag invocation because a tag library
happened to declare a tag of the same name silently sent the call
somewhere it was never written to go. grails-fields already declares
f:with, so the collision is not hypothetical.

Reserve every name the metaclass answers to for an arbitrary receiver,
which covers DefaultGroovyMethods and any extension module on the
compiling classpath. A call that names its namespace is unaffected.
…name

A page resolves an unqualified name through a real methodMissing rather
than one installed onto its metaclass, and the field it resolves against
is documented as null until the page is initialised. Reaching the lookup
regardless arrives at the same missing-method answer, but only because a
dynamic call on a null receiver yields no tag library; say it instead,
and pin the behaviour with a spec.
The rewriting has to run after the transforms that apply the traits a
class calls tags through, and did - but only because a transform that
declares no priority defaults to zero, which happened to put it last
among the globals. A transform added later with the same default would
have displaced it silently.

Give it a slot in GroovyTransformOrder, as every other Grails global
transform has, and pin the relationship to artefact trait injection.
…alls onto metaclasses

The guide claimed every tag library describes itself when no build writes
the index. That path is a local AST transform bound to @taglib, so it
reaches annotated tag libraries only; one declared by convention is
recognised as an artefact too late to describe itself. Say so, along with
the descriptors that path leaves behind when a tag library is renamed.

Deprecate the metaclass-installing methods individually rather than the
class that holds them: methodMissingForTagLib is the dynamic dispatch
path and is not going anywhere. Record the removed and no-op metaclass
API in the upgrade guide, along with the closure tag form tag libraries
are actually written in.

Also drop LocalNameCollector's use of a deprecated Groovy API, which the
build was reporting, and collect the two names it was missing - a catch
parameter and a closure's implicit it.
The descriptor format ships for the first time in this release, so
starting it at two makes the number mean nothing later. Nothing outside
these files reads the constant.

Also remove the javadoc left above its replacement on FRAMEWORK_METHOD_NAMES
and on findTags.
findTagNames duplicated findTags with the same two loops and the same
declaring-class guard, and getIncompleteNamespaces exposed a field the
isNamespaceComplete question already answers. Neither had a caller
anywhere, in main code or in a test.
A test source set builds its runtime classpath from the main output
rather than from the main runtime classpath, so the packaged index never
reached it: a page rendered by a test resolved its tags against an index
missing the application's own tag libraries. Add it to the test and
integrationTest runtimes, and assert it.

The generator now reads sources with the encoding the project compiles
with rather than always UTF-8, and the settings file is written through
the key constants rather than through literals that repeat them.

Generated AST nodes are built per call site instead of reusing the
process-wide THIS_EXPRESSION and NULL singletons, which carry node
metadata that a statically compiled class writes to.

Drop the developmentMode fields nothing reads - one of which the trait
materialised into every controller and tag library - and the empty
@PostConstruct that remained once tags stopped being installed onto
metaclasses.

Also cover the build with a configuration cache run, which the index
tasks turn out to survive, and pin the on-disk format on both sides of
the module boundary that has to restate it.
…re missing

Descriptor URLs are resolved against the manifest that names them rather
than searched for on the classpath, which turned one full classpath walk
per tag library into none.

Pin the three ways these discovery rules differ from the ones they
replaced - an Object member name, a non boolean is accessor, and a name
containing a dollar - through both the tree and the compiled class, so
the claim that the two views cannot drift is enforced for them too.

Document the tag call that a controller annotated outside
grails-app/controllers does not get compiled, with a spec pinning both
shapes, and the GrailsTagException a resolved call now reports for a tag
the runtime has not registered.

Cover grails-mail's text:newLine, which had no test before its signature
was changed here, and say why it was changed: it works either way, but a
closure tag now warns and the framework should not trip its own warning.

Drop getAmbiguousTagNames, which restates isAmbiguous, and the benchmark
spec that was gated off by an environment variable and so never ran.
…e review

Every tag library compiled into one directory adds itself to a manifest
they all share, unguarded. Writing 32 of them at once lost 29: each
writer put back a copy that had never seen the others. A lost entry is
silent - the descriptor is there, nothing names it, so the tag library is
never discovered. Guard the read-modify-write with a monitor for threads
of this JVM and a file lock for a second process, and prove it with a
spec that fails without either.

Pin what a class calling a tag through methodMissing now gets back: both
ways of declaring a tag capture output, where a method-declared one used
to return its own return value. Record that in the upgrade guide.

Say plainly when strictTags can be used - a namespace only some of its
jars described cannot be checked, which is the normal state of g - and
correct the claim that a tag's kind decides whether a call is resolved.
It does not; both forms are dispatched by name.
request.withFormat { form multipartForm { } } was compiled into a call to
the g:form tag. form there is a format in a DSL: a closure is given a
delegate when it runs, and a name the delegate answers to is the
delegate's, not a tag library's. Nothing about that is knowable when the
closure is compiled, so an unqualified call inside one is left alone. A
call naming its namespace is unaffected, which is how a tag body keeps
the faster path.

Restore TagLibrary.initializeTagLibrary. It does nothing now, but a trait
method is part of the binary contract - Groovy weaves a call to the
generated helper into every implementing class, so removing it raised
NoSuchMethodError for every tag library compiled against an earlier
release, asset-pipeline's among them.
An import left unused when the manifest read moved to a channel, a blank
line left by removing a method, a groovy.lang import group that should
not be separated from org.codehaus.groovy, and an import moved out of its
group when @PostConstruct was restored.
It compiled a file into a temporary grails-app/controllers directory and
relied on the artefact injector recognising it by location. That passed
on macOS and failed on Ubuntu and Windows, in every CI run and again on
a rerun, and I could not reproduce it locally in any configuration -
alone, with --rerun-tasks, or with the whole module suite.

The behaviour is not in doubt: every application under
grails-test-examples declares its controllers that way and has its tag
calls compiled. What is left here keys on the trait, which is what the
rewriting actually reads, and on the annotated case that the trait
reaches too late.
@testlens-app

testlens-app Bot commented Aug 16, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 30cac71
▶️ Tests: 70162 executed
⚪️ Checks: 80/80 completed


Learn more about TestLens at testlens.app.

@codeconsole
codeconsole requested a review from jdaugherty August 16, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants