Resolve tag calls at compile time instead of through the metaclass - #16134
Open
codeconsole wants to merge 50 commits into
Open
Resolve tag calls at compile time instead of through the metaclass#16134codeconsole wants to merge 50 commits into
codeconsole wants to merge 50 commits into
Conversation
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.
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.
A tag call takes more shapes than attributes and a body: nothing at all, a body alone, a single value the tag reads under its own name, or attributes only known once they have been evaluated. Only the shapes evident in the source could be expressed as an invocation, so the rest stayed dynamic. The arguments are forwarded as written and adapted the same way, and in the same order, that dynamic dispatch adapts them, including how it treats an argument list matching none of the shapes it knows.
A tag call written inside a closure was never resolved. Groovy's expression transformer does not descend into closures and documents the override that reaches them, which was missing, so a tag called in a tag body, in a withFormat block, or in anything else taking a block stayed dynamic. That is where most tag calls in a real tag library are. Calls in a constructor and in a field initialiser were not reached either. A call written without a namespace is resolved too. It reaches a tag only when nothing nearer answers to the name - not a method of the class, not one it inherits, not a field, property, parameter or local - and is then offered to the calling tag library's own namespace before the default one, which is the order dispatch uses at runtime. Scope is not tracked within a body: a name declared anywhere in one is treated as claimed throughout it, which can leave a call dispatched dynamically but never sends one somewhere the author did not write. A tag two libraries declare, and a tag declared as a closure field, are resolved as well. Neither can say which implementation will run, but neither has to: the invocation selects the tag by name at runtime through the same lookup, so registration order and overriding decide it exactly as they did. A namespace the build has declared as filled in while the application runs is left alone entirely, which is the escape hatch for tags decided at runtime rather than described at compile time.
A page reached every tag through the namespace dispatcher, selecting it by name each time it rendered. Where the page names both the namespace and the tag, and the index knows them, there is nothing left to decide, so the expression is compiled into an invocation against the page's own output context. Only in a page that declares compileStatic. A page resolves a name against the model it was rendered with before it reaches a tag library, and that model is not visible when the page compiles, so a model attribute named after a namespace would silently stop winning. Declaring compileStatic is a page giving up dynamic resolution, and it is what reserves the namespace names. A page that has not declared it resolves its tags exactly as before. Two things hold either way. A call written without a namespace is left alone, for the same reason. And a name the page puts into its own binding with g:set is that variable rather than a namespace, which the page does say when it compiles.
Two things wrote descriptors: the build, generating the index from source before compiling, and each tag library describing itself as it compiled. Both reached the compile classpath and both were packaged, so a tag library renamed or deleted between builds could keep being described by the copy written class by class, which nothing cleans. A tag library now describes itself only when nothing already has. That is asked per tag library rather than per build, because generating the index ahead of compilation cannot always describe every one of them: a tag library referring to a class of the same project cannot be resolved before that project is compiled and is skipped there. Treating the build as authoritative for all of them would leave such a tag library described by nothing at all.
Reporting one by default meant complaining about correct code: a namespace holding some compiled tag libraries is not one holding all of them, and a plugin built before descriptors existed contributes tags to g without one. This framework calls such a tag itself, and warned about it on every build. Nothing is reported unless the build states that its tag libraries are all described. What is checked is a call the source shows to be a tag: one naming its namespace, and one written as markup. A call written without a namespace is never checked, here or in the type checking extension, because such a name may equally be a dynamic finder, an injected service method or anything else contributed while the application runs; in a page it may be part of the model. A namespaced expression in a page is checked only where it is resolved, in a page declaring compileStatic.
Strictness was a JVM system property, where every other setting governing how pages compile is read from the build. It is declared in the grails extension instead, alongside the namespaces an application fills in while it runs, and reaches the compiler as a resource written next to the index: a declared input, so changing either recompiles what depends on it, and not packaged, since it says how this project compiles rather than what its tag libraries declare. The index is also put on the page compilation classpath, without which a page could not resolve a tag its own project declares - the source set's class directories do not carry it, and waiting for processResources would mean waiting for the compilation it exists to precede. It is generated in one process with the Java the project is built with, rather than one process per source directory, only the first of which cleared what the last had written.
Covers what is resolved and what is left to dispatch, the precedence an unqualified call follows, why a page is only resolved where it declares compileStatic, the strictTags and dynamicTagNamespaces settings and what each is for, and which tag libraries the build describes and which describe themselves.
Whether a tag library had already been described was decided from the tags read for it, so one declaring none looked undescribed and was described a second time by the compiler, putting a duplicate descriptor and a competing manifest into the class output. It is recorded from the descriptor itself.
The guide said both forms were checked in every page, which the boundary the previous change drew makes untrue of expressions: an expression is checked only where it is resolved, in a page declaring compileStatic, because elsewhere the receiver may come from the model. Markup is unambiguously a tag whatever the page does and is checked everywhere.
Dropping SkipWhenEmpty, so that a build's settings are recorded whether or not a project declares tag libraries, made the task fork a process whenever grails-app/taglib merely existed. A project with no tag libraries has no reason to carry the generator on its compile classpath, so the fork failed the build outright. Adds the functional test that found it, which also holds the plugin to where the index is wired: on the classpath of this project's own compilation, on the classpath of its pages, and travelling with the artifact.
The only figure this work had was end to end, and covered removing the metaclass writes and compiling calls into invocations together, so what either was worth separately was unknown. That matters now that a page is only resolved where it declares compileStatic: an application that has not enabled it gets nothing from the page side, and what is left is the calls written in its tag libraries and controllers. Both sides of each comparison run against this branch, so the metaclass writes are already gone from both, and only whether a call was compiled into an invocation differs - which a build can still turn off per namespace. Pages are compiled once and rendered repeatedly, since compiling them per render measures the compiler instead. Off unless GRAILS_TAGLIB_BENCH is set: a timing run is evidence, not a pass or fail, and is no use running alongside other tests.
A tag library commonly refers to something the same project declares - a service it injects, a base class it extends, a trait it carries - and none of those exist as classes when the index is generated before compilation. Such a tag library could not be read, so it was skipped and left to describe itself as it compiled, into the class output, where a second index competed with this one for the same path when packaged and nothing removed it once the tag library was renamed or deleted: an incremental compilation does not revisit a source that has not changed. The generator now resolves a type this project declares to its own source and compiles it alongside, which is what the compiler itself does for types within one compilation. Nothing is skipped for that reason, so the build describes every tag library and writes the index in one place, which it rewrites in full each time. Deliberately not answered with a stand-in class node. What is missing is exactly what decides the description: a base class carries the namespace, so a stand-in would file the tag library under g; a trait carries tags, which would then be absent; and a parameter type decides whether a method is a tag at all, which runtime asks by assignability. A resolver sees a name and not the context it appears in, so it cannot tell which of those it is being asked about. It would also answer for the first candidate a star import offers, before the real one was tried, and would invent a misspelled type rather than let it fail. Each of those makes the index disagree with what the application does, which is the one thing it must never do. A type not found in source is left unresolved, and the tag library referring to it is skipped as before.
The index was generated once, before compilation, and that one artifact had to serve two purposes it cannot both serve. Read from source, it cannot describe a tag library referring to a type written in another language or generated by the build, so packaging it handed a consumer a partial description, and compiling pages against it hid those tags from every page. A namespace missing some of its tags also made strict checking report calls to tags that do exist. There are two now, each with one job. The first is written before compilation and used only to compile this project, so a call to a tag the project declares still resolves as it compiles; it is neither packaged nor used to compile pages. What it could not read is recorded against the namespaces affected, and nothing in a namespace known to be incomplete is ever reported, so strict checking cannot fail a build over correct code. Where what was missed cannot even be attributed to a namespace, none is treated as complete. The second is written afterwards with this project's own classes on the classpath, where every tag library resolves. That one is authoritative: pages compile against it, it is packaged, and a project depending on this one reads it. Each run replaces the directory. It waits for the classes task rather than for the compile tasks, so that it also waits for anything else writing into the class output, and reaches the artifact and the runtime classpath directly rather than through processResources, which the classes task waits for. Resolving a type this project declares from its Groovy source stays, as what it is: a way to describe more before compilation, not the thing correctness rests on.
The index is regenerated in full each build and nothing writes descriptors into the class output, so a renamed or deleted tag library should not be able to linger. Nothing showed that: the core build builds its test projects once, and what is at stake is what a second build does, since Gradle does not recompile a source that has not changed and so would never revisit anything written per class. Adds an end-to-end project that builds an application twice without a clean, against the artifacts this repository publishes, and asserts that a deleted tag library, a renamed one and a removed tag are each gone from the index beside it, from the manifest naming it, and from the jar - and that nothing has written a second index into the class output. It drives the nested build through the repository's own wrapper rather than Gradle TestKit, which puts Gradle's Groovy 4 on the test classpath and cannot compile against the Groovy 5 Spock this repository builds against. The audit also stopped short of this build's output directories, as it already does for every other build in the repository that has its own.
The settings shared a directory with the descriptors, and that directory is on the runtime classpath so that a page compiled while the application runs can resolve its tags. An executable archive is built from the runtime classpath by copying whole directories, so excluding the settings on the archive tasks could not reach them: a boot jar or a war carried them regardless, and a consumer would inherit settings describing how this project is compiled. They are written to a directory of their own now, which is on the classpaths that compile this project and its pages and on nothing else. There is nothing left for an exclusion to have to catch. The end-to-end test asserts it against a real archive, and fails against the previous arrangement. Both indexes now take everything they must agree on from one configuration by task type. Configuring them one at a time would let the index this project compiles against describe a different set of tag libraries from the one it publishes, with nothing to say so.
The namespace of a tag library that could not be described was matched out of the raw source, so one named in a comment or a string was taken for the declaration. That recorded the wrong namespace as incomplete and left the real one looking complete - which is exactly when a call to a tag that does exist is reported as one that does not. Parsed to the conversion phase instead, which builds the tree and stops before resolving anything, so a type this project has not compiled yet cannot make it fail. Only a namespace the class states itself is trusted: one inherited from a base class cannot be read when whether that base class resolved is the very thing in doubt, and a file declaring more than one claims neither. Anything else leaves every namespace incomplete, which costs a diagnostic rather than inventing an error.
The page comparison put a statically compiled page against a dynamic one, so the difference included compiling the page statically and not only rewriting its tag calls. Both sides are statically compiled now and only the rewriting varies, turned off through the namespace declaration a build can make - which is what the tag library comparison already did. The page figure moves from -70% to -66% per tag call, so a few points of it were never the rewriting.
Which namespace is missing tags was read from whichever class in the file declared one, tag library or not. A helper class beside a tag library could therefore supply the name, recording a namespace nothing was missing from and leaving the one the tag library is really in looking complete - which is when a call to a tag that does exist gets reported as one that does not. A namespace is claimed only where the source leaves no room for doubt: one tag library in the file, declaring its own namespace as a constant. A namespace field on another class, a second tag library, an inherited namespace whose base class may not have resolved, and none stated at all each yield nothing, and every namespace is then treated as incomplete.
A war and an executable archive are built from the runtime classpath, which already carries the descriptors into wherever that archive puts classes. Adding them to every archive task as well put a second copy at the archive root, where nothing reads it and where it would disagree with the first as soon as one was rebuilt. A plain jar is not built from the runtime classpath, so it is the one that needs them added. Covered by building a war, which is the shape that exposed it: the descriptors are in WEB-INF/classes where a page compiled at runtime reads them, the settings are nowhere in it, and no descriptor is carried twice.
processResources no longer carries the index, so the import went with the configuration block that did.
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.
✅ All tests passed ✅Test SummaryCI / Functional Tests (Java 25, indy=false, shard 1) > :grails-test-examples-app1:integrationTest
🏷️ Commit: 91f8d3d Learn more about TestLens at testlens.app. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Calling tags
In a tag library or a controller:
A call written inside a closure — a tag body, a
withFormatblock, anything taking a block — is compiled the same way, as is one in a constructor or a field initialiser.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
Closurefield, 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:Declaring it reserves the namespace names for tag libraries there.
grails.views.gsp.compileStaticapplies 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 } }dynamicTagNamespacesturns 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
Closurefield 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: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
ExpandoMetaClassread-lock contention.Every caller used to mutate its own
ExpandoMetaClassthe 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:
8.0.xCompiling the calls is worth this much again on top, measured with metaclass removal present on both sides and only the rewriting varying:
Where the description comes from
Under the Grails Gradle plugin the index is written twice, because the two things reading it need different guarantees.
generateTagLibraryIndexruns 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.packageTagLibraryIndexruns 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 describe itself as it compiles.What is not rewritten
dynamicTagNamespacesgis that thingcompileStatic<g:set>Limitations
compileStaticpage. That is what declaring it means there. A page that has not declared it is unaffected.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 indynamicTagNamespaces, or call the tag with its namespace.tagLib.someTag(attrs, null)works. A running application does not depend on this.