javascript

Новый Intl.DurationFormat привел к неожиданной ошибке приложения

  • вторник, 2 июня 2026 г. в 00:00:14
https://habr.com/ru/articles/1042182/

Если вы уже используете новый Intl.DurationFormat в совсем проекте, то вам будет полезен мой кейс и поможет вам сэкономить пару часов на дебагинг.

Продукт над которым я работаю - это платежная форма. Это стабильное давно работающее приложение и вдруг пользователи стали сообщать об «Unknown error» при попытке провести транзакцию. При этом проблема была только для одного вида транзакций — «Счет».

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

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

  {
    "transaction": {
      "id": 111111111,
      "status": "Pending",
      "createdAt": "2026-05-11T08:39:15+00:00",
      "expiresAt": "2026-07-01T08:39:15+00:00"
    }
  }

expiresAt — июль, а сейчас май. Так, срок жизни транзакции типа «Счет» ~55 дней, в то время как все другие транзакции живут не больше 15 минут. Дело в том, что банковские переводы ждут подтверждения от банка и могут висеть несколько дней. Именно поэтому баг проявлялся только для этого типа — остальные работали корректно.

У нас в проекте для таймера обратного отсчёта используется компонент TextTimer с полифиллом formatjs/intl-durationformat@ 0.7.4. Полифилл подключён в корне приложения глобально, поэтому нативная реализация браузера не задействована.

Код до фикса:

 const formatter = new Intl.DurationFormat(i18n.locale, {
   hoursDisplay: "auto",
   secondsDisplay: "always",
   style: formatStyle,
 });
 
 function getDiff(unit: dayjs.OpUnitType) {
   let diff = expiresAtDayjs.diff(now, unit);

   if (unit === "m" || unit === "s") {
     diff %= 60;
   }
 
   return Math.max(0, diff);
 }

 formatter.format({
   hours: getDiff("h"),    // = 1320
   minutes: getDiff("m"),
   seconds: getDiff("s"),
 }); // 💥 RangeError

Так в чем же проблема?

Дело в том, что если в таймер приходит длительность больше суток, а в моем случае это было 55 дней, getDiff(“h”) возвращает 1320 — потому что для часов не было % 24, и days не передавался в .format().

Полифилл видел 1320 часов, Intl.DurationFormat не понимал как обработать такой период и выбрасывал RangeError. Ошибка всплывала наверх без ErrorBoundary на компоненте, отсюда «Unknown error» вместо формы оплаты.

Решение

Два изменения:

  1. Добавить % 24 для часов в getDiff

  2. Передать days в .format()

const formatter = new Intl.DurationFormat(i18n.locale, {
  hoursDisplay: "auto",
  secondsDisplay: "always",
  style: formatStyle,
});

function getDiff(unit: dayjs.OpUnitType) {
  let diff = expiresAtDayjs.diff(now, unit);

  if (unit === "h") {
    diff %= 24;          // ← добавили
  } else if (unit === "m" || unit === "s") {
    diff %= 60;
  }

  return Math.max(0, diff);
}

formatter.format({
  days: getDiff("d"),    // ← добавили
  hours: getDiff("h"),   // теперь 1320 % 24 = 8 ✓
  minutes: getDiff("m"),
  seconds: getDiff("s"),
});

Теперь пользователь видит 55 дн. 10:05:00 (RU) или 55d 10:05:00 (EN) вместо ошибки.

А что если бы был ErrorBoundary?

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

 class TimerErrorBoundary extends React.Component {
   componentDidCatch(error: Error) {
     logger.error('TextTimer crashed', { error }); // обязательно!
   }

   render() {
     if (this.state.hasError) return null;
     return this.props.children;
   }
 }

Главное — внутри ErrorBoundary всегда нужно логировать ошибку, иначе баг останется невидимым на проде. Для логирования подойдёт любой мониторинг ошибок, например, Sentry, Datadog, или собственный logger если он есть в проекте.

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

Выводы

  1. Полифилл formatjs/intl-durationformat@0.7.4 отдает RangeError, если передать в негоhours ≥ 24 без days. Нет предупреждений — сразу исключение.

  2. ErrorBoundary на уровне компонента, не страницы. Оборачивайте некритичные элементы - и всегда с логированием в мониторинг.