
Safari всегда был особенным браузером для фронтенд-разработчиков. Его часто называют новым Internet Explorer из-за непредсказуемого поведения вёрстки, отличающегося от спецификаций.
Только в отличие от уже мёртвого и неподдерживаемого IE, Safari регулярно обновляется, создавая новые проблемы для разработчиков. Релиз Safari 26.0 не стал исключением. Усугубил ситуацию новый дизайн iOS 26 Liquid Glass, который сильно изменил привычный интерфейс браузера.
Привет! Меня зовут Дима Фукс. Я — Head of Frontend в Додо. В этой статье я расскажу как о старых проблемах Safari, существующих до сих пор, так и о новых, пришедших с последней версией iOS.
Разберём мы их на примере конкретной задачи — реализации фуллскрин-модалки с текстовым полем внутри. В реальном мире это может быть чат поддержки, окно комментария или мессенджер, встроенный в сайт.
Пишем заготовку модалки
Писать я буду на React+TypeScript+CSS. Сначала реализуем базовое поведение, а по ходу статьи будем расширять и дорабатывать его.
import { type FC } from 'react'; import { createPortal } from 'react-dom'; import styles from './Modal.module.css'; interface Props { onClose?: () => void; } const modalElement = document.getElementById('modal')!; export const Modal: FC<Props> = ({ onClose }) => createPortal( <div className={styles.modal}> <button className={styles.close} type='button' onClick={onClose}> ✖️ </button> <input className={styles.input} type='text' placeholder='Комментарий' /> </div>, modalElement, );
.modal { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgb(199, 106, 106); display: flex; align-items: center; z-index: 2; padding: 16px; flex-direction: column; justify-content: space-between; } .input { width: 100%; padding: 8px; border-radius: 8px; border: 1px solid #a99898; } .close { // ... }
Визуально модалка будет выглядеть на iOS 18 вот так:

На iOS 26 сразу видно интересное: контент страницы просвечивается за модалкой. Однако, это не единственная проблема, так что к ней мы вернёмся после фикса уже привычных багов, характерных для всех версий iOS.

Высота модалки при открытой адресной строке
На данный момент высота модалки устанавливается через 100vh, а значит она должна занимать всю высоту экрана. Но на мобильных устройствах, работающих в том числе на iOS, это значение не учитывает высоту адресной строки. Из-за этого модалка занимает больше места, чем доступно на экране. Видео из iOS 18:
Скриншот из iOS 26:

Проблема с высотой решается заменой 100vh на 100dvh. Но для сохранения совместимости с браузерами, которые не поддерживают единицу измерения dvh, мы добавим следующий код:
.modal { // ... height: 100vh; height: 100dvh; // ... }
Благодаря этому браузер возьмёт последнее валидное для него значение. То есть то, которое он поддерживает.
save-area для полоски многозадачности
В горизонтальном режиме, когда адресная строка свёрнута или отображается внутри нативного приложения сверху в режиме WebView, есть проблема. Вырез, чёлка или полоска многозадачности просто перекрывает элементы. В нашем случае инпут:

Добавляем код для установки отступа с учётом безопасной зоны:
.modal { padding-bottom: calc(env(safe-area-inset-bottom) + 16px); }

Но мы видим ещё одну проблему — модалка не занимает весь экран по ширине. Растянем её, добавив viewport-fit=cover в <meta name='viewport' />:

Но теперь крестик и инпут вылезают за пределы области взаимодействия. Поэтому добавим отступы со всех сторон:
.modal { padding-top: calc(env(safe-area-inset-top) + 16px); padding-bottom: calc(env(safe-area-inset-bottom) + 16px); padding-left: calc(env(safe-area-inset-left) + 16px); padding-right: calc(env(safe-area-inset-right) + 16px); }
Получаем:

Такое поведение встречается и в iOS 18, и в iOS 26, и не отличается от версии к версии.
Блокировка скролла страницы
Казалось бы, простая задача: блокировать скролл страницы при открытии модалки. В большинстве браузеров она решается простым способом:
body.modal-open { overflow: hidden; }
Попробуем реализовать этот способ в нашей модалке, добавляя класс на body в момент её появления:
// ... export const Modal: FC<Props> = ({ onClose }) => { useLayoutEffect(() => { document.body.classList.add('modal-open'); return () => { document.body.classList.remove('modal-open'); }; }, []); return // ... };
Посмотрим на поведение в iOS 18, потому что в iOS 26 поведение аналогичное. Здесь я добавил opacity у модалки, чтобы было видно, что происходит под ней:
Мы видим 2 ситуации, когда контент продолжает скроллиться, несмотря на overflow: hidden:
когда адресная строка скрыта;
когда происходит зум на инпут.
Есть 2 популярных способа решения этой проблемы:
вешать на body
position: fixedиheight: 100%;вешать на body и html
height: 100%.
Оба решения применяются при открытии модалки и имеют похожий принцип: вместо блокировки скролла мы убираем его так, чтобы высота body не превышала высоту экрана. Этот способ работает, но имеет несколько неприятных побочных эффектов. Их можно увидеть прямо на сайте apple.com:
Во-первых, при каждом открытии модалки скролл улетает наверх. Во-вторых, «раскукоживается» адресная строка, хотя пользователь вряд ли этого хотел.
Эти проблемы связаны с тем, что контент страницы теряет свою изначальную высоту.
Первую проблему можно решить, запоминая позицию скролла при открытии модалки и восстанавливая его при закрытии:
// ... export const Modal: FC<Props> = ({ onClose }) => { useLayoutEffect(() => { const htmlScrollTop = document.documentElement.scrollTop; document.body.classList.add('modal-open'); return () => { document.body.classList.remove('modal-open'); document.documentElement.scrollTop = htmlScrollTop; }; }, []); return // ... };
Но это решение работает только для фуллскрин-модалки. Если она будет занимать не всю высоту экрана, то мы увидим, как контент под ней прыгает при открытии и закрытии. К тому же, этот фикс не решает проблему с адресной строкой, поэтому нам нужно другое решение.
Я вдохновлялся этой статьёй, взяв только то, что мне нужно, и немного упростив. Для начала создадим вспомогательный компонент-обёртку:
.holder { position: fixed; left: 0; top: 0; width: 100%; height: 100vh; height: 100dvh; overflow: hidden; z-index: 2; } .scroller { width: 100%; height: 100%; overflow-x: hidden; overflow-y: auto; overscroll-behavior: none; scrollbar-width: none; } .scroller::-webkit-scrollbar { display: none; } .scroller-inner { height: calc(100% + 1px); min-height: calc(100% + 1px); } .scroller-content { position: sticky; width: 100%; height: calc(100% - 1px); top: 0; bottom: 0; }
import { type FC, type ReactNode } from 'react'; import styles from './ScrollLock.module.css'; interface Props { children: ReactNode; className?: string; } export const ScrollLock: FC<Props> = ({ children, className }) => ( <div className={styles.holder}> <div className={styles.scroller}> <div className={styles.scrollerInner}> <div className={`${styles.scrollerContent} ${className}`}>{children}</div> </div> </div> </div> );
Основная задумка состоит из нескольких частей:
.holderпросто переносит стили из уже существующей модалки. Задаёт ей высоту и устанавливает фиксированное расположение..scrollerперехватывает скролл на себя путём свойстваoverscroll-behavior: none. Это свойство говорит браузеру, как должен вести себя скролл при достижении конца прокрутки у дочернего элемента.
По умолчанию в такой ситуации начинает скроллиться родитель, но в значенииnoneэто поведение отключается и родитель не скроллится. Также.scrollerвизуально убирает скролл черезscrollbar-width: none..scroller-innerустанавливает высоту в100% + 1px, чтобы появился скролл у.scroller..scroller-contentявляетсяsticky, чтобы всегда быть прикреплённым к верху и скролл на 1px не был заметен. Также он устанавливает высоту в100% - 1px, чтобы компенсировать лишний пиксель у родителя.
Этот способ корректно блокирует скролл как в iOS 18, так и iOS 26 без каких-либо побочных эффектов.
Цвета верхнего и нижнего бара
Как мы могли заметить выше, при открытии модалки верхний и нижний бар отличаются по стилю от стилей самой модалки. В случае с iOS 18 бары остаются синими, а с iOS 26 — прозрачными:

Для начала решим проблему с iOS 18. Покрасим бары с помощью meta-тега:
export const Modal: FC<Props> = ({ onClose }) => { useLayoutEffect(() => { const metaThemeColor = document.createElement('meta'); metaThemeColor.name = 'theme-color'; metaThemeColor.content = 'rgb(199, 106, 106)'; document.head.appendChild(metaThemeColor); return () => { document.head.removeChild(metaThemeColor); }; }, []); return // ... };
Это помогло, но только для верхнего бара. Теперь модалка выглядит так:

Далее начинаются хаки для перекраски нижнего бара:
// ... export const Modal: FC<Props> = ({ onClose }) => { useLayoutEffect(() => { const metaThemeColor = document.createElement('meta'); metaThemeColor.name = 'theme-color'; metaThemeColor.content = 'rgb(199, 106, 106)'; document.head.appendChild(metaThemeColor); document.body.style.backgroundColor = 'rgb(199, 106, 106)'; requestAnimationFrame(() => { document.head.removeChild(metaThemeColor); requestAnimationFrame(() => { document.head.appendChild(metaThemeColor); }); }); return () => { document.head.removeChild(metaThemeColor); document.body.style.backgroundColor = ''; }; }, []); return // ... };
Благо��аря перекраске body, при открытии модалки нижний бар подхватывает этот цвет и красит в него себя. Но зачем мы удаляем и заново добавляем meta-тег? Дело в том, что по какой-то причине без него перекраска body не триггерит изменения цвета нижнего бара.
По итогу модалка на iOS 18 выглядит так:

Но что с iOS 26? В ней прекращена поддержка meta theme-color и хаки с перекраской body тоже не работают:

В Apple хотели, чтобы Safari автоматически мог определить, в какой цвет красить верхний и нижний бар. Но что-то пошло не так, и это видно. Зададим модалке высоту не в 100dvh, а сильно меньше. Например, в 200px:

Мы видим, что при небольшой высоте Safari всё же начинает учитывать цвет модалки и красить в этот цвет верхний и нижний бар. В ходе экспериментов выяснилось, что максимальная высота, при которой это происходит, равняется примерно 75vh. При этом высота может отличаться в зависимости от диагонали устройства, факта открытия или закрытия адресной строки.
Данные вводные натолкнули на мысль: надо добавить сверху и снизу от модалки небольшие по высоте div'ы. Они заставят Safari красить в их цвет бары:
.fix-ios-top, .fix-ios-bottom { background-color: rgb(199, 106, 106); position: fixed; width: 100%; height: 10px; z-index: 3; } .fix-ios-top { top: 0; } .fix-ios-bottom { bottom: 0; }
// ... export const Modal: FC<Props> = ({ onClose }) => { // ... return createPortal( <> <div className={styles.fixIosTop} /> <div className={styles.modal}> <button className={styles.close} type='button' onClick={onClose}> ✖️ </button> <input className={styles.input} type='text' placeholder='Комментарий' /> </div> <div className={styles.fixIosBottom} /> </>, modalElement, ); };
Наконец получаем корректное отображение как на iOS 18, так и на iOS 26:

А что происходит на сайте apple.com в этом случае?
Как мы видим, окрашивание баров работает, но почему-то не убирается цвет нижнего бара после закрытия модалки. Что же конкретно здесь происходит?
Верхний бар красится за счёт плавного изменения высоты. Safari успевает поймать момент, когда высота фиксированного хэдера достаточно маленькая, чтобы учитывать его для изменения цвета.
Нижний бар красится за счёт того, что
bodyпри открытии модалки имеет высоту 100% иbackground-color, совпадающий с цветом модалки.Нижний бар остаётся закрашенным, даже после скрытия модалки из-за двух факторов: это абсолютно спозиционированный элемент внутри хэдера; высота меню изменяется плавно. Сочетание этих факторов по какой-то причине не позволяет вернуть нижний бар в прозрачное состояние.
Вот код, который эмулирует такое поведение. Я немного доработал модалку — теперь её видно всегда, а раскрывается она по кнопке:
body.modal-open, html.modal-open { overflow: hidden; height: 100%; background-color: rgb(199, 106, 106); } .modal { position: fixed; top: 0; left: 0; height: 200px; width: 100vw; background-color: transparent; transition: height 0.5s, background-color 0.5s; // ... остальные стили } .modal.open { height: 100dvh; background-color: rgb(199, 106, 106); } .modal > div { height: 0; width: 100%; position: absolute; } .modal.open > div { height: 100vh; }
export const Modal: FC = () => { const [open, setOpen] = useState(false); useLayoutEffect(() => { const htmlScrollTop = document.documentElement.scrollTop; if (open) { document.body.classList.add('modal-open'); document.documentElement.classList.add('modal-open'); } return () => { document.body.classList.remove('modal-open'); document.documentElement.classList.remove('modal-open'); document.documentElement.scrollTop = htmlScrollTop; }; }, [open]); return createPortal( <div className={styles.modal + (open ? ` ${styles.open}` : '')}> <div> <button className={styles.close} type='button' onClick={() => setOpen(!open)}> {open ? '✖️' : '🍾'} </button> </div> </div>, modalElement, ); };
Теперь мы наблюдаем похожее на сайт apple.com поведение:
Зум при фокусе на инпут
При тапе на инпут происходят сразу несколько вещей, которые нарушают ожидаемое поведение:
зум на инпут сдвигает верхнюю часть модалки наверх, из-за этого скрывается кнопка закрытия и часть полезного пространства модалки;
даже после того, как клавиатура скрылась, а фокус потерялся на инпуте, зум сохраняется. Пользователь должен сам догадаться, что нужно сделать щипок и убрать зум;
в iOS 26 при открытии клавиатуры снова появляется просвет снизу;
в iOS 26 над клавиатурой появляется уменьшенная адресная строка с невидимой областью вокруг себя, которая перекрывает часть инпута.
Если пользователь захочет, например, вставить или выделить текст в инпуте, он может попасть по этой области. Тогда у него не получится ни выделить, ни вставить текст, а клавиатура скроется и больше ничего не произойдёт;после закрытия модалки
bodyпроскроллится вниз, несмотря на все хаки и блокировки скролла.
Видео из iOS 26:
Кстати, появление emoji-клавиатуры более наглядно показывает проблему наезда адресной строки на инпут:

У данной проблемы есть несколько путей решения. Разберём каждый.
Добавить
maximum-scale=1в<meta name='viewport' />. Это самый быстрый, но при этом малоэффективный вариант: зум пропадает, но вместо этого модалка уезжает наверх, сохраняя все проблемы, перечисленные выше.
Более того, появляется новая проблема: нам приходится выключить зум, а это не очень хорошо с точки зрения пользовательского опыта. Но кому-то и такой вариант подойдёт — в мобильном Safari пользователь всё равно сможет воспользоваться зумом, хоть и при клике на инпут у него не получится это сделать.Схожий вариант: установить
font-sizeу инпута минимум в16px. Тогда Safari перестанет делать зум, но изначальные проблемы всё ещё сохранятся.
Оставим этот вариант, но над сдвигом модалки вверх нужно будет ещё попотеть. Воспользуемся самым популярным хаком: установим при фокусеopacity 0для инпута и через небольшой таймаут вернём непрозрачность:
export const Modal: FC<Props> = ({ onClose }) => { // ... const onInputFocus = (event: FocusEvent<HTMLInputElement>): void => { const input = event.target; input.style.opacity = '0'; setTimeout(() => { input.style.opacity = '1'; }, 100); }; // ... return createPortal( // ... <input type='text' onFocus={onInputFocus} placeholder='Комментарий' />, // ... modalElement, ); };
Теперь фокус выглядит так:
Модалка больше не сдвигается наверх, после её закрытия body больше не скроллится. Но теперь ситуация обратная — клавиатура перекрывает инпут.
Перейдём к более жёстким хакам. При открытии модалки высчитаем высоту полезной области без учёта клавиатуры и установим её как высоту модалки:
export const Modal: FC<Props> = ({ onClose }) => { const [height, setHeight] = useState<number>(); const [offsetTop, setOffsetTop] = useState<number>(); const [offsetTop, setOffsetTop] = useState<number>(); useLayoutEffect(() => { // ... const onResize = (): void => { setHeight(window.visualViewport?.height || undefined); setOffsetTop(window.visualViewport?.offsetTop || undefined); }; window.visualViewport?.addEventListener('resize', onResize); return () => { // ... window.visualViewport?.removeEventListener('resize', onResize); }; }, []); // ... return createPortal( <ScrollLock style={{ height, top: offsetTop }}> // ... </ScrollLock>, modalElement, ); };
Получим такое поведение:
В этом решении есть пара недочётов при скрытии клавиатуры:
она скрывается с задержкой, так как событие изменения
viewportсрабатывает только после закрытия;в iOS 26 есть баг, при котором размер
viewportне всегда корректно пересчитывается. Из-за этого высота модалки может не измениться. Тогда снова произойдёт сдвигbody, поэтому помимоheightмы учитываем ещё иoffsetTop.
Исправим это, не опираясь за события resize при закрытии, а будем ждать события blur на инпуте, чтобы очистить вычисленную высоту. Это позволит изменить высоту модалки одновременно с закрытием клавиатуры, а ещё нам больше не нужно будет опираться на забагованный offsetTop при закрытии — мы сможем просто очистить его из модалки.
export const Modal: FC<Props> = ({ onClose }) => { const [height, setHeight] = useState<number>(); const [offsetTop, setOffsetTop] = useState<number>(); const isInputFocused = useRef(false); const onInputFocus = (event: FocusEvent<HTMLInputElement>): void => { isInputFocused.current = true; // ... }; const onInputBlur = (): void => { isInputFocused.current = false; setHeight(undefined); setOffsetTop(undefined); }; useLayoutEffect(() => { const onResize = (): void => { if (!isInputFocused.current) { return; } setHeight(window.visualViewport?.height || undefined); setOffsetTop(window.visualViewport?.offsetTop || undefined); }; window.visualViewport?.addEventListener('resize', onResize); return () => { window.visualViewport?.removeEventListener('resize', onResize); }; }, []); return createPortal( <ScrollLock style={{ height, top: offsetTop }}> <input type='text' onFocus={onInputFocus} onBlur={onInputBlur} placeholder='Комментарий' /> </ScrollLock>, modalElement, ); };
Теперь мы окончательно избавились от сдвигов:
Остаются проблемы с нижним зазором и адресной строкой, наезжающей на инпут. Решим их таким способом:
.modal { // ... padding-bottom: calc(env(safe-area-inset-bottom) + 16px + var(--additional-save-area, 0px)); } const isIos26 = (): boolean => { const { userAgent } = navigator; return userAgent.includes('iPhone') && userAgent.includes('Version/26'); }; export const Modal: FC<Props> = ({ onClose }) => { const [height, setHeight] = useState<number>(); const [offsetTop, setOffsetTop] = useState<number>(); const fixIosBottomStyle = useMemo(() => { if (height === undefined && offsetTop === undefined) { return {}; } const fixIosBottomOffset = (height || 0) + (offsetTop || 0); return { top: fixIosBottomOffset, height: '100vh', bottom: 'auto', }; }, [height, offsetTop]); return createPortal( <> <div className={styles.fixIosTop} /> <ScrollLock style={{ height, top: offsetTop, // @ts-expect-error ts не понимает css-переменные '--additional-save-area': isIos26() && isInputFocused.current ? '16px' : '0px', }} > <input className={styles.input} type='text' placeholder='Комментарий' /> </ScrollLock> <div className={styles.fixIosBottom} style={fixIosBottomStyle} /> </>, modalElement, ); };
Мы делаем две вещи:
динамически считаем расположение нижнего фейкового элемента для покраски нижнего зазора. Чтобы при появлении клавиатуры его низ был прижат к верху клавиатуры;
добавляем дополнительный отступ между адресной строкой и инпутом в iOS 26.
Получаем:

На самом деле, iOS 26 даёт 3 разных расположения адресной строки. Но наши фиксы и хаки работают во всех режимах. Только добавление дополнительного отступа между инпутом и клавиатурой становится лишним в случае расположения адресной строки сверху. Но в этом случае больше — лучше, чем меньше. Да и понять, какой именно режим включён у пользователя, мы не сможем.

Вот и всё :)
Бонус. Маленькая модалка с оверлеем
Всё это время я разбирал кейс с фуллскрин-модалкой. Но что если она небольшая? Обычно мы добавляем оверлей, затемняющий задний фон. Но из-за зазоров это выглядит странно.

При этом наш фикс с покраской не сработает. Нам требуется полупрозрачный фон. В этом случае оверлей выглядит так:

Вдохновляясь этой статьёй, пойдём совсем по другому пути — не будем использовать полупрозрачный оверлей, а затемним сам body. В коде модалки добавим код, красящий body в полупрозрачный чёрный, а html покрасим в фон, который изначально был у body:
useLayoutEffect(() => { document.documentElement.style.backgroundColor = '#a1d5fa'; document.body.style.backgroundColor = 'rgba(0,0,0,0.5)'; return () => { document.documentElement.style.backgroundColor = ''; document.body.style.backgroundColor = ''; }; }, []);
А на весь контент внутри body (список карточек), повесим размытость:
.card-list.open-modal { filter: blur(4px); }
Получаем размытость как под модалкой, так и в области зазоров:

Эпилог. iOS 26.1, 26.2
Никогда не знаешь, что в Safari баг, а что — целенаправленное поведение. Если разрабатываешь pet-проект, можно забить и надеяться, что проблемы исправятся сами собой.
Однако, в большом production-проекте так сделать не получится. Нужно оперативно исправлять баги, мониторя форумы. Читать баг-репорты или писать их самостоятельно.
Если Apple подтверждает существование бага, придётся создавать жёсткие костыли и ждать обновления iOS. А если наличие бага не подтверждается или он тянется с прошлых мажорных версий операционки, нужно разрабатывать более вдумчивое и долгосрочное решение. В нашем случае я точно знал, что:
всё связанное с безопасной зоной (
safe-area-inset-top) иdvh— не проблема, а специфика iOS (и не только) ещё со времен iPhone X;неработающий
overwflow: hiddenживёт с нами много лет, так что решение нужно железобетонное;проблемы с зумом и сдвигом при фокусе на инпут — давние. У них уже есть популярные способы решения;
проблемы с зазорами, наезжающей адресной строкой и неправильным расчётом
viewport— баги, скорее всего. Я решил их с помощью жёстких костылей. Но исправила ли их Apple в новых версиях iOS?
Зазоры при закрытой клавиатуре исправлены:

А вот с открытой клавиатурой ситуация мало изменилась. Apple исправила только наезжающую на инпут адресную строку:

Причём на этом скриншоте видно, что нижний красный фон остаётся под клавиатурой.
А что насчёт бага при пересчёте viewport? Закомментируем часть кода, связанного с хаками при пересчёте высоты:
export const Modal: FC<Props> = ({ onClose }) => { const [height, setHeight] = useState<number>(); // const [offsetTop, setOffsetTop] = useState<number>(); // const isInputFocused = useRef(false); // const onInputFocus = (event: FocusEvent<HTMLInputElement>): void => { // isInputFocused.current = true; // ... // }; // const onInputBlur = (): void => { // isInputFocused.current = false; // setHeight(undefined); // setOffsetTop(undefined); // }; useLayoutEffect(() => { const onResize = (): void => { // if (!isInputFocused.current) { // return; // } setHeight(window.visualViewport?.height || undefined); // setOffsetTop(window.visualViewport?.offsetTop || undefined); }; window.visualViewport?.addEventListener('resize', onResize); return () => { window.visualViewport?.removeEventListener('resize', onResize); }; }, []); return createPortal( <ScrollLock style={{ height, top: offsetTop }}> <input type='text' onFocus={onInputFocus} // onBlur={onInputBlur} placeholder='Комментарий' /> </ScrollLock>, modalElement, ); };
И вот что мы увидим при сравнении iOS 18, iOS 26.0 и iOS 26.2:
В iOS 18 есть косяки с анимацией, но не сильно критичные. В iOS 26.0 модалкой невозможно пользоваться без хаков. А вот в iOS 26.2 всё выглядит довольно хорошо. Хоть анимация закрытия всё равно оставляет желать лучшего, но это вполне рабочее решение. Выходит, что пересчёт исправлен.
Когда пользователи обновятся до iOS 26.2, можно будет убрать хаки с расчётами offsetHeight и сбросом высоты в onBlur. И хотя анимация закрытия всё равно оставляет желать лучшего, этого вполне достаточно для комфортной работы. Но если это смущает, можно оставить один хак — с onBlur
Ну и последнее — зазоры у маленькой модалки не были исправлены:

Если вы дочитали до этого момента, то, во-первых, спасибо, а во-вторых, желаю вам никогда не испытывать проблемы с iOS 26 в 2026 году!
Понравилась статья? Накиньте плюсиков в карму! А если хотите быть в курсе всех последних новостей Dodo Engineering, подписывайтесь на наш Telegram-канал.
