Experience Fragment Property Propagation: When a ResourceChangeListener Copies Instead of Inherits
A ResourceChangeListener that stamps theme and config properties onto a newly created Experience Fragment by copying them from the nearest matching site page — a write-once-at-creation pattern that looks like inheritance but is actually a snapshot, and silently diverges from its source the moment that source page changes.
Content Objective
In this chapter, you'll understand:
- How a
ResourceChangeListenercan be used to copy properties onto a new resource at creation time, as an alternative to read-time property inheritance - Why listening only for
ADDEDevents creates a permanent snapshot instead of a live relationship - The path-mirroring technique used to find "the page this fragment logically belongs to" when a fragment tree doesn't share a structure with the site tree
- Why hardcoded, listener-registered content paths are a coupling risk as a site's content structure evolves
The Problem: Inheritance That Only Happens Once
The two page-property mechanisms covered earlier in this series — InheritanceValueMap walking a page's own ancestor chain, and Granite dialog-level sling:resourceSuperType/granite:hide mechanisms — both share one property: they're evaluated live, every time something reads the value. Whatever the ancestor's current value is, that's what you get, right now.
Experience Fragments don't sit in the same tree as the pages that reference them, so that ancestor-walk approach doesn't apply directly — an XF under /content/experience-fragments/... has no JCR ancestor relationship to the site page whose theme or configuration it should logically match. One team's solution was a ResourceChangeListener that fires when a new XF is created, works out which site page it "belongs to" by mirroring the XF's own folder structure against the real page tree, and copies two properties from that page directly onto the new XF's jcr:content.
That solves the immediate problem — new XFs get the right theme and config reference without an author having to set them by hand. It also introduces a different one: what's copied is a snapshot, not a live reference, and nothing about the code signals that distinction to a future maintainer.
Architecture
Sling's ResourceChangeListener is a service registered against a set of paths and change types (ADDED, CHANGED, REMOVED); it's a push-based, event-driven mechanism — the opposite of InheritanceValueMap's pull-based, read-time walk. Registering for ADDED only means the listener runs exactly once per resource, at the moment it's created, and never again for that same resource.
The propagation logic in this case has three parts:
- Locate the logical parent page — the site page that "owns" the new XF, found by mirroring the XF's relative path against the real page tree, not by any structural JCR relationship (there isn't one).
- Walk upward from the deepest matching candidate page toward the site root, checking each candidate for the two properties, stopping as soon as both are found (or the root is reached with nothing found).
- Write those two values onto the new XF's
jcr:content(and itsmastervariation'sjcr:content) via a service-user resource resolver, committing the change immediately.
How It Works
Path mirroring, not ancestry. Because an XF's path and a page's path share no real JCR relationship, "closest matching page" here means something specific: take the XF's path relative to its fragment root, and try that same relative path under the corresponding site root, starting from the deepest match and working upward one segment at a time. An XF at experience-fragments/mysite/product-launches/spring/promo-banner gets checked against mysite/product-launches/spring/promo-banner, then mysite/product-launches/spring, then mysite/product-launches, and so on — each checked for the two properties on its jcr:content, until both are found or the walk runs out of segments.
A one-time copy, not a live link. Once the properties are written to the XF's jcr:content, nothing connects that value back to where it came from. If the source page's theme or config reference changes six months later — a rebrand, a config consolidation, a site restructure — every XF created before that change keeps the old, now-incorrect value forever. There's no listener for CHANGED on the site pages that would trigger a re-copy, and no marker on the XF indicating the value was ever derived from another page rather than set directly by an author.
Registration paths are structural commitments. The listener is registered against a fixed, explicit list of content paths via its OSGi component property array. Adding a new fragment root to the site later means someone has to remember to add a matching entry to that property list — there's no dynamic discovery. A new content area that isn't added to the list silently gets no property propagation at all, with nothing in the logs to indicate anything was skipped (the listener simply never fires for paths it isn't registered against).
Real Project Example
A production ExperienceFragmentThemePropagationListener was registered like this:
@Component(
service = { ResourceChangeListener.class, ThemePropagationListener.class },
property = {
ResourceChangeListener.PATHS + "=/content/experience-fragments/mysite/product-launches",
ResourceChangeListener.PATHS + "=/content/experience-fragments/mysite/faq-answers",
ResourceChangeListener.CHANGES + "=ADDED"
}
)
public class ExperienceFragmentThemePropagationListener implements ResourceChangeListener, ThemePropagationListener {
@Override
public void onChange(List<ResourceChange> changes) {
for (ResourceChange change : changes) {
String xfPath = change.getPath();
try (ResourceResolver resolver = serviceResolver()) {
Resource xfRoot = resolver.getResource(xfPath.replace("/master", ""));
if (xfRoot == null || !xfRoot.isResourceType(NameConstants.NT_PAGE)) {
continue;
}
String siteRootPath = resolveSiteRootFor(xfPath);
Map<String, String> inherited = findPropertiesFromClosestMatchingPage(resolver, xfPath, siteRootPath);
stampProperties(xfRoot.getChild(JcrConstants.JCR_CONTENT), inherited, resolver);
Resource master = xfRoot.getChild("master");
if (master != null) {
stampProperties(master.getChild(JcrConstants.JCR_CONTENT), inherited, resolver);
}
} catch (Exception e) {
LOG.error("Failed to propagate theme properties to {}", xfPath, e);
}
}
}
}
Six months after this shipped, a site-wide theme consolidation changed the themeName property on several top-level pages. Every XF created before that consolidation kept rendering with its original, stamped-at-creation theme value — the fragments looked correct in isolation but visually mismatched the pages they were now embedded in, and nobody connected the visual drift back to this listener, since the XF's own properties looked perfectly normal (a real, explicitly-set value, indistinguishable from one an author had set by hand).
Production Troubleshooting
The fix that actually mattered wasn't re-architecting the listener — it was making the copy's provenance visible, and giving a way to re-sync it deliberately:
private void stampProperties(Resource contentResource, Map<String, String> inherited, ResourceResolver resolver)
throws PersistenceException {
if (contentResource == null || inherited.isEmpty()) {
return;
}
ModifiableValueMap props = contentResource.adaptTo(ModifiableValueMap.class);
if (props == null) {
return;
}
inherited.forEach(props::put);
// Marks this value as a derived copy, not an author-set value - lets a
// later audit or resync job distinguish "copied from a page" from
// "explicitly set here" without guessing.
props.put("themePropagationSource", inherited.get("sourcePagePath"));
props.put("themePropagationTimestamp", Calendar.getInstance());
resolver.commit();
}
Alongside that, a scheduled audit job compares each XF's themePropagationSource page's current properties against what was stamped, and reports (rather than silently fixes) any XF whose copied value no longer matches its source — re-copying automatically was deliberately avoided, since an author may have intentionally overridden the stamped value on a specific XF afterward, and a blind re-sync would silently discard that override.
Why Architects Care
A ResourceChangeListener registered for ADDED only is a reasonable, common pattern for "set a sensible default when something is created" — the risk isn't the pattern itself, it's when that default gets mentally filed as "inheritance" rather than "a one-time copy," because the two look identical at the moment of creation and only diverge later, silently, with no error and no obviously wrong-looking value. The general lesson: any time a value is copied from one resource to another at a single point in time, that copy needs either a visible marker of where it came from, or an explicit decision that staleness is acceptable — leaving it unmarked is how six-month-old visual drift becomes a mystery instead of an expected, traceable outcome.
Summary
A ResourceChangeListener registered for ADDED events on Experience Fragment paths copies theme and config properties from the nearest matching site page onto a newly created fragment, using a path-mirroring walk rather than a JCR ancestor relationship (since XFs and pages don't share a tree). Because the listener never fires again for that fragment, the copied values are a permanent snapshot — when the source page's properties later change, every previously created fragment keeps the old value with no signal that anything is out of date. The fix was making the copy's source and timestamp explicit on the fragment, and adding an audit job that reports drift instead of silently re-syncing over a possible manual override.
What's Next
The next post in this series works through the real incident this pattern caused — a rebrand that visually broke dozens of previously-created fragments with no error anywhere in the system, and the audit tooling built to find every affected fragment.
Enjoyed this chapter?
Get an email when I publish the next chapter. No spam — just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.