Путешествие по Next.js: от ошибок с not-found до форка next-runtime-env
Недавно столкнулся с интересным багом в Next.js. Если на странице not-found
делать навигацию через router.push(pathname)
, теряются все переменные окружения, которые мы инициализируем через библиотеку next-runtime-env
(значение window.__ENV
становится undefined
).
В проекте мы используем next-runtime-env
, так как придерживаемся подхода Build once, deploy many — это позволяет держать один Docker-образ, в который при запуске прокидываются нужные переменные окружения. Next.js из коробки не поддерживает такое поведение, ведь он хочет собирать env-переменные на этапе сборки приложения.
Баг проявился на not-found
странице, где у нас есть кнопка, позволяющая создать элемент в один клик, если что-то не найдено. Этот же компонент кнопки используется и на других страницах, и вот что интересно: на остальных страницах router.push(pathname)
работает корректно, а на not-found
— нет.
Сначала я подумал, что проблема кроется в next-runtime-env
. Наверное, библиотека переопределяется при обновлении страницы, потому что скрипт, устанавливающий переменные в window.__ENV
, размещён в root layout. Я также пробовал версионировать Next.js, предполагая, что баг связан с определёнными версиями фреймворка, но это не дало результатов. В итоге, временным решением стало использование window.location.href
, что предотвращало рефреш страницы и помогало сохранить переменные.
Однако на этом история не закончилась.
Проблема сузилась: терялся только клиентский конфиг
Я углубился в проблему, пытаясь понять, почему конфиг теряется только в клиентской среде. Первоначально я подозревал router.push(pathname)
— думал, что он вызывает полную перезагрузку страницы, очищая window
и не вызывая повторного выполнения скрипта из root layout. Однако это оказалось не так.
Пытаясь воспроизвести баг в минималистичных песочницах, я столкнулся с тем, что там баг не проявлялся. Тогда я обратил внимание на middleware next-intl
, который мог магическим образом влиять на переменные окружения при навигациях. В процессе дебага я заметил, что в песочницах и в проекте я попадал на not-found
разными способами: через notFound()
и через router.push(/not-existed-pathname)
. Это привело меня к ещё большему числу экспериментов.
В комментариях в моем телеграмм блоге к моим попыткам выяснить причину проблемы прозвучал резонный вопрос: «Что именно решает next-runtime-env
?» Я ответил, что обычно использую сторонние библиотеки для управления сложностью проекта, но проблема явно требовала более детального углубления в корни.
Скопив энергию для дебага, я начал тестировать разные гипотезы:
Заменить нативный инлайн скрипт на скрипт из
next/script
, чтобы поиграться с разными стратегиями загрузки.Проверить, не связано ли это с тем, что начиная с 14-й версии,
not-found
готовится по умолчанию на сервере.Понять, почему происходит полный рефреш страницы при навигации через
router.push
.
Концов не было видно
После многочисленных экспериментов, включая замену нативных скриптов на next/script
с разными стратегиями загрузки, я так и не смог получить конфиг на странице not-found
через router.push
. Я пробовал дублировать скрипты на сервере, играл с директивой use client
, но безуспешно.
В какой-то момент мой энтузиазм иссяк. Я даже удалил next-intl
из проекта, чтобы проверить, не влияет ли его middleware, но это не решило проблему.
Тогда я заметил, что на странице not-found
присутствует объект window.__next_f
, который напоминал мне RSC Payload. В нём я увидел свой скрипт с конфигом, но по каким-то причинам Next.js не гидрировал эту часть страницы.
На этом этапе я уже подумывал написать парсер RSC payload, чтобы выдёргивать оттуда конфиг и ассайнить его в window
, но что-то меня отвлекло, и тогда я решил поискать что пишут вообще про этот __next_f
.
Интересно, что при рендере страницы not-found
, Next.js выбрасывает определённую ошибку, и на все ошибки он реагирует одинаково — перестает рендерить страницу, включая layout, где у меня происходил assignment конфигурации. Как пояснили в одном из тредов, проблема заключается в том, что Next.js останавливает рендеринг любой страницы при возникновении ошибки, будь то NotFoundError
или другая. В результате сервер перестает рендерить страницу, включая все layout-элементы. Важно отметить, что начальный HTML, который отдаёт сервер, не содержит специфичных элементов layout’а.
Неважно, вызывается ли ошибка через notFound()
или через обычный new Error()
, рендеринг маршрута прекращается, и HTML, возвращаемый сервером, не содержит нужных тегов для скриптов, работающих с beforeInteractive
. Когда завершится гидратация, скрипт добавляется в HTML, но к этому моменту основной app-bootstrap
скрипт уже выполнен, и его влияние теряется. Поэтому стратегия загрузки beforeInteractive
не работает на страницах с ошибками.
Однако, все остальные стратегии загрузки, такие как afterInteractive
или lazyOnLoad
, работают корректно. Это подтолкнуло меня к мысли проверить, как реализована загрузка скриптов в next-runtime-env
, и оказалось, что там тоже используется beforeInteractive
. Я решил открыть PR в репозиторий, тем более что уже нашёл два ишью, где разработчики столкнулись с похожими проблемами — потерей конфигурации на клиенте.
Хорошо, что я не единственный, кто столкнулся с этим. Посмотрим, как команда отреагирует на PR. Мне интересно, почему они не добавили поддержку стратегии загрузки скриптов через проп strategy, и есть ли какое-то концептуальное ограничение, которое я пока не улавливаю. Если концептуальных ограничений не окажется — отлично! В противном случае, возможно, я сделаю собственную реализацию для передачи конфигурации на клиент без потерь.
Форк и его судьба
В конце концов, я решил выпустить свой форк библиотеки next-runtime-env
. Его можно найти на GitHub: awesome-next-runtime-env.
Все отличия от оригинала я описал в PR. Получит ли этот форк дальнейшее развитие — будет зависеть от того, зальют ли они мой PR или нет.
Таким образом, история поиска решения, начиная с навигации на not-found
странице и заканчивая форком библиотеки, оказалась полна неожиданных поворотов.