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

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(...)
}

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

Запись в кэш после вставки SVG

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

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

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

fetch
  -> подготовка SVG к вставке
  -> вставка в DOM
  -> запись в кэш

Сначала SVG нужно успешно вставить в DOM. Только после этого я пытаюсь сохранить исходную строку в хранилище. Ниже показана обработка Result, возвращённого из storage.set():

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);

На практике это меняет две вещи.

Во-первых, если storage.set() возвращает Result с ошибкой, уже успешная загрузка и вставка не превращаются в 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-документом, ошибка логируется, а загрузчик переходит к сетевому запросу. Если свежий SVG удалось вставить, загрузчик пытается обновить запись в кэше.

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

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

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

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

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

К 5.0 нашёл несколько таких мест.

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

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

document.querySelector('[') бросает DOMException. Если не перехватить это исключение, оно выходит за пределы Result-контракта.

Теперь resolveContainer() перехватывает исключение при поиске элемента и возвращает ошибку с кодом container_invalid через Result:

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, не меняя успешный результат основной операции.

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

Материалы