Недавно столкнулся с интересным багом в 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?" Я ответил, что обычно использую сторонние библиотеки для управления сложностью проекта, но проблема явно требовала более детального углубления в корни.

Скопив энергию для дебага, я начал тестировать разные гипотезы:

  1. Заменить нативный инлайн скрипт на скрипт из next/script, чтобы поиграться с разными стратегиями загрузки.

  2. Проверить, не связано ли это с тем, что начиная с 14-й версии, not-found готовится по умолчанию на сервере.

  3. Понять, почему происходит полный рефреш страницы при навигации через 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 странице и заканчивая форком библиотеки, оказалась полна неожиданных поворотов.

Комментарии (0)