Nội dung bài viết

Mình có một widget chat nhúng vào website khác. Nó chạy trong iframe, bên trong có nút gọi hotline, đúng một thẻ <a href="tel:..."> bọc cái icon SVG. Đơn giản tới mức không ai nghĩ nó hỏng được.

Vậy mà nó hỏng. Trên iPhone bấm vào không có gì xảy ra. Android và desktop vẫn gọi bình thường.

Mất khá lâu mình mới nhận ra "bấm không có gì" ở đây không phải một lỗi, mà là ba lỗi khác nhau nằm đè lên nhau. Và cái thứ ba thì không nằm trong code.

Lỗi 1: icon SVG ăn mất cú bấm

Cấu trúc ban đầu:

<a href="tel:19001234" class="call-button" aria-label="Gọi hotline 1900 1234">
  <svg class="call-button__icon" aria-hidden="true">…</svg>
</a>

Icon phủ gần kín nút, nên hầu hết cú bấm rơi trúng nó chứ không trúng thẻ <a>. Safari coi phần tử SVG con là một đích ngắm riêng: event.target sẽ là <svg> hoặc <path>, không phải cái link.

Với link https:// thì chuyện này vô hại, sự kiện vẫn nổi lên và trình duyệt vẫn điều hướng. Nhưng với các giao thức ngoài web như tel:, Safari khó tính hơn nhiều và cú bấm không kích hoạt gì cả.

Cách sửa chỉ một dòng, nhưng phải đặt đúng chỗ:

/* Đúng: icon trong suốt với chuột, cú bấm rơi thẳng vào thẻ <a> */
.call-button__icon {
  pointer-events: none;
}

Đừng đặt pointer-events: none lên chính .call-button, vì như thế là giết luôn cả cái nút.

Icon ở đây thuần trang trí, đã có aria-hidden="true" và link đã có aria-label, nên không mất gì về khả năng truy cập.

Sửa xong mình khá chắc là đã xong. Không hề.

Lỗi 2: WebKit chặn giao thức ngoài web trong iframe khác origin

Vẫn không gọi được. Nhưng lần này có một dấu hiệu rất quý mà mình suýt bỏ qua:

Bấm thì không có gì, nhưng giữ lâu vào nút thì Safari hiện menu có mục "Call".

Câu đó nói lên một điều rất cụ thể: Safari hiểu đó là số điện thoại. Nó phân tích được href, nó biết phải làm gì. Nó chỉ từ chối làm.

Đây không phải lỗi phân tích cú pháp, đây là chính sách. WebKit chặn điều hướng tới các giao thức ngoài web (tel:, mailto:, sms:, app scheme) khi điều hướng đó nằm trong một iframe khác origin. Widget của mình chạy ở chat.example.com nhúng vào example.com: khác origin, dù cùng tên miền gốc.

Cái làm mình mất thời gian là trong code có sẵn một comment nói rằng chuyện này không thể xảy ra:

// Cùng frame thì Safari nhận ra giao thức và mở app gọi;
// khung nhúng không đặt `sandbox` nên điều hướng trong iframe không bị chặn.

Comment đó sai. WebKit chặn theo origin của khung, chẳng liên quan gì tới sandbox. Nhưng nó được viết rất tự tin, lý lẽ nghe hợp lý, nên nó đẩy mình đi tìm ở chỗ khác mất một lúc lâu.

Một comment sai nguy hiểm hơn hẳn không có comment nào. Không có comment thì người ta đi đọc code. Có comment sai thì người ta tin.

Ba giá trị target, ba kết quả khác nhau

Đây là phần mình nhớ nhất trong cả vụ này. Với link tel: đặt trong iframe trên iOS:

targetChuyện gì xảy ra
_blankSafari mở một tab trống rồi không có gì để điều hướng. Trình gọi điện không bật lên.
Để trốngĐiều hướng nằm trong iframe nên WebKit chặn. Bấm không có gì.
_topĐiều hướng ở khung ngoài cùng, iOS bắt được và mở app gọi.

Chỗ dễ đi sai: bỏ _blank là đúng, nhưng chưa đủ. Rất nhiều câu trả lời trên mạng dừng ở "đừng dùng target="_blank" cho tel:". Đúng, nhưng nếu bạn đang ở trong iframe thì để trống vẫn hỏng. Phải là _top.

// Chọn theo GIAO THỨC, không theo tên kênh,
// vì mailto: và sms: dính đúng lỗi này
const props = isExternalScheme(url)
  ? { target: "_top" }
  : { target: "_blank", rel: "noopener noreferrer" };

Lo lắng thường gặp: _top có làm trang cha bị chuyển đi mất không? Không. Hệ điều hành nuốt luôn giao thức tel: trước khi nó kịp thành một lượt điều hướng thật, trang cha đứng yên. Còn khi widget được mở thẳng chứ không nhúng, _top chính là khung hiện tại, nên chẳng đổi gì.

Lỗi 3: thứ không nằm trong code

Sửa xong _top, mình bấm thử. Lần này có phản hồi, nhưng là một hộp thoại lỗi:

Safari cannot open the page because the address is invalid.

Thoạt nhìn thì tệ hơn trước: từ "không có gì" thành "báo lỗi đỏ". Suýt nữa mình đã lùi lại bản cũ.

May là trong ảnh chụp màn hình có một chi tiết: thanh tiêu đề của trình gỡ lỗi ghi chữ VIRTUAL, kèm tên máy iPhone 15 Pro Max và phiên bản 17.5.

Đó là iOS Simulator. Và Simulator không có app Điện thoại.

Không có app nào đăng ký nhận giao thức tel: thì Safari chỉ còn biết báo "địa chỉ không hợp lệ". Trên máy thật, cùng cú bấm đó sẽ ra hộp xác nhận rồi gọi.

Chi tiết này giải thích luôn một triệu chứng mà mình đã hiểu sai ngay từ đầu:

"iOS 15.5 trở lên đều không gọi được. Thử cả 17.5 cũng không được."

Nghe như lỗi phiên bản. Thật ra là mọi phiên bản Simulator đều thế, vì không phiên bản nào có app Điện thoại. Khi một triệu chứng xuất hiện ở mọi phiên bản thì khả năng cao thủ phạm không phải phiên bản, mà là thứ gì chung của cả môi trường test.

Xác nhận lần cuối

Giữ lâu vào nút trên Simulator, menu hiện ra:

1 (900) 232-343
├─ Send Message
├─ Add to Contacts
└─ Copy Phone Number

Thiếu đúng mục "Call". Trên iPhone thật, đó là mục đầu tiên.

Ghép lại thì thành một chuỗi khép kín:

  1. Trước khi sửa: bấm im lặng, giữ lâu có menu. Safari hiểu là số điện thoại nhưng cố tình không đi, tức là bị chặn.
  2. Sau khi sửa: bấm ra "address is invalid". Điều hướng đã ra tới hệ điều hành, không còn bị chặn nữa.
  3. Giữ lâu: menu thiếu mục "Call". Hệ điều hành không có app để nhận.

Cả ba cùng chỉ về một kết luận: code đã đúng, chỉ thiếu một cái máy có app Điện thoại. Hộp thoại lỗi kia không phải bước lùi, nó là bằng chứng bản sửa đã chạy.

Bảng tra nhanh theo triệu chứng

Nếu bạn đang gặp tel: không chạy trên iOS, đây là cách đọc triệu chứng:

Triệu chứngNghi can
Bấm không có gì, giữ lâu cũng không ra menuCú bấm chưa tới được thẻ <a>, nghi phần tử con ăn mất (lỗi 1)
Bấm không có gì, giữ lâu có menu đủ mụcSafari hiểu nhưng từ chối đi, nghi iframe khác origin (lỗi 2)
Bấm ra "address is invalid"Điều hướng đã tới hệ điều hành nhưng không có app nhận, nghi Simulator (lỗi 3)
Giữ lâu ra menu thiếu mục "Call"Gần như chắc chắn là Simulator
Mở tab trống rồi đứng imĐang dính target="_blank"

Một phụ thuộc vô hình cần canh

Bản sửa _top sống được là nhờ iframe không có thuộc tính sandbox.

Hễ thêm sandbox vào, khung mất mọi quyền mặc định, trong đó có quyền điều hướng khung ngoài cùng. Nút gọi chết ngay, và không có lỗi nào trong console. Bấm vào chỉ đơn giản là không có gì.

Đây đúng kiểu thay đổi mà sáu tháng sau sẽ có người làm với một lý do rất chính đáng ("siết bảo mật cho iframe"), rồi không ai nối được nó với việc hotline ngừng hoạt động. Nên mình ghi hẳn một cảnh báo ngay tại chỗ tạo iframe, kèm bộ cờ tối thiểu phải khai bù nếu thật sự cần dùng sandbox:

<iframe
  src="https://chat.example.com/widget"
  title="Chat hỗ trợ"
  sandbox="allow-scripts allow-same-origin allow-popups allow-top-navigation-by-user-activation"
></iframe>

Thiếu allow-top-navigation-by-user-activation là mất nút gọi.

Đừng "thông minh" hơn mức cần thiết

Khi thấy hộp lỗi ở lỗi 3, phản xạ tự nhiên là: thôi bắt sự kiện click bằng JS rồi tự đẩy tel: ra chỗ khác. Đừng. Hai lý do:

Nó không cứu được thứ bạn đang thấy. Mọi con đường đều kết thúc ở cùng một chỗ là giao tel: cho hệ điều hành. Simulator không có app Điện thoại, nên dù đi bằng window.top.location, window.open hay một thẻ <a> ẩn thì nó vẫn báo đúng câu lỗi đó.

Nó có thể làm hỏng máy thật. iOS chỉ mở app gọi khi cú bấm còn nguyên cử chỉ người dùng. Chuyển sang xử lý bằng JS, nhất là khi có preventDefault hay bất kỳ bước bất đồng bộ nào, rất dễ đánh mất cử chỉ đó. Bạn đổi một nút "không chạy trên Simulator" lấy một nút "không chạy trên iPhone thật". Vụ trao đổi quá tệ.

Thẻ <a href="tel:..." target="_top"> trần là đúng rồi, để yên cho nó.

Ba điều mình mang đi

Một triệu chứng có thể là nhiều lỗi chồng lên nhau. Sửa xong lỗi 1 mà vẫn hỏng không có nghĩa là lỗi 1 sửa sai. Rất dễ lùi lại một bản sửa đúng chỉ vì nó chưa làm triệu chứng biến mất.

Lỗi hiện ra có thể là tiến bộ. Từ "im lặng" sang "báo lỗi" thường nghĩa là bạn đã đẩy được vấn đề đi xa thêm một chặng. Đọc kỹ nội dung lỗi trước khi lùi.

Luôn hỏi "test trên máy thật hay máy ảo" trước khi chẩn đoán. Với các giao thức mở app ngoài trình duyệt, môi trường test có thể chính là thủ phạm. Và khi có ai báo "mọi phiên bản đều hỏng", hãy nghi ngờ môi trường trước khi nghi ngờ phiên bản.

Sau tất cả, bản sửa cuối cùng gói lại chỉ có hai dòng: một pointer-events: none và một target="_top". Phần còn lại của bài viết này là thời gian đi tìm chúng.

Nếu bạn từng gặp ca tel: nào lạ hơn, để lại nhận xét cho mình biết nhé.