Files
dev-conventions/conventions/stack/htmx/web-ui.md
T
av f073a68f75 web-ui: сбой без фрагмента и запрос без поля поверхности
- R34: глобальный слушатель htmx:responseError — на 5xx htmx ничего не
  свопит, и интерфейс замирает без признака сбоя
- R35: 400 вместо поверхности по умолчанию, иначе своп идёт с чужим id
- в R14 снято противоречие: слушатель больше не числится среди
  отвергаемых настроек
2026-07-25 20:15:36 +03:00

495 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Веб-UI на htmx
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
показывает и какие действия поддерживает — в спеках, не здесь. Форма записи
`LANGUAGE.md`.
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
Здесь — только специфика htmx-транспорта, без дублирования.
## Область действия
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки ответов
между мажорами менялись. Правила описывают то, как написан код веб-UI, а не
то, какие экраны и действия у приложения есть.
## Стек и границы
### R1. Стек: роутер, серверные шаблоны, htmx
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
без Node и бандлера, без реактивного фреймворка.
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
артефакт, который расходится с исходником; приложению, где разметку целиком
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
модель состояния рядом с серверной (R2), и дальше на каждом экране
приходится решать, какая из них главная. Сам htmx — вендорный ассет и
живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму
приложения аптайм чужого хоста.
### R2. Клиент не пересчитывает доменное состояние
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
клиент свопит присланную разметку.
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
базе другое». Вдобавок клиентский пересчёт по определению не работает в
деградированном режиме (R11, R12) — значит, серверную версию того же
вычисления всё равно придётся держать.
### R3. Реактивный слой вводится отдельным решением
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
только когда есть виджет, которому он действительно нужен, и отдельным
решением.
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего
списка, немедленно доступен всему остальному коду — и граница R1/R2
перестаёт держаться сама собой. Отдельное решение — единственный момент,
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
каждом экране есть выбор между двумя моделями состояния.
## Единый источник разметки
### R4. Партиал = страница = фрагмент
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
обработчика; отдельной разметки под фрагмент нет.
**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту,
что открыта, и страница начинает выглядеть иначе, чем результат свопа того
же региона. Заметно это становится только на глаз и только тому, кто открыл
оба пути подряд.
### R5. Корень партиала — элемент с целевым `id`
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
регион, и ответный фрагмент несёт тот же `id`.
**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его
вовсе, первый своп проходит успешно, а следующее действие и поллер уже не
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
логе.
### R6. Сборку view делает общая функция
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
htmx-ветка.
**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не
одинаковые данные: скопированная сборка view расходится по набору полей, и
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
класс расхождений, который R4 закрывает для разметки.
## Обработчик действия
### R7. Доменный вызов одинаков для htmx и обычного запроса
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
на способе ответа:
| № | Запрос | Ответ |
|---|---|---|
| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию |
| R7.2 | обычный | PRG-редирект (303) |
```go
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
if !isHTMX(r) {
redirect(w, r, id, msg) // без htmx — обычный PRG-редирект (303)
return
}
data, _ := s.deps.Read(r.Context(), id) // перечитать актуальное состояние
view := buildView(id, data, "") // тем же view-builder'ом
if actionErr != nil {
view.BlockError = userErr(r, actionErr, id)
}
s.render(w, "source_block", view) // фрагмент = тот же шаблон
```
**Почему.** Ветвление до вызова даёт две реализации одного действия, и
дальше дефект воспроизводится только на одной поверхности — причём
деградированный путь (R11) открывают реже, то есть чинить будут не тот.
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
целиком: view, собранный из аргументов запроса, покажет намерение, а не
результат.
### R8. Шаблон рендерится в буфер, потом в ответ
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
буфер пишется в ответ.
**Почему.** Прямая запись в ответ отправляет клиенту статус и часть
разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем,
а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина
региона», и причина по такому симптому не читается.
## Одно действие — два региона
### R9. Второй регион едет тем же ответом через `hx-swap-oob`
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
партиалом с тем же `id`, что и на странице (R4, R5).
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают
состояние в разные моменты и приезжают в произвольном порядке, поэтому
панель действий может отразить состояние до действия. Плюс лишний
раунд-трип на каждое действие.
### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
регион меняется не на каждое действие.
**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
одинаковую разметку на каждое действие и связывает два шаблона там, где
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
## Graceful degradation
### R11. Форма действия работает без JS
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
рабочий обработчик.
**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
медленная сеть, — и без рабочего `action` форма в этот момент не отправляет
ничего, молча. Тот же `action` — единственное, что делает действие
проверяемым без браузера с JS.
### R12. Фильтр, поиск и пагинация — серверные
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
клиентской фильтрации загруженной разметки нет.
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
фильтр отвечает по неполным данным и делает это молча — результат выглядит
валидным. Вдобавок состояние отбора в query переживает своп (R25) и
перезагрузку, его можно послать ссылкой и увидеть в логе.
### R13. Область обязательной деградации
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
| № | Поверхность | Поведение без JS |
|---|---|---|
| R13.1 | действия и навигация | работают полностью (R11, R12) |
| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
**Почему.** Без явной границы правило деградации читается как запрет на
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
виджета, который был нужен. Запись в отступления держит список честным:
видно, какие именно места ломаются с выключенным JS, а не «где-то
что-то».
## Ошибки на htmx-пути
### R14. Ошибка действия на htmx-пути — 200 с фрагментом
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
пользователь не увидит ничего. Своп ошибочных ответов настраивается
(`htmx.config.responseHandling`, расширение `response-targets`), но любая
такая настройка — свой JS-конфиг на клиенте, и платится она из R1 и R2.
Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и
её решает глобальный слушатель (R34). Для REST API и не-JS редиректа с
`?err=` статус по-прежнему используется: там его кто-то читает.
Цена решения: в логе доступа провалившееся действие выглядит как `200`.
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`),
а не по коду ответа.
### R34. Сбой без ответа-фрагмента показывается глобальным слушателем
**ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и
`htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
`response-targets`) не настраивается.
**Почему.** R14 закрывает доменный отказ, до которого обработчик дошёл.
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
и пользователь повторяет действие, которое могло уже примениться. Слушатель
— несколько строк без доменного состояния, то есть внутри границы R2, и он
не спорит с R14: там настройки отвергнуты как замена фрагменту, который
обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп
тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт
целевого `id`, и после первого же такого свопа регион перестаёт находиться
(R5).
### R15. Наружу идёт сообщение публичного канала
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам
`lang/go/errors.md`; `err.Error()` в разметку не рендерится.
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
легче всего забыть, что это тот же публичный канал, что и страница:
разметка уезжает в браузер пользователя целиком. Статус 200 (R14)
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
### R16. Сообщение об ошибке — в отдельном поле view
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
сообщение не переиспользуются.
**Почему.** У доменного поля может быть своё непустое значение, и сообщение
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
требует R17.
### R17. При ошибке активное состояние не меняется
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
прежний выбор плюс сообщение.
**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
что пользователь узнает о состоянии. Показав намеренное состояние вместо
фактического, интерфейс расходится с сервером, и следующее действие человек
делает по ложной картине — на сервере оно применится к другому объекту.
## Живой поллинг
Живое обновление устроено как фрагмент-эндпоинт под `/fragments/…` плюс
`hx-get`, `hx-trigger="every Ns"`, `hx-swap="outerHTML"` в разметке:
```html
{{define "progress"}}<div id="item-live-{{.ID}}"
{{if .Active}} hx-get="/fragments/items/{{.ID}}/progress"
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
...
</div>{{end}}
```
### R18. Поллер самозавершается
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
`hx-*`-атрибутов.
**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка
держит постоянный поток запросов за неизменными данными, и закрывает его
только пользователь. Условие остановки живёт в разметке ответа, потому что
это единственный канал, которым сервер управляет поллером.
Встроенная альтернатива — ответ со статусом 286 — не используется: она не
совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом
и тоже без поллера.
### R19. Условие живости ведёт собственное состояние приложения
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
приложение, а не по ответу внешнего сервиса.
**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его
недоступности поллер либо останавливается, пока работа идёт, либо не
останавливается никогда. Приложение — единственный участник, который знает
про операцию всё и может ответить на каждом тике.
### R20. Поллер свопит фрагмент целиком через `outerHTML`
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
содержимое.
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
выключается (R18). Своп содержимого оставил бы старый узел с его таймером,
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
при совпадении корневого `id` (R5).
### R21. Поллер не свопит контейнер с активными полями ввода
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
редактировать нечего.
**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
внутри него. У поллера это происходит по таймеру, то есть в момент, который
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
как «приложение стирает мой ввод».
### R22. Браузер не ходит во внешний сервис напрямую
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные
внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
серверным изменением.
### R23. Источник данных для тика
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
| № | Что показывает тик | Откуда берёт |
|---|---|---|
| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
его недоступность становится недоступностью страницы. Снимок разрывает эту
связь: частоту обращений к внешнему сервису задаёт воркер, а не
пользователи. Контракт снимка держат узким, чтобы способ доставки
(поллинг сегодня, SSE потом) менялся, не задевая остальной код. Для
собственного состояния той же цены нет: хранилище и так своё, а лишний слой
кеша добавил бы только рассинхрон.
### R24. Поллинг URL страницы вместо отдельного фрагмент-роута
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
на URL самой страницы, а нужный узел вырезается `hx-select`:
```html
hx-get="/item/{{.ID}}" hx-trigger="every 3s"
hx-select="#item-main" hx-swap="outerHTML"
```
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
копии разметки (R4).
Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот
же узел, который свопится.
## Своп и выход со страницы
### R25. Действие не уводит со страницы, если предмет остаётся на ней
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
пагинацию — они в query (R12). Полная навигация ради изменения одного
региона возвращает пользователя в начало списка и стоит перерисовки всей
страницы. Не сохраняется при свопе только контекст внутри самого
заменяемого поддерева — фокус, выделение, введённый текст (R21).
### R26. Выход со страницы — форма без `hx-*`
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
**Почему.** Своп для такого действия оставил бы на месте регион,
описывающий объект, которого на странице больше нет. Отсутствие
htmx-атрибутов при этом само работает маркером «это выход»: намерение видно
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
сменить страницу, существующий только на htmx-пути.
### R27. Асинхронное действие свопит промежуточное состояние
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
промежуточное состояние, а итог догоняет самозавершающийся поллер (R18).
**Почему.** Мнимый результат расходится с сервером до следующего тика, и
всё это время пользователь принимает решения по несуществующему исходу —
включая повтор действия, которое на самом деле выполняется. Промежуточное
состояние вдобавок объясняет, почему регион продолжает обновляться сам.
## Различение поверхности одного действия
### R28. Поверхность различается скрытым полем формы
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
фрагментом, поверхность передаётся явным скрытым полем
(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`.
**Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer`
может не прийти вовсе; и то и другое меняется без участия обработчика, и
ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с
действием, поэтому связь «эта страница → этот фрагмент» читается там, где
её заводят.
### R35. Запрос без поля поверхности получает 400
**ДОЛЖЕН.** Обработчик, различающий поверхности (R28), отвечает статусом
400, когда поля `surface` в запросе нет; поверхность по умолчанию не
выбирается.
**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
чего регион перестаёт находиться таргетом (R5), и ошибка воспроизводится
как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением
глобального слушателя (R34) — сразу и на той странице, где форму сломали.
Вкладка, открытая до появления поля, получает тот же 400 и чинится
перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной
разметке.
## Статика, вендоринг, кэш
Раздел не про htmx — это упаковка любого server-rendered приложения;
разъедется в языковой слой, когда понадобится там.
### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
`Cache-Control: public, max-age=31536000, immutable`.
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
шага раскладки файлов, который может отстать от бинаря и оставить новую
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы
способом навсегда закрепить у пользователя старый файл.
### R30. Меняемые ассеты версионируются хешем содержимого
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
строит хелпер шаблона.
**Почему.** Хеш содержимого — единственная версия, которую невозможно
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
от этого не защищают, а цена промаха при иммутабельном кэше (R29) —
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
хеш не проставляли в каждом шаблоне руками.
### R31. Вендорный ассет в `?v=` не нуждается
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
параметра версии.
**Почему.** Содержимое под этим именем не меняется: обновление вендора
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
явное разрешение снимает вопрос, не нарушает ли это R30.
### R32. Вендор не коммитится, а добывается по манифесту
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
задачи.
**Почему.** Манифест делает версию и происхождение ассета видимыми в
diff'е — у закоммиченного минифицированного файла обновление выглядит
стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 —
единственная проверка, что скачали то же самое, что проверяли; зависимость
сборки от задачи не даёт собраться без ассета в свежем клоне.
### R33. Шрифты и скрипты — self-hosted
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.
**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь
вдобавок разворачивается в сети без выхода наружу, где CDN просто не
отвечает.
<!-- local:эталоны -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->