Trải nghiệm đặt hàng bị vỡ vì một nút Back: Khi cách fix tiện nhất là khởi đầu của bug hồi quy
Một bug nghe rất quen thuộc
Hãy tưởng tượng bạn đang phát triển một website thương mại điện tử. Một ngày nọ, tester gửi cho bạn một bug ticket:
Các bước tái hiện:
- Vào Giỏ hàng (
/cart).- Bấm "Thanh toán" để chuyển sang Form điền địa chỉ & thanh toán (
/checkout).- Điền thông tin và bấm "Xác nhận đặt hàng".
- Hệ thống tạo đơn thành công và chuyển người dùng sang trang Chi tiết đơn hàng (
/order/123).- Tại đây, người dùng bấm nút "Quay lại" (Back) trên giao diện.
Lỗi gặp phải: Màn hình quay trở lại Form thanh toán (
/checkout) vừa nhập thay vì quay về Giỏ hàng hay Danh sách đơn hàng.
Phản xạ tự nhiên của hầu hết chúng ta — và của cả các công cụ AI khi nhận yêu cầu này — là mở ngay component của Trang Chi tiết đơn hàng (OrderDetailComponent), tìm hàm xử lý nút Back:
// order-detail.component.ts
onBack(): void {
if (window.history.length > 1) {
this.location.back(); // hoặc window.history.back()
} else {
this.router.navigate(['/orders']);
}
}
Suy nghĩ xuất hiện ngay lúc đó: "À, nút Back đang gọi location.back(), bảo sao nó quay lại trang Checkout trước đó. Chỉ cần đổi thành this.router.navigate(['/orders']) chuyển thẳng về Danh sách đơn hàng là xong!"
Sửa xong, thử lại luồng vừa rồi: Bấm Back -> Về danh sách đơn hàng. Ticket được đóng, ai cũng vui vẻ.
Nhưng vài ngày sau, một bug khác xuất hiện:
"Khách hàng đang ở màn hình Quản lý đơn hàng (
/orders), đã lọc danh sách theo 'Đơn đang giao' và đang ở trang số 3. Khách bấm vào xem đơn#123. Sau khi xem xong, bấm Quay lại thì bị văng về Trang chủ (hoặc trang 1 chưa lọc), mất sạch toàn bộ bộ lọc và vị trí đang xem."
Lúc này bạn mới nhận ra: đoạn code location.back() trước đó không hề viết ẩu. Người đồng nghiệp trước đã cố tình dùng nó để người dùng khi duyệt đơn hàng từ danh sách có thể quay lại đúng trang số 3 và giữ nguyên bộ lọc.
Việc sửa cứng onBack() ở trang Chi tiết chỉ là chữa triệu chứng. Và trong lập trình web, chữa triệu chứng ở một màn hình dùng chung luôn là cách nhanh nhất để tạo ra bug hồi quy (regression bug).
Chuyện gì thực sự xảy ra trong ngăn xếp trình duyệt?
Để hiểu tại sao bug xuất hiện, hãy nhìn vào cách trình duyệt lưu trữ lịch sử duyệt trang (History Stack) trong một ứng dụng Single Page Application (SPA).
Khi người dùng đi qua luồng mua hàng:
- Đang ở Giỏ hàng (
/cart). - Chuyển sang Form thanh toán (
/checkout). - Điền thông tin, bấm Xác nhận đặt hàng.
- Ứng dụng gọi
router.navigate(['/order', 123])để mở trang Chi tiết đơn hàng.
Lúc này, History Stack của trình duyệt trông như sau:
[Giỏ hàng] -> [Form thanh toán (đã hoàn tất)] -> [Chi tiết đơn hàng #123]
Bản chất của một trang Checkout sau khi đã thanh toán thành công là: Nó là một trạng thái đã chết (dead state). Người dùng không còn bất kỳ lý do gì để quay lại một form thanh toán vừa được xử lý xong.
Nếu bạn để nguyên trang Checkout trong history stack, thì ngăn xếp lịch sử đã bị "rác". Khi người dùng đứng ở trang Chi tiết và bấm Back (dù là nút Back trên UI hay nút Back vật lý của trình duyệt), trình duyệt chỉ đơn giản là lùi lại 1 bước — và bước đó chính là trang Checkout vừa thanh toán.
Gốc rễ của vấn đề không nằm ở trang Chi tiết đơn hàng. Gốc rễ nằm ở thời điểm Form thanh toán kết thúc nhiệm vụ của nó.
Lời giải chuẩn kiến trúc: replaceUrl
Thay vì điều hướng sang trang Chi tiết bằng cách "đẩy thêm một trang mới vào ngăn xếp" (pushState), ta cần bảo với Router: "Hãy thay thế trang Form thanh toán đã hoàn tất này bằng trang Chi tiết đơn hàng mới tạo."
Trong hầu hết các Router hiện đại (như Angular Router, React Router, Next.js hay Vue Router), điều này được xử lý thông qua cờ replace / replaceUrl:
// checkout.component.ts (Sau khi gọi API thanh toán thành công)
this.router.navigate([`/order`, orderId], {
replaceUrl: true // Trong React Router sẽ là: navigate(`/order/${orderId}`, { replace: true })
});
Khi có replaceUrl: true, History Stack sẽ biến đổi như sau:
Trước khi bấm đặt hàng:
[Giỏ hàng] -> [Form thanh toán]
Sau khi đặt hàng thành công (với replaceUrl):
[Giỏ hàng] -> [Chi tiết đơn hàng #123] <-- Form thanh toán đã bị thay thế hoàn toàn
Bây giờ, hãy nhìn lại trang Chi tiết đơn hàng. Logic location.back() ban đầu trở nên hoàn hảo cho mọi luồng:
- Luồng Mua hàng:
[Giỏ hàng] -> [Thanh toán] -> [Chi tiết](Form thanh toán bị thế chỗ). Khi ở Chi tiết bấm Back -> Quay thẳng về[Giỏ hàng]. - Luồng Quản trị/Lịch sử:
[Danh sách đơn (trang 3)] -> [Chi tiết]. Bấm Back -> Quay về đúng[Trang 3]với đầy đủ bộ lọc cũ.
Một dòng cấu hình đúng chỗ tại nơi bắt đầu luồng dữ liệu (Form component) đã giải quyết triệt để vấn đề mà không làm ảnh hưởng đến bất kỳ màn hình nào khác.
Bảng tra cứu: Khi nào nên và không nên dùng replaceUrl?
| Kịch bản | Dùng replaceUrl: true? |
Tại sao? |
|---|---|---|
| Hoàn tất Form tạo mới / Thanh toán chuyển sang trang Chi tiết/Thành công | CÓ | Form đã xong vòng đời, không được để user bấm Back quay lại form rỗng/đã gửi. |
| Đăng nhập (Login) chuyển về Trang chủ / Dashboard | CÓ | Đã đăng nhập rồi thì không có lý do gì bấm Back lại văng về màn hình Login. |
| Thay đổi Bộ lọc (Filter) / Tìm kiếm / Đổi Tab trên cùng một trang | CÓ | Giúp URL cập nhật theo filter nhưng không làm user phải bấm Back 10 lần mới thoát khỏi trang. |
| Từ Danh sách bấm vào xem một phần tử | KHÔNG | Phải giữ Danh sách trong history để user có thể bấm Back quay lại bất cứ lúc nào. |
| Form nhiều bước (Multi-step Wizard) đang nhập dở | KHÔNG | User cần bấm Back để quay lại bước 1 chỉnh sửa thông tin trước khi submit. |
Bài học khi sửa code cùng AI Agent trên codebase lạ
Tình huống này phản ánh một thực tế rất phổ biến khi làm việc với AI: AI rất giỏi giải quyết bài toán bạn đưa ra, nhưng nó chỉ có góc nhìn cục bộ dựa trên câu hỏi hiện tại chứ không tự động biết lịch sử của hệ thống.
Nếu bạn đưa cho AI một bug ticket: "Ở màn hình Chi tiết bấm Back đang bị sai, hãy sửa nó", AI sẽ nhảy vào sửa hàm onBack() ở trang Chi tiết ngay lập tức, vì đó là cách trực tiếp nhất để làm thỏa mãn câu lệnh của bạn.
Để tránh những pha "chữa lợn lành thành lợn què" khi pair-programming với AI, đây là 3 thói quen bạn nên áp dụng:
1. Yêu cầu AI kiểm tra Git History trước khi sửa code lạ
Khi thấy một đoạn code trông có vẻ rườm rà (ví dụ vừa kiểm tra history, vừa dùng location.back()), đừng vội xóa. Hãy yêu cầu AI:
"Chạy
git loghoặcgit blametrên method này xem người trước viết nó với mục đích gì, có commit nào giải thích ngữ cảnh không?"
Một commit message như fix: keep table filters when navigating back from detail sẽ ngay lập tức nhắc bạn và AI nhớ về các luồng khác đang tồn tại.
2. Mô tả bài toán theo luồng người dùng (User Journey) thay vì chỉ định chỗ sửa
- Cách chưa tốt: "Sửa nút Back ở component Chi tiết để navigate về trang Danh sách." (Ép AI sửa cục bộ).
- Cách tốt: "Sau khi Submit Form thanh toán thành công và chuyển sang màn Chi tiết, bấm Back đang quay lại Form cũ. Hãy phân tích toàn bộ luồng điều hướng và tìm nguyên nhân gốc rễ sao cho không làm hỏng luồng từ Danh sách vào Chi tiết."
3. Câu hỏi phản biện trước khi Commit (Sanity Check)
Trước khi lưu code do AI tạo ra, hãy dành 5 giây hỏi lại:
"Giải pháp này có gây ra side-effect (tác dụng phụ) cho các màn hình khác đang mở component này không? Có giải pháp nào xử lý từ gốc luồng điều hướng thay vì sửa ở màn hình cuối không?"
Tóm lại
Một dòng lệnh location.back() hay router.navigate() nhìn thì rất đơn giản, nhưng đằng sau nó là toàn bộ trải nghiệm điều hướng của người dùng.
Lần tới khi gặp lỗi liên quan đến việc "bấm Back nhảy sai trang", đừng vội sửa nơi nút Back đang đứng. Hãy nhìn lại toàn bộ hành trình duyệt trang và tự hỏi: "Màn hình trước đó đã kết thúc nhiệm vụ chưa, và nó có xứng đáng tiếp tục nằm trong lịch sử duyệt web của người dùng hay không?"
All rights reserved