Học từ web lớn #7: Countdown có time-travel và tombstone giỏ hàng (Best Buy)
Hai mẹo nhỏ từ Best Buy Canada: một offset thời gian chung để QA xem trước banner flash sale, và dòng giữ chỗ khi xoá món khỏi giỏ để trang không nhảy.
Trang thương mại điện tử có hai việc nghe rất bình thường nhưng hay làm khổ cả dev lẫn QA:
- Banner chạy theo lịch. "Black Friday bắt đầu sau 2 ngày 03:12:45", tới giờ thì đổi sang "Đang sale, kết thúc sau…", hết giờ thì ẩn. Làm sao QA kiểm tra được trạng thái lúc 0 giờ ngày thứ Sáu khi hôm nay mới thứ Ba? Đổi giờ máy tính thì phá luôn HTTPS, cookie, token đăng nhập.
- Xoá một món khỏi giỏ. Server trả về giỏ mới, món đó biến mất, mọi dòng bên dưới nhảy lên. Người dùng mất dấu: "mình vừa xoá đúng cái nào?", và nút "Xoá" của dòng tiếp theo trượt đúng vào chỗ con trỏ đang đứng.
Best Buy Canada (bestbuy.ca) có lời giải gọn cho cả hai. Bài này đọc code của họ, chỉ ra vài chỗ nên làm khác, rồi viết lại bằng React + TypeScript.
Tip
Tóm tắt
- Mọi chỗ cần "bây giờ" (countdown, banner theo lịch) đọc qua một đồng hồ chung có offset. QA đổi offset là cả trang "du hành" tới thời điểm đó.
- Bật công cụ đó bằng một cờ trên URL (Best Buy:
?contentProofing=true), và nhớ rằng nó chỉ để xem trước, không phải bảo mật. - Countdown: làm tròn lên tới giây, hẹn giờ theo ranh giới giây, có trạng thái "đã hết".
- Xoá khỏi giỏ: chèn lại dòng đã xoá dưới dạng tombstone (dòng giữ chỗ) đúng vị trí cũ, hiện "Đã xoá X".
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Best Buy làm thế nào?
#1. Một offset thời gian cho cả trang
Hook đếm ngược nằm ở client/components/banners/components/Countdown/hooks/countdownHook.ts (thư mục gốc của dump tên là src(1)):
const getRemainingTime = (toDate: Date) => {
const globalOffset = (typeof window !== "undefined" && window.globalDateOffset) || 0;
return toDate.getTime() - new Date().getTime() + globalOffset;
};
const countdownHook = (toDate: Date): TimeFormat => {
const [timer, setTimerState] = React.useState(formatTime(getRemainingTime(toDate)));
let interval: number = 0;
// …
React.useEffect(() => {
const remainingTime = getRemainingTime(toDate);
if (remainingTime >= 0) {
setTimer(remainingTime);
interval = window.setInterval(() => setTimer(getRemainingTime(toDate)), SECOND);
}
return () => window.clearInterval(interval);
}, []);
return timer;
};
ts
Biến toàn cục window.globalDateOffset mặc định là 0. Hook chọn giai đoạn của banner timeline (Timeline/hooks/syncActiveStage.ts) cũng đọc đúng biến đó:
const now = new Date().getTime() - globalOffset;
return stages.findIndex((stage) => now >= stage.onsetTime && (now <= stage.offsetTime || !stage.offsetTime));
ts
Ai gán biến này? Một widget QA tên DateOverride (components/banners/components/DateOverride/index.tsx): một form nhỏ cho nhập "MM/DD/YYYY - HH:MM:SS" kèm AM/PM, submit thì:
setTime(new Date().getTime() - formattedDate.getTime()); // offset = giờ thật - giờ muốn xem
const setTime = (offset: number) => {
window.globalDateOffset = offset;
setCurrentTime(new Date().getTime() - offset); // hiện "Currently Viewing: …"
};
ts
Và widget chỉ hiện khi URL có cờ, trong components/App/index.tsx:
const ContentProofing: React.FC = () => {
if (typeof window !== "undefined" && window.location.search.toLowerCase().indexOf("contentproofing=true") >= 0) {
return <DateOverride />;
}
return null;
};
tsx
Tên ContentProofing nói rõ mục đích: đội nội dung duyệt trước (proofing) banner, timeline, countdown của một chiến dịch ở đúng ngày giờ nó sẽ chạy, ngay trên môi trường thật, không cần dev đổi code hay đổi giờ server.
Nhìn kỹ thì có vài chỗ nên làm khác:
- Dấu của offset ngược và lặp lại ở nhiều nơi.
offset = giờ thật - giờ giả, nên countdown phải cộng offset, còn timeline phải trừ. Hai file, hai dấu khác nhau cho cùng một ý "bây giờ". Gom vào một hàmnow()thì không ai phải nhớ dấu. - Đổi offset không làm component render lại. Countdown chỉ cập nhật ở tick
setIntervalkế tiếp; timeline thì ở tick kế tiếp của chu kỳcheckIntriêng của nó. Với QA, một giây trễ không sao, nhưng đây là dấu hiệu "offset không phải state". useEffect(..., []): đổitoDate(ví dụ CMS trả lịch mới) thì hook vẫn đếm tới ngày cũ.- Hết giờ không có trạng thái riêng.
setTimerbỏ qua giá trị âm, interval vẫn chạy mãi (chỉ được dọn khi unmount) và màn hình đứng ở0:00:00. Việc chuyển banner sang "đã kết thúc" phụ thuộc hoàn toàn vào hook timeline. Math.floortheo giây làm countdown hiện00:00:00trong suốt giây cuối cùng, khi sale chưa bắt đầu.
#2. Tombstone khi xoá khỏi giỏ
business-rules/use-cases/CartRemover.ts (dù đuôi .ts, nội dung là JavaScript đã biên dịch, có cả __awaiter):
this.removeItem = (lineItemId, prevCart, regionCode, postalCode, locale) =>
this.apiBasketProviderFactory.make(regionCode, postalCode, locale)
.removeCartItem(cartId, lineItemId)
.then((res) => {
const prevShipments = this.flagRemovedLineItem(prevCart.shipments, lineItemId);
res.shipments = mergePrevShipment(res.shipments, prevShipments);
resolve(res);
});
this.flagRemovedLineItem = (shipments, lineItemId) => shipments.map((shipment) => {
shipment.lineItems = shipment.lineItems.map((lineItem) =>
lineItem.id === lineItemId ? { ...lineItem, removed: true } : lineItem);
return shipment;
});
js
(đã rút gọn). Giỏ hàng chia theo shipment, mỗi shipment là một người bán (Best Buy hoặc người bán trên marketplace). Sau khi API xoá trả về giỏ mới (không còn dòng đó), CartRemover lấy giỏ cũ, đánh dấu dòng vừa xoá removed: true, rồi mergePrevShipment trong cartMerger.ts chèn nó trở lại:
export const mergePrevShipment = (newShipments, oldShipments) => {
if (!newShipments || !newShipments.length) return oldShipments; // giỏ rỗng: giữ giỏ cũ
let returnShipments = [...newShipments];
oldShipments.forEach((oldShipment, shipmentIndex) => {
oldShipment.lineItems.forEach((lineItem, lineItemIndex) => {
if (lineItem.removed) {
const i = _.findIndex(returnShipments, { seller: { id: oldShipment.seller.id } });
returnShipments = i > -1
? insertLineItemIntoShipment({ lineItem, lineItemIndex, matchingShipmentIndex: i, returnShipments })
: insertLineItemAndShipment({ lineItem, oldShipments, returnShipments, shipmentIndex });
}
});
});
return returnShipments;
};
js
Ở UI, LineItem.tsx thấy removed thì thêm class removedItem vào khung dòng. CSS (LineItem/styles.css) chạy một animation "quét" 1 giây trên dòng (swipeDeletedItem), thu phần nội dung về max-height: 0, và cho hiện khối RemovedItemMessaging vốn được render sẵn nhưng ẩn (height: 0; opacity: 0): "{tên sản phẩm} was removed from your cart", kèm thông báo riêng nếu món đó có gói bảo hành hoặc quà tặng kèm cũng bị xoá theo. Trang giỏ rỗng (EmptyCart) cũng render danh sách thông báo này, nên xoá món cuối cùng vẫn thấy được mình vừa xoá gì.
Vì giỏ cũ ở lần xoá sau đã chứa các tombstone trước, các thông báo cứ thế tích luỹ đúng chỗ qua nhiều lần xoá liên tiếp. Các chỗ đếm số dòng ở trang giỏ (CartPage.tsx, selectors) lọc !li.removed trước khi đếm.
Hai điểm nhỏ: flagRemovedLineItem gán đè shipment.lineItems trên object của giỏ cũ (mutation), và Best Buy không có nút "Hoàn tác".
#Bên trong trình duyệt: hẹn giờ và layout shift
setInterval(fn, 1000) không chạy đúng mỗi 1000ms. Mỗi lần gọi có thể trễ vài ms (main thread bận), tab nền bị giới hạn tối đa 1 lần/giây hoặc thưa hơn, và trên một số trình duyệt tab nền lâu bị "đóng băng". Nếu bạn cộng dồn ("mỗi tick trừ 1 giây") thì countdown trôi dần. Best Buy đã làm đúng phần quan trọng: mỗi tick tính lại từ toDate - now. Phần còn lại là thời điểm tick: interval bắt đầu ở mili giây ngẫu nhiên, nên số giây trên màn hình đổi lệch với đồng hồ hệ thống tới gần một giây, và thỉnh thoảng nhảy 2 giây một lúc. Hẹn setTimeout tới đúng ranh giới giây kế tiếp giải quyết cả hai.
Layout shift khi xoá. Dòng biến mất làm mọi thứ bên dưới dịch lên. Với Core Web Vitals, dịch chuyển xảy ra trong 500ms sau một thao tác của người dùng không bị tính vào CLS, nên đây không phải chuyện điểm số. Nó là chuyện thao tác nhầm: tay đang ở vị trí nút "Xoá", nút của dòng kế tiếp trượt vào đúng chỗ đó. Tombstone giữ chiều cao gần như cũ, nên không có gì trượt.
sequenceDiagram participant U as Người dùng participant C as Client participant S as Server U->>C: Bấm "Xoá" dòng B C->>S: DELETE line item B S-->>C: Giỏ mới: A, C Note over C: flagRemoved(giỏ cũ, B) → A, B(removed), C Note over C: mergeTombstones(giỏ mới, giỏ cũ) → A, B(removed), C C-->>U: "Đã xoá B" đúng chỗ dòng B, tổng tiền không tính B
#Viết lại
#Một đồng hồ có offset
src/lib/countdown.ts
export interface Clock {
/** thời điểm "hiện tại" theo đồng hồ này (ms) = giờ thật + offset */
now(): number;
/** độ lệch so với giờ thật (ms); dương = đang ở tương lai */
getOffset(): number;
/** QA: xem trang như thể đang ở thời điểm `date` */
travelTo(date: Date | number): void;
travelBy(ms: number): void;
reset(): void;
subscribe(listener: Listener): () => void;
}
export function createClock(initialOffset = 0): Clock {
let offset = initialOffset;
const listeners = new Set<Listener>();
const emit = () => listeners.forEach((l) => l());
return {
now: () => Date.now() + offset,
getOffset: () => offset,
travelTo(date) {
offset = +date - Date.now();
emit();
},
travelBy(ms) {
offset += ms;
emit();
},
reset() {
offset = 0;
emit();
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
/**
* Đọc offset từ URL, CHỈ khi có cờ QA: ?qaNow=2026-11-28T09:00. Không có cờ thì offset = 0.
* Đây là công cụ xem trước nội dung, không phải bảo mật: server vẫn phải tự kiểm tra giờ.
*/
export function offsetFromUrl(search = typeof location === 'undefined' ? '' : location.search): number {
const value = new URLSearchParams(search).get('qaNow');
if (!value) return 0;
const target = Date.parse(value);
return Number.isNaN(target) ? 0 : target - Date.now();
}
export const clock = createClock(offsetFromUrl());
ts
now()là cửa duy nhất để lấy thời gian. Offset lưu theo nghĩa tự nhiên:giờ giả - giờ thật(dương là tương lai), nên ở đâu cũng là phép cộng.travelTo/travelBy/resetbáo cho listener, nên mọi component dùng đồng hồ render lại ngay.offsetFromUrlđọc?qaNow=2026-11-28T09:00. Gửi link đó cho người duyệt nội dung là họ thấy trang đúng lúc 9 giờ sáng hôm đó.
#Tính toán thuần
src/lib/countdown.ts
export function remaining(target: number, now: number): Remaining {
// làm tròn LÊN tới giây: còn 0,4 giây thì vẫn hiện "1 giây", không hiện 0 khi chưa hết
const total = Math.max(0, Math.ceil((target - now) / SECOND) * SECOND);
return {
days: Math.floor(total / DAY),
hours: Math.floor((total % DAY) / HOUR),
minutes: Math.floor((total % HOUR) / MINUTE),
seconds: Math.floor((total % MINUTE) / SECOND),
total,
done: total === 0,
};
}
export interface Stage<T = unknown> {
start: number;
/** không có end = kéo dài mãi */
end?: number;
data: T;
}
/** Stage đang hiệu lực tại `now` (Best Buy: syncActiveStage), -1 nếu không có. */
export function activeStageIndex(stages: Stage[], now: number) {
return stages.findIndex((s) => now >= s.start && (s.end === undefined || now < s.end));
}
ts
remaining làm tròn lên tới giây: còn 0,4 giây thì hiện "1 giây", và chỉ hiện 0 khi đã thật sự hết, kèm done: true. activeStageIndex tương đương syncActiveStage của Best Buy nhưng là hàm thuần, nhận now từ ngoài vào nên test được không cần giả lập đồng hồ.
#Hook React
src/lib/countdown.ts
export function useNow(c: Clock = clock, step = SECOND): number {
// offset là external store: QA đổi giờ thì component render lại ngay, không đợi tick
const offset = useSyncExternalStore(c.subscribe, c.getOffset, () => 0);
const [now, setNow] = useState(() => c.now());
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const update = () => {
setNow(c.now());
// hẹn lần sau đúng ranh giới giây tiếp theo (+5ms cho chắc đã qua)
timer = setTimeout(update, step - (c.now() % step) + 5);
};
update();
return () => clearTimeout(timer);
}, [c, step, offset]);
return now;
}
export function useCountdown(target: number, c: Clock = clock): Remaining {
return remaining(target, useNow(c));
}
ts
useSyncExternalStore(c.subscribe, c.getOffset)biến offset thành dữ liệu React biết theo dõi. Đổi offset → component render lại → effect chạy lại vớioffsetmới trong deps → hẹn giờ lại từ đầu.setTimeout(update, step - (now % step) + 5)hẹn đúng ranh giới giây kế tiếp. Lần nào cũng tính lại, nên dù một tick trễ 200ms thì tick sau vẫn rơi đúng ranh giới.- Component nào cần "giờ hiện tại" (banner timeline) dùng
useNow; component đếm ngược dùnguseCountdown. Cả hai cùng một đồng hồ.
Khác bản gốc: offset là state có thông báo thay vì biến window toàn cục; hết giờ có done; làm tròn lên thay vì xuống; tick căn theo ranh giới giây; đổi target thì hook tự tính lại (nó chỉ là tham số của một hàm thuần). Widget QA trong demo là nút bấm thay cho form nhập ngày.
#Tombstone
src/lib/cartTombstone.ts
export function flagRemoved(shipments: Shipment[], lineItemId: string): Shipment[] {
return shipments.map((shipment) =>
shipment.lineItems.some((li) => li.id === lineItemId)
? {
...shipment,
lineItems: shipment.lineItems.map((li) => (li.id === lineItemId ? { ...li, removed: true } : li)),
}
: shipment,
);
}
/**
* Ghép các tombstone từ giỏ cũ vào giỏ mới mà server trả về.
* - Seller còn trong giỏ mới: chèn dòng đã xoá vào đúng chỉ số cũ trong shipment đó.
* - Seller không còn (dòng cuối cùng của seller vừa bị xoá): chèn lại cả shipment, chỉ chứa
* tombstone, ở đúng vị trí cũ.
* - Giỏ mới rỗng: giữ nguyên giỏ cũ (chỉ còn tombstone), để vẫn hiện được thông báo.
*/
export function mergeTombstones(next: Shipment[], prev: Shipment[]): Shipment[] {
if (next.length === 0) return prev.map((s) => ({ ...s, lineItems: s.lineItems.filter((li) => li.removed) }));
let result = next.map((s) => ({ ...s, lineItems: [...s.lineItems] }));
prev.forEach((oldShipment, shipmentIndex) => {
oldShipment.lineItems.forEach((item, itemIndex) => {
if (!item.removed) return;
const target = result.find((s) => s.sellerId === oldShipment.sellerId);
if (target) {
// không chèn trùng nếu server (hoặc lần ghép trước) đã có dòng này
if (target.lineItems.some((li) => li.id === item.id)) return;
target.lineItems.splice(Math.min(itemIndex, target.lineItems.length), 0, { ...item });
} else {
const ghost: Shipment = { ...oldShipment, lineItems: [{ ...item }] };
result = [...result.slice(0, shipmentIndex), ghost, ...result.slice(shipmentIndex)];
}
});
});
return result;
}
ts
flagRemovedtrả về mảng mới, không sửa giỏ cũ (khác bản gốc). Với React, sửa trực tiếp state cũ là nguồn gốc của các lỗi "UI không cập nhật".mergeTombstonesgiữ đúng hành vi của Best Buy: seller còn thì chèn vào đúng chỉ số cũ trong shipment; seller đã biến mất (dòng cuối của người bán đó vừa bị xoá) thì chèn lại cả shipment chỉ chứa tombstone; giỏ mới rỗng thì giữ giỏ cũ.- Thêm một kiểm tra không chèn trùng: nếu vì lý do nào đó server vẫn trả về dòng đó (xoá thất bại một phần, hoặc ghép hai lần), không có hai dòng cùng
id. cartTotalbỏ qua tombstone;dropTombstonesdọn sạch khi rời trang giỏ hoặc tải lại.
Xem toàn bộ src/lib/countdown.ts
src/lib/countdown.ts
import { useEffect, useState, useSyncExternalStore } from 'react';
/**
* Đồng hồ dùng chung cho mọi countdown/banner theo lịch, có "du hành thời gian" cho QA.
* Ý tưởng từ Best Buy Canada: countdownHook + window.globalDateOffset + widget DateOverride
* (bật bằng ?contentProofing=true). Mọi chỗ cần "bây giờ" đều đi qua MỘT offset.
*/
type Listener = () => void;
export interface Clock {
/** thời điểm "hiện tại" theo đồng hồ này (ms) = giờ thật + offset */
now(): number;
/** độ lệch so với giờ thật (ms); dương = đang ở tương lai */
getOffset(): number;
/** QA: xem trang như thể đang ở thời điểm `date` */
travelTo(date: Date | number): void;
travelBy(ms: number): void;
reset(): void;
subscribe(listener: Listener): () => void;
}
export function createClock(initialOffset = 0): Clock {
let offset = initialOffset;
const listeners = new Set<Listener>();
const emit = () => listeners.forEach((l) => l());
return {
now: () => Date.now() + offset,
getOffset: () => offset,
travelTo(date) {
offset = +date - Date.now();
emit();
},
travelBy(ms) {
offset += ms;
emit();
},
reset() {
offset = 0;
emit();
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
/**
* Đọc offset từ URL, CHỈ khi có cờ QA: ?qaNow=2026-11-28T09:00. Không có cờ thì offset = 0.
* Đây là công cụ xem trước nội dung, không phải bảo mật: server vẫn phải tự kiểm tra giờ.
*/
export function offsetFromUrl(search = typeof location === 'undefined' ? '' : location.search): number {
const value = new URLSearchParams(search).get('qaNow');
if (!value) return 0;
const target = Date.parse(value);
return Number.isNaN(target) ? 0 : target - Date.now();
}
export const clock = createClock(offsetFromUrl());
// ---- tính toán thuần, dễ test ----
export interface Remaining {
days: number;
hours: number;
minutes: number;
seconds: number;
/** tổng ms còn lại, 0 khi đã hết */
total: number;
done: boolean;
}
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
export function remaining(target: number, now: number): Remaining {
// làm tròn LÊN tới giây: còn 0,4 giây thì vẫn hiện "1 giây", không hiện 0 khi chưa hết
const total = Math.max(0, Math.ceil((target - now) / SECOND) * SECOND);
return {
days: Math.floor(total / DAY),
hours: Math.floor((total % DAY) / HOUR),
minutes: Math.floor((total % HOUR) / MINUTE),
seconds: Math.floor((total % MINUTE) / SECOND),
total,
done: total === 0,
};
}
export interface Stage<T = unknown> {
start: number;
/** không có end = kéo dài mãi */
end?: number;
data: T;
}
/** Stage đang hiệu lực tại `now` (Best Buy: syncActiveStage), -1 nếu không có. */
export function activeStageIndex(stages: Stage[], now: number) {
return stages.findIndex((s) => now >= s.start && (s.end === undefined || now < s.end));
}
// ---- hook React ----
/**
* "Bây giờ" theo `clock`, cập nhật đúng lúc sang giây mới (căn theo ranh giới giây thay vì
* setInterval(1000) trôi dần), và cập nhật NGAY khi QA đổi offset.
*/
export function useNow(c: Clock = clock, step = SECOND): number {
// offset là external store: QA đổi giờ thì component render lại ngay, không đợi tick
const offset = useSyncExternalStore(c.subscribe, c.getOffset, () => 0);
const [now, setNow] = useState(() => c.now());
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
const update = () => {
setNow(c.now());
// hẹn lần sau đúng ranh giới giây tiếp theo (+5ms cho chắc đã qua)
timer = setTimeout(update, step - (c.now() % step) + 5);
};
update();
return () => clearTimeout(timer);
}, [c, step, offset]);
return now;
}
export function useCountdown(target: number, c: Clock = clock): Remaining {
return remaining(target, useNow(c));
}
ts
Xem toàn bộ src/lib/cartTombstone.ts
src/lib/cartTombstone.ts
/**
* "Bia mộ" (tombstone) cho dòng sản phẩm vừa xoá khỏi giỏ hàng.
* Ý tưởng từ Best Buy Canada (business-rules/use-cases/CartRemover.ts + cartMerger.ts):
* server trả về giỏ hàng MỚI không còn dòng đã xoá; client chèn lại dòng đó, đánh dấu
* `removed: true`, ĐÚNG vị trí cũ. UI hiện "Đã xoá X khỏi giỏ" thay cho dòng đó, nên các dòng
* bên dưới không bị nhảy lên và người dùng biết chắc mình vừa xoá cái gì.
*/
export interface LineItem {
id: string;
name: string;
price: number;
quantity: number;
removed?: boolean;
}
export interface Shipment {
sellerId: string;
sellerName: string;
lineItems: LineItem[];
}
/** Đánh dấu dòng `lineItemId` là đã xoá, trả về mảng MỚI (Best Buy sửa trực tiếp object cũ). */
export function flagRemoved(shipments: Shipment[], lineItemId: string): Shipment[] {
return shipments.map((shipment) =>
shipment.lineItems.some((li) => li.id === lineItemId)
? {
...shipment,
lineItems: shipment.lineItems.map((li) => (li.id === lineItemId ? { ...li, removed: true } : li)),
}
: shipment,
);
}
/**
* Ghép các tombstone từ giỏ cũ vào giỏ mới mà server trả về.
* - Seller còn trong giỏ mới: chèn dòng đã xoá vào đúng chỉ số cũ trong shipment đó.
* - Seller không còn (dòng cuối cùng của seller vừa bị xoá): chèn lại cả shipment, chỉ chứa
* tombstone, ở đúng vị trí cũ.
* - Giỏ mới rỗng: giữ nguyên giỏ cũ (chỉ còn tombstone), để vẫn hiện được thông báo.
*/
export function mergeTombstones(next: Shipment[], prev: Shipment[]): Shipment[] {
if (next.length === 0) return prev.map((s) => ({ ...s, lineItems: s.lineItems.filter((li) => li.removed) }));
let result = next.map((s) => ({ ...s, lineItems: [...s.lineItems] }));
prev.forEach((oldShipment, shipmentIndex) => {
oldShipment.lineItems.forEach((item, itemIndex) => {
if (!item.removed) return;
const target = result.find((s) => s.sellerId === oldShipment.sellerId);
if (target) {
// không chèn trùng nếu server (hoặc lần ghép trước) đã có dòng này
if (target.lineItems.some((li) => li.id === item.id)) return;
target.lineItems.splice(Math.min(itemIndex, target.lineItems.length), 0, { ...item });
} else {
const ghost: Shipment = { ...oldShipment, lineItems: [{ ...item }] };
result = [...result.slice(0, shipmentIndex), ghost, ...result.slice(shipmentIndex)];
}
});
});
return result;
}
/** Bỏ hết tombstone, ví dụ khi người dùng rời trang giỏ hàng hoặc tải lại giỏ. */
export const dropTombstones = (shipments: Shipment[]) =>
shipments
.map((s) => ({ ...s, lineItems: s.lineItems.filter((li) => !li.removed) }))
.filter((s) => s.lineItems.length > 0);
/** Tổng tiền: tombstone không được tính. */
export const cartTotal = (shipments: Shipment[]) =>
shipments.reduce(
(sum, s) => sum + s.lineItems.reduce((acc, li) => acc + (li.removed ? 0 : li.price * li.quantity), 0),
0,
);
ts
#Tại sao không làm cách khác?
Mock thời gian bằng thư viện (sinon.useFakeTimers, vi.setSystemTime)? Tuyệt vời cho unit test, nhưng chỉ chạy trong môi trường test. Thứ Best Buy cần là cho người không viết code xem trước trang thật trên production.
Đổi giờ hệ điều hành? Làm hỏng chứng chỉ HTTPS ("ngày không hợp lệ"), cookie hết hạn, token đăng nhập. Và chỉ đổi được giờ của một máy.
Offset phía server? Nếu banner được render ở server (SSR/CMS), bạn cần cả hai: server cũng phải nhận tham số thời gian giả lập. Offset ở client chỉ đổi phần JavaScript tính toán.
Xoá ngay ở client, gọi API sau (optimistic UI)? Nhanh hơn, nhưng vẫn để dòng biến mất và các dòng khác nhảy lên. Tombstone kết hợp được với optimistic UI: hiện tombstone ngay khi bấm, nếu API lỗi thì khôi phục dòng.
Animation thu nhỏ dòng rồi mới xoá? Đẹp hơn cách nhảy đột ngột, nhưng người dùng vẫn không có xác nhận "đã xoá X". Hai cách có thể dùng cùng nhau: tombstone ở lại cho tới khi rời trang.
#Edge case và cạm bẫy
- Đây không phải bảo mật. Ai cũng gõ được
?qaNow=hay?contentProofing=true. Giá sale, mã giảm giá, quyền mua phải do server kiểm tra theo giờ server. Offset ở client chỉ đổi những gì người dùng nhìn thấy. - Nên tắt hẳn trên production nếu không cần, hoặc chỉ bật cho người dùng nội bộ. Một banner "Đang sale" hiện sai cho khách (vì link QA bị chia sẻ) có thể gây khiếu nại.
- Giờ của máy người dùng sai.
Date.now()là đồng hồ máy khách, có thể lệch vài phút. Với sale quan trọng, lấy giờ server (headerDatehoặc một API) để tính thêm một offset "đồng bộ đồng hồ", tách biệt với offset QA. - SSR và hydration. Countdown render ở server với giờ server, tới client đã khác vài giây, React báo lệch nội dung khi hydrate. Hiện placeholder ở server và chỉ tính số ở client, hoặc thêm
suppressHydrationWarningcho phần số. - Múi giờ.
Date.parse('2026-11-28T09:00')không có múi giờ được hiểu là giờ địa phương của máy. Lịch sale nên lưu dạng UTC hoặc có offset rõ ràng. - Tombstone và
key. Dòng tombstone phải giữkey={li.id}như dòng gốc, để React không unmount/mount lại mọi dòng phía sau. - Tombstone với screen reader: đặt
role="status"để thông báo "Đã xoá…" được đọc lên.
#Dùng trong dự án
import { activeStageIndex, clock, useCountdown, useNow } from './lib/countdown';
function SaleBanner({ start, end }: { start: number; end: number }) {
const now = useNow();
const stage = activeStageIndex([{ start: 0, end: start, data: 0 }, { start, end, data: 1 }], now);
const left = useCountdown(stage === 0 ? start : end);
if (stage === -1) return null; // đã kết thúc
return <div>{stage === 0 ? 'Bắt đầu sau' : 'Kết thúc sau'} {left.days}n {left.hours}:{left.minutes}:{left.seconds}</div>;
}
// Công cụ QA chỉ có trong bản build nội bộ:
if (import.meta.env.VITE_QA_TOOLS) Object.assign(window, { qaClock: clock });
tsx
// Giỏ hàng (Redux/Zustand/useReducer đều được)
async function removeItem(id: string) {
const next = await api.removeLineItem(cartId, id);
set((state) => ({ shipments: mergeTombstones(next.shipments, flagRemoved(state.shipments, id)) }));
}
ts
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { activeStageIndex, createClock, remaining } from './countdown';
import { flagRemoved, mergeTombstones, type Shipment } from './cartTombstone';
describe('countdown', () => {
it('làm tròn lên tới giây và có done', () => {
expect(remaining(10_400, 10_000)).toMatchObject({ seconds: 1, done: false });
expect(remaining(10_000, 10_000)).toMatchObject({ seconds: 0, done: true });
expect(remaining(0, 10_000).total).toBe(0);
});
it('travelTo đổi now() và báo listener', () => {
const c = createClock();
let calls = 0;
c.subscribe(() => calls++);
c.travelTo(Date.now() + 86_400_000);
expect(c.now() - Date.now()).toBeGreaterThan(86_399_000);
expect(calls).toBe(1);
});
it('chọn đúng stage', () => {
const stages = [{ start: 0, end: 100, data: 'a' }, { start: 100, data: 'b' }];
expect(activeStageIndex(stages, 99)).toBe(0);
expect(activeStageIndex(stages, 100)).toBe(1);
});
});
describe('tombstone', () => {
const item = (id: string) => ({ id, name: id, price: 1, quantity: 1 });
const prev: Shipment[] = [
{ sellerId: 's1', sellerName: 'S1', lineItems: [item('a'), item('b'), item('c')] },
{ sellerId: 's2', sellerName: 'S2', lineItems: [item('d')] },
];
it('chèn lại đúng vị trí cũ', () => {
const next = [{ ...prev[0], lineItems: [item('a'), item('c')] }, prev[1]];
const merged = mergeTombstones(next, flagRemoved(prev, 'b'));
expect(merged[0].lineItems.map((li) => `${li.id}${li.removed ? '†' : ''}`)).toEqual(['a', 'b†', 'c']);
});
it('seller biến mất thì giữ lại shipment chỉ có tombstone', () => {
const merged = mergeTombstones([prev[0]], flagRemoved(prev, 'd'));
expect(merged[1]).toMatchObject({ sellerId: 's2', lineItems: [{ id: 'd', removed: true }] });
});
it('không sửa giỏ cũ', () => {
flagRemoved(prev, 'a');
expect(prev[0].lineItems[0].removed).toBeUndefined();
});
});
ts
Với hook, dùng vi.useFakeTimers() + vi.setSystemTime() và @testing-library/react để kiểm tra số giây đổi đúng sau vi.advanceTimersByTime(1000).
#So với thư viện và chuẩn web
- Không có API web chuẩn nào để "giả lập giờ" cho cả trang. Một đồng hồ có offset trong code vẫn là cách đơn giản nhất, và chạy được cho cả người không dùng DevTools.
- Các thư viện ngày giờ (date-fns, Day.js, Temporal khi có) chỉ lo phần tính toán; nguồn "bây giờ" vẫn nên là một hàm duy nhất mà bạn kiểm soát.
- Mẫu tombstone xuất hiện ở nhiều nơi khác: Gmail "Đã chuyển cuộc trò chuyện vào thùng rác. Hoàn tác", danh sách file của Google Drive. Ý chung: xác nhận đúng chỗ thay vì để nội dung biến mất.
Bài này thuộc series Học từ mã nguồn các web lớn (65 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Mở đầu
Phần 1: trải nghiệm người dùng
- #1 Dark mode không nháy (Gatsby)
- #2 useValueByMedia: giá trị theo breakpoint (Toptal)
- #3 Đoán ý định: idle, exit-intent, lắc chuột (Toptal, InVision)
- #4 Validate form thân thiện (Udacity, Pipedrive)
- #5 Magic number, EXIF và dán ảnh (Ello)
- #6 fastSmoothScroll: cuộn xa vẫn nhanh (Telegram)
- #7 Countdown time-travel và tombstone giỏ hàng (Best Buy) (bài này)
Phần 2: hiệu năng và đo lường
- #8 Hàng đợi requestAnimationFrame 3ms (Apple)
- #9 srcset cho artwork và preconnect tự động (Apple)
- #10 Quick-fetch: gọi API trước khi bundle tải xong (HubSpot)
- #11 Performance span và long task (Postman, Calibre)
- #12 Gọi Web Worker như hàm async (Telegram)
- #13 Đồng hồ media: đếm giờ xem và sửa lệch (Skillshare, Simplified)
Phần 3: nhiều tab, phiên đăng nhập, cập nhật
- #14 Bầu tab chủ cho thông báo (HubSpot, Telegram)
- #15 Đồng bộ đăng nhập giữa tab và giới hạn cookie (Elastic, InVision)
- #16 SPA tự cập nhật: Trello, Zappos, Facebook, Elastic
Phần 4: kiến trúc
- #17 Reverse portal: di chuyển component không remount (Zappos)
- #18 Page behaviors: gắn hành vi từ CMS (Intercom)
- #19 Giới hạn tần suất hiển thị bằng bitmask (Outlook)
- #20 Chuyển REST sang GraphQL dần dần (Coursera)
Phần 5: soạn thảo và hiển thị nội dung
- #21 Autocomplete tại con trỏ trong contenteditable (Ello, Telegram)
- #22 Dark mode cho email HTML bằng CIELAB (Outlook)
- #23 Thu nhỏ bảng rộng cho vừa khung, vá font emoji (Outlook)
- #24 Cảnh báo quên đính kèm file (Outlook)
- #25 Cắt chữ nhiều dòng chính xác (Pipedrive, Apple, Toptal)
- #26 Parse ngày giờ gõ tay khoan dung (Outlook, Elastic)
Phần 6: tương tác
- #27 menuAim: rê chéo vào submenu không bị đóng (Udemy)
- #28 Kéo, vuốt chung cho chuột và cảm ứng (Telegram)
- #29 Phím tắt theo phạm vi, IME và bàn phím phi Latinh (Trello)
- #30 Nội suy màu: đổi theme và nền theo cuộn (Telegram, Anchor)
- #31 Đường gióng hút cạnh và undo/redo trên canvas (Simplified)
- #32 Giả lập mù màu bằng feColorMatrix (Trello)
- #33 Vạch thước theo mức zoom và cấp phát màu cho biểu đồ (Anchor, Similarweb)
Phần 7: mạng, dữ liệu và async
- #34 Ô tìm kiếm chuẩn production (Similarweb, Udemy, Pipedrive)
- #35 Điều phối async: latest-wins, single-flight (Glitch, Toptal)
- #36 Polling thưa dần và debounce động (Trello)
- #37 Cache phía client theo version (Similarweb, Telegram, Udemy)
- #38 Upload file lớn theo chunk (Outlook, Tribe)
- #39 Phát video qua Service Worker bằng Range request (Telegram, Outlook)
Phần 8: hiệu năng và vận hành
- #40 TTI governor: hoãn việc phụ tới sau khi trang dùng được (Outlook)
- #41 Tự đo LCP/CLS/TBT và phục vụ bản nhẹ cho máy yếu (Udemy)
Phần 9: thuật toán và dữ liệu phía client
- #42 Three-way merge ngay trên trình duyệt (Postman)
- #43 Dọn cache GraphQL bằng đếm tham chiếu (Outlook)
- #44 LRU cache theo 3 cách (Apple, Similarweb, Outlook)
- #45 Lọc tăng dần trong Web Worker (Similarweb)
- #46 Xếp hạng gợi ý trên client, so ngầm với server (Outlook)
- #47 Nối OT với editor: undo stack và chặn echo (Glitch)
- #48 Ngôn ngữ markup riêng dịch hai chiều với HTML (Coursera)
Phần 10: thời gian, ngôn ngữ, định dạng
- #49 Xử lý giờ mùa hè không cần thư viện (Udemy, Pipedrive)
- #50 Intl thực chiến: Segmenter, số rút gọn, số nhiều, fallback locale (Apple, Outlook)
- #51 WebVTT và bôi chọn transcript (Udemy, Coursera)
Phần 11: tương tác và giao diện
- #52 Kéo-thả trên lịch (Pipedrive)
- #53 Focus stack cho popup lồng nhau (Outlook)
- #54 Clipboard: copy, dán chữ thuần, dán thông minh (Trello, Anchor, Pipedrive, Tribe)
- #55 Khôi phục vị trí cuộn khi trang còn đang tải (Apple, Zappos)
- #56 Lấy màu từ ảnh: gradient nền, màu chữ (Apple, Telegram, Trello)
- #57 JS đọc breakpoint từ CSS, phát hiện chế độ PWA (Ello, Apple)
Phần 12: media
- #58 Tin nhắn thoại: ghi âm, waveform realtime, nén 5-bit (Telegram)
- #59 Chụp frame video thành ảnh, canvas bị taint (Coursera, Outlook)
Phần 13: đo lường, bảo mật, vận hành
- #60 Đo lúc ảnh màn hình đầu tải xong, cả khi chuyển trang SPA (Zappos, Best Buy, Outlook)
- #61 Buffer analytics chờ SDK, phát hiện adblock (Best Buy)
- #62 Proof-of-work trên client để chặn API ẩn danh (Udemy)
- #63 Prefetch không làm lệch số liệu, nén cài đặt bằng bit (Outlook)
- #64 Micro-frontend: AngularJS và React chung app, giao tiếp xuyên frame (Similarweb, InVision, HubSpot)
Code + demo: examples/web-patterns · Mở trên StackBlitz

