0% прочитано

Nested Links in Vue: Why an Anchor Inside Another Anchor Breaks the DOM and Click Behavior

In MessageAttachments.vue, an attachment card used one anchor to open a file and another nested anchor to download it. This article shows why @click.stop does not fix invalid HTML, how browser parsing can change the DOM, and why the two actions should be sibling interactive elements.

30 вересня 2026 р. 8 хв читанняVue.js

This bug came from a real ICanUp component: MessageAttachments.vue, which renders file attachments in messages.

I wanted two simple actions for a file: clicking the card opens the attachment, while a separate control inside the card downloads it.

The UI idea looked natural. The HTML structure did not. It produced an <a> inside another <a>. Vue warned about the invalid structure, and in an SSR application that is more than a cosmetic issue.

When one interactive element is nested inside another, inspect the HTML structure first. @click.stop can change event propagation, but it does not make invalid markup valid.

The Real MessageAttachments.vue Case

HTML - Invalid structure with a nested anchor
<a    :href="media.viewUrl || media.url"    target="_blank"    rel="noopener noreferrer"    class="attachment-card">    <i class="fa-regular fa-file-pdf"></i>     <div class="min-w-0 flex-1">        <div>{{ media.originalName || media.name }}</div>        <div>{{ media.extension || "file" }}</div>    </div>     <a        :href="media.downloadUrl || media.url"        download

The outer <a> had a valid purpose: open media.viewUrl || media.url in a new tab. The inner <a> used media.downloadUrl || media.url together with the download attribute.

The problem was not that both actions used links. The problem was the nesting. HTML does not allow one <a> to be a descendant of another <a>.

In our case, the same structure also reached the generated SSR HTML, so the warning was not something I wanted to ignore.

Why @click.stop Does Not Fix Nested Anchors

HTML - @click.stop controls the event, not the HTML structure
<a    :href="media.downloadUrl || media.url"    download    @click.stop>    <i class="fa-solid fa-download"></i></a>

@click.stop in Vue calls stopPropagation(). That is useful when a click should not continue to a parent handler.

But this bug exists before any click happens. The DOM structure itself is invalid. An event modifier does not change HTML rules and cannot turn nested anchors into valid markup.

So an approach like "add .stop and it works" may hide part of the behavior while leaving the real cause untouched.

Events can be stopped. Invalid HTML structure has to be redesigned.

The Browser May Build a Different DOM From the Template

This matters especially for HTML received from the server. The browser parses the response first and builds a DOM. When the markup violates HTML parsing rules, the parser may automatically close or reconstruct elements.

The template can therefore appear to describe one tree while the browser already has another tree before the application continues.

With nested <a> elements, I do not want to depend on the browser preserving the structure I wrote.

During troubleshooting, I inspect not only the Vue template but also the actual DOM in DevTools. With invalid HTML, those can be different things.

Why This Matters More With SSR and Hydration

ICanUp uses SSR. The server generates HTML, the browser parses it, and Vue then attaches to the existing DOM during hydration.

If the server sends nested <a> elements and the browser corrects them while parsing, Vue may receive a DOM whose structure no longer matches the rendered expectation.

That is why the nested-anchor warning was not only about template cleanliness for me. It pointed to a real risk of server markup and browser DOM diverging.

The Correct Structure: One Container and Two Sibling Actions

HTML - Open and download as sibling anchors
<div class="relative attachment-card">    <a        :href="media.viewUrl || media.url"        target="_blank"        rel="noopener noreferrer"        class="absolute inset-0 rounded-xl focus-visible:outline-none"    ></a>     <i class="relative z-10 pointer-events-none fa-regular fa-file-pdf"></i>     <div class="relative z-10 pointer-events-none min-w-0 flex-1">        <div>{{ media.originalName || media.name }}</div>        <div>{{ media.extension || "file" }}</div>    </div>     <a

After the fix, the card became a neutral <div> container. The open link covers the card through absolute positioning, while the download action remains a separate sibling <a> with a higher z-index.

The card text and icon are not separate interactive elements. They can use pointer-events-none so the click reaches the open link.

The DOM now contains two independent actions, with neither one nested inside the other.

Why @click.stop Can Remain After the Fix

Once the links became siblings, @click.stop was no longer a way to "fix" nested anchors. The structural problem had already been solved by the HTML itself.

I kept the modifier on the download action as a local propagation guard in case the container receives its own click behavior later. But that is now an additional behavior, not the foundation of the fix.

The distinction matters: structure is responsible for valid HTML, while event handlers are responsible for event behavior.

Both Actions Should Remain Real Interactive Elements

Another possible "fix" would be to replace everything with <div @click>. I did not want that trade-off.

For opening a URL and downloading a file, <a> already provides the browser behavior we need: keyboard focus, Enter activation, normal link semantics, target, rel, and download.

Instead of hiding invalid nesting behind non-interactive div elements, it is better to arrange the actual links correctly.

How I Verified the Fix

At that time, this part of the project did not have a dedicated component-test suite for this Vue component, so I do not want to invent a test that was not part of the real work.

The verification used several practical levels:

  • there is no longer an <a> inside another <a>;
  • open and download are two separate links;
  • the maximum anchor nesting depth is one;
  • viewUrl || url and downloadUrl || url remain attached to the correct actions;
  • the frontend build passes;
  • git diff --check passes;
  • the browser no longer reports the nested-anchor or hydration warning;
  • opening and downloading the attachment work independently.

What I Check Now When Click Behavior Looks Wrong

When a UI card is clickable while also containing a separate button or link, I now start by inspecting the HTML semantics.

The checklist is simple:

  • is one interactive element nested inside another;
  • can the actions become siblings instead;
  • is stopPropagation() still necessary after the structure is correct;
  • does the actual DOM match what I see in the Vue template;
  • can server-rendered markup create a hydration risk.

This is often faster than immediately adding more click handlers and trying to repair the symptoms.

Conclusion

In MessageAttachments.vue, the bug looked like a conflict between two click actions, but the real cause was lower in the stack: invalid HTML structure.

@click.stop was useful for event behavior, but it could not fix an <a> inside another <a>. The correct solution was to introduce a neutral container and make open and download two sibling links.

This case became a useful reminder for me: when clicks behave strangely in a complex component, the problem may not be JavaScript. Sometimes the first thing to inspect is the HTML the browser actually received and built.