Ця помилка з’явилася в реальному компоненті ICanUp - MessageAttachments.vue, який показує вкладення в повідомленнях.
Для файлу я хотіла дві прості дії: клік по картці відкриває вкладення, а окрема кнопка всередині картки завантажує його.
На рівні інтерфейсу це виглядало природно. На рівні HTML вийшов <a> усередині іншого <a>. Vue попередив про невалідну структуру, а для застосунку з SSR це вже не просто косметична проблема.
Реальний випадок у MessageAttachments.vue
<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Зовнішній <a> мав природну роль: відкрити media.viewUrl || media.url у новій вкладці. Внутрішній <a> використовував media.downloadUrl || media.url і атрибут download.
Проблема не в тому, що обидві дії використовують посилання. Проблема в їхній вкладеності. HTML не допускає <a> як нащадка іншого <a>.
У нашому випадку ця сама структура потрапляла і в згенерований SSR HTML, тому попередження не можна було залишити як нешкідливу дрібницю.
Чому @click.stop не виправляє nested anchors
<a :href="media.downloadUrl || media.url" download @click.stop> <i class="fa-solid fa-download"></i></a>@click.stop у Vue викликає stopPropagation(). Це корисно, коли потрібно не дозволити події кліку піти до батьківського обробника.
Але тут помилка існує до кліку. Сам DOM описаний неправильно. Модифікатор події не змінює правила HTML і не перетворює вкладені посилання на допустиму структуру.
Тому підхід "додамо .stop і все працює" може приховати частину поведінки, але залишити справжню причину на місці.
Події можна зупиняти. Невалідну структуру HTML потрібно перебудовувати.
Браузер може побудувати не той DOM, який видно у шаблоні
Особливо важливо це розуміти з HTML, який приходить із сервера. Браузер спочатку розбирає отриману розмітку і будує DOM. Якщо розмітка порушує правила HTML, парсер може автоматично закрити або перебудувати елементи.
Тобто шаблон може візуально показувати одне дерево, а браузер перед подальшою роботою вже мати інше.
Для вкладених <a> не можна покладатися на те, що браузер збереже саме ту структуру, яку ми написали.
Чому це особливо небезпечно з SSR і hydration
У ICanUp використовується SSR. Сервер генерує HTML, браузер його розбирає, а потім Vue під’єднується до вже створеного DOM під час hydration.
Якщо сервер віддав вкладені <a>, а браузер виправив їх під час розбору HTML, Vue може отримати DOM, структура якого вже не відповідає тому, що очікував рендер.
Саме тому попередження про nested anchors для мене було не питанням чистоти шаблону. Воно вказувало на реальний ризик розбіжності між серверною розміткою і DOM у браузері.
Правильна структура: спільний контейнер і дві сусідні дії
<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Після виправлення картка стала нейтральним контейнером <div>. Посилання відкриття розтягується на площу картки через абсолютне позиціонування, а завантаження залишається окремим сусіднім <a> із вищим z-index.
Текст та іконка картки не є окремими інтерактивними елементами. Для них можна використовувати pointer-events-none, щоб клік потрапляв у посилання відкриття.
У результаті в DOM є дві незалежні дії, але жодна з них не вкладена в іншу.
Чому @click.stop можна залишити після виправлення
Після того як посилання стали сусідніми, @click.stop більше не є способом "лікувати" nested anchors. Структурна проблема вже вирішена самим HTML.
Я залишила модифікатор на дії завантаження як локальний захист від поширення кліку, якщо контейнер у майбутньому отримає власну обробку. Але це вже додаткова поведінка, а не основа виправлення.
Це важливе розділення: структура відповідає за валідний HTML, обробники подій відповідають за поведінку подій.
Дві дії мають залишатися справжніми інтерактивними елементами
Інший можливий "фікс" - замінити все на <div @click>. Я не хотіла йти цим шляхом.
Для відкриття URL і завантаження файлу <a> уже має потрібну браузерну поведінку: фокус із клавіатури, відкриття через Enter, стандартні можливості посилання, target, rel і download.
Тому краще не маскувати неправильну вкладеність неінтерактивними div, а правильно розкласти самі посилання.
Як я перевіряла виправлення
У цій частині проєкту на той момент не було окремого набору component tests для цього Vue-компонента, тому я не стала вигадувати тест, якого в реальній роботі не було.
Перевірка складалася з кількох практичних рівнів:
- у структурі більше немає
<a>усередині<a>; - є два окремі посилання: відкриття і завантаження;
- максимальна глибина вкладеності anchor дорівнює одному рівню;
viewUrl || urlіdownloadUrl || urlзалишилися на своїх діях;- збірка frontend проходить;
git diff --checkпроходить;- у браузері немає попередження про nested anchors або hydration;
- відкриття вкладення і завантаження працюють незалежно.
Що я тепер перевіряю, коли кліки поводяться дивно
Коли в інтерфейсі є картка, яка сама клікабельна і водночас містить окрему кнопку або посилання, я спочатку дивлюся на семантику HTML.
Питання для перевірки прості:
- чи не вкладений один інтерактивний елемент в інший;
- чи можна зробити дії сусідніми;
- чи потрібен
stopPropagation()після того, як структура вже правильна; - чи відповідає фактичний DOM тому, що я бачу у Vue template;
- чи не створює серверна розмітка ризик для hydration.
Це часто швидше, ніж одразу додавати нові обробники кліку й намагатися виправити симптоми.
Висновок
У MessageAttachments.vue проблема виглядала як конфлікт двох кліків, але справжня причина була нижче - у невалідній структурі HTML.
@click.stop був корисним для керування подією, але не міг виправити <a> усередині <a>. Правильним рішенням стало зробити нейтральний контейнер і винести відкриття та завантаження у два сусідні посилання.
Для мене цей кейс став хорошим нагадуванням: коли кліки в складному компоненті поводяться дивно, проблема може бути не в JavaScript. Іноді спочатку потрібно просто перевірити HTML, який браузер насправді отримав і побудував.



