April 8, 2024

Дарк и баги

Однажды я захотел сделать свой собственный юзерскрипт. Для тех кто не в теме, юзерскрипты aka пользовательские скрипты это браузерные плагины на минималках. Они внедряются в определённые страницы и меняют их в угоду пользователя.

Разумеется, для такого нужен браузерный плагин для юзерскриптов — TamperMonkey там или GreasyMonkey... Но я пошёл путём нейтрала и выбрал опенсурсный ViolentMonkey. Среди всего прочего, там предоставлялся шаблон юзерскрипта по феншую.

Ну я возьми да скачай шаблон.

ОН НЕ ЗНАЕТ

Сначала всё шло достаточно хорошо, код пишется, нейронные связи нарабатываются... Но что-то мне не давало покоя и я решил туда впилить иконки.

В комплекте с шаблоном шла такая штука как unocss, о которой я тогда знал почти ничего. А у неё в комплекте шёл шаблон для того чтобы пилить иконки на чистом css. Включался он буквально одной строчкой в конфиге:

presets: [presetIcons(), ...]

Разумеется я довольный прописал её, но оно не заработало.

"Ладно," — подумал я. — "Я дурачок и где-то ошибся."

И в самом деле, я скоро выяснил, что надо ещё дополнительно поставить источник иконок. После чего всё заработало, я довольный пошёл разбираться с css и прочим.

Во время разработки используется специальный режим, который отслеживает изменения и пересобирает их на лету. Таким образом ты всё время работаешь с актуальной версией. Удобно. Но когда я решил собрать релизную версию, иконки не заработали.

"Следствие вели" с Дарком

Тут я начал присматриваться к тому что у меня работает. Выяснилось, что если просто запустить режим разработки, то он тоже не добавит иконку. Но если что-то сохранить, то при пересборке иконка добавится.

Начинаю разбираться как это работает. Вкратце: unocss построен поверх "правил". Правило это, грубо говоря, класс css. Поверх этого у нас есть плагин transformDirectives, который преобразовывает некоторые директивы в код. В итоге мы имеем такой файлик css:

@unocss;

.test {
  @apply i-mdi-add;
  @apply font-size-7;
}

В данном случае @unocss и @apply — директивы, а i-mdi-add и font-size-7 — правила. В итоге мы после обработки должны получить что-то такое:

.test {
  /* код для иконки */
  font-size: 1.5rem;
}

Вот только по факту мы получаем такое:

.test {
  @apply i-mdi-add;
  font-size: 1.5rem;
}

И тут я заметил, что в конфиге то у меня не был указан плагин transformDirectives. Странно. А как оно тогда работало? В любом случае. добавление плагина ни на что не повлияло, значит дело в чём-то другом.

Другим моментиком стало, что если врубить отображение отладочных сообщений (DEBUG=* yourcommand очень полезная вещь), то мы увидим такое:

Иконка загружается уже после того, как мы собрали нужный юзерскрипт. Это уже явно бред. Тут мне стало понятно, что проблема вряд ли в моём коде, а значит пора идти разгребать конфиги и сорцы.

More deeper

Первым делом я полез узнавать, чем я вообще собираю всю эту катавасию. Оказалось rollup. Отлично, идём в rollup.config.mjs и пугаемся его размеров

import { defineExternal, definePlugins } from '@gera2ld/plaid-rollup';
import { defineConfig } from 'rollup';
import userscript from 'rollup-plugin-userscript';
import pkg from './package.json' assert { type: 'json' };

export default defineConfig(Object.entries({
  'userscript': 'src/userscript/index.ts',
}).map(([name, entry]) => ({
  input: entry,
  plugins: [
    ...definePlugins({
      esm: true,
      minimize: false,
      postcss: {
        inject: false,
        minimize: true,
      },
      extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
    }),
    userscript((meta) => meta.replace('process.env.AUTHOR', pkg.author)),
  ],
  external: defineExternal([
    '@violentmonkey/ui',
    '@violentmonkey/dom',
    // 'solid-js',
    // 'solid-js/web',
  ]),
  output: {
    format: 'iife',
    file: `dist/${name}.user.js`,
    banner: `(async () => {`,
    footer: `})();`,
    globals: {
      'solid-js': 'await import("https://esm.sh/solid-js")',
      'solid-js/web': 'await import("https://esm.sh/solid-js/web")',
      '@violentmonkey/dom': 'VM',
      '@violentmonkey/ui': 'VM',
    },
    indent: false,
  },
})));

Ого, тут парочка кастомных плагинов. Подозрительно. Сначала жертвой пал rollup-plugin-userscript впрочем, он просто добавлял заголовки для юзерскрипта. Вердикт: не виновен.

Дальше у нас падает взгляд на огромную штуку из @gera2ld/plaid-rollup. Вот там уже есть где разбежаться, она импортит кучу плагинов. babel, postcss, terser и ещё парочку своих.

После долгих и нудных раборок с бабелем я решил вынести вердикт "не виновен (пока что)", но как только я заглянул в конфиг postcss, всё начало проясняться:

module.exports = {
  plugins: {
    'postcss-calc': {},
    'postcss-nested': {},
    '@unocss/postcss': {},
  },
};

Логичным вопросом будет "а что это за @unocss/postcss, не он ли виновник?" и как мы быстро обнаруживаем что этот плагин действительно заменяет функциональность transformDirectives. Но почему она не работает?

Ответ вскоре нашёлся:

// @ts-expect-error types doesn't allow async callback but it seems work
root.walkAtRules(directiveName, async (rule) => {

Как говорится, никогда не спрашивай женщину о её возрасте, мужчину о его зарплате и веб разработчика why it seems work.

Bugs dies twice

Как настоящий джентльмен, я решил отправить им ишью, мол на самом деле это не работает. Для этого всего то навсего надо сделать минимальный воспроизводимый пример. Минимальный в смысле там должен быть баг, только баг и ничего кроме бага.

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

Прекрасно, берём postcss-cli и unocss с плагинчиком и пишем асинхронное правило с задержкой на 1 секунду.

Всё работает.

Добавляю иконки.

Всё работает.

На этом моменте я реши сдаться и пойти с другой стороны. Мееедленно аккуратно выпиливая части системы. Под нож попало всё — и babel, и куча плагинов и в конце концов rollup.

И тогда стало понятно что для бага надо ещё одно маааленькое, но важное условие. Наличие в файле директивы @unocss. Я не знаю почему и как, но если у неё его нет, то всё работает. Иначе не работает.

Мораль

морали нет, ишью на гитхабе, пул реквест с фиксом там же. ждём одобрения