Skip to content

Contribute this plugin's beans through its descriptor - #472

Open
codeconsole wants to merge 20 commits into
wondrify:5.2.xfrom
codeconsole:feat/plugin-descriptor-beans
Open

codeconsole wants to merge 20 commits into
wondrify:5.2.xfrom
codeconsole:feat/plugin-descriptor-beans

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Grails 8 compiles a beans closure on a plugin descriptor into a sibling auto-configuration, and takes bean registration that needs more than a closure through a BeanRegistrar. Moving to both puts every bean this plugin contributes in one place and retires doWithSpring(), which Grails deprecates in 8.0.

Reshaped after review: the standalone GSP beans have moved out of the plugin into a new asset-pipeline-gsp module, part of this PR. It also gives asset-pipeline-spring-boot an auto-configuration.

Requires Grails 8

grailsVersion moves to 8.0.0 > *. The beans DSL does not exist before 8.0.0-M6. The build pins M6, which has carried the beans DSL and beanRegistrar() on Maven Central since 26 August.

The auto-configuration is renamed, from asset.pipeline.grails.AssetPipelineAutoConfiguration to asset.pipeline.AssetPipelineAutoConfiguration — a generated sibling lands in the descriptor's package. apache/grails-core#16221 makes the qualified name settable, but merged after the M6 tag, so the original name is restorable on a later milestone. Same constraint keeps the hand-written AutoConfiguration.imports: apache/grails-core#16224, which would write it, also merged after M6.

Both belong in the release notes; there is no changelog in this repo, so they are stated here.

A bug this review found

AssetProcessorService reads contextPath and serverBaseURL off grailsLinkGenerator for every asset url it builds, and nothing but the link generator's own constructor ever sets that field. Gating the bean on the absence of GSP's standalone lookup left the first assetPath() throwing:

java.lang.NullPointerException: Cannot get property 'contextPath' on null object
    at AssetProcessorService.assetBaseUrl(AssetProcessorService.groovy:150)

unless grails.assets.url was set, which returns before the dereference. The specification resolved tag libraries but never rendered, so it never saw it. The bean is now declared by name, the way grails-url-mappings declares its own, and an application bringing its own keeps it.

Two definitions that cannot be beans

BeanRegistry.Spec and a @Bean method both describe a class plus qualifiers, and two definitions here say more:

  • assetResourceLocator inherits its search locations from abstractGrailsResourceLocator, a classless abstract definition.
  • assetPipelineFilter holds the filter as a nested bean definition rather than an instance, so AOT can generate source for it (Contribute the asset filter as a bean definition #465).

Both come from AssetPipelineBeanDefinitionRegistrar, a BeanDefinitionRegistryPostProcessor — the shape Grails uses for abstractGrailsResourceLocator itself. The AOT spec now processes what beanRegistrar() contributes, not only the filter definition.

asset-pipeline-gsp

The tag libraries the plugin carries also work in an application that renders GSP without being a Grails application. The plugin used to wire them there itself: three beans a Grails application never uses, each gated on a type from another module's internals, which every future Grails bean would have had to guard against. They now live in asset-pipeline-gsp, and adding that library is what says which kind of application it is, so none of those conditions remain.

It is not a Grails-free module and cannot be — a GSP tag library is a Grails class — so it declares api project(':asset-pipeline-grails'). Its spec builds a url through the tag library rather than only resolving it, which is what would have caught the link generator above. Resolving the tag libraries through GSP's standalone lookup is pending on apache/grails-core#16184: the 8.0.0-M6 lookup finds them only by @TagLib, which they no longer carry, while the @Artefact("TagLib") marker the Grails compiler gives them is what #16184's lookup reads.

grails-bom constrains cloud.wondrify:asset-pipeline-*, which resolved the sibling projects to the last published version; its build.gradle substitutes them back to projects, since it compiles against a class this build generates.

asset-pipeline-spring-boot

The filter AssetPipelineService declares becomes an auto-configuration, so an application no longer has to @Import it by hand. An application that declares AssetPipelineService itself keeps its own, and assets.enabled=false declines the filter. AssetPipelineService reads the manifest through its injected ResourceLoader rather than out of the servlet context. Nothing in it is Grails-aware.

Tests

41 specs in asset-pipeline-grails, 6 in asset-pipeline-gsp and 6 in asset-pipeline-spring-boot, all passing, plus i18n-asset-pipeline-grails, which depends on the plugin. ./gradlew build green.

A Spring Boot application had to declare for itself what the Grails plugin declares
for a Grails application: the filter that serves what the pipeline compiled, and -
where it renders its views with GSP - the tag libraries that write an asset's URL
into a page.

Both are auto-configured now, so an application adds the dependency and writes no
configuration. The tag libraries are contributed only where the plugin that carries
them and a GSP tag library lookup are both there, and either bean an application
declares itself is left alone.

AssetPipelineService reads the manifest through the ResourceLoader it is given
rather than through the servlet context, which is the same context and is there
before a servlet container is.
The tag libraries moved into the Spring Boot module's auto-configuration, which made
a Grails-free module depend on this one to compile. They belong here: this plugin
carries them, and it is the thing that knows how to wire them - in a Grails
application through its artefacts, and in an application that renders GSP without
being one through the beans this contributes.

The condition is the tag library lookup GSP registers for a standalone application;
a Grails application has the plugin's own, so nothing here applies to it.

Two things this closes:

The settings an application writes reach the pipeline itself. The plugin descriptor
copies grails.assets into AssetPipelineConfigHolder as the context is built, and that
never runs for an application with no plugins, so a configured mapping was quietly
ignored while every other setting was honoured.

The tag libraries say what they are. They were found by the directory they sit in,
which is a Grails convention and nothing an application without Grails can use;
@taglib is the annotation the lookup reads, and it is what makes them discoverable as
beans.

The specification drives them the way such an application does - Spring beans, GSP's
standalone lookup, no plugin manager and no artefact scanning - so a later change that
assumes Grails fails here rather than in somebody's application.
The BOM was there to supply versions for Spock, AssertJ and the JUnit launcher, which left
a module whose whole point is working without Grails unable to run its tests without it.
The versions it was supplying are now named in gradle.properties, where every other module
in this build gets its own: spock 2.4-groovy-5.0, assertj 3.27.7, junit-platform 6.0.3.

Nothing about the tests changes. They cover this module's own auto-configuration - the
filter that serves the assets, and an application that registers one itself keeping it -
and they import nothing from Grails.
Naming assertj and the junit platform in gradle.properties pinned numbers that belong to
Spring Boot, and would sit still while the boot version moved. Spring Boot publishes a bom
that manages both, and a Spring Boot module is entitled to it. Spock is not Spring Boot's,
and comes from spock-bom the way it does in every other module here.

It resolves to what the Grails bom was handing over - assertj 3.27.7, junit-platform 6.0.3 -
which is unsurprising, since that bom was relaying Spring Boot's numbers too.
Until now the readme told an application to add asset.pipeline.springboot to its component
scan, so an application upgrading into an auto-configuration has a bean of its own for the
same filter. Two definitions of one bean is a startup failure, not a warning, which makes
the backoff worth asserting rather than assuming: one filter, and a context that started.
Configuring itself means an application that had this on its class path without wiring it -
and there was nothing to do with it unwired - now finds a filter on /assets/* where before
there was none. Declaring the bean was the only way to say no, which is a strange way to
ask for nothing.

assets.enabled=false is the way to say it, named after assets.mapping, which the Micronaut
adapter already reads. Unset means enabled, so nothing changes for anyone who wants it.

The readme said to component scan the package; it now says what to configure, which is
nothing, and what to set to opt out.
assets.enabled=false was read by the auto-configuration, and the auto-configuration is not what
defines the filter - AssetPipelineService is, and an application that still names this package in
its component scan registers that class itself. Such an application set the property, kept the
filter, and had the readme tell it in one paragraph that the switch worked and in the next that
its component scan was harmless.

The condition goes on the configuration that declares the bean, so both routes to it obey.
Deleting @ConditionalOnMissingBean and running it: it still passes. A configuration class the
runner has already registered is dropped from the auto-configuration's @import by the parser,
before any condition is consulted, so the single filter it asserts arrives whether or not the
condition exists. The outcome is worth keeping - two definitions of one bean is a startup failure
- but the wording claimed to cover something else, and the specification below is what covers it.
The settings were written to the pipeline only when it held none. The pipeline holds them on a
static, which outlives an application context, so the second context in a JVM - a devtools reload,
or the next cached context in a test run - kept the settings of the first. A developer who changed
grails.assets.mapping and reloaded saw the old mapping until the JVM restarted, with nothing said.

The application being built is the one that asked, so its settings win, and a replacement says so
at debug.
A field of type ResourceLoader named resourceLoader is satisfied by an application's own bean of
that name before it is satisfied by the context, and a plain DefaultResourceLoader resolves
"assets/manifest.properties" against the class path where the context resolves it against the
servlet context. An application with such a bean would have lost its manifest and served
uncompiled assets.
Every test drives its auto-configuration by importing the class, so deleting the line that lists
it in META-INF/spring left the whole feature gone and the suite green. Each module now reads that
file and looks for its own class in it.
This module contributes the tag libraries and the settings they read. It does not register a
resolver or load a manifest - asset-pipeline-spring-boot does - so a standalone application with
this module alone renders development markup and non-digest urls with nothing to explain why.
Neither the class nor the readme said the two go together.
@GrailsBeans
// An ordering hint, not a dependency: everything used below comes from grails-web-url-mappings,
// while the class named lives in grails-url-mappings, which this plugin declares compileOnly.
@AutoConfiguration(beforeName = 'org.grails.plugins.web.mapping.UrlMappingsAutoConfiguration')

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 should reference the class since it's only used in a grails project.

@codeconsole codeconsole Aug 25, 2026

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.

This PR generates an AutoConfiguration that works inside AND outside of Grails

The generated auto-configuration is deliberately read outside Grails — that's what makes the third module unnecessary. Its only class-level condition is @ConditionalOnWebApplication(SERVLET), so a plain Spring Boot servlet application processes it, and AssetPipelineTagLibAutoConfigurationSpec drives exactly that: a WebApplicationContextRunner with GSP's standalone lookup, no plugin manager and no artefact scanning. That is the case the tag-lib beans exist for, and the one #470 was written to serve.

In that application org.grails.plugins.web.mapping.UrlMappingsAutoConfiguration may genuinely be absent. It lives in org.apache.grails:grails-url-mappings, which this plugin declares compileOnly and references nowhere else. Everything the beans actually use — grails.web.mapping.LinkGenerator, org.grails.web.mapping.DefaultLinkGenerator — is in org.apache.grails.web:grails-web-url-mappings, a different artifact. So the class literal turns an ordering hint into a hard runtime class-load of a jar nothing here needs.

It isn't theoretical. I just switched the annotation back to before = [UrlMappingsAutoConfiguration] on this branch:

AssetPipelineAutoConfigurationSpec > the auto-configuration is read where grails-url-mappings is absent FAILED
    Caused by: java.lang.TypeNotPresentException
        Caused by: java.lang.ClassNotFoundException

which is the startup warning #469 was opened for:

WARN o.s.core.annotation.MergedAnnotation : Failed to introspect meta-annotation @AutoConfiguration
java.lang.TypeNotPresentException: Type org.grails.plugins.web.mapping.UrlMappingsAutoConfiguration not present

The ordering is unaffected either way — beforeName and before are merged by the same sorter, and it still applies wherever the class is present.

The two positions can't both hold, though, and that's worth settling: if this module really is Grails-only, then the tag-lib beans have no purpose and #470 should be dropped rather than folded in here. If it isn't — which is what this PR argues, and why there's no asset-pipeline-gsp module — then the annotation can't name a class the standalone case doesn't have. Happy to go either way, but they need to match.

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.

Resolving this: beforeName is right. Under the split proposed in the review, this auto-configuration is still read by a standalone application, for assetProcessorService and grailsLinkGenerator, which is what the apache/grails-core#16184 example relies on. GspAutoConfiguration orders against UrlMappingsAutoConfiguration by name for the same reason. It changes nothing for a Grails application.

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.

Agreed, and unchanged here.

@@ -1 +1 @@
asset.pipeline.grails.AssetPipelineAutoConfiguration No newline at end of file
asset.pipeline.AssetPipelineAutoConfiguration

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.

Wasn't this removed?

@codeconsole codeconsole Aug 25, 2026

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.

needs a grails-core update. currently autogeneration only works for internal grails projects.

apache/grails-core#16224

@codeconsole codeconsole Sep 2, 2026

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.

@jdaugherty now that 16224 has been merged do you have any other feedback on this PR?

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.

Understood on apache/grails-core#16224. Keep the file for now. Why is the autoconfiguration using a different package now?

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.

A generated sibling lands in the plugin descriptor's package, and the descriptor is asset.pipeline.AssetPipelineGrailsPlugin, so the sibling is asset.pipeline.AssetPipelineAutoConfiguration rather than asset.pipeline.grails. as before.

apache/grails-core#16221 makes the qualified name settable through autoConfigurationName, so the original is restorable - but it merged on 31 August, after the M6 tag, so not while pinned to M6. Same constraint you applied to this file, and both are noted in the PR description as things a later milestone lets us undo.

The auto-configuration named the class it registers before, and a class literal in
an annotation has to be loaded when Spring reads it. That class is carried by
grails-url-mappings, which a Spring Boot application using this plugin's tag
libraries without the rest of Grails does not have, so every startup logged a
failed introspection and the ordering was dropped.

Naming it as a string asks nothing of the class path, and the ordering still applies
wherever the class is there.
The plugin descriptor is about to use the beans DSL and BeanRegistrar, neither of
which exists before Grails 8. 8.0.0-M6 is not published: Maven Central carries
milestones only as far as M5, and the Apache staging repository does not have it
either. The 8.0.0 snapshot does carry both, from the repository this build
already declares, so that is what it builds against until a milestone that has
them is released.
Grails 8 compiles a `beans` closure on a plugin descriptor into a sibling
auto-configuration, and takes bean registration that needs more than a closure
through a BeanRegistrar. Moving to both puts every bean this plugin contributes
in one place and retires doWithSpring(), which Grails deprecates in 8.0.

The sibling, asset.pipeline.AssetPipelineAutoConfiguration, is a plain
auto-configuration listed in AutoConfiguration.imports. Spring Boot reads it
wherever it reads auto-configurations, including an application that renders GSP
without being a Grails application and so runs no plugin lifecycle - which is
what asset.pipeline.grails.AssetPipelineTagLibAutoConfiguration was written to
serve. Both it and asset.pipeline.grails.AssetPipelineAutoConfiguration are
replaced by the closure, so the tag libraries are wired for such an application
without a module of their own to hold them.

One generated class means one set of beans, so each carries the condition that
says which application it belongs to: the tag libraries where GSP's standalone
lookup is present, the link generator where it is not - a Grails application,
which is also the only one with the url mappings holder DefaultLinkGenerator
autowires.

Two definitions describe more than a class and a set of qualifiers, which is all
a @bean method and BeanRegistry.Spec can express: assetResourceLocator inherits
its search locations from abstractGrailsResourceLocator, a classless abstract
definition, and assetPipelineFilter holds the filter as a nested definition so
that ahead-of-time processing can generate source for it. Both are contributed
through a BeanDefinitionRegistryPostProcessor, which reaches the registry where
they can be stated directly - the shape Grails itself uses for
abstractGrailsResourceLocator.

The declared grailsVersion moves to 8.0.0, which is what the DSL requires.
It is generated from the beans closure on the plugin descriptor rather than
written here, and its package is the descriptor's, which reads at a glance like
the class this commit series removes from asset.pipeline.grails.
A beans property on a plugin descriptor is compiled without the annotation, the
way doWithSpring and watchedResources are - GlobalGrailsClassInjectorTransformation
claims any closure whose top-level statements are all bean/field/method calls.
Declaring @GrailsBeans as well said nothing the descriptor did not already say.

Also carries into the closure what the tag library auto-configuration gained while
this branch was away from it: the settings a reloaded application writes replace
the ones a previous context left on the pipeline's static, rather than being
ignored until the JVM restarts. And the specification that notices when the class
stops being auto-configured now names the generated one, that being the class
this series leaves listed.
@codeconsole
codeconsole force-pushed the feat/plugin-descriptor-beans branch from 2a5ea81 to aae8126 Compare August 25, 2026 16:46
@codeconsole
codeconsole requested a review from jdaugherty August 31, 2026 04:21

@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.

Same theme as on apache/grails-core#16184: Grails plugin code changed to serve a non-Grails consumer. Here it is the three tag-lib beans in the descriptor, the StandaloneTagLibraryLookup conditions on them and on grailsLinkGenerator, the @TagLib annotations, the README section and the test dependencies. A Grails application gets nothing from any of it, and the plugin's correctness in Grails now depends on an org.grails internal that only grails-gsp-spring-boot registers. Details inline.

The Grails 8 modernisation is welcome and can merge on its own: beanRegistrar() in place of doWithSpring(), the registrar for the two definitions the DSL cannot express, and a beans block holding what the hand-written auto-configuration holds today. Pin it to 8.0.0-M6, which carries both.

The standalone wiring goes where we asked for it on #16184: a separate library the gsp-spring-boot example includes in place of its hello/AssetPipelineConfiguration. That class is the blueprint. The library depends on this plugin and on asset-pipeline-spring-boot, carries the tag-lib beans and the settings bridge, and is conditioned on the standalone lookup, which is fine there because that module is standalone by definition. It needs the grailsUrlMappingsHolder and @Artefact lookup changes pushed on #16184, so it targets the first Grails milestone that carries them.

The asset-pipeline-spring-boot changes are Grails-free and look right. Please split them into their own PR so they can merge now.

Comment on lines +87 to +88
bean('grailsLinkGenerator', LinkGenerator)
.annotate(ConditionalOnMissingBean, type: 'org.grails.web.pages.StandaloneTagLibraryLookup')

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.

Please remove this condition. It gates a Grails bean on the absence of a GSP-standalone internal, and the reason given for it, that only a Grails application has a URL mappings holder, is being addressed on the other side: apache/grails-core#16184 now has GspAutoConfiguration contribute an empty grailsUrlMappingsHolder, which is where we said that fix belongs.

As written the two PRs contradict each other. #16184 adds the holder so a link generator can exist in a standalone application. This PR removes the link generator from exactly that application. Without it AssetProcessorService.assetBaseUrl dereferences grailsLinkGenerator.contextPath on the first assetPath() and every <asset:javascript> fails with a NullPointerException unless grails.assets.url is set. The new spec never renders a tag, so it does not see this.

Declare it the way url-mappings declares its own: bean('grailsLinkGenerator', LinkGenerator).conditionalOnMissingBeanName().

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 fixed in 498ed2b. Reproduced before changing it, in a standalone context with the condition still in place:

java.lang.NullPointerException: Cannot get property 'contextPath' on null object
    at asset.pipeline.grails.AssetProcessorService.assetBaseUrl(AssetProcessorService.groovy:150)

The chain is as you described. AssetMethodTagLib.assetPath uses SERVER_BASE_URL or CONTEXT_PATH, never NONE, so every call reaches assetBaseUrl; grailsLinkGenerator is set only by AssetSupportingLinkGenerator/AssetSupportingCachingLinkGenerator in their constructors, so suppressing the bean leaves it null. grails.assets.url returns before the dereference, which is why it was survivable.

Declared now as bean('grailsLinkGenerator', LinkGenerator).conditionalOnMissingBeanName().

Worth noting for #16184: with the condition gone the bean needs a UrlMappingsHolder, and without one the standalone context fails on NoSuchBeanDefinitionException rather than the NPE. With the holder GspAutoConfiguration contributes, assetBaseUrl returns /assets/. So the two sides meet where you said they should.

Comment on lines +102 to +123
bean(AssetsTagLib)
.conditionalOnMissingBean()
.annotate(ConditionalOnBean, value: AssetProcessorService,
type: 'org.grails.web.pages.StandaloneTagLibraryLookup')
{ AssetProcessorService assetProcessorService, GrailsApplication grailsApplication ->
AssetsTagLib tagLib = new AssetsTagLib()
tagLib.assetProcessorService = assetProcessorService
tagLib.grailsApplication = grailsApplication
tagLib
}

bean(AssetMethodTagLib)
.conditionalOnMissingBean()
.annotate(ConditionalOnBean, value: AssetProcessorService,
type: 'org.grails.web.pages.StandaloneTagLibraryLookup')
{ AssetProcessorService assetProcessorService ->
AssetMethodTagLib tagLib = new AssetMethodTagLib()
tagLib.assetProcessorService = assetProcessorService
tagLib
}

bean('assetPipelineConfiguration', InitializingBean)

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.

These belong in the separate library, as asked on apache/grails-core#16184 for the example's AssetPipelineConfiguration. A Grails application never uses them, and this is Spring Boot conditional wiring inside the plugin's beans block: each bean carries a @ConditionalOnBean on a type from another module's internals, and every bean added here for Grails in future has to remember the inverse guard or it lands in a plain Boot application. Nothing enforces that, and the PR description says both conditions were wrong on the first attempt.

Once these move, nothing in this block needs a StandaloneTagLibraryLookup condition and the plugin does not have to defend against a context it was not written for.

@codeconsole codeconsole Sep 11, 2026

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.

Accepted — moved into a new asset-pipeline-gsp module in this PR. None of the beans there carries a StandaloneTagLibraryLookup condition: adding the library is what says which kind of application it is.

It is not a Grails-free module, and cannot be - a GSP tag library is a Grails class, so the tag libraries, AssetProcessorService and the link generator stay in the plugin and the library declares api project(':asset-pipeline-grails'). What it leaves behind is the plugin lifecycle.

import grails.core.GrailsApplication
import org.grails.buffer.GrailsPrintWriter

@TagLib

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.

Please drop this. Grails compiles both tag libraries with @Artefact("TagLib"), and StandaloneTagLibraryLookup on apache/grails-core#16184 registers tag-library beans by that artefact type as well as by @TagLib. A Grails application gets nothing from the annotation.

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.

Keeping this one, because it contradicts the M6 pin below. Disassembling 8.0.0-M6, StandaloneTagLibraryLookup.detectAndRegisterTabLibBeans() is:

22: ldc           // class grails/gsp/TagLib
24: invokeinterface  ApplicationContext.getBeansWithAnnotation:(Ljava/lang/Class;)Ljava/util/Map;

Annotation only - Artefact appears nowhere in the class. The artefact-type registration is on #16184, which has not merged, so against the M6 this now pins, dropping @TagLib stops the standalone lookup finding the tag libraries at all. Same constraint you applied to the imports file: it can go once a milestone carries #16184. Say if you would rather unpin instead.

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.

Dropped in 8771a11, reversing my reply above. Having read #16184's diff, the artefact lookup and the UrlMappingsHolder the plugin's link generator needs arrive together there. Without the holder a standalone GSP application does not start on M6, so the annotation only protected a path that cannot run on M6 anyway, and once #16184 ships it is redundant, as you said. Both compiled classes still carry @Artefact(value="TagLib").

The specification that resolves the tag libraries through the standalone lookup is @PendingFeature on #16184, since M6's lookup reads only @TagLib. Checked that it fails with PendingFeatureSuccessfulError once the lookup does find them, so it flags itself when a milestone carrying #16184 is pinned.

import grails.gsp.TagLib


@TagLib

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.

Same as AssetsTagLib: please drop it.

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.

Same as AssetsTagLib - keeping it for now, see that thread. The M6 lookup finds tag libraries by this annotation and nothing else.

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.

Dropped as well in 8771a11; see the AssetsTagLib thread.

Comment thread asset-pipeline-grails/README.md Outdated

For contributions to the core plugin please see the repository for the core plugin at [asset-pipeline](https://github.com/wondrify/asset-pipeline)

Outside Grails

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.

Moves with the library. The plugin README should describe the plugin.

@codeconsole codeconsole Sep 11, 2026

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.

Moved to asset-pipeline-gsp/README.md in this PR.

class AssetPipelineGrailsPlugin extends Plugin {
def grailsVersion = '7.0.0 > *'

def grailsVersion = '8.0.0 > *'

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.

Correct, since the beans DSL does not exist before M6. Please state it in the release notes with the auto-configuration rename.

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.

Stated in the PR description, with the rename. There is no CHANGELOG and no release-drafter config in this repository, so that is the only place release notes can be drafted from - tell me if they live somewhere I have not found.


}
}
registry.registerBean('assetPipelineBeanDefinitionRegistrar',

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.

Minor. The AOT spec still processes the filter definition only. This bean is registered with an instance supplier and no spec runs it through ApplicationContextAotGenerator. Since #465 is what this registrar exists to preserve, it is worth a case that processes the registrar bean itself.

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.

Added, and it was worth more than minor. My first attempt hand-rolled the shape - a RootBeanDefinition with an instance supplier - and it failed:

AotBeanProcessingException: Error processing bean with name 'assetPipelineBeanDefinitionRegistrar': instance supplier is not supported

But that is not the path that ships. Registering the real thing, applicationContext.register(plugin.beanRegistrar()), passes: Spring has a BeanRegistrarAotContribution for exactly this. So there is no AOT break, and the spec now covers the registrar through the path a Grails application uses, asserting it actually contributed assetPipelineFilter and assetResourceLocator rather than passing over an empty context.

@ConditionalOnProperty(name = AssetPipelineAutoConfiguration.ENABLED, matchIfMissing = true)
@ConditionalOnMissingBean(name = AssetPipelineAutoConfiguration.FILTER_BEAN_NAME)
@Import(AssetPipelineService.class)
public class AssetPipelineAutoConfiguration {

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 module's changes are fine and Grails-free. Please split them into their own PR so they are not held on the plugin discussion.

@codeconsole codeconsole Sep 11, 2026

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.

Leaving these in this PR rather than splitting them out; #473 is closed.

@GrailsBeans
// An ordering hint, not a dependency: everything used below comes from grails-web-url-mappings,
// while the class named lives in grails-url-mappings, which this plugin declares compileOnly.
@AutoConfiguration(beforeName = 'org.grails.plugins.web.mapping.UrlMappingsAutoConfiguration')

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.

Resolving this: beforeName is right. Under the split proposed in the review, this auto-configuration is still read by a standalone application, for assetProcessorService and grailsLinkGenerator, which is what the apache/grails-core#16184 example relies on. GspAutoConfiguration orders against UrlMappingsAutoConfiguration by name for the same reason. It changes nothing for a Grails application.

@@ -1 +1 @@
asset.pipeline.grails.AssetPipelineAutoConfiguration No newline at end of file
asset.pipeline.AssetPipelineAutoConfiguration

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.

Understood on apache/grails-core#16224. Keep the file for now. Why is the autoconfiguration using a different package now?

The plugin's beans block carried three beans a Grails application never uses,
each guarded by a condition on a type from another module's internals, and every
bean added here for Grails in future had to remember the inverse guard or land in
a plain Spring Boot application. Nothing enforced that, and both guards were wrong
the first time. They move to asset-pipeline-gsp, with the specification that drives
them, the test dependencies that exist only for it, and the README section that
describes it.

One of those guards was doing more harm than gating. AssetProcessorService reads
contextPath and serverBaseURL off grailsLinkGenerator for every asset url it
builds, and nothing but the link generator's own constructor ever sets that field,
so suppressing the bean where GSP's standalone lookup is present left the first
assetPath() throwing:

    java.lang.NullPointerException: Cannot get property 'contextPath' on null object
        at AssetProcessorService.assetBaseUrl(AssetProcessorService.groovy:150)

unless grails.assets.url was set, which returns before the dereference. The
specification never rendered a tag, so it never saw it. The bean is now declared
the way grails-url-mappings declares its own, by name, and an application that
brings its own keeps it.

Also pins Grails 8.0.0-M6, which has carried the beans DSL and beanRegistrar on
Maven Central since 26 August. The hand-written imports file stays: the compiler
that would write it merged after the M6 tag.
The tag libraries the Grails plugin carries also work in an application that
renders GSP without being a Grails application, and until now the plugin wired
them there itself - three beans a Grails application never uses, each gated on a
type from another module's internals. Adding this library is what says which kind
of application it is, so none of those conditions is needed and no bean added to
the plugin in future has to remember the inverse of one.

It is not a Grails-free module and cannot be: a GSP tag library is a Grails class,
and the tag libraries, AssetProcessorService and the link generator all stay in
the plugin. What an application leaves behind by using this is the plugin
lifecycle, not Grails.

The specification now builds a url through the tag library the lookup returns
rather than only resolving it, which is the half that was missing: resolving never
touched the link generator, so it never noticed when the link generator was not
there.

grails-bom constrains cloud.wondrify:asset-pipeline-*, which resolved the sibling
projects to the last published version - and this module compiles against a class
this build generates, which that version does not carry. The substitution in
build.gradle keeps them the projects they are.
The Grails compiler already marks both with @ArteFact("TagLib"), which is how a
Grails application finds them. The annotation was added so GSP's standalone lookup
would find them too, and apache/grails-core#16184 teaches that lookup to read the
artefact marker as well - naming the asset pipeline's library as the case.

Against the 8.0.0-M6 this build pins, that lookup still reads only @taglib, so the
specification that resolves the tag libraries through it is pending on #16184, and
alerts once it starts passing. Nothing that works on M6 is lost: standalone GSP
does not start there without the UrlMappingsHolder #16184 also contributes.

The url case takes the tag library from the context instead of the lookup, so the
link generator it guards is still covered on M6.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants