N
Naveenr.dev
Chapter rw-39
9 min read2026-08-28
📖 AEM - Adobe Experience Manager SeriesChapter rw-39 · 13 chapters

Real-World Scenarios: Dead Hidden Fields After a Multifield Migration

A field an author fills in that visibly does nothing — tracing a dialog's hidden, dead flat fields back to a multifield migration that never cleaned up after itself, and a template-contract test that would have caught it.

Background reading: Dialog Show/Hide Widgets and Conditional Multifields covers the mechanics this post assumes.

Problem Statement

An author working on a legacy product-tile template opened the component dialog, found an "Image Path" field and an "Alt Text" field sitting near the top (unlabeled as anything special), filled them in expecting them to set the tile's image, and saved. Nothing changed on the published page. She tried again with a different asset path. Still nothing. She filed it as "the dialog is broken" — no error, no console warning, just a field that visibly accepts input and has zero effect.

Support escalated it as a rendering bug. The actual cause was two dialog generations coexisting in the same content structure: a mediaItems multifield (the current, working mechanism) and a pair of flat imagePath/altText fields left over from before the multifield existed — marked sling:hideResource="{Boolean}true" during the migration instead of deleted, and never actually removed. The fields the author found weren't hidden in her dialog view (a downstream cleanup task had accidentally un-hidden them while "simplifying" the dialog, not realizing what they were), but the Sling Model backing the component had no code path reading ./imagePath or ./altText at all anymore — only the multifield's per-item resources.

Approach and Why

Two separate problems needed fixing, and conflating them would have led to the wrong fix:

  1. Immediate defect: the flat fields were visible and authorable but functionally inert. The fastest correct fix is deleting them from the dialog outright — not re-hiding them, since re-hiding is exactly what caused this in the first place (a "temporary" state that outlived everyone's memory of why it existed).
  2. Systemic gap: nothing in the test suite or the build would have caught a dialog field with no corresponding model property, because dialog XML and Sling Model code are validated by two entirely different toolchains that never talk to each other. A regression test needed to assert the two stay in sync going forward, not just fix today's instance.

The fix intentionally does not try to make the old fields "work again" by wiring them back into the model — the multifield is the correct, current mechanism (supports multiple media items, which the flat fields never could), and reviving the flat fields would just recreate two parallel, conflicting ways to set the same visual result.

POC

java
class MediaTileDialogContractTest {

    // Reads the same content.xml the component actually ships, the same way
    // AEM's dialog merger would encounter it — not a copy/paste of its text.
    private static Document loadDialog() throws Exception {
        Path dialogPath = Path.of(
            "src/main/content/jcr_root/apps/myapp/components/mediatile/_cq_dialog/.content.xml");
        return DocumentBuilderFactory.newInstance()
            .newDocumentBuilder()
            .parse(dialogPath.toFile());
    }

    @Test
    void everyDialogFieldNameHasACorrespondingModelProperty() throws Exception {
        Set<String> dialogFieldNames = collectFieldNames(loadDialog());
        Set<String> modelReadProperties = ModelPropertyScanner.scan(MediaTileImpl.class);

        Set<String> orphanedFields = new HashSet<>(dialogFieldNames);
        orphanedFields.removeAll(modelReadProperties);

        assertThat(orphanedFields)
            .as("dialog fields with no corresponding model property read - likely dead")
            .isEmpty();
    }

    @Test
    void hiddenFieldsWithoutAResourceSuperTypeAreTreatedAsDeadNotMerged() throws Exception {
        Document dialog = loadDialog();
        boolean hasSuperType = dialog.getDocumentElement().hasAttribute("sling:resourceSuperType");
        List<String> hiddenFieldNames = collectHiddenFieldNames(dialog);

        // A component with no resourceSuperType has nothing to merge against,
        // so a sling:hideResource field here can only be a leftover, never
        // a legitimate inherited-field suppression.
        if (!hasSuperType) {
            assertThat(hiddenFieldNames)
                .as("hidden fields on a component with no super type - dead leftovers, should be deleted")
                .isEmpty();
        }
    }

    @Test
    void multifieldItemFieldsAreTheOnlyMediaFieldsPresent() throws Exception {
        Set<String> dialogFieldNames = collectFieldNames(loadDialog());
        assertThat(dialogFieldNames)
            .as("flat image/alt fields should not coexist with the mediaItems multifield")
            .doesNotContain("imagePath", "altText");
    }
}

The second test is the one that fails against the state this scenario started in: no sling:resourceSuperType on the component, but hidden fields present anyway.

Implementation

Dialog fix — delete, don't re-hide:

xml
<!-- BEFORE: leftover fields marked hidden during the multifield migration -->
<imagePath
    jcr:primaryType="nt:unstructured"
    sling:hideResource="{Boolean}true"
    sling:resourceType="granite/ui/components/coral/foundation/form/pathfield"
    fieldLabel="Image Path"
    name="./imagePath"
    rootPath="/content/dam"/>
<altText
    jcr:primaryType="nt:unstructured"
    sling:hideResource="{Boolean}true"
    sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
    fieldLabel="Alt Text"
    name="./altText"/>

<!-- AFTER: removed entirely - the mediaItems multifield is the only path -->

Model scanner used by the contract test — a small reflection-based helper, not a full static-analysis tool, deliberately kept simple:

java
final class ModelPropertyScanner {

    private ModelPropertyScanner() {
    }

    static Set<String> scan(Class<?> modelClass) {
        Set<String> properties = new HashSet<>();
        for (Field field : modelClass.getDeclaredFields()) {
            ValueMapValue valueMapValue = field.getAnnotation(ValueMapValue.class);
            if (valueMapValue != null) {
                String name = valueMapValue.name();
                properties.add(name.isEmpty() ? field.getName() : stripLeadingDot(name));
            }
            ChildResource childResource = field.getAnnotation(ChildResource.class);
            if (childResource != null) {
                properties.add(field.getName());
            }
        }
        return properties;
    }

    private static String stripLeadingDot(String name) {
        return name.startsWith("./") ? name.substring(2) : name;
    }
}

Content cleanup — a JCR query confirming production impact before deleting the field from the dialog, since deleting the dialog field doesn't remove any values already stored under old content:

sql
SELECT [jcr:path] FROM [nt:unstructured]
WHERE ISDESCENDANTNODE('/content')
AND [imagePath] IS NOT NULL

Any hits from that query needed a one-time content report sent to the site's content team — not an automated delete, since a human should confirm none of those paths were actually still relevant before the property is orphaned for good.

Rollout Steps

  1. Ran the JCR query above in a staging environment first; found 11 pages still carrying a stale imagePath value from before the migration — none in active use per the content team's review, all safe to leave orphaned rather than migrate.
  2. Deployed the dialog fix (field deletion) alongside the new contract test, verified the test fails against the pre-fix dialog XML and passes after.
  3. Added the same contract test pattern to two other components flagged during the same review as having a sling:resourceSuperType-less component with hidden fields — one had a second, unrelated dead field from an even older redesign.
  4. Communicated to the content team that the two fields no longer exist in the dialog, so any documentation or training material referencing them needed updating.

Why This Approach Held Up

The contract test catches the actual failure mode — a dialog field with no reader — rather than the specific field names involved, so it protects every future dialog change on this component, not just this one incident. Deleting instead of re-hiding removes the temptation for the next person doing a "quick simplification" to accidentally resurface a dead field the way this one was resurfaced. And treating "hidden field, no resourceSuperType" as a hard signal gives a fast, mechanical way to tell a legitimate merge-exclusion apart from an abandoned leftover without having to read the component's whole history first.

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.