You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/08/29 07:49:56 UTC
(causeway) branch htmx updated (f30a08ad0bf -> e31bd024400)
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a change to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
from f30a08ad0bf docs(openspec): plan configurable vertical application menus
new 04bcf45c5fd docs(openspec): propose HTMX resource page reload
new 8080e1048cb feat(htmx): reload resource pages during development
new e31bd024400 docs(openspec): archive HTMX resource page reload
The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../.openspec.yaml | 0
.../design.md | 102 +++++++++++++++
.../proposal.md | 30 +++++
.../generic-htmx-web-component-viewer/spec.md | 56 +++++++++
.../2026-08-29-reload-htmx-resource-pages/tasks.md | 30 +++++
.../generic-htmx-web-component-viewer/spec.md | 33 ++++-
viewers/webcomponents/htmx/README.adoc | 9 +-
.../CausewayModuleViewerWebcomponentsHtmx.java | 7 +-
.../htmx/HtmxClasspathPageLoader.java | 25 +++-
.../webcomponents/htmx/HtmxPageDefinition.java | 24 +++-
.../webcomponents/htmx/HtmxViewerProperties.java | 17 +++
.../htmx/HtmxClasspathPageLoaderTest.java | 138 ++++++++++++++++++++-
.../htmx/HtmxPageFragmentRegistryTest.java | 26 ++++
.../htmx/HtmxViewerControllerTest.java | 26 ++++
.../htmx/HtmxViewerPropertiesTest.java | 29 +++++
.../sample-htmx-petclinic/README.adoc | 13 +-
.../src/main/resources/application.properties | 1 +
.../PetClinicHtmxApplication_IntegTest.java | 40 ++++++
18 files changed, 588 insertions(+), 18 deletions(-)
copy openspec/changes/archive/{2026-08-29-property-presentation-overrides => 2026-08-29-reload-htmx-resource-pages}/.openspec.yaml (100%)
create mode 100644 openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/design.md
create mode 100644 openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/proposal.md
create mode 100644 openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/specs/generic-htmx-web-component-viewer/spec.md
create mode 100644 openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/tasks.md
(causeway) 01/03: docs(openspec): propose HTMX resource page reload
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit 04bcf45c5fd6b39eeaf82592731e2498e9495f52
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 08:21:05 2026 +0200
docs(openspec): propose HTMX resource page reload
---
.../reload-htmx-resource-pages/.openspec.yaml | 2 +
.../changes/reload-htmx-resource-pages/design.md | 102 +++++++++++++++++++++
.../changes/reload-htmx-resource-pages/proposal.md | 30 ++++++
.../generic-htmx-web-component-viewer/spec.md | 56 +++++++++++
.../changes/reload-htmx-resource-pages/tasks.md | 30 ++++++
5 files changed, 220 insertions(+)
diff --git a/openspec/changes/reload-htmx-resource-pages/.openspec.yaml b/openspec/changes/reload-htmx-resource-pages/.openspec.yaml
new file mode 100644
index 00000000000..50adc91034f
--- /dev/null
+++ b/openspec/changes/reload-htmx-resource-pages/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-29
diff --git a/openspec/changes/reload-htmx-resource-pages/design.md b/openspec/changes/reload-htmx-resource-pages/design.md
new file mode 100644
index 00000000000..51213272020
--- /dev/null
+++ b/openspec/changes/reload-htmx-resource-pages/design.md
@@ -0,0 +1,102 @@
+## Context
+
+`HtmxClasspathPageLoader` currently discovers every private page resource, reads and validates its bytes, and creates an `HtmxPageDefinition` containing the resulting immutable HTML string.
+`HtmxPageFragmentRegistry` then combines those definitions with Java fragment factories in one immutable logical-type map, and `HtmxPageRenderer` reads the stored string for every route.
+This provides deterministic startup failure and efficient production rendering, but an IntelliJ resource build changes the classpath output file without changing the stored string.
+
+The desired development loop is:
+
+```text
+edit existing HTML -> IntelliJ copies resource -> browser refresh -> current HTML renders
+```
+
+The change must not make private pages publicly fetchable, weaken startup conflict detection, silently substitute generic layout after an invalid edit, or add development-only dependencies to production applications.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Allow an application to opt into request-time content reload for existing startup-registered HTML pages.
+- Preserve current cached immutable behavior as the default.
+- Preserve startup discovery, identity, count, conflict, and initial-content validation in both modes.
+- Reuse exactly the same bounded decoding rules during reload.
+- Make the Petclinic sample usable with IntelliJ build and browser refresh without manual Spring restart.
+
+**Non-Goals:**
+
+- Discover newly added pages without restarting.
+- Notice renamed or deleted registrations and rebuild the registry dynamically.
+- Reload Java fragment factories, Java classes, application properties, CSS, JavaScript, grids, or menu resources through this mechanism.
+- Read directly from a source-tree directory or expose a filesystem path through an HTTP endpoint.
+- Add Spring Boot DevTools or an application-wide restart mechanism.
+
+## Decisions
+
+### Add an explicit resource-page mode
+
+`HtmxViewerProperties` will expose a `ResourcePageMode` enum through `causeway.viewer.webcomponents.htmx.resource-page-mode` with values `CACHED` and `RELOAD`.
+`CACHED` remains the default and retains the current startup-decoded immutable string behavior.
+`RELOAD` reopens and validates the registered resource whenever its page definition renders.
+
+An enum is preferred to an inverted cache Boolean because the operational behavior is visible in configuration and can be extended later without ambiguous double negatives.
+Automatic detection of an exploded classpath is rejected because an application's behavior must not change implicitly between launch mechanisms.
+
+### Keep the registry immutable
+
+Startup will continue to enumerate the bounded classpath pattern and register an immutable set of logical-type definitions before accepting requests.
+Every resource will be decoded and validated once during startup even in `RELOAD` mode, preserving fail-fast detection for initially defective resources.
+Java factory definitions and resource definitions will continue to share the same duplicate check.
+
+Reload therefore changes content ownership, not registration ownership:
+
+```text
+ startup render
+CACHED Resource -> validate -> immutable HTML -> stored HTML
+RELOAD Resource -> validate -> Resource handle -> read + validate -> current HTML
+```
+
+Classpath rescanning on every request is rejected because it would move page-count, duplicate, addition, deletion, and factory-conflict semantics into concurrent request handling.
+A filesystem watch service is rejected because classpaths can contain directories and jars, watcher events can be missed, and lifecycle complexity is unnecessary for editing existing files.
+
+### Centralize bounded decoding
+
+The loader's current byte limit, non-empty check, UTF-8 decoder, NUL rejection, and bounded safe diagnostics will be extracted or exposed as one reusable resource-decoding operation.
+Both startup validation and request-time reload will call that operation.
+The reload path will not retain last-known-good HTML after a read or validation failure because stale fallback would conceal the developer's edit.
+It will also not use generic object layout as fallback because a registered custom page remains authoritative.
+
+### Retain resource handles only in reload mode
+
+A resource page definition will retain either cached HTML or a reloadable resource-backed content provider according to the configured mode.
+The provider will create a fresh input stream for each render and will not cache by timestamp.
+Timestamp caching is rejected because classpath resources do not uniformly support reliable modification times and coarse timestamp resolution can miss rapid edits.
+
+The immutable registry and page definitions remain safe for concurrent requests because reload uses request-local streams and state.
+
+### Opt Petclinic into reload mode
+
+The Petclinic sample will set `causeway.viewer.webcomponents.htmx.resource-page-mode=reload` in its application configuration.
+This makes the sample's normal IntelliJ launch demonstrate the intended workflow without requiring an extra active profile.
+Production applications remain cached unless they explicitly opt in.
+
+The documentation will explain that IntelliJ must first copy the edited source resource into the running module's classpath output.
+It will also describe IntelliJ automatic build and the setting that permits auto-make while the application is running as an optional way to remove the explicit build action.
+
+## Risks / Trade-offs
+
+- [Risk] Reload mode performs resource I/O for every custom object-page render. → Keep it opt-in, retain `CACHED` as the production default, and read only the selected registered page rather than rescanning all pages.
+- [Risk] IntelliJ can expose a resource briefly while copying it. → Apply bounded validation on every read, fail the affected request safely, and allow the next refresh to retry.
+- [Risk] Developers may expect newly added or renamed pages to appear immediately. → Document that registration identity remains startup-bound and test that reload affects content only.
+- [Risk] A reload failure could accidentally reveal an absolute path or resource description. → Continue using bounded filename-based source identifiers and safe diagnostic codes.
+- [Risk] Concurrent requests can observe different file generations. → Treat each request as an independent development-time read and retain no shared mutable content state.
+- [Risk] Petclinic tests may assume immutable content. → Cover both modes explicitly and keep general HTMX test fixtures in the default cached mode unless a test opts in.
+
+## Migration Plan
+
+Add the configuration enum with `CACHED` as its default, refactor decoding without changing cached behavior, add reload-backed definitions, and then opt the Petclinic sample into `RELOAD`.
+Applications require no migration.
+Rollback consists of removing the Petclinic override or explicitly setting `resource-page-mode=cached`.
+
+## Open Questions
+
+None.
diff --git a/openspec/changes/reload-htmx-resource-pages/proposal.md b/openspec/changes/reload-htmx-resource-pages/proposal.md
new file mode 100644
index 00000000000..3cb32a3fa4a
--- /dev/null
+++ b/openspec/changes/reload-htmx-resource-pages/proposal.md
@@ -0,0 +1,30 @@
+## Why
+
+Classpath HTML pages are decoded into immutable strings during HTMX viewer startup, so editing an existing page during development requires restarting the Spring application context before the change can be observed.
+An opt-in reload mode would shorten the authoring loop to build-and-refresh while preserving immutable, fail-fast packaged behavior by default.
+
+## What Changes
+
+- Add an explicit HTMX viewer resource-page mode with cached production behavior as the default and reload-on-render behavior as an opt-in development mode.
+- Keep resource discovery, logical-type registration, page-count bounds, duplicate detection, and conflicts with Java fragment factories fixed and validated at startup in both modes.
+- In reload mode, retain each discovered page resource and decode its current content whenever that already-registered page is rendered.
+- Reapply the existing bounded, non-empty UTF-8 and NUL-content validation to every reload and report a bounded failure instead of silently serving stale content or generic fallback.
+- Do not rescan the classpath during rendering, so adding, deleting, or renaming a page still requires application restart.
+- Keep packaged dependency resources, private-resource visibility, CSP, route handling, response cache control, and Java fragment-factory behavior unchanged.
+- Configure the Petclinic sample to demonstrate edit, IntelliJ build, and browser refresh without a manual application restart.
+- Document the IntelliJ resource-copy requirement and the optional automatic-build setting that can remove the explicit build step.
+
+## Capabilities
+
+### New Capabilities
+
+None.
+
+### Modified Capabilities
+
+- `generic-htmx-web-component-viewer`: Add opt-in current-content reloading for startup-registered private classpath HTML pages while retaining cached immutable behavior by default.
+
+## Impact
+
+The change affects HTMX viewer configuration properties, classpath page loading and page definitions, registry wiring, request-time error handling, module and integration tests, Petclinic development configuration, and viewer documentation.
+No production default, route, public page URL, component vocabulary, third-party dependency, or packaged application contract changes.
diff --git a/openspec/changes/reload-htmx-resource-pages/specs/generic-htmx-web-component-viewer/spec.md b/openspec/changes/reload-htmx-resource-pages/specs/generic-htmx-web-component-viewer/spec.md
new file mode 100644
index 00000000000..143000dbf9d
--- /dev/null
+++ b/openspec/changes/reload-htmx-resource-pages/specs/generic-htmx-web-component-viewer/spec.md
@@ -0,0 +1,56 @@
+## MODIFIED Requirements
+
+### Requirement: Convention-registered private HTML pages
+The HTMX viewer SHALL discover trusted `.html` page resources from one documented private classpath root and SHALL register each resource by the exact public logical type represented by its filename.
+It SHALL support configured `CACHED` and `RELOAD` resource-page modes, SHALL default to `CACHED`, and MUST keep the registered page set immutable between application-context startups in both modes.
+
+#### Scenario: Application packages an exact logical-type page
+- **WHEN** an application packages `META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html`
+- **THEN** viewer startup registers the literal resource for exact logical type `petclinic.PetOwner`
+- **AND** no application Java bean, annotation, manifest, template controller, or client-side page fetch is required
+
+#### Scenario: Page is supplied by a dependency jar
+- **WHEN** one application module or dependency jar contributes a qualifying page beneath the private root
+- **THEN** classpath discovery includes that page in the same immutable registry
+- **AND** the resource is not exposed by the ordinary static-resource handler
+
+#### Scenario: Cached page content is loaded
+- **WHEN** a qualifying resource is decoded during startup in the default `CACHED` mode
+- **THEN** it is read as bounded non-empty UTF-8 literal HTML and retained immutably for subsequent renders
+- **AND** no expression, route value, object identifier, metamodel value, persistence value, or GraphQL result is interpolated into its text
+
+#### Scenario: Reload page content is initially loaded
+- **WHEN** a qualifying resource is decoded during startup in configured `RELOAD` mode
+- **THEN** the same bounded non-empty UTF-8 and NUL-content validation runs before registration completes
+- **AND** the definition retains authority for its exact logical type without retaining startup content as a stale fallback
+
+#### Scenario: Existing reload page is edited
+- **WHEN** the content of an already-registered page resource changes on the running classpath in `RELOAD` mode
+- **AND** a subsequent route render selects that page
+- **THEN** the viewer opens and validates the resource again and renders its current literal HTML
+- **AND** no Spring application-context restart, classpath rescan, public page fetch, or Java fragment-factory invocation is required
+
+#### Scenario: Reloaded content is defective
+- **WHEN** an already-registered page in `RELOAD` mode becomes unreadable, oversized, empty, malformed UTF-8, or contains forbidden NUL content
+- **THEN** the affected render fails with a bounded safe diagnostic
+- **AND** the viewer does not serve cached stale content, expose an absolute resource path, or silently use generic object layout fallback
+
+#### Scenario: Page registration changes while running
+- **WHEN** a page resource is added, deleted, renamed, or changed to claim another logical type after viewer startup
+- **THEN** the immutable registry does not add, remove, or rename that registration in either mode
+- **AND** an application-context restart is required to re-run bounded discovery and conflict validation
+
+#### Scenario: Invalid resource-page mode is configured
+- **WHEN** application configuration supplies a resource-page mode other than `CACHED` or `RELOAD`
+- **THEN** viewer startup fails through bounded configuration binding
+- **AND** it does not infer behavior from the classpath launch mechanism
+
+#### Scenario: Page registration is defective
+- **WHEN** a discovered page has an invalid logical-type filename, exceeds a documented bound, is empty, contains malformed UTF-8 or forbidden NUL content, cannot be read, or conflicts with another definition
+- **THEN** viewer startup fails with a bounded safe diagnostic
+- **AND** the viewer does not silently use generic layout fallback for that defective registration
+
+#### Scenario: Registry discovery is bounded
+- **WHEN** classpath discovery reaches the documented finite page-count ceiling
+- **THEN** startup rejects additional registrations deterministically
+- **AND** does not allocate an unbounded page registry
diff --git a/openspec/changes/reload-htmx-resource-pages/tasks.md b/openspec/changes/reload-htmx-resource-pages/tasks.md
new file mode 100644
index 00000000000..78ba026efe0
--- /dev/null
+++ b/openspec/changes/reload-htmx-resource-pages/tasks.md
@@ -0,0 +1,30 @@
+## 1. Configuration and resource model
+
+- [ ] 1.1 Add the `CACHED` and `RELOAD` resource-page modes to HTMX viewer configuration with `CACHED` as the default and bounded enum binding.
+- [ ] 1.2 Refactor private page resource decoding into one reusable bounded validation operation used at startup and during reload.
+- [ ] 1.3 Extend resource page definitions to retain either immutable cached HTML or a reloadable resource-backed content provider without adding shared mutable state.
+
+## 2. Viewer wiring and failure behavior
+
+- [ ] 2.1 Wire the configured mode through classpath discovery while preserving startup enumeration, initial validation, duplicate detection, Java factory conflicts, and immutable registry identity.
+- [ ] 2.2 Make `RELOAD` definitions reopen and validate only their selected registered resource for each render while leaving factory and generic-page behavior unchanged.
+- [ ] 2.3 Preserve bounded safe reload failures without stale-content or generic-layout fallback and without exposing absolute resource paths.
+
+## 3. Automated verification
+
+- [ ] 3.1 Extend loader and page-definition unit tests for cached stability, current-content reload, repeated reload, and all existing content validation bounds.
+- [ ] 3.2 Extend registry and renderer tests to prove page additions, deletions, renames, duplicates, and factory conflicts remain startup-bound in both modes.
+- [ ] 3.3 Add HTTP integration coverage showing that a changed registered resource appears after refresh in `RELOAD` mode and remains unchanged in default `CACHED` mode.
+- [ ] 3.4 Verify invalid configured modes fail binding and invalid reloaded content fails safely without stale or generic output.
+
+## 4. Petclinic developer experience
+
+- [ ] 4.1 Configure the Petclinic sample to opt into `RELOAD` mode and retain the production viewer default as `CACHED`.
+- [ ] 4.2 Add Petclinic integration coverage for the configured mode and the edit-build-refresh resource workflow.
+- [ ] 4.3 Document IntelliJ resource copying, browser refresh, automatic build, and the restart-required boundary for added, deleted, or renamed pages.
+
+## 5. Validation
+
+- [ ] 5.1 Run focused HTMX viewer unit and integration tests for both resource-page modes.
+- [ ] 5.2 Run the Petclinic integration and browser acceptance suites to confirm route, CSP, cache-control, interaction, and page-selection behavior remain stable.
+- [ ] 5.3 Run formatting, compilation, OpenSpec validation, and final diff checks.
(causeway) 03/03: docs(openspec): archive HTMX resource page reload
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit e31bd024400542f2e1468fda119ac136ed7e1e2d
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 09:48:57 2026 +0200
docs(openspec): archive HTMX resource page reload
---
.../.openspec.yaml | 0
.../design.md | 0
.../proposal.md | 0
.../generic-htmx-web-component-viewer/spec.md | 0
.../tasks.md | 0
.../generic-htmx-web-component-viewer/spec.md | 33 ++++++++++++++++++++--
6 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/openspec/changes/reload-htmx-resource-pages/.openspec.yaml b/openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/.openspec.yaml
similarity index 100%
rename from openspec/changes/reload-htmx-resource-pages/.openspec.yaml
rename to openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/.openspec.yaml
diff --git a/openspec/changes/reload-htmx-resource-pages/design.md b/openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/design.md
similarity index 100%
rename from openspec/changes/reload-htmx-resource-pages/design.md
rename to openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/design.md
diff --git a/openspec/changes/reload-htmx-resource-pages/proposal.md b/openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/proposal.md
similarity index 100%
rename from openspec/changes/reload-htmx-resource-pages/proposal.md
rename to openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/proposal.md
diff --git a/openspec/changes/reload-htmx-resource-pages/specs/generic-htmx-web-component-viewer/spec.md b/openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/specs/generic-htmx-web-component-viewer/spec.md
similarity index 100%
rename from openspec/changes/reload-htmx-resource-pages/specs/generic-htmx-web-component-viewer/spec.md
rename to openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/specs/generic-htmx-web-component-viewer/spec.md
diff --git a/openspec/changes/reload-htmx-resource-pages/tasks.md b/openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/tasks.md
similarity index 100%
rename from openspec/changes/reload-htmx-resource-pages/tasks.md
rename to openspec/changes/archive/2026-08-29-reload-htmx-resource-pages/tasks.md
diff --git a/openspec/specs/generic-htmx-web-component-viewer/spec.md b/openspec/specs/generic-htmx-web-component-viewer/spec.md
index 375373f30e1..048f4cc9b35 100644
--- a/openspec/specs/generic-htmx-web-component-viewer/spec.md
+++ b/openspec/specs/generic-htmx-web-component-viewer/spec.md
@@ -94,6 +94,7 @@ The HTMX route resolver SHALL select one trusted custom page registered for the
### Requirement: Convention-registered private HTML pages
The HTMX viewer SHALL discover trusted `.html` page resources from one documented private classpath root and SHALL register each resource by the exact public logical type represented by its filename.
+It SHALL support configured `CACHED` and `RELOAD` resource-page modes, SHALL default to `CACHED`, and MUST keep the registered page set immutable between application-context startups in both modes.
#### Scenario: Application packages an exact logical-type page
- **WHEN** an application packages `META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html`
@@ -105,11 +106,37 @@ The HTMX viewer SHALL discover trusted `.html` page resources from one documente
- **THEN** classpath discovery includes that page in the same immutable registry
- **AND** the resource is not exposed by the ordinary static-resource handler
-#### Scenario: Page content is loaded
-- **WHEN** a qualifying resource is decoded during startup
-- **THEN** it is read as bounded non-empty UTF-8 literal HTML
+#### Scenario: Cached page content is loaded
+- **WHEN** a qualifying resource is decoded during startup in the default `CACHED` mode
+- **THEN** it is read as bounded non-empty UTF-8 literal HTML and retained immutably for subsequent renders
- **AND** no expression, route value, object identifier, metamodel value, persistence value, or GraphQL result is interpolated into its text
+#### Scenario: Reload page content is initially loaded
+- **WHEN** a qualifying resource is decoded during startup in configured `RELOAD` mode
+- **THEN** the same bounded non-empty UTF-8 and NUL-content validation runs before registration completes
+- **AND** the definition retains authority for its exact logical type without retaining startup content as a stale fallback
+
+#### Scenario: Existing reload page is edited
+- **WHEN** the content of an already-registered page resource changes on the running classpath in `RELOAD` mode
+- **AND** a subsequent route render selects that page
+- **THEN** the viewer opens and validates the resource again and renders its current literal HTML
+- **AND** no Spring application-context restart, classpath rescan, public page fetch, or Java fragment-factory invocation is required
+
+#### Scenario: Reloaded content is defective
+- **WHEN** an already-registered page in `RELOAD` mode becomes unreadable, oversized, empty, malformed UTF-8, or contains forbidden NUL content
+- **THEN** the affected render fails with a bounded safe diagnostic
+- **AND** the viewer does not serve cached stale content, expose an absolute resource path, or silently use generic object layout fallback
+
+#### Scenario: Page registration changes while running
+- **WHEN** a page resource is added, deleted, renamed, or changed to claim another logical type after viewer startup
+- **THEN** the immutable registry does not add, remove, or rename that registration in either mode
+- **AND** an application-context restart is required to re-run bounded discovery and conflict validation
+
+#### Scenario: Invalid resource-page mode is configured
+- **WHEN** application configuration supplies a resource-page mode other than `CACHED` or `RELOAD`
+- **THEN** viewer startup fails through bounded configuration binding
+- **AND** it does not infer behavior from the classpath launch mechanism
+
#### Scenario: Page registration is defective
- **WHEN** a discovered page has an invalid logical-type filename, exceeds a documented bound, is empty, contains malformed UTF-8 or forbidden NUL content, cannot be read, or conflicts with another definition
- **THEN** viewer startup fails with a bounded safe diagnostic
(causeway) 02/03: feat(htmx): reload resource pages during development
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch htmx
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit 8080e1048cb91b71640134180e0bfb863f69727f
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Sat Aug 29 09:46:56 2026 +0200
feat(htmx): reload resource pages during development
---
.../changes/reload-htmx-resource-pages/tasks.md | 32 ++---
viewers/webcomponents/htmx/README.adoc | 9 +-
.../CausewayModuleViewerWebcomponentsHtmx.java | 7 +-
.../htmx/HtmxClasspathPageLoader.java | 25 +++-
.../webcomponents/htmx/HtmxPageDefinition.java | 24 +++-
.../webcomponents/htmx/HtmxViewerProperties.java | 17 +++
.../htmx/HtmxClasspathPageLoaderTest.java | 138 ++++++++++++++++++++-
.../htmx/HtmxPageFragmentRegistryTest.java | 26 ++++
.../htmx/HtmxViewerControllerTest.java | 26 ++++
.../htmx/HtmxViewerPropertiesTest.java | 29 +++++
.../sample-htmx-petclinic/README.adoc | 13 +-
.../src/main/resources/application.properties | 1 +
.../PetClinicHtmxApplication_IntegTest.java | 40 ++++++
13 files changed, 356 insertions(+), 31 deletions(-)
diff --git a/openspec/changes/reload-htmx-resource-pages/tasks.md b/openspec/changes/reload-htmx-resource-pages/tasks.md
index 78ba026efe0..412f6290fd9 100644
--- a/openspec/changes/reload-htmx-resource-pages/tasks.md
+++ b/openspec/changes/reload-htmx-resource-pages/tasks.md
@@ -1,30 +1,30 @@
## 1. Configuration and resource model
-- [ ] 1.1 Add the `CACHED` and `RELOAD` resource-page modes to HTMX viewer configuration with `CACHED` as the default and bounded enum binding.
-- [ ] 1.2 Refactor private page resource decoding into one reusable bounded validation operation used at startup and during reload.
-- [ ] 1.3 Extend resource page definitions to retain either immutable cached HTML or a reloadable resource-backed content provider without adding shared mutable state.
+- [x] 1.1 Add the `CACHED` and `RELOAD` resource-page modes to HTMX viewer configuration with `CACHED` as the default and bounded enum binding.
+- [x] 1.2 Refactor private page resource decoding into one reusable bounded validation operation used at startup and during reload.
+- [x] 1.3 Extend resource page definitions to retain either immutable cached HTML or a reloadable resource-backed content provider without adding shared mutable state.
## 2. Viewer wiring and failure behavior
-- [ ] 2.1 Wire the configured mode through classpath discovery while preserving startup enumeration, initial validation, duplicate detection, Java factory conflicts, and immutable registry identity.
-- [ ] 2.2 Make `RELOAD` definitions reopen and validate only their selected registered resource for each render while leaving factory and generic-page behavior unchanged.
-- [ ] 2.3 Preserve bounded safe reload failures without stale-content or generic-layout fallback and without exposing absolute resource paths.
+- [x] 2.1 Wire the configured mode through classpath discovery while preserving startup enumeration, initial validation, duplicate detection, Java factory conflicts, and immutable registry identity.
+- [x] 2.2 Make `RELOAD` definitions reopen and validate only their selected registered resource for each render while leaving factory and generic-page behavior unchanged.
+- [x] 2.3 Preserve bounded safe reload failures without stale-content or generic-layout fallback and without exposing absolute resource paths.
## 3. Automated verification
-- [ ] 3.1 Extend loader and page-definition unit tests for cached stability, current-content reload, repeated reload, and all existing content validation bounds.
-- [ ] 3.2 Extend registry and renderer tests to prove page additions, deletions, renames, duplicates, and factory conflicts remain startup-bound in both modes.
-- [ ] 3.3 Add HTTP integration coverage showing that a changed registered resource appears after refresh in `RELOAD` mode and remains unchanged in default `CACHED` mode.
-- [ ] 3.4 Verify invalid configured modes fail binding and invalid reloaded content fails safely without stale or generic output.
+- [x] 3.1 Extend loader and page-definition unit tests for cached stability, current-content reload, repeated reload, and all existing content validation bounds.
+- [x] 3.2 Extend registry and renderer tests to prove page additions, deletions, renames, duplicates, and factory conflicts remain startup-bound in both modes.
+- [x] 3.3 Add HTTP integration coverage showing that a changed registered resource appears after refresh in `RELOAD` mode and remains unchanged in default `CACHED` mode.
+- [x] 3.4 Verify invalid configured modes fail binding and invalid reloaded content fails safely without stale or generic output.
## 4. Petclinic developer experience
-- [ ] 4.1 Configure the Petclinic sample to opt into `RELOAD` mode and retain the production viewer default as `CACHED`.
-- [ ] 4.2 Add Petclinic integration coverage for the configured mode and the edit-build-refresh resource workflow.
-- [ ] 4.3 Document IntelliJ resource copying, browser refresh, automatic build, and the restart-required boundary for added, deleted, or renamed pages.
+- [x] 4.1 Configure the Petclinic sample to opt into `RELOAD` mode and retain the production viewer default as `CACHED`.
+- [x] 4.2 Add Petclinic integration coverage for the configured mode and the edit-build-refresh resource workflow.
+- [x] 4.3 Document IntelliJ resource copying, browser refresh, automatic build, and the restart-required boundary for added, deleted, or renamed pages.
## 5. Validation
-- [ ] 5.1 Run focused HTMX viewer unit and integration tests for both resource-page modes.
-- [ ] 5.2 Run the Petclinic integration and browser acceptance suites to confirm route, CSP, cache-control, interaction, and page-selection behavior remain stable.
-- [ ] 5.3 Run formatting, compilation, OpenSpec validation, and final diff checks.
+- [x] 5.1 Run focused HTMX viewer unit and integration tests for both resource-page modes.
+- [x] 5.2 Run the Petclinic integration and browser acceptance suites to confirm route, CSP, cache-control, interaction, and page-selection behavior remain stable.
+- [x] 5.3 Run formatting, compilation, OpenSpec validation, and final diff checks.
diff --git a/viewers/webcomponents/htmx/README.adoc b/viewers/webcomponents/htmx/README.adoc
index c46dbbe9893..2f30e43d7c8 100644
--- a/viewers/webcomponents/htmx/README.adoc
+++ b/viewers/webcomponents/htmx/README.adoc
@@ -24,6 +24,9 @@ The shell uses external same-origin scripts and styles and requires no inline sc
`causeway.viewer.webcomponents.htmx.brand` defaults to `Apache Causeway`.
`causeway.viewer.webcomponents.htmx.language` defaults to `en`.
`causeway.viewer.webcomponents.htmx.application-stylesheet` optionally adds one same-origin application stylesheet after the default theme.
+`causeway.viewer.webcomponents.htmx.resource-page-mode` accepts `cached` or `reload` and defaults to `cached`.
+Cached mode validates and retains private HTML page content at startup, while reload mode additionally reopens and validates an already-registered page whenever it renders.
+Reload mode is intended for development and adds one bounded classpath-resource read per custom page render.
`causeway.viewer.webcomponents.htmx.wicket-comparison-path` optionally adds a footer link and is intended only for comparison samples.
`causeway.viewer.webcomponents.htmx.component-toolkit` accepts `vaadin` or `native` and defaults to `vaadin`.
The Vaadin policy enables qualified reference editors, basic, numeric, and local-temporal editors and read-only fields, ordinary action buttons, deterministic wide collection Grid presentation, and qualified application Menu Bar tiers, while the native policy is the immediate complete rollback.
@@ -93,8 +96,12 @@ Without an exact custom registration, its child is `<cw-object editable>` and th
The preferred application customization is a UTF-8 HTML file beneath `META-INF/causeway/webcomponents/pages/`.
The filename before `.html` is the exact public logical type, so `petclinic.PetOwner.html` registers `petclinic.PetOwner` without an application bean, annotation, manifest, template controller, or client-side fetch.
The private resource is packaged in the application or a dependency jar but is not served by Spring's ordinary static-resource handler.
-The viewer discovers all direct `*.html` children during startup, accepts at most 512 pages, accepts at most 256 KiB per page, and retains one immutable literal string for each accepted page.
+The viewer discovers all direct `*.html` children during startup, accepts at most 512 pages, and accepts at most 256 KiB per page.
+In default `cached` mode it retains one immutable literal string for each accepted page.
+In opt-in `reload` mode it retains the immutable startup registration but reopens that resource and validates its current content for each render.
Pages must be non-empty strict UTF-8, contain no NUL character, and use a filename-safe Causeway logical type.
+Reload does not rescan the classpath, so adding, deleting, or renaming a page requires an application restart.
+A defective reload fails the affected request instead of serving stale content or selecting generic layout.
A page contains only the markup placed beneath the viewer-owned route context.
It does not contain a document shell, GraphQL client, second object context, route interaction controller, or interpolated object identifier.
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/CausewayModuleViewerWebcomponentsHtmx.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/CausewayModuleViewerWebcomponentsHtmx.java
index 59a368b367a..c615b99fcbf 100644
--- a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/CausewayModuleViewerWebcomponentsHtmx.java
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/CausewayModuleViewerWebcomponentsHtmx.java
@@ -39,8 +39,11 @@ HtmxRouteCodec htmxRouteCodec(final HtmxViewerProperties properties) {
@Bean
HtmxPageFragmentRegistry htmxPageFragmentRegistry(
final List<HtmxPageFragmentFactory> factories,
- final ApplicationContext applicationContext) {
- final var resourcePages = new HtmxClasspathPageLoader(applicationContext).load();
+ final ApplicationContext applicationContext,
+ final HtmxViewerProperties properties) {
+ final var resourcePages = new HtmxClasspathPageLoader(
+ applicationContext,
+ properties.getResourcePageMode()).load();
return new HtmxPageFragmentRegistry(factories, resourcePages);
}
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxClasspathPageLoader.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxClasspathPageLoader.java
index 9596b8c6e61..8043a1fbea1 100644
--- a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxClasspathPageLoader.java
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxClasspathPageLoader.java
@@ -43,9 +43,17 @@ final class HtmxClasspathPageLoader {
"[A-Za-z_][A-Za-z0-9_$-]*(?:\\.[A-Za-z_][A-Za-z0-9_$-]*)*");
private final ResourcePatternResolver resolver;
+ private final HtmxViewerProperties.ResourcePageMode resourcePageMode;
HtmxClasspathPageLoader(final ResourcePatternResolver resolver) {
+ this(resolver, HtmxViewerProperties.ResourcePageMode.CACHED);
+ }
+
+ HtmxClasspathPageLoader(
+ final ResourcePatternResolver resolver,
+ final HtmxViewerProperties.ResourcePageMode resourcePageMode) {
this.resolver = java.util.Objects.requireNonNull(resolver);
+ this.resourcePageMode = java.util.Objects.requireNonNull(resourcePageMode);
}
List<HtmxPageDefinition> load() {
@@ -82,14 +90,21 @@ private HtmxPageDefinition load(final Resource resource) {
"Private HTML page '" + bounded(filename) + "' does not have a valid logical-type filename.");
}
final var source = "resource:" + bounded(filename);
+ final var startupHtml = decode(resource, source);
+ return resourcePageMode == HtmxViewerProperties.ResourcePageMode.RELOAD
+ ? HtmxPageDefinition.reloadingResource(logicalTypeName, source, () -> decode(resource, source))
+ : HtmxPageDefinition.resource(logicalTypeName, source, startupHtml);
+ }
+
+ static String decode(final Resource resource, final String source) {
final byte[] bytes;
try (InputStream input = resource.getInputStream()) {
bytes = readBounded(input, source);
} catch (IOException ex) {
- throw failure("HTMX_PAGE_UNREADABLE", "Private HTML page '" + source + "' cannot be read.");
+ throw failure("HTMX_PAGE_UNREADABLE", "Private HTML page '" + bounded(source) + "' cannot be read.");
}
if (bytes.length == 0) {
- throw failure("HTMX_PAGE_EMPTY", "Private HTML page '" + source + "' is empty.");
+ throw failure("HTMX_PAGE_EMPTY", "Private HTML page '" + bounded(source) + "' is empty.");
}
final String html;
try {
@@ -99,12 +114,12 @@ private HtmxPageDefinition load(final Resource resource) {
.decode(ByteBuffer.wrap(bytes))
.toString();
} catch (CharacterCodingException ex) {
- throw failure("HTMX_PAGE_INVALID_UTF8", "Private HTML page '" + source + "' is not valid UTF-8.");
+ throw failure("HTMX_PAGE_INVALID_UTF8", "Private HTML page '" + bounded(source) + "' is not valid UTF-8.");
}
if (html.indexOf('\0') >= 0) {
- throw failure("HTMX_PAGE_NUL_CONTENT", "Private HTML page '" + source + "' contains a NUL character.");
+ throw failure("HTMX_PAGE_NUL_CONTENT", "Private HTML page '" + bounded(source) + "' contains a NUL character.");
}
- return HtmxPageDefinition.resource(logicalTypeName, source, html);
+ return html;
}
private static byte[] readBounded(final InputStream input, final String source) throws IOException {
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageDefinition.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageDefinition.java
index 39e1411f16a..06d38ccb5a7 100644
--- a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageDefinition.java
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageDefinition.java
@@ -19,6 +19,7 @@
package org.apache.causeway.viewer.webcomponents.htmx;
import java.util.Objects;
+import java.util.function.Supplier;
final class HtmxPageDefinition {
@@ -34,19 +35,19 @@ String attributeValue() {
private final String logicalTypeName;
private final Source source;
private final String safeSourceIdentifier;
- private final String resourceHtml;
+ private final Supplier<String> resourceContent;
private final HtmxPageFragmentFactory factory;
private HtmxPageDefinition(
final String logicalTypeName,
final Source source,
final String safeSourceIdentifier,
- final String resourceHtml,
+ final Supplier<String> resourceContent,
final HtmxPageFragmentFactory factory) {
this.logicalTypeName = logicalTypeName;
this.source = source;
this.safeSourceIdentifier = safeSourceIdentifier;
- this.resourceHtml = resourceHtml;
+ this.resourceContent = resourceContent;
this.factory = factory;
}
@@ -54,11 +55,24 @@ static HtmxPageDefinition resource(
final String logicalTypeName,
final String safeSourceIdentifier,
final String html) {
+ final var cachedHtml = Objects.requireNonNull(html);
return new HtmxPageDefinition(
Objects.requireNonNull(logicalTypeName),
Source.RESOURCE,
Objects.requireNonNull(safeSourceIdentifier),
- Objects.requireNonNull(html),
+ () -> cachedHtml,
+ null);
+ }
+
+ static HtmxPageDefinition reloadingResource(
+ final String logicalTypeName,
+ final String safeSourceIdentifier,
+ final Supplier<String> content) {
+ return new HtmxPageDefinition(
+ Objects.requireNonNull(logicalTypeName),
+ Source.RESOURCE,
+ Objects.requireNonNull(safeSourceIdentifier),
+ Objects.requireNonNull(content),
null);
}
@@ -85,7 +99,7 @@ String safeSourceIdentifier() {
}
String render(final HtmxObjectRoute route) {
- return source == Source.RESOURCE ? resourceHtml : factory.render(route);
+ return source == Source.RESOURCE ? resourceContent.get() : factory.render(route);
}
private static String bounded(final String value) {
diff --git a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerProperties.java b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerProperties.java
index 50b68834504..1ba074e9bfe 100644
--- a/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerProperties.java
+++ b/viewers/webcomponents/htmx/src/main/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerProperties.java
@@ -23,6 +23,11 @@
@ConfigurationProperties(prefix = "causeway.viewer.webcomponents.htmx")
public class HtmxViewerProperties {
+ public enum ResourcePageMode {
+ CACHED,
+ RELOAD
+ }
+
public enum ComponentToolkit {
VAADIN("vaadin"),
NATIVE("native");
@@ -65,6 +70,7 @@ public String externalName() {
private String language = "en";
private String wicketComparisonPath;
private String applicationStylesheet;
+ private ResourcePageMode resourcePageMode = ResourcePageMode.CACHED;
private ComponentToolkit componentToolkit = ComponentToolkit.VAADIN;
private boolean componentToolkitConfigured;
private EditorToolkit editorToolkit = EditorToolkit.VAADIN;
@@ -124,6 +130,17 @@ public void setApplicationStylesheet(final String applicationStylesheet) {
this.applicationStylesheet = applicationStylesheet;
}
+ public ResourcePageMode getResourcePageMode() {
+ return resourcePageMode;
+ }
+
+ public void setResourcePageMode(final ResourcePageMode resourcePageMode) {
+ if (resourcePageMode == null) {
+ throw new IllegalArgumentException("Resource page mode must be cached or reload.");
+ }
+ this.resourcePageMode = resourcePageMode;
+ }
+
public ComponentToolkit getComponentToolkit() {
return componentToolkit;
}
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxClasspathPageLoaderTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxClasspathPageLoaderTest.java
index 9628c58c530..2929022d671 100644
--- a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxClasspathPageLoaderTest.java
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxClasspathPageLoaderTest.java
@@ -18,6 +18,7 @@
*/
package org.apache.causeway.viewer.webcomponents.htmx;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLClassLoader;
@@ -60,6 +61,79 @@ void loadsLiteralUtf8ResourceWithExactLogicalType() {
.isEqualTo("<p>Olé {{objectId}}</p>");
}
+ @Test
+ void cachedAndReloadModesRetainRegistrationButDifferForCurrentContent() {
+ final var cachedResource = mutable("petclinic.Cached.html", "<p>initial cached</p>");
+ final var cached = loader(HtmxViewerProperties.ResourcePageMode.CACHED, cachedResource)
+ .load().get(0);
+ cachedResource.set("<p>changed cached</p>");
+ assertThat(cached.render(route("petclinic.Cached"))).isEqualTo("<p>initial cached</p>");
+
+ final var reloadResource = mutable("petclinic.Reload.html", "<p>initial reload</p>");
+ final var reloading = loader(HtmxViewerProperties.ResourcePageMode.RELOAD, reloadResource)
+ .load().get(0);
+ reloadResource.set("<p>changed once</p>");
+ assertThat(reloading.render(route("petclinic.Reload"))).isEqualTo("<p>changed once</p>");
+ reloadResource.set("<p>changed twice</p>");
+ assertThat(reloading.render(route("petclinic.Reload"))).isEqualTo("<p>changed twice</p>");
+ assertThat(reloading.logicalTypeName()).isEqualTo("petclinic.Reload");
+ }
+
+ @Test
+ void reloadReappliesEveryBoundedContentValidationWithoutStaleFallback() {
+ final var resource = mutable("petclinic.Reload.html", "<p>valid</p>");
+ final var page = loader(HtmxViewerProperties.ResourcePageMode.RELOAD, resource)
+ .load().get(0);
+
+ resource.set(new byte[0]);
+ assertRenderFailure(page, "HTMX_PAGE_EMPTY");
+ resource.set(new byte[] {(byte) 0xc3, (byte) 0x28});
+ assertRenderFailure(page, "HTMX_PAGE_INVALID_UTF8");
+ resource.set("<p>\0</p>");
+ assertRenderFailure(page, "HTMX_PAGE_NUL_CONTENT");
+ resource.set(new byte[HtmxClasspathPageLoader.MAXIMUM_PAGE_BYTES + 1]);
+ assertRenderFailure(page, "HTMX_PAGE_SIZE_EXCEEDED");
+ resource.unreadable = true;
+ assertRenderFailure(page, "HTMX_PAGE_UNREADABLE");
+ }
+
+ @Test
+ void additionsDeletionsAndRenamesRemainStartupBoundInBothModes() throws Exception {
+ final var pagesDirectory = temporaryDirectory.resolve(RESOURCE_DIRECTORY);
+ Files.createDirectories(pagesDirectory);
+ final var original = pagesDirectory.resolve("petclinic.Original.html");
+ Files.writeString(original, "<p>original</p>", StandardCharsets.UTF_8);
+ try (var classLoader = new URLClassLoader(
+ new java.net.URL[] {temporaryDirectory.toUri().toURL()},
+ null)) {
+ final var resolver = new PathMatchingResourcePatternResolver(classLoader);
+ final var cached = new HtmxClasspathPageLoader(
+ resolver,
+ HtmxViewerProperties.ResourcePageMode.CACHED).load();
+ final var reload = new HtmxClasspathPageLoader(
+ resolver,
+ HtmxViewerProperties.ResourcePageMode.RELOAD).load();
+
+ Files.writeString(
+ pagesDirectory.resolve("petclinic.Added.html"),
+ "<p>added</p>",
+ StandardCharsets.UTF_8);
+ Files.move(original, pagesDirectory.resolve("petclinic.Renamed.html"));
+
+ assertThat(cached).extracting(HtmxPageDefinition::logicalTypeName)
+ .containsExactly("petclinic.Original");
+ assertThat(cached.get(0).render(route("petclinic.Original")))
+ .isEqualTo("<p>original</p>");
+ assertThat(reload).extracting(HtmxPageDefinition::logicalTypeName)
+ .containsExactly("petclinic.Original");
+ assertThatThrownBy(() -> reload.get(0).render(route("petclinic.Original")))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("HTMX_PAGE_UNREADABLE")
+ .hasMessageContaining("resource:petclinic.Original.html")
+ .hasMessageNotContaining(temporaryDirectory.toString());
+ }
+ }
+
@Test
void discoversExplodedAndJarResources() throws Exception {
final var explodedPage = temporaryDirectory.resolve(RESOURCE_DIRECTORY).resolve("petclinic.Pet.html");
@@ -150,8 +224,27 @@ private static void assertFailure(final String code, final HtmxClasspathPageLoad
.hasMessageNotContaining("Byte array resource");
}
+ private static void assertRenderFailure(final HtmxPageDefinition page, final String code) {
+ assertThatThrownBy(() -> page.render(route(page.logicalTypeName())))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining(code)
+ .hasMessageContaining("resource:petclinic.Reload.html")
+ .hasMessageNotContaining("fixture secret")
+ .hasMessageNotContaining("<p>valid</p>");
+ }
+
+ private static HtmxObjectRoute route(final String logicalTypeName) {
+ return new HtmxObjectRoute(logicalTypeName, "opaque");
+ }
+
private static HtmxClasspathPageLoader loader(final Resource... resources) {
- return new HtmxClasspathPageLoader(new FixedResolver(resources));
+ return loader(HtmxViewerProperties.ResourcePageMode.CACHED, resources);
+ }
+
+ private static HtmxClasspathPageLoader loader(
+ final HtmxViewerProperties.ResourcePageMode mode,
+ final Resource... resources) {
+ return new HtmxClasspathPageLoader(new FixedResolver(resources), mode);
}
private static Resource named(final String filename, final String content) {
@@ -172,6 +265,49 @@ public String getDescription() {
};
}
+ private static MutableResource mutable(final String filename, final String content) {
+ return new MutableResource(filename, content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static final class MutableResource extends AbstractResource {
+
+ private final String filename;
+ private byte[] content;
+ private boolean unreadable;
+
+ private MutableResource(final String filename, final byte[] content) {
+ this.filename = filename;
+ this.content = content;
+ }
+
+ void set(final String content) {
+ set(content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ void set(final byte[] content) {
+ this.content = content;
+ this.unreadable = false;
+ }
+
+ @Override
+ public String getFilename() {
+ return filename;
+ }
+
+ @Override
+ public String getDescription() {
+ return "fixture secret location:" + filename;
+ }
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ if (unreadable) {
+ throw new IOException("fixture secret exception");
+ }
+ return new ByteArrayInputStream(content);
+ }
+ }
+
private static class FixedResolver implements ResourcePatternResolver {
private final Resource[] resources;
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistryTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistryTest.java
index 940333a2116..46f36eacba1 100644
--- a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistryTest.java
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxPageFragmentRegistryTest.java
@@ -19,6 +19,8 @@
package org.apache.causeway.viewer.webcomponents.htmx;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
@@ -55,6 +57,30 @@ void selectsLiteralResourcePagesWithoutRouteInterpolation() {
.isEqualTo("<p>{{objectId}}</p>");
}
+ @Test
+ void reloadContentProviderIsRequestLocalWhileRegistryIdentityRemainsImmutable() {
+ final var content = new AtomicReference<>("<p>first</p>");
+ final var reads = new AtomicInteger();
+ final var page = HtmxPageDefinition.reloadingResource(
+ "petclinic.PetOwner",
+ "resource:petclinic.PetOwner.html",
+ () -> {
+ reads.incrementAndGet();
+ return content.get();
+ });
+ final var registry = new HtmxPageFragmentRegistry(List.of(), List.of(page));
+
+ assertThat(registry.find("petclinic.PetOwner").orElseThrow()
+ .render(new HtmxObjectRoute("petclinic.PetOwner", "1")))
+ .isEqualTo("<p>first</p>");
+ content.set("<p>second</p>");
+ assertThat(registry.find("petclinic.PetOwner").orElseThrow()
+ .render(new HtmxObjectRoute("petclinic.PetOwner", "2")))
+ .isEqualTo("<p>second</p>");
+ assertThat(reads).hasValue(2);
+ assertThat(registry.find("petclinic.AddedAfterStartup")).isEmpty();
+ }
+
@Test
void rejectsResourceAndFactoryDuplicate() {
final var page = HtmxPageDefinition.resource(
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
index 7a59acc2ccb..9b26697bb0a 100644
--- a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerControllerTest.java
@@ -19,6 +19,7 @@
package org.apache.causeway.viewer.webcomponents.htmx;
import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.Optional;
import java.util.Set;
@@ -310,6 +311,31 @@ void rendersLiteralResourcePageInsideEscapedRouteContext() {
.containsOnlyOnce("<cw-interaction-controller");
}
+ @Test
+ void controllerResponsesUseCurrentReloadContentAndStableCachedContent() {
+ final var current = new AtomicReference<>("<article data-version=\"initial\"></article>");
+ final var reloading = HtmxPageDefinition.reloadingResource(
+ "petclinic.Reload",
+ "resource:petclinic.Reload.html",
+ current::get);
+ final var cached = HtmxPageDefinition.resource(
+ "petclinic.Cached",
+ "resource:petclinic.Cached.html",
+ current.get());
+ final var controller = controller(List.of(), List.of(reloading, cached));
+
+ current.set("<article data-version=\"current\"></article>");
+
+ assertThat(controller.route(request(
+ "/htmx/object/petclinic.Reload/1", "", true)).getBody())
+ .contains("data-version=\"current\"")
+ .doesNotContain("data-version=\"initial\"");
+ assertThat(controller.route(request(
+ "/htmx/object/petclinic.Cached/1", "", true)).getBody())
+ .contains("data-version=\"initial\"")
+ .doesNotContain("data-version=\"current\"");
+ }
+
@Test
void rendersBoundedInvalidAndLandingStates() {
final var controller = controller(List.of());
diff --git a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerPropertiesTest.java b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerPropertiesTest.java
index f48c6be210e..6aaaabf822b 100644
--- a/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerPropertiesTest.java
+++ b/viewers/webcomponents/htmx/src/test/java/org/apache/causeway/viewer/webcomponents/htmx/HtmxViewerPropertiesTest.java
@@ -19,12 +19,33 @@
package org.apache.causeway.viewer.webcomponents.htmx;
import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class HtmxViewerPropertiesTest {
+ @Test
+ void resourcePagesDefaultToCachedAndBindOnlyKnownModes() {
+ final var defaults = new HtmxViewerProperties();
+ assertThat(defaults.getResourcePageMode())
+ .isEqualTo(HtmxViewerProperties.ResourcePageMode.CACHED);
+
+ final var reload = bindResourcePageMode("reload");
+ assertThat(reload.getResourcePageMode())
+ .isEqualTo(HtmxViewerProperties.ResourcePageMode.RELOAD);
+
+ assertThatThrownBy(() -> bindResourcePageMode("automatic"))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("resource-page-mode");
+ assertThatThrownBy(() -> defaults.setResourcePageMode(null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("cached or reload");
+ }
+
@Test
void defaultsToEveryQualifiedVaadinAdapter() {
final var properties = new HtmxViewerProperties();
@@ -183,4 +204,12 @@ void invalidValuesAreRejectedWithoutBroadeningPolicy() {
assertThatThrownBy(() -> properties.setVaadinFieldFamilies("basic,basic"))
.isInstanceOf(IllegalArgumentException.class);
}
+
+ private static HtmxViewerProperties bindResourcePageMode(final String value) {
+ final var environment = new MockEnvironment().withProperty(
+ "causeway.viewer.webcomponents.htmx.resource-page-mode",
+ value);
+ return Binder.get(environment)
+ .bindOrCreate("causeway.viewer.webcomponents.htmx", Bindable.of(HtmxViewerProperties.class));
+ }
}
diff --git a/viewers/webcomponents/sample-htmx-petclinic/README.adoc b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
index e61a22e2d6c..6868c71e507 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/README.adoc
+++ b/viewers/webcomponents/sample-htmx-petclinic/README.adoc
@@ -100,15 +100,26 @@ petclinic.Visit.html
----
Each exact logical-type filename is discovered at startup and its literal UTF-8 content is placed beneath the viewer-provided route context.
+The sample opts into `causeway.viewer.webcomponents.htmx.resource-page-mode=reload`, while the viewer's production-oriented default remains `cached`.
The files compose object headers, ordinary application regions, properties, actions, collections, and declared columns while GraphQL remains authoritative for identity, values, visibility, usability, validation, invocation, and results.
`petclinic.PetOwner.html` demonstrates natural member association by nesting `updateName` beneath `name`, `addPet` and `removePet` beneath `pets`, and `bookVisit` beneath `visits`.
The retained `PetOwner.layout.xml` expresses the equivalent generated-grid associations, while the direct HTML needs no adjacent Petclinic-only association wrapper.
They use the configured `static/css/application.css`, public Causeway hooks, and design variables without inline styles or raw Vaadin elements.
The existing `*.layout.xml`, `*.columnOrder.txt`, and `menubars.layout.xml` resources intentionally remain in the artifact.
-If an exact HTML page is removed from packaging, the route immediately falls back to `<cw-object editable>` and the retained effective grid and column order.
+If an exact HTML page is removed from packaging before startup, the route falls back to `<cw-object editable>` and the retained effective grid and column order.
An unreadable, malformed, duplicate, invalidly named, empty, or oversized discovered page fails startup instead of disguising the error as successful generic fallback.
+=== IntelliJ page-editing loop
+
+After starting `PetClinicHtmxApplication` from IntelliJ, edit an existing page under `src/main/resources/META-INF/causeway/webcomponents/pages/`.
+Run *Build > Build Project* so IntelliJ copies the resource into the running module's classpath output, then refresh the browser to render the current content without restarting Spring.
+To remove the explicit build action, enable IntelliJ automatic build and permit auto-make while an application is running in the IDE's advanced compiler settings.
+The precise setting names vary by IntelliJ release, so verify that the edited file is copied into the module output if refresh still shows old content.
+Reload mode rereads content only for pages registered at startup.
+Adding, deleting, or renaming a page changes registry identity and therefore still requires an application restart.
+An empty, unreadable, oversized, malformed UTF-8, or NUL-containing edit fails that request rather than serving stale HTML or generic layout; correct the file, rebuild it, and refresh again.
+
The primary menus cover Pet Owner creation, search, list, count, and upcoming visits.
HTML object pages cover editable properties, multiline notes, enum values, references, responsive cards, adjacent action groups, related Pets, contributed Visits, and contributed booking.
The sample's current integration tests cover fixture identities, all four packaged pages, private-resource isolation, retained menu and grid resources, rich service and object operations, shell and fragment responses, exact resource precedence, generic fallback contracts, browser assets, and Wicket availability.
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application.properties b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application.properties
index c21e025aff7..9fbcfeb0b3a 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application.properties
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/main/resources/application.properties
@@ -28,5 +28,6 @@ causeway.viewer.webcomponents.htmx.graph-ql-endpoint=/graphql
causeway.viewer.webcomponents.htmx.brand=Pet Clinic
causeway.viewer.webcomponents.htmx.wicket-comparison-path=/wicket/
causeway.viewer.webcomponents.htmx.application-stylesheet=/css/application.css
+causeway.viewer.webcomponents.htmx.resource-page-mode=reload
causeway.viewer.webcomponents.htmx.reference-minimum-search-length=2
causeway.viewer.webcomponents.htmx.reference-maximum-results=50
diff --git a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
index d1d84cc68cb..0302824a854 100644
--- a/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
+++ b/viewers/webcomponents/sample-htmx-petclinic/src/test/java/org/apache/causeway/viewer/webcomponents/sample/htmx/petclinic/PetClinicHtmxApplication_IntegTest.java
@@ -23,6 +23,8 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -31,6 +33,7 @@
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
+import org.apache.causeway.viewer.webcomponents.htmx.HtmxViewerProperties;
import org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain.PetOwner;
import org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain.PetOwnerRepository;
import org.apache.causeway.viewer.webcomponents.sample.htmx.petclinic.domain.VisitRepository;
@@ -54,6 +57,9 @@ class PetClinicHtmxApplication_IntegTest {
@Autowired
private VisitRepository visitRepository;
+ @Autowired
+ private HtmxViewerProperties viewerProperties;
+
@Test
void loadsDeterministicPetclinicFixture() {
final var mary = ownerRepository.findById(PetOwner.MARY_ID);
@@ -143,6 +149,40 @@ void servesStableShellCanonicalFragmentsAssetsAndWicketComparison() throws Excep
}
}
+ @Test
+ void reloadsChangedRegisteredPageAndRejectsInvalidCurrentContent() throws Exception {
+ assertThat(viewerProperties.getResourcePageMode())
+ .isEqualTo(HtmxViewerProperties.ResourcePageMode.RELOAD);
+ final var resource = getClass().getClassLoader().getResource(
+ "META-INF/causeway/webcomponents/pages/petclinic.PetOwner.html");
+ assertThat(resource).isNotNull();
+ assertThat(resource.getProtocol()).isEqualTo("file");
+ final var path = Path.of(resource.toURI());
+ final var original = Files.readAllBytes(path);
+ final var marker = "<p data-reload-verification>current classpath content</p>";
+ try {
+ Files.writeString(path, marker, StandardCharsets.UTF_8);
+ final var changed = get("/htmx/object/petclinic.PetOwner/s_owner-mary");
+ assertThat(changed.statusCode()).isEqualTo(200);
+ assertThat(changed.body())
+ .contains(marker)
+ .doesNotContain("petclinic-owner-page");
+
+ Files.write(path, new byte[0]);
+ final var invalid = get("/htmx/object/petclinic.PetOwner/s_owner-mary");
+ assertThat(invalid.statusCode()).isEqualTo(500);
+ assertThat(invalid.body())
+ .doesNotContain(marker)
+ .doesNotContain("petclinic-owner-page")
+ .doesNotContain(path.toString());
+ } finally {
+ Files.write(path, original);
+ }
+ assertThat(get("/htmx/object/petclinic.PetOwner/s_owner-mary").body())
+ .contains("petclinic-owner-page")
+ .doesNotContain(marker);
+ }
+
@Test
void packagesHtmlPagesAndRetainsLayoutFallbackResources() throws Exception {
final var loader = getClass().getClassLoader();