Особенности национальной верстки: PWA

Всем привет! В этой статье мы не будем в очередной раз размышлять о том, почему бизнес и пользователи все чаще начинают отдавать предпочтение PWA вместо мобильных приложений, какие у них есть плюсы, минусы и так далее.

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

Забегая вперед, сразу скажу, кому статья может быть полезна. Не столько важно, являетесь вы владельцем продукта или разработчиком. Если вы создаете PWA-приложение, в котором планируется реализовать открытие ссылок на сторонние ресурсы в браузере, вы можете столкнуться с неочевидной проблемой в верстке. Об этом я, frontend-разработчик IT-компании SimbirSoft Эльвина, расскажу в статье.

Дисклеймер:

И да, сразу оговорюсь, что это скорее сторителлинг, никакого rocket science тут не будет. Рассказываю о том, как баг нашли, локализовали и исправили. Статью написала, чтобы опытом поделиться. Ну и мемчиками вас позабавить.

Одной из важнейших, на мой взгляд, особенностей PWA является то, что оно выглядит как стандартное мобильное приложение: имеет отдельную иконку запуска на рабочем столе / главном экране, занимает всю его площадь, позволяя нам не захламлять экранное пространство браузерными элементами управления вроде адресной строки или панели вкладок, а сосредоточиться на приложении. В конце концов, мы можем практически полностью перенести функциональности мобильного приложения в PWA. Безусловно, ограничения (а точнее — невозможность реализации определенные фичи) все еще существуют. Но на данный момент технология PWA продолжает совершенствоваться и дополняться новыми фичами, что со временем приведет к тому, что PWA будут неотличимы от мобильного приложения.

А теперь перейдем к сути.

Для пользователя PWA очень удобен — функциональность мобильного приложения, а весит в 100500 раз меньше.

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

Для разработчика… А вот для разработчика это «веб со своими приколами», над которыми, зачастую, придется поломать голову.

285bdbdd947f98fa0b3b00d8b0d06890.jpg

Приведу пример того, над чем поломали голову мы.

Мы разрабатывали приложение bnpl-сервиса Подели, в котором пользователи могут добавлять карты для оплаты. Привязка осуществляется через эквайринг, который открывается по ссылке в браузере. После успешной (или неуспешной) привязки карты, нас должно перебросить обратно в приложение.

Это должно работать так↓

89e0c58fb6350ee63c6afa0c66411af5.png

А вот что увидели мы ↓

ed801305bd451975769402deaaf553c6.png

Причем баг пропадал после того, как пользователь сворачивал и снова разворачивал приложение. Ключевым моментом во всей этой истории было то, что баг воспроизводился ТОЛЬКО в PWA, в вебе все было ок.

Первая мысль была примерно такая: у нас есть элемент «подложка», скорее всего что-то вылезло, нужно фиксануть расположение элементов после возврата, и все будет четко.

Ну как звучит, так и оценили: «Вроде изян».

b945c657982d8d19cbfe4ac06b6e25af.png

Поскольку мы знали, в каком случае приложение «восстанавливает свою высоту», решение должно казалось простым — написать обертку для «условной» перезагрузки страницы. Выглядела она примерно так:

export const WithWindowPersistedCacheCheck = ({
  children,
}: {
  children: JSX.Element;
}) => {
  useEffect(() => {
    const handleWindowPersistCheck = (event: PageTransitionEvent) => {
      if (event.persisted) {
        location.reload();
      }
    };

    window.addEventListener("pageshow", handleWindowPersistCheck);
    return () => {
      window.removeEventListener("pageshow", handleWindowPersistCheck);
    };
  }, []);

  return children;
};

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

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

В этот момент мы и обнаружили самое интересное: элемент, который нам мешал, — вовсе не элемент. В этом месте вообще нет никаких элементов! Проблему обнаружили в процессе игры «Найди 10 отличий». Обратите внимание на 2 скриншота:

a6d3c3a4659bb7feb385ca79999ceb2b.png

Да-да, высота, которую у нас якобы занимала подложка, оказалась высотой элементов управления браузером!

Собственно, мы подошли к моменту кульминации понимания нашей проблемы: при открытии внешней ссылки в браузере из PWA, при возвращении PWA «думает», что оно обычное веб-приложение и перестает занимать всю высоту экрана. Иными словами, оно резервирует место на экране под браузерные элементы управления.

Первое, с чего мы начали, — попытались вычислить высоту элементов управления браузером.

Высота панели навигации:

window.outerHeight - window.innerHeight;

Высота верхней панели:

window.screen.availHeight - window.outerHeight;

На основе этих данных мы надеялись получить недостающую высоту приложения и увеличить на это значение высоту окна браузера, получив таким образом искомую высоту PWA.

Проблема этого решения заключалась в том, что:

— во-первых, не у всех браузеров есть верхняя панель (зависит от ОС и оболочки),

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

Нужно было конкретно определять, находимся ли мы в режиме PWA (полноэкранный режим).

Для этого существует 2 способа:

• первый — из CSS

display-mode: standalone

• второй — из JS

navigator.standalone

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

Для второго варианта мы попытались создать resize-хук:

export const usePWAResize = () => {
  const [minAppHeight, setMinAppHeight] = useState(0);

  const calculateTargetHeight = () => {
    let windowHeight = window.innerHeight;
    if (window.visualViewport) {
      windowHeight += window.visualViewport.offsetTop;
    }
    if (navigator.standalone) {
      windowHeight += window.outerHeight - window.innerHeight;
    } else {
      windowHeight +=
        window.screen.height -
        window.innerHeight -
        window.visualViewport?.offsetTop;
    }
    setMinAppHeight(windowHeight);
  };

  useEffect(() => {
    calculateTargetHeight();
    window.addEventListener("resize", calculateTargetHeight);
    return () => {
      window.removeEventListener("resize", calculateTargetHeight);
    };
  }, []);

  return minAppHeight;
};

Из него мы получили искомую высоту приложения и подставили её инлайном к нашему элементу-обёртке.

Это даже как-то заработало, но ключевое слово тут «как-то». Как я сказала ранее, высота панелей навигации у всех браузеров разные +, а у каких-то её нет совсем. Из-за этой особенности на некоторых устройствах верстка вставала как надо, а на других что-то да выпадало.

59a32a7ea1fa808d518b7a5719eaf346.png

Но самой главной проблемой было то, что это не работало на устройствах Android — в браузере этой ОС не существует свойства standalone у интерфейса navigator. Он есть только в safari на iOS (яблочники ликуют :))

Нужно было решение более простое и универсальное.

Для начала мы решили вывести все известные нам высоты в режимах PWA и веб:

screen.availHeight

screen.height

window.outerHeight

document.getElementById («wrapper»)?.offsetHeight

Получается, что document.getElementById («wrapper»)?.offsetHeight — высота «обертки» нашего приложения. Именно это значение и отличалось в зависимости от режима приложения, но всегда оставалось корректным.

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

Решение не элегантное, зато выглядит как то, что должно работать) Пробуем:

useEffect(() => {
    const pageWrapper = document.getElementById("wrapper");
    const currentHeight = pageWrapper?.offsetHeight;
    let initialHeight = sessionStorage.getItem("initialHeight") || "0";
    let parsedInitialHeight = initialHeight || 0;

    if (initialHeight === "undefined" || initialHeight === "0") {
      sessionStorage.setItem("initialWrapperHeight", JSON.stringify(currentHeight));
      initialHeight = JSON.parse(
        sessionStorage.getItem("initialWrapperHeight") as string
      );
    }
    if (initialHeight !== "undefined" && initialHeight !== "0") {
      parsedInitialHeight = initialHeight;
    }
    if (
      Number(parsedInitialHeight) &&
      currentHeight &&
      Number(parsedInitialHeight) > currentHeight
    ) {
      pageWrapper?.setAttribute("style", `min-height: ${initialHeight}px`);
    }
  }, []);

Добавив этот эффект и обернув наш компонент в ХОК WithWindowPersistedCacheCheck, мы добились нужного нам поведения. Причем как в вебе (его эти изменения не затронули, т.к. он работал изначально корректно), так и в PWA.

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

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

Спасибо за внимание!

© Habrahabr.ru