fix(ext-view): clear cross-engine error, propagate globals across JteExtension builder chain

- BaseViewExtension: wrap the ViewRuntimeBridge provider collision with a message
  naming the real constraint (one view engine per FlashApp) instead of the generic
  "duplicate provider" error.
- BaseViewExtension/JteExtension: carry registered globals across JteExtension's
  immutable settings builders (templateRoot/serveStatics/staticPrefix/withStaticCors/
  staticCors). addGlobal() called before any of those used to be silently dropped,
  since each builder method returned a fresh instance with an empty globals list.
- Correct flash-ext-view-jte docs (README, architecture.md, model-and-globals.md):
  globals merge flat with one typed @param per key, not under a global.* namespace
  like Thymeleaf — the docs previously claimed the same reserved namespace for both
  engines, which doesn't match JteRuntime's actual merge behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-09 13:31:21 +00:00
co-authored by Claude Sonnet 5
parent 9de2800a83
commit 4feabc45d5
7 changed files with 128 additions and 11 deletions
@@ -15,6 +15,23 @@ import java.util.function.Function;
public abstract class BaseViewExtension<TTarget> implements FlashExtension {
private final List<GlobalValue> globals = new ArrayList<>();
protected BaseViewExtension() {}
/**
* Seeds this instance with globals carried over from a prior one. Subclasses whose
* fluent settings methods return a new instance (e.g. {@code JteExtension.templateRoot(...)})
* must route through this constructor — otherwise {@link #addGlobal} calls made before
* such a method silently vanish, since the new instance would start with an empty list.
*/
protected BaseViewExtension(List<GlobalValue> seedGlobals) {
globals.addAll(seedGlobals);
}
/** Snapshot of globals registered so far — for subclasses to carry over into a new instance. */
protected final List<GlobalValue> globals() {
return List.copyOf(globals);
}
public BaseViewExtension<TTarget> addGlobal(String key, Function<Request, Object> resolver) {
String k = Objects.requireNonNull(key, "global key must not be null").trim();
if (k.isEmpty()) {
@@ -34,7 +51,13 @@ public abstract class BaseViewExtension<TTarget> implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ViewRuntimeBridge<TTarget> runtime = createRuntime(List.copyOf(globals));
ctx.provide(ViewRuntimeBridge.class, runtime);
try {
ctx.provide(ViewRuntimeBridge.class, runtime);
} catch (IllegalStateException e) {
throw new IllegalStateException("Another flash-ext-view implementation is already installed in "
+ "this FlashApp. Only one view engine (e.g. ThymeleafExtension or JteExtension, not both) "
+ "can be active per app — install a single implementation.", e);
}
ctx.addAnnotationProcessor(handlerClass -> {
validateHandlerClass(handlerClass);
return List.of();
@@ -0,0 +1,40 @@
package dev.relism.flash.ext.view.core;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.RequestHandler;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class BaseViewExtensionTest {
private static final class FakeViewExtension extends BaseViewExtension<Object> {
@Override
protected ViewRuntimeBridge<Object> createRuntime(List<GlobalValue> globals) {
return new ViewRuntimeBridge<>() {
@Override public Object resolve(Class<?> handlerClass) { return null; }
@Override public RenderedView render(BaseViewHandler<Object> handler, Object target,
dev.relism.flash.models.Request req,
dev.relism.flash.models.Response res) { return null; }
};
}
@Override
protected void validateHandlerClass(Class<? extends RequestHandler> handlerClass) {}
}
@Test
void configure_twoViewEngines_failsWithClearMessage() {
FlashContext ctx = new FlashContext();
new FakeViewExtension().configure(null, ctx);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> new FakeViewExtension().configure(null, ctx));
assertTrue(ex.getMessage().contains("Only one view engine"),
"expected a clear cross-engine message, got: " + ex.getMessage());
}
}