Кто давно работает с next.js и использует css модули возможно сталкивался с багом, когда модульные стили некоторых компонентов не отрабатывают при возврате назад в браузере. При загрузке сайта все стили как и положено применяются, потом переходишь по ссылке на другую страницу сайта, возвращаешься обратно и вуаля - стили некоторых компонентов слетели. Стоит сразу уточнить, что переход по ссылке происходит в рамках сугубо клиентской навигации, т.е через компонент Link из next/link.

Я работаю на проекте средней сложности, используется всё еще next 12 версии (т.е. это page router), страницы рендерятся по SSR.С точки зрения интерфейса есть центральная и боковые колонки, шапка и куча разных блоков. Стили подключаются как через css модули, так и через public, так и через импорт из внутренней библиотеки uikit. И иногда я стал замечать что при возврате назад по истории браузера стили компонентов из боковых колонок ломались. По началу показалось, что проблема связана с css модулями, ведь если стили из них перенести в public, то проблема исчезает. Но при более внимательном осмотре оказалось что при переходе назад стили слетают почти всегда и не только у одного компонента.

Рис. 1. Блок из правой колонки сайта Yahoo без стилей и с ними, как пример
Рис. 1. Блок из правой колонки сайта Yahoo без стилей и с ними, как пример

К сожалению я не могу привести скриншот с рабочего проекта, т.к. для этого требуется разрешение компании в которой я работаю, поэтому для примера я нашел похожий блок в правой колонке на сайте Yahoo и вырубил стили на нём для наглядности. Кстати в Yahoo тоже используется nextjs.

При осмотре проблемных компонентов, оказалось что они подключались через динамический импорт dynamic(), а так как большой необходимости подключать эти компоненты динамически нет, то было решено импортировать их через обычный импорт, что избавило от бага.

Но почему же и как возникает эта проблема и есть ли другие способы её решения?

Давайте рассмотрим как next.js подключает стили. Как я уже сказал, сайт создан на next 12 версии с применением SSR, а потому все нижесказанное необязательно будет применимо в других версиях некста.

Откроем сайт на главной странице и зайдем в панель элементов, сделаем поиск по 'style' и увидим что в head чанки стилей подключены через тег link с атрибутом rel='stylesheet', при этом на каждый такой чанк есть еще и link с rel='preload'.

Рис. 2. Стили загружаемые еще на стадии SSR
Рис. 2. Стили загружаемые еще на стадии SSR

Но если проскроллить код вниз - то там будут стили подключенные уже без соответствующего link rel='preload' . Названия чанков содержат хэш из содержимого файла, который меняется только когда файл изменится, а не при сборке.

Рис. 3. Стили подключенные на клиенте
Рис. 3. Стили подключенные на клиенте

Чанки стилей подключенные вверху будут и в коде страницы, их нам отдает сервер сразу при SSR, они нужны для страницы сразу же и они оптимизированы через preload. Если открыть эти чанки, то в них обнаружатся все типы используемых на проекте стилей: как глобальные внутренние стили, так и стили из uikit, так и модульные. Стили же подключенные без preload отсутствуют в коде полученном из SSR, в них содержаться стили сугубо для компонентов с динамическим импортом.

Если первой будет открыта главная страница то загружены будут только глобальные стили, если нет, то глобальные и те что грузятся для конкретных страниц. Если сайт будет загружен не на главной странице, то можно заметить, что у некоторых link есть атрибут data-n-p, а у некоторых data-n-g (рис.2). При этом на главной странице можно видеть только link с атрибутом data-n-g. Атрибутом 'data-n-р' помечаются теги link для вторых страниц (p значит page), а 'data-n-g' помечаются глобальные стили.

Рис. 4. Стили компонента после перехода на другую страницу
Рис. 4. Стили компонента после перехода на другую страницу

Если загрузить сайт не на главной (назовем это вторая страница) странице и затем перейти (по клиентской навигации) на любую другую вторую страницу, то атрибут пропадет. Вместе с ним исчезает и сам тег link rel='stylesheet'. Но если сделать поиск по имени файла стилей, то мы найдем тег style с этим же чанком. Обратите внимание что у него установлен атрибут 'data-n-href' с ссылкой, и внутри него находятся стили из чанка (рис. 4). Стоит заметить, что такое поведение будет только при переходе на вторые страницы, если перейти на главную, то там не будет файла style, ост��нется только link rel='preload'.

Таким образом при клиентском переходе на другие страницы сайта next заменяет стили из link на теги style c data-n-href атрибутом, в котором хранится ссылка на чанк. Теперь перейдем на главную, чтобы увидеть баг и посмотрим, что изменилось с тегами link и style нашего чанка стилей. Попробуйте найти что поменялось.

Рис. 5. Те же стили, но когда есть баг
Рис. 5. Те же стили, но когда есть баг

У тега style появился media атрибут media='x', таким образом next помечает стили которые не должны примениться. Почему же next некорректно помечает нужные стили?

Что делает nextjs со стилями под капотом

Искать причин�� начнем в коде самого next (из node_modules), там можно найти код устанавливающий атрибут media='x' для ненужных стилей - он обнаружен в файле /dist/client/index.js.

Рис. 6. Скрин кода next отключающего ненужные стили
Рис. 6. Скрин кода next отключающего ненужные стили

Используя отладчик можно распутать клубок вверх по коду, в таком случае обнаружится вызов метода getClientBuildManifest в модуле route-loader.js, в котором формируется список файлов нужных для роута из манифеста билда.

Рис. 7. Код получения списка css файлов для роута
Рис. 7. Код получения списка css файлов для роута

Сам этот манифест билда создаётся функцией generateClientManifest, вызываемой внутри метода createAssets класса BuildManifestPlugin (получается она и создаёт asset файлов) из плагина build-manifest-plugin из webpack по адресу dist\build\webpack\plugins\build-manifest-plugin.js.

Рис. 8. Сохранение билда манифеста
Рис. 8. Сохранение билда манифеста

Сохраняется он напрямую в папку билда next и получает имя build-manifest.json. В нем и находится мапинг файлов под каждый роут, в т.ч. и на главную страницу. Этот файл нужен для оптимизации работы приложения, он позволяет загружать только те ресурсы, которые нужны для конкретной страницы. И по какой-то причине среди этих файлов нет css чанка со стилями нужными для работы того самого компонента, на котором ломаются стили.

Рис. 9. Файл build-manifest, содержащий список файлов нужных для каждого роута
Рис. 9. Файл build-manifest, содержащий список файлов нужных для каждого роута

По коду внутри плагина видно, что набор assets пополняется за счет вызовов функции getEntrypointFiles, которая получает список файлов для каждого entrypoint - входных точек для webpack, используя метод entrypoint.getFiles() самого webpack. Таким образом, получается next не способен правильно сформировать список ассетов нужных для начальной страницы, и по какой-то причине опускает файл содержащий нужные dynamic стили. Почему именно это происходит?

Поиск информации о этой проблеме

На github у next.js есть несколько issues с описанием данной или связанных проблем:

Из них можно узнать, что эта проблема существовала еще с 9 версии и вплоть до 2024 года, при том что это была одна из самых популярных проблем с очень большим числом голосов, но команда next совершенно не торопилась её фиксить.

В итоге в конце 2024 года команда next наконец-то выкатила pr с исправлением данной проблемы https://github.com/vercel/next.js/pull/72959.

Процитирую описание причины проблемы оттуда:

The root cause of the issue is the mini-css-extract-plugin (which handles Production CSS) skipped injecting the stylesheet since the link tag with the target href already existed. This is fine, but the expected stylesheet is missing as Next.js removes "server-rendered" stylesheets after the navigation.

Перевод: Основная причина проблемы заключается в том, что mini-css-extract-plugin (который отвечает за продакшн CSS) пропускает вставку таблицы стилей, так как тег link с целевым href уже существует. Это нормально, но ожидаемая таблица стилей отсутствует, так как Next. js удаляет «отрендеренные на сервере» таблицы стилей после навигации.

В самом pr вносятся правки, которые гарантируют, что CSS-чанки не удаляются, пока они ещё нужны для отображения компонентов на новой странице. Введен новый механизм отслеживания активных CSS-чанков (html‑context.shared‑runtime.ts), который позволяет Next.js понимать, какие стили ещё используются и не удалять их преждевременно. Также добавлена переменная dynamicCssManifest которая хранит (как Set) набор подключаемых динамических стилей и система должна проверять используются ли стили из неё, прежде чем их удалить.

Рис. 10. Схема описывающая проблему со стилями из PR в github репе nextjs
Рис. 10. Схема описывающая проблему со стилями из PR в github репе nextjs

Решение из интернета

Среди комментариев в первом issue из списка выше, под номером 17464, нашелся одобренный множеством людей комментарий о решении данной проблемы с помощью самописного хука useNextCssRemovalPrevention.

Посмотреть его можно тут. У этого проекта даже есть страница на сайте тут.

Рассмотрев его код, перенес его на проект и проверил. Вкратце суть его в том чтобы исключить возможность удаления еще используемых стилей, для чего предотвращается преждевременная пометка стилей через media='x' и добавляется своя метка на этапе завершения окончания перехода на новый маршрут.

Но этот хук у меня работал плохо, он не подчищал уже ненужные стили и при каждом переходе по навигации добавлялись всё новые копии тегов style. Поэтому от него пришлось отказаться.

Для себя решил, что самым оптимальным решением проблемы остаётся избавиться от dynamic импортов, как и написал в самом начале. Сделав это, обнаружил, что теперь в build_manifest.json появились чанки css в которых находятся стили элементов ранее работавших с багом.

Резюме

Next.js полон сюрпризов, несмотря на большое комьюнити и большое число серьезных проектов где он используется, иногда они не спешат править крупные баги.

Если вы попадете в похожую ситуации и поиск в гугл не даст результатов, и даже ИИ не сможет ничего подсказать, поищите issues next.js на их githab, иногда в комментариях к ним, можно найти много интересного.

А в будущем мы будем переходить на свежий next, надеюсь оно того стоит. Спасибо, что дочитали, надеюсь кому-то будет полезным.