Кэш не должен ломать загрузку спрайта

11 Авг 2026

iconly 5.0.0. В этот раз почти не добавлял новых возможностей. Вместо этого прошёлся по поведению, которое обычно становится заметно, когда ломается что-то рядом: хранилище недоступно, в кэше лежит плохой SVG, пользовательский обработчик бросает исключение или два init() запускаются одновременно.

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

В 4.2 это правило не выполнялось. Если чтение IndexedDB или другого хранилища возвращало ошибку, init() завершался с ok: false. Ошибка записи после успешного fetch делала то же самое. Получалось, что необязательная оптимизация могла остановить основную операцию, хотя сеть и DOM оставались полностью рабочими.

В 5.0 меняю именно этот контракт.

У одной версии может быть несколько файлов

До этого запись кэша определялась только значением version:

const cacheResult = await storage.get(resolved.version);

При записи использовался тот же ключ:

await storage.set({
  version: resolved.version,
  data,
});

Пока на странице один файл спрайта, этого достаточно. Но такой ключ не описывает сам ресурс. Два загрузчика с разными файлами и одинаковой версией 1.0 могут обратиться к одной и той же записи.

Например:

createIconly({
  file: '/admin-icons.svg',
  version: '1.0',
});

createIconly({
  file: '/shop-icons.svg',
  version: '1.0',
});

Раньше для хранилища это были два запроса к ключу 1.0.

В 5.0 ключ кэша строится из URL файла и версии:

const createCacheKey = (
  file: string,
  version: string,
  baseUrl: string,
): string => {
  try {
    return JSON.stringify([new URL(file, baseUrl).href, version]);
  } catch {
    return JSON.stringify([file, version]);
  }
};

URL сначала нормализуется относительно ownerDocument.baseURI. Поэтому /icons.svg, переданный на странице с конкретным базовым URL, получает стабильную абсолютную форму. Если new URL() по какой-то причине не срабатывает, остаётся пара из исходного file и version.

Для встроенных хранилищ это просто новый строковый ключ. С пользовательским IconStorage есть важная деталь: значение теперь нужно считать непрозрачным.

export interface IconStorage {
  get(key: string): Promise<Result<IconRecord | undefined>>;
  set(record: IconRecord): Promise<Result<void>>;
}

Поле IconRecord.version пока сохраняю, но при записи туда попадает уже составной ключ. То есть пользовательское хранилище не должно разбирать его как пользовательскую версию набора иконок.

Это одна из причин, почему изменение выходит мажорным релизом. Форма string почти не изменилась, но смысл этой строки для внешней реализации хранилища изменился заметно.

Ошибка чтения кэша больше не блокирует загрузку

Следующий вопрос оказался важнее самого ключа.

В предыдущей схеме ошибка чтения останавливала init():

const cacheResult = await storage.get(resolved.version);

if (!cacheResult.ok) {
  return fail(cacheResult.error);
}

Но если IndexedDB временно недоступен, зачем из-за этого отказываться от SVG, который можно получить обычным fetch()?

Теперь чтение кэша работает как необязательная попытка:

let cacheAvailable = true;
let data: string | undefined;

try {
  const cacheResult = await storage.get(cacheKey);

  if (cacheResult.ok) {
    data = cacheResult.value?.data;
  } else {
    cacheAvailable = false;
    logError(cacheResult.error);
  }
} catch (error: unknown) {
  cacheAvailable = false;
  logError(
    createIconlyError(
      'storage_read_failed',
      'Failed to read from icon storage.',
      error,
    ),
  );
}

Здесь я специально обрабатываю оба варианта пользовательского хранилища: оно может корректно вернуть Result с ошибкой, а может неожиданно бросить исключение или вернуть отклонённый Promise.

В обоих случаях ошибка остаётся видимой через onError и logger.error, но основной сценарий продолжается и переходит к сети.

Получается немного непривычная, но полезная ситуация:

const errors: IconlyError[] = [];

const iconLoader = createIconly({
  file: '/icons.svg',
  onError: (error) => errors.push(error),
});

const result = await iconLoader.init();

errors может содержать storage_read_failed, а result.ok при этом быть true, если SVG удалось скачать и вставить.

Это не означает, что ошибка кэша игнорируется. Я просто перестаю смешивать два разных результата: состояние оптимизации и результат основной операции.

Есть ещё одно следствие. После неудачного чтения я не пытаюсь тут же делать set() в то же хранилище. На время текущего init() он считается недоступным:

if (cacheAvailable) {
  // storage.set(...)
}

Если чтение уже показало, что хранилище не работает как ожидается, дополнительная запись редко улучшит ситуацию.

Ошибка записи тоже не отменяет успешную вставку

Та же логика применяется к записи в кэш.

В 4.2 после fetch сначала выполнялся storage.set(). Если запись возвращала ошибку, init() завершался раньше вставки в DOM.

Теперь последовательность другая:

fetch
  -> разбор SVG
  -> sanitize / защитная обработка
  -> вставка в DOM
  -> запись в кэш

Сначала SVG должен успешно пройти тот путь, ради которого вообще существует загрузчик. Только после этого я пытаюсь сохранить исходную строку в хранилище.

const insertResult = insertSvg(
  containerResult.value,
  fetchResult.value,
  { sanitize: resolved.sanitize },
);

if (!insertResult.ok) {
  return fail(insertResult.error);
}

if (cacheAvailable) {
  const storeResult = await storage.set({
    version: cacheKey,
    data: fetchResult.value,
  });

  if (!storeResult.ok) {
    logError(storeResult.error);
  }
}

return ok(undefined);

Практически здесь два изменения.

Во-первых, ошибка записи больше не превращает уже успешную загрузку и вставку в ok: false. Пользователь получает иконки, а проблему с кэшем можно отдельно увидеть через обработчик ошибок.

Во-вторых, некорректный загруженный SVG не попадает в хранилище до проверки. Если разбор или вставка не удались, запись вообще не выполняется.

Мне нравится это правило больше прежнего — сохранять только то, что текущая цепочка уже смогла использовать.

Данные из кэша тоже приходится проверять

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

Раньше найденная запись кэша воспринималась как готовый результат. Если сохранённая строка не разбиралась как SVG, insertSvg() возвращал parse_error, и на этом init() заканчивался.

Теперь SVG из кэша сначала пробуется как обычно:

if (data) {
  const insertResult = insertSvg(containerResult.value, data, {
    sanitize: resolved.sanitize,
  });

  if (insertResult.ok) {
    return ok(undefined);
  }

  if (insertResult.error.cause) {
    return fail(insertResult.error);
  }

  logError(insertResult.error);
}

Если строка просто не является валидным SVG-документом, ошибка логируется, а загрузчик переходит к сетевому запросу. Успешно полученная свежая копия затем заменяет запись.

Так повреждённая запись кэша становится состоянием, из которого можно восстановиться, а не тупиком.

При этом я не хочу маскировать любое исключение под обычное отсутствие записи в кэше. Если ошибка вставки содержит cause, она остаётся критической. Например, пользовательский санитайзер может сам бросить исключение. В таком случае молча скачать тот же SVG ещё раз — не восстановление, а сокрытие реальной проблемы в пользовательском коде.

То есть запасной путь здесь довольно узкий: испорченный документ из кэша можно заменить свежим, неожиданную ошибку выполнения — нет.

Исключения не должны обходить Result

Result появился в 3.0. Тогда же я зафиксировал, что штатные ошибки init() возвращаются через Result, а не выбрасываются наружу. Но одно дело вернуть Result из собственных ожидаемых веток и другое — удержать этот контракт на всех границах.

К 5.0 я нашёл несколько мест, где исключение всё ещё могло пройти мимо него.

Самый простой пример — некорректный CSS-селектор:

createIconly({
  container: '[',
});

document.querySelector('[') бросает DOMException. Если вспомогательная функция не ловит его сама, никакого Result пользователь уже не получает.

Теперь resolveContainer() оборачивает поиск в DOM и возвращает container_invalid:

try {
  // document.querySelector(...)
} catch (error: unknown) {
  return err(
    createIconlyError(
      'container_invalid',
      'Failed to resolve container.',
      error,
    ),
  );
}

То же самое сделано вокруг разбора SVG и вставки в DOM.

Но более интересная граница — пользовательские обработчики. Раньше такой код мог разрушить контракт ошибок самой библиотеки:

createIconly({
  onError: () => {
    throw new Error('reporter failed');
  },
});

Теперь обработчик и logger изолированы отдельно:

try {
  resolved.onError?.(error);
} catch {
  // callback не должен менять Result
}

try {
  resolved.logger?.error?.('[Iconly error]', error);
} catch {
  // logger тоже
}

Здесь мне важно, чтобы диагностический обработчик оставался именно обработчиком. Ошибка в нём не должна подменять исходную ошибку init().

Наконец, весь runInit() имеет последний защитный try/catch. Для неожиданной ветки добавлен unexpected_error:

try {
  // основной init flow
} catch (error: unknown) {
  return fail(
    createIconlyError(
      'unexpected_error',
      'Unexpected error while initializing Iconly.',
      error,
    ),
  );
}

Аналогичная защита появляется у синхронного createSprite().render().

Я не пытаюсь доказать, что JavaScript-среда в принципе больше никогда не сможет бросить исключение. Задача практичнее: ожидаемые браузерные API, пользовательское хранилище и обработчики не должны случайно обходить публичный Result-контракт.

Два одновременных init() — одна операция

Ещё один сценарий обнаруживается, если вызвать init() дважды, пока первый запрос ещё выполняется.

Вместо двух независимых операций теперь экземпляр хранит текущий Promise:

let inFlight: Promise<Result<void>> | null = null;

const init = (): Promise<Result<void>> => {
  if (!inFlight) {
    inFlight = runInit().finally(() => {
      controller = null;
      inFlight = null;
    });
  }

  return inFlight;
};

Параллельные вызовы получают один и тот же Promise:

const first = iconLoader.init();
const second = iconLoader.init();

console.log(first === second); // true

Это заодно делает поведение abort() однозначнее во время активной загрузки: отменяется текущая общая операция, а не один из нескольких случайно запущенных запросов.

После завершения inFlight сбрасывается, и следующий init() снова может выполнить обычную операцию.

IndexedDB тоже должен переживать существующую базу

На уровне адаптера я поправил ещё один сценарий. Раньше IndexedDB открывался с фиксированной версией 1. Это неудобно, если база с таким dbName уже существует, но пользователь выбирает другой storeName.

Теперь адаптер сначала открывает существующую базу без принудительной версии. Если нужного хранилища объектов нет, соединение закрывается, номер версии увеличивается и хранилище создаётся через onupgradeneeded.

Кроме того, соединение закрывается на versionchange, а отклонённый dbPromise сбрасывается, чтобы следующая попытка могла снова открыть базу.

Это не меняет публичный API хранилища, но соответствует тому же принципу релиза: хранилище не должно быть хрупким только потому, что окружение оказалось чуть сложнее идеального тестового сценария.

Что теперь означает ok: true

В 5.0 внешний код по-прежнему выглядит знакомо:

const iconLoader = createIconly({
  file: '/icons.svg',
  version: '1.0',
});

const result = await iconLoader.init();

if (!result.ok) {
  console.error(result.error);
}

Но смысл result.ok теперь точнее.

ok: true означает, что спрайт удалось получить и вставить. Это не обещание, что попутно идеально сработал кэш. Восстановимая ошибка хранилища может быть отдельно отправлена в onError или logger, не меняя успешный результат основной операции.

Раньше хранилище было вынесено в отдельную абстракцию, но ядро всё ещё относилось к нему как к обязательной части успеха. Теперь зависимость стала честнее: кэш ускоряет следующий запуск, когда доступен, и отходит в сторону, когда нет.