PWA cài trên iPhone: cập nhật an toàn, deploy và những chi tiết của iOS

Cập nhật chờ người dùng, deploy làm hỏng tab đang mở, logo đo trên máy thật, ô nhập làm trang phóng to, và Web Push cho app local-first.

· Đăng ngày · Cập nhật · 8 phút đọc · Phiên bản: vite-plugin-pwa 2, Workbox 7 · Về dự án →

Một PWA được thêm vào màn hình chính của iPhone trông như app native, và người dùng đối xử với nó y như vậy: mở từ biểu tượng, để chạy nền nhiều ngày, và mong nó vẫn chạy ngon khi có bản mới. Kế hoạch tài chính, app lập kế hoạch thu chi của mình, chủ yếu được dùng theo cách đó. Bài này gom lại những gì cần làm để cập nhật an toàn, sống sót qua những lần deploy thay file ngay dưới một tab đang mở, và xử lý đúng các chi tiết của iOS: logo nhảy lên lúc mở app, ô nhập làm trang bị phóng to, và thông báo đẩy.

Cách app đồng bộ dữ liệu khi offline là một chủ đề khác, đã viết trong bài đồng bộ PWA local-first.

Cập nhật khi người dùng sẵn sàng, không phải giữa chừng

Service worker cache sẵn toàn bộ app, nên app mở được khi không có mạng và cả khi server chết. Mặt trái là bản mới không tự xuất hiện: trình duyệt tải nó về ở nền, và nó chờ tới khi các trang đang mở xong việc với bản cũ.

App tự quyết định lúc chuyển:

  • Tab đang hiện thì nhận một thông báo nhỏ "đã có bản mới", kèm nút Tải lại, nằm đó tới khi được bấm. Tự tải lại có thể làm mất một form đang gõ dở.
  • Tab đang ẩn thì chuyển luôn. Không ai nhìn, không có gì để làm gián đoạn.
  • Tab đang mở kiểm tra bản mới mỗi 30 phút và mỗi khi hiện lại, vì một app đã cài có thể mở liền nhiều ngày mà không có lần điều hướng nào để trình duyệt tự kiểm tra.

Còn có đường thủ công trong Cài đặt › Phiên bản và cập nhật. Mỗi lần build đều ghi ra một file /version.json không bao giờ bị cache, nên trang so được mã build của chính nó với bản mới nhất. Trang đó tách ba trạng thái rất dễ bị gộp nhầm: đã mới nhất, sẵn sàng (bản mới đã tải xong, áp dụng chỉ cần tải lại) và lỗi (có bản mới nhưng không tải được, ví dụ do mất mạng). Báo "đã mới nhất" khi việc tải thất bại là nói dối người dùng.

Cuối cùng, app gọi navigator.storage.persist() để trình duyệt không tự xoá dữ liệu IndexedDB khi thiếu dung lượng. Với app local-first, dữ liệu đó có thể là bản duy nhất của những thay đổi chưa kịp đồng bộ.

Một lần deploy có thể làm hỏng những tab đang mở

Lỗi như sau: một tab đang chạy bản của hôm qua chuyển sang một trang có code được tải lười (lazy). Tên file của đoạn code đó chứa mã băm nội dung của bản build hôm qua, và bản deploy hôm nay không còn file đó nữa. Quy tắc rewrite cho single-page app của host trả index.html cho mọi đường dẫn không tồn tại, nên trình duyệt nhận về HTML trong khi chờ JavaScript, không parse được, và trang hỏng.

Ba thay đổi sửa được lỗi này:

  1. File asset không còn thì trả 404 thật. Rewrite giờ bỏ qua /assets/, nên yêu cầu một chunk không còn tồn tại sẽ lỗi rõ ràng thay vì nhận về HTML.
  2. Cache theo đúng thứ có thể thay đổi. File có mã băm trong /assets/ được cache một năm với immutable; còn index.html, script service worker và version.json là no-cache, nên lần tải sau luôn hỏi bản hiện hành.
  3. Chunk tải lỗi thì chuyển tab sang bản mới nhất. Vite phát sự kiện vite:preloadError khi một dynamic import thất bại. App bắt sự kiện đó và kích hoạt service worker đang chờ nếu có, không thì tải lại trang.

Việc phục hồi có một chốt chặn, vì khi hệ thống sập thật thì chunk cũng tải lỗi, và tải lại vô tận chỉ làm mọi thứ tệ hơn:

const last = Number(sessionStorage.getItem(RELOAD_KEY)) || 0;
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
if (Date.now() - last < 30_000) {
  toast.error(t("pwa.loadFailed"), { id: "stale-version" });
  return false;
}

Tối đa một lần tự tải lại mỗi 30 giây; sau đó người dùng thấy thông báo lỗi và tự quyết định.

Sơ đồ tuần tự giữa một tab đang chạy bản build hôm qua và host đang phục vụ bản deploy hôm nay. Trước: tab xin một chunk có mã băm cũ, rewrite của SPA trả 200 kèm index.html, và trang hỏng vì nhận HTML thay cho JavaScript. Sau: cùng request đó nhận 404 vì rewrite bỏ qua /assets/, và handler của vite:preloadError kích hoạt service worker đang chờ hoặc tải lại, tối đa một lần mỗi 30 giây.Tab đang mởbản build hôm quaHostbản deploy hôm nayTrướcGET /assets/page-<mã băm cũ>.js200 index.html (rewrite của SPA)nhận HTML khi chờ JS:trang hỏngSauGET /assets/page-<mã băm cũ>.js404: rewrite bỏ qua /assets/vite:preloadError: kích hoạt service worker đangchờ hoặc tải lại, tối đa một lần mỗi 30 giây
Một lỗi 404 rõ ràng biến trang hỏng thành một lỗi mà app phục hồi được.

Logo nhảy lúc mở app: đo thay vì đoán

App hiện logo ngay từ lần vẽ đầu tiên, bằng HTML thuần nằm ngoài React, rồi làm mờ dần khi trang đầu tiên sẵn sàng. Trên app đã cài vào iPhone, logo nhảy lên thấy rõ một lúc sau khi mở.

Lần sửa đầu tiên dựa trên một giả định về nguyên nhân: thanh trạng thái xuất hiện một chút sau lần vẽ đầu và làm đổi mép trên của trang, nên logo được đặt theo mép dưới. Nó vẫn nhảy. Thế là một bản build tạm ghi lại kích thước thật trên máy, và các con số nói một chuyện khác hẳn: trang cao 844 px ở lần vẽ đầu và 797 px sau đó 74 ms. Thứ dịch chuyển là mép dưới, nó lùi lên qua thanh home indicator; còn mép trên và 100vh thì đứng yên.

Biết vậy rồi thì cách sửa đơn giản: ở chế độ standalone, splash là một khung cố định cao 100vh tính từ mép trên, và logo nằm giữa khung đó, thứ không bao giờ đổi:

@media (display-mode: standalone) {
  .splash {
    bottom: auto;
    height: 100vh;
  }
}

Logo vẫn xê dịch trên màn hình khi trang đổi kích thước, nên hai ngày sau có thêm một bước: trong app đã cài, một script thường nhỏ (public/splash.js) giấu logo cho tới khi trang có kích thước cuối cùng, tức là lúc trang đổi kích thước hoặc sau 300 ms nếu không có lần đổi nào, rồi cho logo hiện dần trong 150 ms.

Đoạn code đo đạc bị gỡ ngay ở commit liền sau commit thêm nó. Bài học mới là phần đáng giá: hai lời giải thích hợp lý cho một lần xê dịch bố cục đều sai, còn ghi lại innerHeight theo từng frame trong 4 giây đầu trên máy thật chỉ mất vài phút.

Ô nhập làm trang bị phóng to

iOS tự phóng to trang vào bất kỳ ô nhập nào có cỡ chữ dưới 16 px khi được chọn, cả trong Safari lẫn trong app đã cài. Ô nhập của app là 14 px, nên mỗi lần chạm vào một ô số tiền là trang phóng to và bố cục bị lệch.

Giờ ô nhập là 16 px trên điện thoại và 14 px từ breakpoint sm trở lên, và viewport đặt maximum-scale=1. Trên iOS, cách này chặn được việc tự phóng to mà vẫn cho phép chụm hai ngón để phóng to; còn Chrome trên Android sẽ tắt luôn thao tác chụm với cài đặt đó, trừ khi người dùng ép phóng to trong cài đặt trợ năng. Nên biết điều này trước khi chép thẻ meta đó.

Thanh tab thu nhỏ như của iOS

Trên điện thoại, app có thanh tab ở đáy, thu nhỏ khi cuộn xuống, giống thanh công cụ của chính Safari. Bản đầu tiên còn tự phóng to lại khi tới cuối các trang ngắn, và cú nảy (rubber-band) của iOS khi kéo quá cuối trang bị tính là cuộn lên. Những quy tắc làm nó có cảm giác native:

  • Chỉ cuộn lên mới đưa thanh đầy đủ trở lại, chạm cuối trang thì không.
  • Bỏ qua cú nảy khi kéo quá đầu hoặc quá cuối trang.
  • Phải di chuyển 24 px theo một hướng mới đổi trạng thái.
  • Trang cuộn được ít hơn 240 px thì luôn giữ thanh đầy đủ.
  • Mỗi trang bắt đầu với thanh đầy đủ; khôi phục vị trí cuộn cũ không tính là cuộn xuống.

Thông báo đẩy từ một app local-first

Trên iPhone, Web Push chỉ chạy với web app đã thêm vào màn hình chính, từ iOS 16.4. Nhắc nhở của app là những thứ như hoá đơn thẻ tín dụng đến hạn sau ba ngày, tháng trước chưa chốt sổ, hay một khoản tiết kiệm sắp đáo hạn.

Dữ liệu nằm trên thiết bị, nhưng điện thoại không chạy được code theo lịch từ một web app. Nên server tự tính ra các nhắc nhở từ những bản ghi đã đồng bộ, bằng chính engine tính toán mà app dùng (nó nằm trong một package dùng chung cho cả hai). Nhờ vậy không có thêm một bản cài đặt thứ hai, hơi khác đi một chút, của câu hỏi "hoá đơn này đến hạn khi nào". Vài chi tiết:

  • Mỗi ngày một thông báo, khoảng 8 giờ sáng giờ Việt Nam, gom mọi thứ đến hạn. Mỗi nhắc nhở được "giữ chỗ" trong một bảng trước khi gửi, nên một lần thử lại hay một instance server thứ hai không gửi trùng được.
  • Số tiền được ẩn mặc định, vì thông báo hiện trên màn hình khoá.
  • Declarative Web Push, có từ iOS 18.4, cho phép hệ thống tự hiện thông báo chỉ từ nội dung gửi tới, không cần đánh thức service worker. Trình duyệt khác nhận cùng nội dung đó qua một service worker đẩy nhỏ, nó hiện thông báo và mở đúng trang khi được chạm.
  • Đăng ký được dọn dẹp. Chỉ nhận endpoint thuộc dịch vụ push của các trình duyệt, bỏ khi nhận HTTP 404 hoặc 410, và bỏ nếu app không gia hạn trong 30 ngày. Đăng xuất là gỡ đăng ký của thiết bị đó.

Mở app sẽ xoá số đếm trên biểu tượng qua Badging API, nên con số trên biểu tượng chỉ là những nhắc nhở chưa xem.

Tóm tắt

Vấn đề App làm gì
Cập nhật làm gián đoạn Thông báo kèm nút Tải lại ở tab đang hiện, chuyển âm thầm ở tab ẩn, kiểm tra mỗi 30 phút và khi quay lại
Báo "đã mới nhất" khi không phải /version.json không bao giờ cache; tách rõ đã mới nhất, sẵn sàng và lỗi
Deploy làm hỏng tab đang mở 404 cho asset không còn, file có mã băm là immutable, phục hồi qua vite:preloadError với chốt 30 giây
Logo nhảy lúc mở app Đo trên máy thật: mép dưới dịch chuyển; logo nằm giữa khung 100vh, chỉ hiện khi trang đã đổi kích thước
Trang phóng to vào ô nhập Ô 16 px trên điện thoại, maximum-scale=1
Nhắc nhở cho app offline Server tính bằng engine dùng chung; giữ chỗ trước khi gửi; ẩn số tiền

Phần lớn những điều trên được phát hiện nhờ dùng app hằng ngày trên iPhone, chứ không phải nhờ thử trên trình duyệt desktop với cửa sổ cỡ điện thoại. Một PWA đã cài là một môi trường khác, và phải được thử như một môi trường khác.

Nếu bạn từng phát hành bản cập nhật cho một PWA mà người dùng cài sẵn trên iPhone, mình rất muốn được trao đổi. Email của mình ở trang chủ.