Học từ Facebook #18: Keep-alive tab trong SPA
Cách facebook.com giữ tab Home, Messenger còn sống khi chuyển trang (có TTL) và báo cho mọi thứ bên trong tự dừng khi bị ẩn. Viết lại bằng React 19.
Mở facebook.com, cuộn News Feed xuống bài thứ 30, bấm sang Messenger, rồi bấm lại Home trong vòng khoảng một phút. Feed hiện ra ngay lập tức, đúng bài thứ 30. Không spinner, không cuộn lại từ đầu, không gọi lại API.
Trong một SPA thông thường dùng React Router, đổi route nghĩa là unmount cây component của trang cũ: mất state, mất vị trí cuộn, mất dữ liệu đã tải (trừ khi cache ở đâu đó). Quay lại thì mount lại từ đầu. Facebook không làm vậy với một vài tab quan trọng: họ giữ tab cũ còn sống (keep-alive) trong một khoảng thời gian, chỉ ẩn nó đi.
Nghe đơn giản ("đừng unmount là được"), nhưng làm đúng có 3 bài toán:
- Giữ cái gì, giữ bao lâu? Giữ mọi tab mãi mãi thì bộ nhớ tăng không ngừng.
- Tab bị ẩn phải "ngủ". Video phải pause, timer phải dừng, log impression (bài #2) không được đếm tiếp. Nhưng tab đó vẫn đang mount, effect của nó vẫn sống. Làm sao nó biết mình đang bị ẩn?
- Ẩn thế nào?
display: none,visibility: hidden, haycontent-visibility: hidden? Mỗi cách giữ lại những thứ khác nhau.
Bài này đọc cách Facebook giải 3 bài toán đó trong router của họ, rồi viết lại thành hai mảnh dùng được ở bất kỳ app React nào.
Tip
Tóm tắt
- Keep-alive = không unmount route cũ, chỉ ẩn nó, kèm TTL để trả bộ nhớ. Facebook cấu hình theo từng tab (Home khoảng 60 giây, Messenger giữ mãi).
- Mọi thứ bên trong tab ẩn phải biết mình đang ẩn: HiddenSubtree gộp trạng thái theo cây (phép OR), có bản store passive để dừng video/timer mà không gây re-render.
- Ẩn bằng
content-visibility: hidden+inert: gần rẻ nhưdisplay: none, giữ được vị trí cuộn bên trong, không lọt focus. - React 19.2 có
<Activity>: an toàn hơn mặc định vì huỷ effect khi ẩn; cách của Facebook giữ mọi thứ "nóng" nhưng phải tự cho từng thứ "ngủ".
#Facebook làm thế nào?
Code nằm rải rác ở 3 nơi trong facebook-codebase: 20.Router/buildCometRouter.js, 2.js (các module MaintainedRouteConfig, CometMainRoutes.react) và thư mục HiddenSubtreeContextProvider/.
#1. Cấu hình theo từng tab: MaintainedRouteConfig
Mỗi "tab" lớn của facebook.com có một tabKey (home, messenger…). Module MaintainedRouteConfig quy định tab nào được giữ và giữ bao lâu. Đọc bản minify thì thấy:
c = b("gkx")("1217157") ? 6e4 : (a = b("qex")._("1158889")) != null ? a : 0;
...
home: { maintainRouteForMs: c, maintained: c > 0 },
messenger: { maintained: !0, visibilityHidden: !0 },
health_communities_discovery: { maintainRouteForMs: f /* 3 phút */, maintained: !0 },
global_serp: { maintainRouteForMs: d ? 2 * 60 * 1e3 : 0, maintained: d },
js
Vài điều đáng chú ý:
- Home được giữ 60 giây nếu gatekeeper
1217157bật, nếu không thì lấy giá trị từ một thí nghiệm A/B (qex), có thể là 0 (không giữ). Có lẽ chính Facebook cũng chưa chắc giữ bao lâu là tối ưu nên đo bằng thí nghiệm (suy đoán). - Messenger không có
maintainRouteForMs: giữ mãi trong phiên. Hợp lý, vì người ta qua lại Messenger liên tục và trạng thái chat (đang gõ dở, cuộn tới đâu) rất quan trọng. - Trang kết quả tìm kiếm (
global_serp) giữ 2 phút, có gate. Người dùng thường bấm vào một kết quả rồi quay lại danh sách. visibilityHidden: truecủa Messenger là cờ để phần render chọn cách ẩn riêng (xem mục 3).
#2. Router chuyển tab cũ vào "kho giữ"
Trong buildCometRouter.js, hàm cập nhật state của router (tên minify là L) xử lý lúc tabKey đổi. Viết lại cho dễ đọc:
// prevTab: tab vừa rời, nextTab: tab sắp mở, maintained: map tabKey -> route state
if (nextTab !== prevTab) {
// tab sắp mở đang nằm trong kho -> lấy ra, huỷ hẹn giờ
if (maintained[nextTab]) { maintained = withoutKey(maintained, nextTab); clearTimeout(timers[nextTab]); }
// tab vừa rời được phép giữ -> cất vào kho + hẹn giờ huỷ
const config = MaintainedRouteConfig[prevTab];
if (config?.maintained && !maintained[prevTab]) {
maintained = { ...maintained, [prevTab]: prevState.main };
if (config.maintainRouteForMs != null)
timers[prevTab] = setTimeout(() => { maintained = withoutKey(maintained, prevTab); notify(); },
config.maintainRouteForMs);
}
}
js
Kho này (CometRouterMaintainedRoutesStateContext) chỉ chứa route state (URL, entry point, scroll key…), không chứa DOM. Chính React giữ DOM, vì cây component của tab cũ vẫn được render.
#3. CometMainRoutes render TẤT CẢ tab còn sống
Component CometMainRoutes.react gộp kho với tab hiện tại rồi render từng cái:
var j = babelHelpers["extends"]({}, e /* kho */, (a = {}, a[f /* tab hiện tại */] = d.main, a));
return Object.keys(j).map(function (a) {
var l = a === f; // tab này có đang active không
return jsx(HiddenSubtreeContextProvider, {
isBackgrounded: !l, isHidden: !l,
children: jsx(CometHeroInteractionWithDiv, { hidden: !l, xstyle: c({ isHidden: !l, tabVisibilityHidden: e }), … })
}, key);
});
js
Ba chi tiết:
- Mỗi tab được bọc trong
HiddenSubtreeContextProvidervớiisHidden = !active. Đây là cách mọi thứ bên trong biết mình đang bị ẩn. - Việc ẩn bằng CSS do một
xstyleprovider bên ngoài quyết định, nhậnisHiddenvàtabVisibilityHidden(cờvisibilityHiddenở trên). Nhờ cờ này Messenger có thể được ẩn theo cách khác các tab còn lại; tên cờ gợi ývisibility: hidden, nhưng style cụ thể nằm ở module khác, không có trong repo. keycủa từng tab ổn định (theotabKeyhoặc entity key), nên React không unmount khi tab đổi vai trò từ active sang ẩn.
#4. HiddenSubtreeContextProvider: hai context cho hai nhu cầu
Đây là phần hay nhất. Provider cung cấp hai context:
| Context | Giá trị | Khi trạng thái đổi |
|---|---|---|
HiddenSubtreeContext |
{ hidden, backgrounded, hiddenOrBackgrounded } |
component đọc nó re-render |
HiddenSubtreePassiveContext |
{ getCurrentState(), subscribeToChanges(cb) } |
không ai re-render; chỉ callback đã đăng ký được gọi |
Và hai quy tắc:
- OR với tổ tiên. Trạng thái của một provider =
own OR parent. Một video nằm trong dialog nằm trong tab Home bị ẩn → video bị ẩn, dù dialog không biết gì về tab. (Có propignoreParentcho layer nổi luôn hiển thị.) - Chỉ báo khi thật sự đổi. Hàm notify so sánh trạng thái gộp mới với lần báo trước (
h(a, n.current)), giống thì im lặng. Cha đổibackgroundedtrong khi con đãhiddensẵn → con không bị làm phiền.
Thuật ngữ trong bài: context reactive là context thường (đổi giá trị thì component đọc nó re-render); store passive là object có getCurrentState() + subscribe(), đọc và nghe mà không re-render.
Tại sao cần bản passive? Một tab Feed có hàng trăm component: video, ảnh, bộ đếm impression, animation. Nếu tất cả đọc context reactive, mỗi lần đổi tab là hàng trăm component re-render chỉ để biết "mình đang bị ẩn", đúng lúc người dùng đang chờ tab mới hiện ra. Với store passive, đổi tab chỉ chạy vài callback pause() / clearInterval(), không đụng tới cây React. Các hook hạ tầng của Facebook như useViewportDuration dùng HiddenSubtreePassiveContext đúng theo cách đó.
sequenceDiagram
participant U as Người dùng
participant R as Router
participant M as CometMainRoutes
participant H as HiddenSubtree tab Home
participant V as Video trong Feed
U->>R: bấm Messenger
R->>R: cất state Home vào kho, hẹn giờ 60s
R->>M: render Home ẩn + Messenger active
M->>H: isHidden = true
H->>V: callback passive, hidden = true
V->>V: pause, không re-render
U->>R: bấm Home trước 60s
R->>R: lấy Home khỏi kho, huỷ hẹn giờ
M->>H: isHidden = false
H->>V: hidden = false
V->>V: play tiếp
#Bên trong trình duyệt: ẩn bằng cách nào?
Giữ cây React mount mới là một nửa. Nửa còn lại là cách ẩn DOM, vì mỗi cách giữ lại những thứ khác nhau:
| Cách ẩn | Còn tốn layout/paint? | Giữ vị trí cuộn của phần tử con? | Ghi chú |
|---|---|---|---|
display: none |
Không | Phụ thuộc trình duyệt, không nên tin | Box bị huỷ hoàn toàn; khi hiện lại phải layout lại từ đầu |
visibility: hidden |
Có: vẫn layout, vẫn chiếm chỗ | Có | Rẻ khi hiện lại, nhưng tab ẩn vẫn tốn layout mỗi lần trang thay đổi |
content-visibility: hidden |
Không (bỏ qua render nội dung) | Có: trình duyệt giữ "rendering state" | Phần tử áp dụng size containment, như thể rỗng; cần tự định vị |
content-visibility: hidden là lựa chọn mặc định trong bản viết lại: trình duyệt bỏ qua layout và paint của nội dung (gần rẻ như display: none), nhưng giữ lại trạng thái render, trong đó có scrollTop của các vùng cuộn bên trong. Vì phần tử bị áp size containment nên tab ẩn được đặt position: absolute; inset: 0, và KeepAliveOutlet tự bọc mọi tab trong một div có position: relative để tab ẩn không đè lên phần khác của trang.
Ngoài ra tab ẩn nhận thêm:
inert: không Tab vào được, không click được, trình đọc màn hình bỏ qua. Nếu thiếu, người dùng bàn phím có thể Tab vào một ô input của tab đang ẩn.aria-hidden: thừa khi đã cóinert, nhưng giữ cho trình duyệt cũ.
#Viết lại
#HiddenSubtreeProvider
Phần lõi: trạng thái riêng trong ref, gộp với cha, chỉ báo khi trạng thái gộp đổi:
src/lib/hiddenSubtree.tsx
const parent = useContext(HiddenSubtreeContext);
const parentStore = useContext(HiddenSubtreePassiveContext);
const ownRef = useRef<HiddenState>({ hidden, backgrounded });
const lastNotifiedRef = useRef<HiddenState | null>(null);
const listenersRef = useRef(new Set<(state: HiddenState) => void>());
const getCurrentState = useCallback(
() => (ignoreParent ? ownRef.current : merge(ownRef.current, parentStore.getCurrentState())),
[ignoreParent, parentStore],
);
// Gọi khi props của mình đổi HOẶC khi cha báo đổi. Chỉ phát cho listener nếu trạng thái
// gộp khác lần phát trước: cha đổi backgrounded mà mình đang hidden sẵn thì con không cần biết.
const notifyIfChanged = useCallback(() => {
const next = getCurrentState();
if (lastNotifiedRef.current && same(next, lastNotifiedRef.current)) return;
lastNotifiedRef.current = next;
Array.from(listenersRef.current).forEach((listener) => listener(next));
}, [getCurrentState]);
// layout effect: listener (vd: pause video) chạy cùng commit với lúc màn hình bị ẩn,
// trước khi trình duyệt vẽ frame tiếp theo.
useLayoutEffect(() => {
ownRef.current = { hidden, backgrounded };
notifyIfChanged();
}, [hidden, backgrounded, notifyIfChanged]);
useEffect(() => {
if (ignoreParent) return;
return parentStore.subscribe(notifyIfChanged);
}, [ignoreParent, parentStore, notifyIfChanged]);
tsx
Xem toàn bộ src/lib/hiddenSubtree.tsx (context reactive, các hook useIsHidden, useVisibleInterval, useDocumentHidden)
src/lib/hiddenSubtree.tsx
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
/**
* "Cây con đang bị ẩn" (ý tưởng từ HiddenSubtreeContextProvider của Facebook).
*
* Khi một màn hình vẫn được giữ mount nhưng không hiển thị (keep-alive tab, dialog bị che…),
* mọi thứ bên trong cần biết để tự dừng: video, timer, animation, log impression.
*
* Có 2 context:
* - Reactive: đổi giá trị -> component dùng nó re-render. Tiện, nhưng đắt nếu cây lớn.
* - Passive: chỉ có getCurrentState() + subscribe(). Đổi trạng thái KHÔNG làm re-render;
* ai quan tâm thì tự subscribe (dừng timer, pause video…). Facebook dùng loại này cho
* hầu hết các hook hạ tầng.
*/
export interface HiddenState {
/** bị ẩn hẳn (vd: tab không active) */
hidden: boolean;
/** vẫn thấy nhưng ở "nền" (vd: browser tab ẩn, bị dialog che) */
backgrounded: boolean;
}
export interface HiddenSubtreeStore {
getCurrentState(): HiddenState;
/** chỉ được gọi khi trạng thái gộp THỰC SỰ đổi */
subscribe(listener: (state: HiddenState) => void): () => void;
}
const VISIBLE: HiddenState = { hidden: false, backgrounded: false };
const HiddenSubtreeContext = createContext<HiddenState>(VISIBLE);
const HiddenSubtreePassiveContext = createContext<HiddenSubtreeStore>({
getCurrentState: () => VISIBLE,
subscribe: () => () => {},
});
// Con bị ẩn nếu chính nó HOẶC bất kỳ tổ tiên nào bị ẩn (phép OR).
const merge = (a: HiddenState, b: HiddenState): HiddenState => ({
hidden: a.hidden || b.hidden,
backgrounded: a.backgrounded || b.backgrounded,
});
const same = (a: HiddenState, b: HiddenState) =>
a.hidden === b.hidden && a.backgrounded === b.backgrounded;
export function HiddenSubtreeProvider({
hidden,
backgrounded = false,
ignoreParent = false,
children,
}: {
hidden: boolean;
backgrounded?: boolean;
/** không gộp với tổ tiên (vd: một layer nổi lên trên cùng luôn hiển thị) */
ignoreParent?: boolean;
children: ReactNode;
}) {
const parent = useContext(HiddenSubtreeContext);
const parentStore = useContext(HiddenSubtreePassiveContext);
const ownRef = useRef<HiddenState>({ hidden, backgrounded });
const lastNotifiedRef = useRef<HiddenState | null>(null);
const listenersRef = useRef(new Set<(state: HiddenState) => void>());
const getCurrentState = useCallback(
() => (ignoreParent ? ownRef.current : merge(ownRef.current, parentStore.getCurrentState())),
[ignoreParent, parentStore],
);
// Gọi khi props của mình đổi HOẶC khi cha báo đổi. Chỉ phát cho listener nếu trạng thái
// gộp khác lần phát trước: cha đổi backgrounded mà mình đang hidden sẵn thì con không cần biết.
const notifyIfChanged = useCallback(() => {
const next = getCurrentState();
if (lastNotifiedRef.current && same(next, lastNotifiedRef.current)) return;
lastNotifiedRef.current = next;
Array.from(listenersRef.current).forEach((listener) => listener(next));
}, [getCurrentState]);
// layout effect: listener (vd: pause video) chạy cùng commit với lúc màn hình bị ẩn,
// trước khi trình duyệt vẽ frame tiếp theo.
useLayoutEffect(() => {
ownRef.current = { hidden, backgrounded };
notifyIfChanged();
}, [hidden, backgrounded, notifyIfChanged]);
useEffect(() => {
if (ignoreParent) return;
return parentStore.subscribe(notifyIfChanged);
}, [ignoreParent, parentStore, notifyIfChanged]);
const store = useMemo<HiddenSubtreeStore>(
() => ({
getCurrentState,
subscribe(listener) {
listenersRef.current.add(listener);
return () => {
listenersRef.current.delete(listener);
};
},
}),
[getCurrentState],
);
const merged = ignoreParent ? { hidden, backgrounded } : merge({ hidden, backgrounded }, parent);
// useMemo theo 2 boolean: giá trị context chỉ đổi tham chiếu khi thực sự đổi
const value = useMemo(
() => ({ hidden: merged.hidden, backgrounded: merged.backgrounded }),
[merged.hidden, merged.backgrounded],
);
return (
<HiddenSubtreeContext.Provider value={value}>
<HiddenSubtreePassiveContext.Provider value={store}>{children}</HiddenSubtreePassiveContext.Provider>
</HiddenSubtreeContext.Provider>
);
}
/** Reactive: component re-render mỗi khi trạng thái đổi. */
export function useHiddenSubtree(): HiddenState {
return useContext(HiddenSubtreeContext);
}
/** true nếu bị ẩn hoặc đang ở nền. */
export function useIsHidden(): boolean {
const { hidden, backgrounded } = useContext(HiddenSubtreeContext);
return hidden || backgrounded;
}
/** Passive: lấy store để tự subscribe, không gây re-render. */
export function useHiddenSubtreePassive(): HiddenSubtreeStore {
return useContext(HiddenSubtreePassiveContext);
}
/**
* setInterval tự dừng khi cây con bị ẩn/ở nền và chạy lại khi hiện ra.
* Dùng store passive nên việc ẩn/hiện không làm component re-render.
*/
export function useVisibleInterval(callback: () => void, ms: number) {
const store = useHiddenSubtreePassive();
const callbackRef = useRef(callback);
useLayoutEffect(() => {
callbackRef.current = callback;
});
useEffect(() => {
let timer: ReturnType<typeof setInterval> | undefined;
const sync = (state: HiddenState) => {
const shouldRun = !state.hidden && !state.backgrounded;
if (shouldRun && timer === undefined) {
timer = setInterval(() => callbackRef.current(), ms);
} else if (!shouldRun && timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
sync(store.getCurrentState());
const unsubscribe = store.subscribe(sync);
return () => {
unsubscribe();
clearInterval(timer);
};
}, [store, ms]);
}
/** Tab trình duyệt có đang bị ẩn không (Page Visibility API). */
export function useDocumentHidden(): boolean {
const [hiddenDoc, setHiddenDoc] = useState(
() => typeof document !== 'undefined' && document.visibilityState === 'hidden',
);
useEffect(() => {
const onChange = () => setHiddenDoc(document.visibilityState === 'hidden');
document.addEventListener('visibilitychange', onChange);
return () => document.removeEventListener('visibilitychange', onChange);
}, []);
return hiddenDoc;
}
tsx
#Đi qua code
ownRef+getCurrentState. Trạng thái riêng được lưu trong ref, cập nhật tronguseLayoutEffect.getCurrentState()luôn đọc giá trị mới nhất của mình và của cha (parentStore.getCurrentState()), nên một component con gọi nó bất cứ lúc nào cũng thấy đúng, kể cả khi chưa re-render.notifyIfChanged. Được gọi từ hai nơi: khi props của mình đổi (layout effect), và khi cha báo đổi (subscribe vào store của cha). Nó tính trạng thái gộp, so vớilastNotifiedRef, giống thì bỏ qua. Đây chính làh(a, n.current)trong bản gốc.- Tại sao
useLayoutEffect? Để callbackpause()chạy trong cùng commit với lúc tab bị ẩn, trước khi trình duyệt vẽ frame tiếp theo. DùnguseEffectthì video có thể chạy thêm một frame (và phát thêm vài chục ms âm thanh) sau khi tab đã biến mất. Array.from(listenersRef.current)trước khi lặp: một listener có thể tự huỷ đăng ký trong lúc được gọi.- Context reactive được
useMemotheo hai boolean, nên giá trị chỉ đổi tham chiếu khi thật sự đổi. Không cóuseMemo, mọi consumer sẽ re-render mỗi lần provider render. useVisibleIntervallà ví dụ một hook "hạ tầng" dùng store passive: tự dừng/chạysetInterval, không gây re-render khi ẩn/hiện.useDocumentHiddenđể bọc cả app:<HiddenSubtreeProvider hidden={false} backgrounded={useDocumentHidden()}>. Khi người dùng chuyển sang tab trình duyệt khác, mọi thứ cũng "ngủ" qua cùng một cơ chế.
#KeepAliveOutlet
Tính danh sách tab được giữ ngay trong lúc render:
src/lib/keepAlive.tsx
// Tính danh sách giữ lại NGAY TRONG LÚC RENDER khi tab đổi ("adjust state when a prop
// changes"). Nếu đợi tới effect, commit đổi tab sẽ không còn tab cũ trong cây -> React
// unmount nó -> mất hết state, đúng điều keep-alive muốn tránh.
let maintained = state.maintained;
if (state.activeKey !== activeKey) {
maintained = nextMaintained(state.maintained, state.activeKey, activeKey, config, maxMaintained);
setState({ activeKey, maintained });
}
// Cấu hình đổi (vd: tắt keep-alive) thì bỏ các tab không còn được phép giữ.
const allowed = maintained.filter((entry) => config[entry.key]?.maintained);
if (allowed.length !== maintained.length) {
maintained = allowed;
setState({ activeKey, maintained });
}
tsx
Render mọi tab còn sống, tab ẩn bọc HiddenSubtree và inert:
src/lib/keepAlive.tsx
const orderRef = useRef<string[]>([]);
if (!orderRef.current.includes(activeKey)) orderRef.current = [...orderRef.current, activeKey];
const liveKeys = new Set([activeKey, ...maintained.map((entry) => entry.key)]);
const keys = orderRef.current.filter((key) => liveKeys.has(key));
// Container position:relative: tab ẩn (content-visibility) được đặt absolute, cần một khung
// để không đè lên phần khác của trang.
return (
<div data-keepalive-outlet style={{ position: 'relative' }}>
{keys.map((key) => {
const active = key === activeKey;
return (
<div
key={key}
data-keepalive-key={key}
aria-hidden={!active || undefined}
// inert: tab ẩn không nhận focus/click và trình đọc màn hình bỏ qua
inert={!active}
style={
active
? undefined
: hideMode === 'display'
? { display: 'none' }
: { contentVisibility: 'hidden', position: 'absolute', inset: 0, overflow: 'hidden' }
}
>
<HiddenSubtreeProvider hidden={!active} backgrounded={!active}>
<KeepAliveContext.Provider value={{ routeKey: key, active }}>{render(key)}</KeepAliveContext.Provider>
</HiddenSubtreeProvider>
</div>
);
})}
</div>
);
}
tsx
Xem toàn bộ src/lib/keepAlive.tsx
src/lib/keepAlive.tsx
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { HiddenSubtreeProvider } from './hiddenSubtree';
/**
* Keep-alive cho các "tab" của SPA (ý tưởng từ CometRouter + MaintainedRouteConfig + CometMainRoutes).
*
* Khi rời một tab được cấu hình `maintained`, cây React của nó KHÔNG bị unmount mà được giữ lại
* (ẩn đi) trong một thời gian `maintainForMs`. Quay lại trong thời gian đó: hiện ra tức thì,
* nguyên state, nguyên vị trí cuộn. Hết hạn: unmount để trả lại bộ nhớ.
* Mỗi tab được bọc HiddenSubtreeProvider để code bên trong biết mình đang bị ẩn mà tự dừng.
*/
export interface KeepAliveRouteConfig {
maintained: boolean;
/** giữ bao lâu sau khi rời tab; null/undefined = giữ mãi (như Messenger của Facebook) */
maintainForMs?: number | null;
}
export interface MaintainedEntry {
key: string;
/** thời điểm (Date.now()) sẽ bị huỷ, null = không hết hạn */
expiresAt: number | null;
}
export interface KeepAliveOutletProps {
activeKey: string;
render: (key: string) => ReactNode;
config: Record<string, KeepAliveRouteConfig | undefined>;
/** giữ tối đa bao nhiêu tab ẩn cùng lúc; vượt quá thì huỷ tab rời đi lâu nhất */
maxMaintained?: number;
/**
* Cách ẩn tab không active:
* - 'display': display:none (rẻ nhất, nhưng phần tử cuộn bên trong có thể mất scrollTop)
* - 'content-visibility': content-visibility:hidden, trình duyệt bỏ qua render nhưng giữ
* nguyên trạng thái render (kể cả vị trí cuộn của phần tử con).
*/
hideMode?: 'display' | 'content-visibility';
onMaintainedChange?: (entries: MaintainedEntry[]) => void;
}
interface KeepAliveInfo {
routeKey: string;
active: boolean;
}
const KeepAliveContext = createContext<KeepAliveInfo | null>(null);
/** Cho component bên trong biết nó thuộc tab nào và tab đó có đang active không. */
export function useKeepAliveInfo() {
return useContext(KeepAliveContext);
}
function nextMaintained(
list: MaintainedEntry[],
prevKey: string,
activeKey: string,
config: KeepAliveOutletProps['config'],
maxMaintained: number,
): MaintainedEntry[] {
// 1. Tab vừa được mở lại: bỏ khỏi danh sách giữ (FB: `withoutKey(A, tabKey)` + clearTimeout).
let next = list.filter((entry) => entry.key !== activeKey);
// 2. Tab vừa rời đi: nếu được cấu hình maintained thì giữ lại kèm hạn huỷ.
const prevConfig = config[prevKey];
if (prevConfig?.maintained && !next.some((entry) => entry.key === prevKey)) {
const ttl = prevConfig.maintainForMs;
next = [...next, { key: prevKey, expiresAt: ttl != null ? Date.now() + ttl : null }];
}
// 3. Giới hạn số tab giữ lại: huỷ tab rời đi lâu nhất.
if (next.length > maxMaintained) next = next.slice(next.length - maxMaintained);
return next;
}
export function KeepAliveOutlet({
activeKey,
render,
config,
maxMaintained = Infinity,
hideMode = 'content-visibility',
onMaintainedChange,
}: KeepAliveOutletProps) {
const [state, setState] = useState<{ activeKey: string; maintained: MaintainedEntry[] }>({
activeKey,
maintained: [],
});
// Tính danh sách giữ lại NGAY TRONG LÚC RENDER khi tab đổi ("adjust state when a prop
// changes"). Nếu đợi tới effect, commit đổi tab sẽ không còn tab cũ trong cây -> React
// unmount nó -> mất hết state, đúng điều keep-alive muốn tránh.
let maintained = state.maintained;
if (state.activeKey !== activeKey) {
maintained = nextMaintained(state.maintained, state.activeKey, activeKey, config, maxMaintained);
setState({ activeKey, maintained });
}
// Cấu hình đổi (vd: tắt keep-alive) thì bỏ các tab không còn được phép giữ.
const allowed = maintained.filter((entry) => config[entry.key]?.maintained);
if (allowed.length !== maintained.length) {
maintained = allowed;
setState({ activeKey, maintained });
}
// Hẹn giờ huỷ: đồng bộ timer với danh sách hiện tại. Effect chỉ lo side effect,
// danh sách là nguồn sự thật duy nhất.
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
useEffect(() => {
const timers = timersRef.current;
const live = new Set(maintained.map((entry) => entry.key));
timers.forEach((timer, key) => {
if (!live.has(key)) {
clearTimeout(timer);
timers.delete(key);
}
});
maintained.forEach(({ key, expiresAt }) => {
if (expiresAt == null || timers.has(key)) return;
timers.set(
key,
setTimeout(
() => {
timers.delete(key);
setState((current) => ({
...current,
maintained: current.maintained.filter((entry) => entry.key !== key),
}));
},
Math.max(0, expiresAt - Date.now()),
),
);
});
}, [maintained]);
useEffect(() => {
const timers = timersRef.current;
return () => {
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
};
}, []);
useEffect(() => {
onMaintainedChange?.(maintained);
}, [maintained, onMaintainedChange]);
// Thứ tự render ổn định theo lần đầu xuất hiện: đổi tab không làm React di chuyển node DOM
// (gỡ ra chèn lại node chứa <iframe> làm nó tải lại, <video> bị gỡ khỏi document thì dừng phát).
const orderRef = useRef<string[]>([]);
if (!orderRef.current.includes(activeKey)) orderRef.current = [...orderRef.current, activeKey];
const liveKeys = new Set([activeKey, ...maintained.map((entry) => entry.key)]);
const keys = orderRef.current.filter((key) => liveKeys.has(key));
// Container position:relative: tab ẩn (content-visibility) được đặt absolute, cần một khung
// để không đè lên phần khác của trang.
return (
<div data-keepalive-outlet style={{ position: 'relative' }}>
{keys.map((key) => {
const active = key === activeKey;
return (
<div
key={key}
data-keepalive-key={key}
aria-hidden={!active || undefined}
// inert: tab ẩn không nhận focus/click và trình đọc màn hình bỏ qua
inert={!active}
style={
active
? undefined
: hideMode === 'display'
? { display: 'none' }
: { contentVisibility: 'hidden', position: 'absolute', inset: 0, overflow: 'hidden' }
}
>
<HiddenSubtreeProvider hidden={!active} backgrounded={!active}>
<KeepAliveContext.Provider value={{ routeKey: key, active }}>{render(key)}</KeepAliveContext.Provider>
</HiddenSubtreeProvider>
</div>
);
})}
</div>
);
}
tsx
Những quyết định quan trọng:
Tính danh sách giữ lại ngay trong lúc render. Bản đầu tiên của mình cập nhật danh sách trong useLayoutEffect sau khi activeKey đổi, và nó không hoạt động: ở lần commit đổi tab, tab cũ chưa có trong danh sách, nên React unmount nó; effect chạy xong mới thêm lại thì đã là một instance mới, sạch trơn. Cách đúng là pattern "adjust state when a prop changes": so state.activeKey với prop, khác thì tính danh sách mới và setState ngay trong render. React render lại component đó trước khi commit, nên không có frame nào tab cũ bị gỡ ra.
Timer là side effect, danh sách là nguồn sự thật. Effect chỉ đồng bộ setTimeout theo expiresAt của từng entry: entry mới thì hẹn giờ, entry biến mất thì huỷ giờ. Logic này chịu được StrictMode (effect chạy 2 lần) và không bao giờ có timer "mồ côi".
Thứ tự render ổn định. Các tab được render theo thứ tự xuất hiện lần đầu (orderRef), không theo thứ tự "active cuối cùng". React không phải di chuyển node DOM giữa các anh em. Di chuyển (gỡ ra rồi chèn lại) một node chứa <iframe> làm iframe tải lại từ đầu; <video> bị gỡ khỏi document thì dừng phát. Cả hai đều phá ý nghĩa của keep-alive. API mới Element.moveBefore() (có trong Chromium từ bản 133; trình duyệt khác: xem caniuse) di chuyển node mà giữ nguyên trạng thái, nhưng React chưa dùng nó, nên giữ thứ tự ổn định vẫn là cách chắc chắn.
maxMaintained bổ sung thứ Facebook không cần (họ chỉ giữ vài tab cố định): app có route động (mỗi profile, mỗi group là một "tab") nên cần giới hạn, huỷ tab rời đi lâu nhất trước, giống LRU.
stateDiagram-v2
[*] --> Active: mở tab
Active --> Maintained: rời tab, config maintained
Active --> Unmounted: rời tab, không maintained
Maintained --> Active: quay lại trước khi hết hạn
Maintained --> Unmounted: hết TTL hoặc vượt maxMaintained
Unmounted --> Active: mở lại, mount từ đầu
#Demo
Home giữ 15 giây, Feed 30 giây, Messages giữ mãi, Settings không giữ. Thử: bấm Like và gõ chữ ở Home, cuộn Feed xuống giữa, mở Messages chờ vài giây rồi chuyển đi. Quay lại trước khi hết hạn: mọi thứ còn nguyên ("Mount lúc" không đổi). Bộ đếm thời gian và chấm cam trong Messages dừng hẳn khi tab bị ẩn. Tắt "Bật keep-alive" để so sánh: mỗi lần đổi tab là mount lại từ đầu.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Tại sao không làm cách khác?
"Cache dữ liệu (React Query, SWR) là đủ rồi?" Cache dữ liệu giúp quay lại không phải gọi API, nhưng vẫn phải render lại cả cây, mất state UI (input đang gõ, tab con đang chọn, accordion đang mở), mất vị trí cuộn của vùng cuộn bên trong, video phải tải lại. Với trang nặng như Feed, render lại vài trăm component cũng tốn cả trăm ms trên điện thoại tầm trung. Keep-alive và cache dữ liệu bổ sung cho nhau chứ không thay thế nhau.
"Sao không giữ mọi tab mãi mãi?" Mỗi tab giữ lại là một cây DOM, ảnh đã decode, video buffer, listener… Facebook chọn từng tab và đặt TTL, và chính giá trị TTL của Home được quyết định bằng thí nghiệm A/B. Bài học: keep-alive là đánh đổi bộ nhớ lấy tốc độ, phải đo chứ không đoán.
"Sao không để mỗi component tự nghe document.visibilityState?" Vì "bị ẩn" có nhiều nguồn: tab trình duyệt ẩn, tab của app không active, bị dialog che, nằm trong carousel trượt khỏi màn hình. HiddenSubtree gom mọi nguồn về một câu hỏi duy nhất, và phép OR theo cây tự lo việc lồng nhau.
"Context reactive là đủ, cần gì passive?" Đã nói ở trên: đổi tab mà kéo theo hàng trăm re-render thì keep-alive mất một nửa ý nghĩa. Quy tắc nhỏ: UI cần hiển thị khác đi khi ẩn → reactive; side effect cần dừng/chạy → passive.
#Edge case và cạm bẫy
- Effect vẫn chạy trong tab ẩn. Đây là điểm khác lớn nhất so với
<Activity>(bên dưới). MộtsetIntervalgọi API mỗi 5 giây trong tab ẩn vẫn tiếp tục gọi, trừ khi bạn dùnguseVisibleIntervalhoặc tự subscribe. Khi áp dụng keep-alive, hãy rà mọi polling, websocket subscription, animation. - Phím tắt toàn cục. Tab ẩn vẫn có listener
keydownđăng ký lêndocument. Facebook bọc mỗi tab trongCometLayerKeyCommandWrapperđể phím tắt chỉ áp dụng cho tab active (bài #8 có nói về key command theo layer). - ID trùng lặp. Hai tab cùng render một form có
id="email"→ hai phần tử trùng id trong DOM. DùnguseId()thay vì id cố định. document.title, meta tag. Tab ẩn nào cũng có thể ghi đè title nếu nó set trong effect. Chỉ tab active mới được đụng vàodocument.title(đọcuseKeepAliveInfo()hoặcuseIsHidden()).- Vị trí cuộn của
window.content-visibilitygiữ scroll của vùng cuộn bên trong tab. Nếu app cuộn bằngwindow(phổ biến nhất, và cũng là cách của Facebook), phải tự lưu và khôi phụcscrollYtheo tab, xem đoạn code ở mục "Dùng trong dự án". Facebook lưumainScrollKeycho từng route và có hẳn hookuseCometScrollAnchorđể cuộn lại đúng chỗ khi tab hiện lại. - Thông báo trung gian khi cha và con đổi cùng lúc. Layout effect của con chạy trước của cha. Nếu cùng một commit đổi props của cả hai provider, con tính trạng thái gộp với
ownRefcũ của cha, báo cho listener, rồi bị báo lại khi cha cập nhật. Listener có thể thấy một trạng thái trung gian trong chớp mắt. Với pause/play thì vô hại; nếu listener của bạn tốn kém, hãy làm nó idempotent hoặc gộp bằngqueueMicrotask. - Dữ liệu cũ. Quay lại sau 50 giây, Feed vẫn là dữ liệu của 50 giây trước. Thường đó là điều người dùng muốn (không bị nhảy), nhưng các con số như "3 thông báo mới" có thể cần làm mới khi tab hiện lại: subscribe store passive, khi
hiddenchuyển từtruesangfalsethì refetch nhẹ. - Bộ nhớ. Đo bằng
performance.measureUserAgentSpecificMemory()(chỉ có ở Chromium, cần cross-origin isolation) hoặc tab Memory của DevTools, trước và sau khi bật keep-alive cho một route.
#So với <Activity> của React, Vue <KeepAlive>
React 19.2 có component <Activity mode="visible" | "hidden"> (bản React trong repo ví dụ, 19.3, có export Activity). Khi mode="hidden", React ẩn children bằng display: none, giữ state, nhưng huỷ effect (chạy cleanup) và hạ độ ưu tiên render của phần bị ẩn. Hiện lại thì effect chạy lại.
| Keep-alive kiểu Facebook | React <Activity> |
Vue <KeepAlive> |
|
|---|---|---|---|
| Giữ state | Có | Có | Có |
| Effect khi ẩn | Vẫn chạy, tự dừng qua HiddenSubtree | Bị cleanup, chạy lại khi hiện | Vẫn sống, có hook onDeactivated/onActivated |
| Giới hạn số lượng | TTL theo tab (bản viết lại thêm maxMaintained) |
Tự quản lý | max (LRU) |
| Cách ẩn DOM | Tuỳ chọn | display: none |
Gỡ khỏi DOM, giữ instance |
<Activity> an toàn hơn mặc định (không lo effect chạy ngầm), nhưng đánh đổi: video bị huỷ effect thì phải tự lưu currentTime, subscription phải kết nối lại. Cách của Facebook giữ mọi thứ "nóng" và cho từng component tự quyết định ngủ thế nào.
Kết hợp hai cách được, nhưng phải đúng chiều: không thể đặt HiddenSubtree bên trong một <Activity> đang ẩn để giữ thứ gì đó chạy tiếp, vì effect bên trong đã bị cleanup. Cách hợp lý là dùng <Activity> cho route thường, còn route cần "nóng" (trình phát nhạc, cuộc gọi, chat đang mở) thì dùng KeepAliveOutlet.
Còn content-visibility: auto (không phải hidden) là chuyện khác: nó bỏ qua render phần nằm ngoài màn hình của một trang dài, không liên quan tới việc giữ trang cũ.
#Dùng trong dự án
Với React Router, đặt KeepAliveOutlet ở layout chung, activeKey lấy từ "tab" của route hiện tại.
Có một cái bẫy lớn: nếu chỉ lưu lại element của useOutlet() cho từng tab, các hook bên trong tab ẩn (useLocation, useSearchParams, useParams, đường dẫn tương đối của useNavigate) vẫn đọc location hiện tại. Feed đang ẩn sẽ "thấy" URL của Messages và có thể fetch lại hoặc redirect lung tung. Cách sửa: lưu location của từng tab và render tab đó bằng useRoutes(routes, location) với location đã lưu. React Router cung cấp location đó cho mọi hook bên dưới.
type TabHandle = { tab?: string };
const TAB_CONFIG = {
home: { maintained: true, maintainForMs: 60_000 },
messages: { maintained: true, maintainForMs: null },
};
/** Render cây route của một tab với location "đóng băng" của tab đó. */
function TabRoutes({ location }: { location: Location }) {
return useRoutes(tabRoutes, location);
}
function AppLayout() {
const location = useLocation();
const tab = (useMatches().at(-1)?.handle as TabHandle | undefined)?.tab ?? 'other';
// location gần nhất của từng tab
const locations = useRef(new Map<string, Location>());
locations.current.set(tab, location);
// Tab bị huỷ (hết TTL, vượt giới hạn) thì xoá location của nó, tránh Map phình mãi.
const prune = useCallback(
(entries: MaintainedEntry[]) => {
const live = new Set([tab, ...entries.map((e) => e.key)]);
for (const key of locations.current.keys()) if (!live.has(key)) locations.current.delete(key);
},
[tab],
);
return (
<HiddenSubtreeProvider hidden={false} backgrounded={useDocumentHidden()}>
<KeepAliveOutlet
activeKey={tab}
config={TAB_CONFIG}
onMaintainedChange={prune}
render={(key) => <TabRoutes location={locations.current.get(key)!} />}
/>
</HiddenSubtreeProvider>
);
}
tsx
App cuộn bằng window thì lưu scrollY theo tab. Lưu ở sự kiện scroll (không lưu lúc đổi tab, vì khi đó trang có thể đã ngắn lại và scrollY đã bị kẹp), khôi phục trong layout effect:
Xem code lưu/khôi phục scrollY theo tab
const scrollByTab = useRef(new Map<string, number>());
useEffect(() => {
const save = () => scrollByTab.current.set(tab, window.scrollY);
window.addEventListener('scroll', save, { passive: true });
return () => window.removeEventListener('scroll', save);
}, [tab]);
useLayoutEffect(() => {
window.scrollTo(0, scrollByTab.current.get(tab) ?? 0); // trước khi vẽ: không nháy
}, [tab]);
tsx
Trong component bên trong, dừng những gì tốn tài nguyên:
function FeedVideo({ src }: { src: string }) {
const ref = useRef<HTMLVideoElement>(null);
const store = useHiddenSubtreePassive();
useEffect(
() => store.subscribe(({ hidden, backgrounded }) => {
if (hidden || backgrounded) ref.current?.pause();
}),
[store],
);
return <video ref={ref} src={src} controls />;
}
tsx
#Kiểm thử
Ví dụ test Vitest + Testing Library (bản phác, không có trong repo)
it('giữ state khi chuyển tab và huỷ sau TTL', () => {
vi.useFakeTimers();
const config = { a: { maintained: true, maintainForMs: 1000 }, b: { maintained: false } };
const render = (key: string) => (key === 'a' ? <Counter /> : <p>B</p>);
const { rerender } = renderTL(<KeepAliveOutlet activeKey='a' config={config} render={render} />);
fireEvent.click(screen.getByRole('button', { name: /\+1/ })); // count = 1
rerender(<KeepAliveOutlet activeKey='b' config={config} render={render} />);
rerender(<KeepAliveOutlet activeKey='a' config={config} render={render} />);
expect(screen.getByText('count: 1')).toBeInTheDocument(); // còn nguyên
rerender(<KeepAliveOutlet activeKey='b' config={config} render={render} />);
act(() => vi.advanceTimersByTime(1001));
rerender(<KeepAliveOutlet activeKey='a' config={config} render={render} />);
expect(screen.getByText('count: 0')).toBeInTheDocument(); // đã mount lại
});
it('store passive chỉ báo khi trạng thái gộp đổi', () => {
const calls: HiddenState[] = [];
function Spy() {
const store = useHiddenSubtreePassive();
useEffect(() => store.subscribe((s) => calls.push(s)), [store]);
return null;
}
const tree = (outer: boolean, inner: boolean) => (
<HiddenSubtreeProvider hidden={outer}>
<HiddenSubtreeProvider hidden={inner}><Spy /></HiddenSubtreeProvider>
</HiddenSubtreeProvider>
);
const { rerender } = renderTL(tree(false, true));
rerender(tree(true, true)); // con đã hidden sẵn -> không báo
expect(calls).toHaveLength(0);
});
tsx
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA (bài này)
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

