Edge Delivery Services · 8 of 29
Understanding the DOM structure received by an AEM Edge Delivery Services block, including rows, cells, rich text, images, links, optional content, selectors, transformations, events, and debugging before and after decorate(block).
This chapter covers:
decorate(block) actually receives: an existing DOM element, not an empty containerdata-aue-* instrumentation, and stale service-worker JavaScriptBy now we have looked at both sides of an authorable EDS block.
Universal Editor defines the content authors can provide.
The block implementation handles what eventually reaches the browser.
Between those two sides is the DOM.
This is where I see many block implementation problems start.
The developer expects:
<div class="hero">
<img>
<h1>...</h1>
<p>...</p>
</div>
but the browser receives rows, cells, paragraphs, <picture> elements, links, and wrappers that do not match that assumption.
The JavaScript is then written against the expected markup rather than the actual markup.
It works for one content example and fails as soon as:
My rule is simple:
Inspect the DOM first. Write the transformation second.
This chapter is about doing that properly.
decorate(block) Starts With an Existing ElementA block JavaScript file normally starts with:
export default function decorate(block) {
// block is already in the page
}
The important part is that block is not an empty container waiting for us to render a component.
It already represents the delivered block.
For example:
export default function decorate(block) {
console.log(block);
}
may log something conceptually similar to:
<div class="hero block">
<div>
<div>
<picture>...</picture>
</div>
<div>
<h1>Explore our products</h1>
<p>Find the right solution for your needs.</p>
<p>
<a href="/products">Explore products</a>
</p>
</div>
</div>
</div>
That structure is our input.
Before changing it, I want to understand it.
When I start a new block, one of my first tools is the browser Elements panel.
I inspect:
<div class="hero block">
and expand everything inside it.
I want to know:
<picture><a>Then I compare that structure with the authoring model.
This tells me whether my understanding of the content contract matches what the browser actually receives.
block.childrenA useful starting point is:
const rows = [...block.children];
Now rows is a normal JavaScript array containing the direct child elements.
For example:
export default function decorate(block) {
const rows = [...block.children];
console.log('row count:', rows.length);
rows.forEach((row, index) => {
console.log(`row ${index}`, row);
});
}
This immediately tells me whether the block has:
I often do this before writing any transformation logic.
Each row can contain one or more cells.
For example:
<div class="cards block">
<div>
<div>
<picture>...</picture>
</div>
<div>
<h3>Product A</h3>
<p>Description</p>
<p><a href="/a">Learn more</a></p>
</div>
</div>
</div>
Here:
const row = block.children[0];
represents the first row.
Then:
const cells = [...row.children];
gives us the cells.
We can inspect them:
export default function decorate(block) {
[...block.children].forEach((row) => {
const cells = [...row.children];
console.log('cells:', cells);
});
}
This is much safer than assuming the first <div> anywhere inside the block must be the image container.
Consider:
block.children
versus:
block.querySelectorAll('div')
They answer different questions.
block.children gives me the direct children.
querySelectorAll('div') gives me matching descendants at every level.
For structural parsing, direct children are often more useful.
If I write:
const divs = block.querySelectorAll('div');
I may get:
all mixed together.
Use the DOM relationship that matches the contract you are trying to read.
querySelector() Is Useful for Semantic ElementsFor elements with clear semantics, selectors are often easier.
For example:
const picture = block.querySelector('picture');
const heading = block.querySelector('h1, h2');
const link = block.querySelector('a');
This works well when the contract says:
The Hero contains one main picture, one heading, and one CTA.
For repeated structures, I may combine both approaches.
Use direct children to identify the item.
Then query within that item:
[...block.children].forEach((row) => {
const picture = row.querySelector('picture');
const heading = row.querySelector('h3');
const link = row.querySelector('a');
// decorate this item
});
That keeps selectors scoped to the item being processed.
Inside:
decorate(block)
I avoid this unless there is a specific page-level requirement:
document.querySelector('.some-element');
Prefer:
block.querySelector('.some-element');
or:
row.querySelector('.some-element');
The narrower scope reduces coupling.
If two copies of the same block exist on a page, document-level queries can easily target the wrong one.
A block should normally be able to exist more than once without one instance manipulating another.
Suppose the block contains:
<div>
<p>This product supports <strong>multiple workflows</strong>.</p>
<p>Read the <a href="/guide">implementation guide</a>.</p>
</div>
If I do:
const text = cell.textContent;
I get the textual value.
That may be useful.
But I lose the structure:
If I need the authored rich content, I should preserve the existing nodes instead of flattening everything into text.
This is why I don't automatically convert every cell into a string.
innerHTMLIt is easy to write:
const markup = cell.innerHTML;
and later:
wrapper.innerHTML = markup;
For trusted delivered markup in a tightly controlled transformation, there may be cases where developers use existing HTML.
But I don't make innerHTML the default block-building technique.
Moving existing DOM nodes is often clearer.
For example:
const wrapper = document.createElement('div');
wrapper.classList.add('card-content');
while (cell.firstChild) {
wrapper.append(cell.firstChild);
}
Now the existing nodes are moved without converting the content to an HTML string and reparsing it.
For external or untrusted runtime data, I avoid inserting raw HTML entirely unless it has been handled through an appropriate trusted sanitization path.
<picture>A block image may arrive as a <picture> structure rather than a simple image URL.
For example:
<picture>
<source ...>
<img
src="..."
alt="Product"
width="..."
height="..."
>
</picture>
So I usually start with:
const picture = block.querySelector('picture');
not:
const imageUrl = block.textContent;
If I need the actual <img>:
const image = picture?.querySelector('img');
Then I can inspect:
image?.alt
image?.width
image?.height
The delivered image structure already gives the browser useful information.
I preserve it unless the component has a real reason to change it.
<picture> Is Different From Recreating ItSuppose the design requires:
<div class="card-image">
<picture>...</picture>
</div>
I can create the wrapper:
const picture = row.querySelector('picture');
if (picture) {
const imageWrapper = document.createElement('div');
imageWrapper.classList.add('card-image');
imageWrapper.append(picture);
row.prepend(imageWrapper);
}
I am moving the existing <picture>.
I am not reading its URL and creating another <img>.
That preserves the useful delivered image markup.
When inspecting an image, I also inspect:
const img = block.querySelector('img');
console.log(img?.alt);
If the image is meaningful, the alternative text should make sense.
If it is purely decorative, the accessibility treatment may be different.
I would not use JavaScript to generate generic values such as:
img.alt = 'Hero image';
That usually does not describe the content.
Image semantics should come from the content/design decision, not a fallback string invented by the frontend.
A CTA may arrive as:
<a href="/products">Explore products</a>
The first thing I normally need is not to rebuild it.
It may simply need a class:
const link = block.querySelector('a');
if (link) {
link.classList.add('button', 'primary');
}
The anchor already knows how to:
Preserving that is usually better than replacing it with a custom clickable element.
Suppose Hero originally had one CTA:
const link = block.querySelector('a');
Later the model allows two.
Now that selector only returns the first one.
If two CTAs are valid, make that explicit:
const links = [...block.querySelectorAll('a')];
const [primary, secondary] = links;
if (primary) {
primary.classList.add('button', 'primary');
}
if (secondary) {
secondary.classList.add('button', 'secondary');
}
The code should reflect the component contract.
Don't accidentally support one item because the original sample content only had one.
Suppose Description is optional.
With description:
<h2>Title</h2>
<p>Description</p>
<p><a href="/products">Explore</a></p>
Without it:
<h2>Title</h2>
<p><a href="/products">Explore</a></p>
If our JavaScript assumes:
const description = cell.children[1];
const cta = cell.children[2];
the second version breaks that assumption.
This is why optional fields matter when choosing selectors.
If the content contract allows optional structures, selectors should tolerate those states.
There are blocks where the content contract intentionally defines positions.
For example:
| Cell | Meaning |
|---|---|
| 1 | Image |
| 2 | Content |
Then:
const [imageCell, contentCell] = [...row.children];
is reasonable.
That is not fragile if the contract guarantees it.
The important part is knowing the difference between:
The model guarantees two cells in this order.
and:
The sample content happened to have two cells.
The first is a contract.
The second is an assumption.
Consider Cards:
export default function decorate(block) {
[...block.children].forEach((row) => {
row.classList.add('card');
const picture = row.querySelector('picture');
const heading = row.querySelector('h3');
const link = row.querySelector('a');
// decorate one card
});
}
Each row is handled independently.
This has several advantages.
One card can omit an optional image without changing how another card is processed.
The number of cards can change.
The logic stays local to the repeated item.
Not every block stays one level deep.
Suppose a cell contains:
<div>
<h3>Plan details</h3>
<ul>
<li>Feature A</li>
<li>Feature B</li>
</ul>
<p>
<a href="/details">View details</a>
</p>
</div>
I would not flatten that structure just to make the JavaScript easier.
The browser already understands:
The block can style and position those elements without recreating them.
Preserving semantics often produces simpler code.
Rich text affects more than JavaScript.
Suppose Description can contain:
<p>...</p>
<ul>...</ul>
<p>...</p>
but the CSS assumes:
.card-description > p:first-child {
...
}
The JavaScript may be perfectly fine while the visual result still breaks.
When rich text is allowed, both DOM handling and CSS need to support the permitted content.
This is another reason not to expose rich text everywhere without a requirement.
Suppose the raw Cards DOM already contains:
Maybe all we need is:
export default function decorate(block) {
[...block.children].forEach((row) => {
row.classList.add('card');
const picture = row.querySelector('picture');
const link = row.querySelector('a');
picture?.closest('div')?.classList.add('card-image');
link?.classList.add('button', 'secondary');
});
}
That may be enough.
I don't need to rebuild the entire card as:
const article = document.createElement('article');
const image = document.createElement('div');
const content = document.createElement('div');
const heading = document.createElement('h3');
// ...
unless the required final structure actually justifies it.
Small transformations are easier to maintain.
There are cases where the delivered structure does need meaningful transformation.
Examples include:
An Accordion may need to associate a trigger with a panel and maintain expanded state.
That is a real behavioral requirement.
In those cases, restructuring the DOM can make sense.
The rule is not:
Never transform the DOM.
The rule is:
Transform it because the component needs that structure, not because rewriting everything feels cleaner.
For debugging, I sometimes compare the structure conceptually as two states.
The DOM represents delivered content.
The DOM represents the enhanced component.
For example, before:
<div class="accordion block">
<div>
<div>What is EDS?</div>
<div>...</div>
</div>
</div>
After decoration, it might become a structure with:
The exact implementation depends on the accessibility and UI requirements.
Thinking in before/after states helps me keep the transformation deliberate.
For interactive components, classes can represent state.
For example:
block.classList.add('is-ready');
or:
item.classList.toggle('is-open');
But for accessibility-relevant states, CSS classes alone are not enough.
An Accordion may also need appropriate ARIA state:
button.setAttribute('aria-expanded', String(isOpen));
The visual state and semantic state need to stay aligned.
Suppose an Accordion has buttons.
A reasonable pattern is:
const buttons = block.querySelectorAll('button');
buttons.forEach((button) => {
button.addEventListener('click', () => {
// update this accordion
});
});
I would avoid a page-level listener that knows about every Accordion unless there is a specific reason for event delegation at that scope.
Keeping behavior inside the block makes repeated instances easier to reason about.
A page might contain:
Accordion A
other content
Accordion B
Clicking Accordion A should not accidentally modify Accordion B.
This is another reason to query relative to:
block
rather than:
document
The block parameter gives us a natural component boundary.
Use it.
For an API-backed block, the DOM may go through more than two states.
For example:
Delivered DOM
contains authored heading and description.
Loading state
shows that runtime data is being retrieved.
Success state
adds the returned information.
Error state
keeps the authored content but shows an appropriate fallback.
This means decorate(block) is not always a single synchronous transformation.
But the same principle applies:
Start from the delivered block.
Then enhance it according to the runtime state.
Suppose the block contains:
Check product availability
Enter your location to see availability near you.
That authored content can render immediately.
I would not blank the entire block while waiting for:
await fetch(...);
Keep stable content visible.
Add dynamic data when it becomes available.
This creates a better failure mode too.
If the API fails, the block can still explain what it is rather than disappearing completely.
Suppose an API returns:
{
"status": "Available"
}
I can render it using:
const status = document.createElement('p');
status.textContent = data.status;
For runtime data, I prefer APIs such as:
textContent
and explicit element creation.
I do not assume external data is safe HTML.
This becomes especially important when API values can contain user-controlled or external content.
A selector such as:
block.querySelector('.hero-title')
depends on a class.
A selector such as:
block.querySelector('h1, h2')
depends on semantics.
A selector such as:
block.children[0]
depends on position.
None is automatically correct.
Choose the selector based on what is stable in the block contract.
Ask:
What is guaranteed to remain true?
That should drive the selector.
A selector like:
block.querySelector('div > div > div > p:nth-child(2)')
is a warning sign.
It may describe today's DOM precisely.
It also couples the implementation to every wrapper in that structure.
If one wrapper changes, the selector breaks.
Prefer selectors tied to:
The goal is not the shortest selector.
It is the most stable selector for the contract.
The same issue can happen in CSS.
Fragile:
.cards > div > div:nth-child(2) > p:nth-child(2) {
...
}
More intentional:
.cards .card-description {
...
}
This may require adding:
description.classList.add('card-description');
during decoration.
That is often worth it.
A few meaningful classes can make the CSS contract much clearer.
The opposite problem is adding a class to every element:
hero-row
hero-cell
hero-cell-inner
hero-title-wrapper
hero-title
hero-description-wrapper
hero-description
hero-cta-wrapper
hero-cta
That can recreate component markup complexity we did not need.
Add classes when they help:
Not because every element needs a custom name.
closest() Is Useful, but Understand What It FindsConsider:
const picture = block.querySelector('picture');
const cell = picture?.closest('div');
This returns the nearest ancestor <div>.
That may be the image cell.
But I verify it.
If the image markup gains another wrapper, the nearest <div> may change.
When using:
closest()
parentElement
children
I still check the actual DOM structure.
DOM traversal is only reliable when we understand the relationships being traversed.
parentElement Can Encode Hidden AssumptionsThis:
const wrapper = link.parentElement.parentElement;
works only while the link remains exactly two levels below the desired wrapper.
If that hierarchy is guaranteed, fine.
If not, it is fragile.
I prefer code that communicates the actual relationship being used.
Sometimes that means:
const cell = link.closest('.some-known-class');
Other times it means parsing direct row/cell structure before looking for the link.
Again, the contract decides.
While developing a block, I use the Elements panel before and after decoration.
I check:
The final screenshot is not enough.
A component can look correct while its DOM is difficult to maintain or inaccessible.
Temporary logs are useful during block development.
For example:
export default function decorate(block) {
console.log('block', block);
[...block.children].forEach((row, index) => {
console.log(`row ${index}`, row);
});
}
Or:
console.log({
picture: block.querySelector('picture'),
heading: block.querySelector('h1, h2'),
links: [...block.querySelectorAll('a')],
});
Once the block is understood, remove unnecessary debugging logs.
The goal is to inspect the contract, not leave console noise in production.
When a block behaves incorrectly, this is the sequence I use.
1. Find the block
Does the expected block element exist?
2. Inspect direct children
Does the row structure match the contract?
3. Inspect each cell
Is the expected content in the expected structural area?
4. Inspect semantic elements
Are images, headings, links, and rich text present?
5. Confirm decoration
Did decorate(block) run?
6. Inspect the transformed DOM
What changed?
7. Check CSS
Do selectors match the transformed structure?
This avoids changing JavaScript before we understand the input.
Most block DOM problems I see fall into a small set of patterns.
One example has an image, title, description, and CTA.
The code assumes all four always exist.
Using textContent when the component needs paragraphs, links, or lists.
Replacing anchors, headings, or pictures without a requirement.
One block accidentally modifies another.
The implementation breaks after a harmless structural change.
The block becomes harder to understand than the delivered structure.
These are usually design problems rather than complicated JavaScript problems.
Everything above assumes decorate(block) runs once against delivered content. Inside Universal Editor that assumption breaks, and it cost us real debugging time on the POC.
decorate() on the same elementAfter an author edits a field, Universal Editor calls decorate(block) again on the same block element. It does not reload the page.
If the block rebuilds its DOM into a new structure — for example, replacing the delivered rows with a <form> or an <article> wrapper — the second run parses the already-transformed markup instead of the original rows.
The symptom we hit: a newly added field appeared only after a full page refresh, then disappeared again on the next edit. The block was re-decorating its own output.
Two habits prevent this:
Universal Editor attaches data-aue-* attributes (data-aue-resource, data-aue-model, and related) to the delivered rows. Those attributes are how the editor maps each authored field back to content.
If decorate() does block.textContent = '' or otherwise rebuilds children from scratch, those attributes are thrown away. The symptom is subtle but serious: the block still renders, but the author's "+" / Add control silently does nothing, because the editor can no longer map a new child to a resource.
The boilerplate fix is moveInstrumentation from scripts.js. When you create a new element to replace a delivered row, move the instrumentation across:
import { moveInstrumentation } from '../../scripts/scripts.js';
[...block.children].forEach((row) => {
const card = document.createElement('div');
// build card from row...
moveInstrumentation(row, card);
block.append(card);
});
Keep the rebuilt element as a descendant of the block so the editor can still find it.
data-aue-model, not by cell textFor container blocks with repeatable child items, it is tempting to identify a child row by matching the text or type of its first cell. That is fragile: select values can render as display names, and empty fields can shift positions.
Each child row carries its own data-aue-model set to the child component id. Detect children by that attribute:
const isChild = (row) => row.getAttribute('data-aue-model') === 'card';
On the POC, a form block was dropping fields because it matched a control's text against a fixed set of field-type strings. Switching to data-aue-model detection fixed it.
When a block behaves differently inside Universal Editor, dump the raw rows only in the editor context:
if (block.hasAttribute('data-aue-resource')) {
console.debug('[block] raw rows', [...block.children].map((row) => ({
cells: row.children.length,
model: row.getAttribute('data-aue-model'),
text: row.firstElementChild?.textContent?.trim().slice(0, 40),
})));
}
This shows the real editor DOM — cell counts, model ids, and first-cell text — which is exactly what you need when authoring and delivery disagree.
The boilerplate registers a service worker. After deploying new block JavaScript, the browser can still run the old file from the service-worker cache. If a block change "isn't taking effect," hard-reload (Cmd+Shift+R) or unregister the service worker before assuming the code is wrong.
The DOM is one of the most important contracts in EDS block development.
I don't treat it as generated markup that I can ignore.
I inspect it because it tells me:
The less unnecessary DOM work the block performs, the easier it is to debug.
For an AEM developer, this is one of the bigger implementation shifts.
With HTL, we often control the final markup directly.
We write:
<div class="cmp-example">
...
</div>
and the server renders that structure.
With an EDS block, I first inspect the content-derived DOM and then enhance it.
That means browser DevTools becomes part of the implementation workflow much earlier.
Instead of asking:
What HTML should my template render?
I often start with:
What HTML did the content already produce, and what does the UI still need?
That is a different development habit.
At architecture level, fragile DOM assumptions become maintenance problems.
If every block:
then the project becomes difficult to evolve.
I would establish a few simple block rules:
These rules are small, but they prevent a large amount of frontend complexity.
The DOM stopped feeling like an implementation detail once I started debugging EDS blocks.
It is the point where the authoring contract becomes something the frontend can actually inspect.
If the content model says one thing and the DOM shows another, I can see the mismatch.
If the DOM is correct before decoration and wrong afterward, I know where the problem started.
If the final DOM is correct but the page looks wrong, I move toward CSS.
That gives me clear boundaries.
And those boundaries make block development much easier than trying to reason about authoring, delivery, JavaScript, and styling at the same time.
decorate(block) receives an existing DOM element containing delivered content.block.children is useful for direct structural parsing.innerHTML the default way to rebuild content.<picture> structures where possible.decorate(block) runs again on the same element after every edit; do not re-decorate your own output.data-aue-* instrumentation; use moveInstrumentation to preserve authoring controls.data-aue-model, not by matching cell text.We now understand what the block receives in the browser, and how that DOM behaves both at delivery time and while an author edits it in Universal Editor.
The next step is to connect everything into a complete authorable component — component definition, model, and filter on the authoring side, and the block implementation on the frontend side — following one component through its full lifecycle.
The next chapter, Building a Universal Editor Component, takes the Hero through that complete path: component definition, model, filter, authoring, delivered structure, block implementation, Preview, local development, and the debugging boundaries between authoring and frontend.
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.