Web Totals

Học từ Facebook #53: Cache route do server phân giải, chờ sẵn promise

Router của facebook.com không có bảng route ở client: nó hỏi server, cache theo param có nghĩa và chờ sẵn route server đẩy kèm dữ liệu. Phân tích và viết lại.

hunghg255hunghg25525 min read
#facebook#router#spa#cache#performance
Học từ Facebook #53: Cache route do server phân giải, chờ sẵn promise

Router của một SPA thường có bảng route viết sẵn ở client: /profile/:id → ProfilePage, /groups/:slug → GroupPage. Với facebook.com thì cách này khó. Có hàng nghìn loại URL, nhiều URL cũ từ thời PHP như /profile.php?id=… hay /permalink.php?story_fbid=…, có vanity URL (/zuck là trang cá nhân, /cocacola là Page), và quyết định "URL này là trang gì" phụ thuộc dữ liệu trong database.

Router Comet của Facebook để server trả lời câu hỏi đó, còn client thì cache câu trả lời cho khéo. Bài này đọc CometRouteStore và CometRouteMapper, rồi viết lại một router mini có cùng ý tưởng.

Tip

Tóm tắt

  • Client không biết URL nào là trang gì. URL lạ được gửi lên server (fetchAnonymousCometRoute), server trả về định nghĩa route hoặc redirect.
  • Kết quả được cache theo path + những param có nghĩa do chính server khai báo. /profile.php?id=1&ref=feed và /profile.php?id=1&__tn__=R dùng chung một mục cache. Redirect được giải tiếp ở client.
  • Server có thể báo trước route của các URL sắp xuất hiện, kèm response GraphQL. Client đặt sẵn một promise cho từng URL; preload route khi rê chuột lên link chờ promise đó thay vì gọi mạng.
  • Điều hướng thật thì không chờ: không có trong cache là gọi mạng ngay, kể cả khi route đang trên đường tới. Bản viết lại cho điều hướng chờ thêm tối đa vài trăm ms, đổi lại có thể tiết kiệm một request.

#Facebook làm thế nào?

#Hai tầng cache

CometRouteStore.getRoute(url) tra hai nơi (26.js):

let route = CometRouteCache.getRoute(url);       // tầng 1: theo URL đầy đủ đã chuẩn hoá
if (route == null) route = CometRouteMapper.getRoute(url); // tầng 2: path + param có nghĩa, trúng thì cài vào tầng 1
js

CometRouteCache là một object đơn giản, khoá là URL đã qua normalizeCometRouterUrl (bỏ origin, bỏ / cuối, thêm / đầu). CometRouteMapper thông minh hơn: mỗi path có một nút, trong nút có các kết quả, khoá là chuỗi JSON ổn định của các param có nghĩa:

function keyFor(routeParams, values) {
  return stableStringify(buildRouteParams(routeParams, values, 'significant'));
}
js

buildRouteParams(…, 'significant') chỉ giữ những param mà định nghĩa route (do server gửi) đánh dấu significant: true, kèm giá trị mặc định và ép kiểu (BOOL nhận "0", "false"). Một route trang cá nhân có thể khai báo id là có nghĩa, còn ref, sk thì không. Vì vậy người dùng bấm vào cùng một người từ feed, từ thông báo, từ bình luận, URL khác nhau nhưng client chỉ hỏi server một lần.

Kết quả có ba loại: routeDefinition (lá, dựng route ngay), routeMatch (đi tiếp một tầng, dùng khi một path có nhiều tầng khớp lồng nhau) và routeRedirect. Redirect được giải ở client, đệ quy qua getRoute(redirectUrl), có tuỳ chọn mang theo param hiện tại (useCurrentParams) và bỏ một số param (strip).

#Bỏ param tracking

Trước khi so khớp với các promise chờ sẵn, URL bị bỏ bốn param tracking:

const TRACKING = ['__cft__', '__tn__', 'fnr_t', 'sw_fnr_id'];
js

__cft__ và __tn__ là các param theo dõi Facebook gắn vào link trong feed. Chúng không đổi nội dung trang.

#Route được đẩy trước

Đây là phần thú vị nhất. CometRelayEnvironmentFactory truyền một callback cho lớp network của Relay:

createRelayFBNetwork(fetchFn, subscribeFn, …, (urls, payload) => {
  CometRouteStore.handleRoutingPayload(urls, payload);
});
js

Có lẽ khi một query GraphQL trả về dữ liệu chứa link (bài viết, tên người, nhóm), server đính kèm luôn route của các link đó. Response GraphQL của Facebook được stream thành nhiều phần, nên danh sách URL có thể tới trước, payload route tới sau (suy đoán dựa trên việc hàm nhận hai tham số riêng). handleRoutingPayload làm hai việc:

function handleRoutingPayload(urls, payload) {
  if (!gkx('1401861')) return;                        // sau một cờ gating
  urls.forEach((url) => {
    // đã có route hoặc đã chờ thì bỏ qua; ngược lại tạo { promise, resolve, reject }
    pending.set(key, entry);
    setTimeout(() => { pending.delete(key); entry.reject(); }, 15000);   // hết hạn sau 15s
  });
  // payload có thì: install(url, route) cho từng URL, resolve/reject promise tương ứng
}
js

Ai dùng các promise chờ sẵn? CometRouteStore có hai hàm lấy route:

// fetch: dùng cho preloadRoute (BaseLink gọi qua useCometPreloader khi rê chuột lên link)
return entry ? entry.promise.catch(() => fetchFromNetwork(url)) : fetchFromNetwork(url);

// fetchForNavigation: dùng khi người dùng thật sự điều hướng, không nhìn tới pending
return cached != null ? Promise.resolve({ route: cached }) : fetchAnonymousCometRouteForTransition(url, actorID);
js

Preload chờ promise, có thể tới 15 giây, và việc đó không chặn ai vì người dùng chưa bấm. Điều hướng thì không bao giờ chờ: route chưa có trong cache là gọi mạng ngay, và router ghi thêm metadata unfetched_rd vào trace để đo những lần như vậy. Đây là lựa chọn hợp lý: không bao giờ để người dùng chờ một payload có thể không tới. Cái giá là nếu payload chỉ còn vài chục ms nữa là tới, request điều hướng vẫn đi, thành ra trùng.

Ngoài ra fetchForValidation hỏi lại server sau khi đã điều hướng xong. Nếu server trả về route khác route đang hiển thị (dữ liệu đổi, hoặc cache sai), client cài route mới. Cache vì vậy có cơ chế tự sửa.

#Những chỗ đáng chú ý

  1. Timer xoá nhầm. Timer hết hạn gọi pending.delete(key) mà không kiểm entry trong map còn là entry của mình không. Payload tới, entry bị xoá, rồi một lần handleRoutingPayload sau tạo entry mới cho cùng URL; timer cũ hết hạn sẽ xoá entry mới. Hậu quả nhẹ (thêm một request mạng), nhưng là loại lỗi dễ lặp lại ở code khác.
  2. Xoá cache theo tiền tố chuỗi. invalidatePath('/groups/1') dùng startsWith, nên xoá luôn /groups/12, /groups/123. Cũng chỉ tốn thêm request.

#Luồng điều hướng

flowchart TD
  C["người dùng bấm link"] --> L{"có trong cache?"}
  L -->|"có"| R["dựng route ngay"]
  L -->|"không"| P{"có promise chờ sẵn?"}
  P -->|"không"| N["hỏi server"]
  P -->|"có"| W["chờ promise (tối đa maxWait; bản gốc: không chờ)"]
  W -->|"payload tới kịp"| R
  W -->|"hết maxWait"| N
  N --> I["cài vào cache theo path + param có nghĩa"] --> R

#Viết lại

#Cache theo param có nghĩa

src/lib/routeCache.ts
export function normalizeUrl(url: string, tracking = TRACKING_PARAMS): string {
  const u = new URL(url, 'https://example.test');
  tracking.forEach((p) => u.searchParams.delete(p));
  u.searchParams.sort();
  let path = u.pathname;
  if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
  const qs = u.searchParams.toString();
  return path + (qs ? `?${qs}` : '');
}

function splitUrl(url: string) {
  const u = new URL(url, 'https://example.test');
  return { path: u.pathname.replace(/(.)\/$/, '$1'), params: Object.fromEntries(u.searchParams) as Record<string, string> };
}

const significantKey = (params: Record<string, string>, names: string[]) =>
  JSON.stringify([...names].sort().map((n) => [n, params[n] ?? null]));
ts
src/lib/routeCache.ts
  function lookup(url: string, depth = 0): { route: ResolvedRoute; source: Source } | null {
    const key = normalizeUrl(url);
    const hit = byUrl.get(key);
    if (hit) return { route: hit, source: 'url-cache' };
    const { path, params } = splitUrl(key);
    const node = byPath.get(path);
    const result = node?.results.get(significantKey(params, node.significant));
    if (!result) return null;
    if (result.type === 'redirect') return depth < maxRedirects ? lookup(result.to, depth + 1) : null;
    const route = { name: result.name, url: key, params };
    byUrl.set(key, route);
    return { route, source: 'path-cache' };
  }
ts

#Điều hướng và prefetch (expect/deliver xem trong spoiler)

src/lib/routeCache.ts
    async navigate(url: string): Promise<{ route: ResolvedRoute; source: Source; waitedMs: number }> {
      const start = performance.now();
      const cached = lookup(url);
      if (cached) return { ...cached, waitedMs: 0 };
      const entry = pending.get(normalizeUrl(url));
      if (entry && maxWaitOnNavigate > 0) {
        const timeout = new Promise<null>((r) => setTimeout(() => r(null), maxWaitOnNavigate));
        const pushed = await Promise.race([entry.promise, timeout]);
        if (pushed) {
          const hit = lookup(url);
          if (hit) return { route: hit.route, source: 'pushed', waitedMs: performance.now() - start };
        }
      }
      const route = await fromNetwork(url);
      return { route, source: 'network', waitedMs: performance.now() - start };
    },
    /** Prefetch khi rê chuột lên link: chờ promise đặt trước tới khi nó hết hạn, giống `fetch` của bản gốc. */
    async prefetch(url: string): Promise<{ route: ResolvedRoute; source: Source }> {
      const cached = lookup(url);
      if (cached) return cached;
      const entry = pending.get(normalizeUrl(url));
      if (entry && (await entry.promise)) {
        const hit = lookup(url);
        if (hit) return { route: hit.route, source: 'pushed' };
      }
      return { route: await fromNetwork(url), source: 'network' };
    },
ts
Xem toàn bộ src/lib/routeCache.ts
src/lib/routeCache.ts
/**
 * Router không có bảng route ở client: hỏi server URL này là trang gì, rồi cache kết quả
 * (ý tưởng từ CometRouteStore + CometRouteMapper của Facebook).
 *
 * - Cache 2 tầng: theo URL đầy đủ (đã chuẩn hoá), và theo path + các param "có nghĩa".
 *   Param không có nghĩa (tracking, ref…) không tách cache.
 * - Bỏ param tracking trước khi so khớp.
 * - Server có thể báo trước "sắp gửi route cho các URL này" (kèm response dữ liệu):
 *   client đặt sẵn một promise chờ cho từng URL. Prefetch (khi rê chuột lên link) chờ
 *   promise này thay vì gọi mạng lần nữa.
 *
 * Bản gốc: prefetch chờ promise tới khi nó hết hạn (15s); điều hướng thật thì không chờ,
 * không có trong cache là gọi mạng ngay. Bản viết lại cho điều hướng chờ thêm một khoảng
 * ngắn có giới hạn (`maxWaitOnNavigate`, đặt 0 để giống bản gốc); timer hết hạn chỉ xoá
 * đúng entry nó tạo; invalidatePath so theo ranh giới "/".
 */

export interface RouteResult {
  type: 'route';
  name: string;
  /** param của query được dùng để tách cache, ví dụ ['id'] cho /profile.php?id=… */
  significant: string[];
}
export interface RedirectResult {
  type: 'redirect';
  to: string;
}
export type ResolveResult = RouteResult | RedirectResult;

export interface ResolvedRoute {
  name: string;
  url: string;
  params: Record<string, string>;
}

export type Source = 'url-cache' | 'path-cache' | 'pushed' | 'network';

export const TRACKING_PARAMS = ['__cft__', '__tn__', 'fnr_t', 'sw_fnr_id', 'utm_source', 'utm_medium', 'fbclid'];

export function normalizeUrl(url: string, tracking = TRACKING_PARAMS): string {
  const u = new URL(url, 'https://example.test');
  tracking.forEach((p) => u.searchParams.delete(p));
  u.searchParams.sort();
  let path = u.pathname;
  if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
  const qs = u.searchParams.toString();
  return path + (qs ? `?${qs}` : '');
}

function splitUrl(url: string) {
  const u = new URL(url, 'https://example.test');
  return { path: u.pathname.replace(/(.)\/$/, '$1'), params: Object.fromEntries(u.searchParams) as Record<string, string> };
}

const significantKey = (params: Record<string, string>, names: string[]) =>
  JSON.stringify([...names].sort().map((n) => [n, params[n] ?? null]));

interface Pending {
  promise: Promise<ResolveResult | null>;
  resolve: (r: ResolveResult | null) => void;
  timer: ReturnType<typeof setTimeout>;
}

export interface RouteStoreOptions {
  /** gọi server phân giải URL */
  resolve: (url: string) => Promise<ResolveResult>;
  /** promise đặt trước tự hết hạn sau khoảng này (bản gốc 15s) */
  pushTtl?: number;
  /** một lần điều hướng chờ promise đặt trước tối đa bao lâu rồi tự gọi mạng (0 = không chờ) */
  maxWaitOnNavigate?: number;
  maxRedirects?: number;
}

export function createRouteStore({ resolve, pushTtl = 15_000, maxWaitOnNavigate = 300, maxRedirects = 5 }: RouteStoreOptions) {
  const byUrl = new Map<string, ResolvedRoute>();
  /** path → { significant names, results theo khoá param có nghĩa } */
  const byPath = new Map<string, { significant: string[]; results: Map<string, ResolveResult> }>();
  const pending = new Map<string, Pending>();

  function install(url: string, result: ResolveResult) {
    const { path, params } = splitUrl(normalizeUrl(url));
    const significant = result.type === 'route' ? result.significant : Object.keys(params);
    let node = byPath.get(path);
    if (!node) {
      node = { significant, results: new Map() };
      byPath.set(path, node);
    }
    node.results.set(significantKey(params, node.significant), result);
  }

  /** Tra cache ở client, đi theo redirect đã biết. */
  function lookup(url: string, depth = 0): { route: ResolvedRoute; source: Source } | null {
    const key = normalizeUrl(url);
    const hit = byUrl.get(key);
    if (hit) return { route: hit, source: 'url-cache' };
    const { path, params } = splitUrl(key);
    const node = byPath.get(path);
    const result = node?.results.get(significantKey(params, node.significant));
    if (!result) return null;
    if (result.type === 'redirect') return depth < maxRedirects ? lookup(result.to, depth + 1) : null;
    const route = { name: result.name, url: key, params };
    byUrl.set(key, route);
    return { route, source: 'path-cache' };
  }

  /** request phân giải đang bay, để prefetch và điều hướng cùng URL không gọi mạng hai lần */
  const inflight = new Map<string, Promise<ResolvedRoute>>();

  function fromNetwork(url: string, depth = 0): Promise<ResolvedRoute> {
    const key = normalizeUrl(url);
    const running = inflight.get(key);
    if (running) return running;
    const request = (async () => {
      const result = await resolve(key);
      install(key, result);
      if (result.type === 'redirect') {
        if (depth >= maxRedirects) throw new Error('Quá nhiều redirect');
        return fromNetwork(result.to, depth + 1);
      }
      return lookup(key)!.route;
    })().finally(() => inflight.delete(key));
    inflight.set(key, request);
    return request;
  }

  return {
    lookup,
    install,
    /** Server báo trước sẽ gửi route cho các URL này. */
    expect(urls: string[]) {
      urls.forEach((url) => {
        const key = normalizeUrl(url);
        if (lookup(key) || pending.has(key)) return;
        let resolveFn!: (r: ResolveResult | null) => void;
        const promise = new Promise<ResolveResult | null>((r) => (resolveFn = r));
        const entry: Pending = {
          promise,
          resolve: resolveFn,
          timer: setTimeout(() => {
            // chỉ xoá nếu entry trong map vẫn là entry mình tạo
            if (pending.get(key) === entry) pending.delete(key);
            resolveFn(null);
          }, pushTtl),
        };
        pending.set(key, entry);
      });
    },
    /** Payload route server đẩy tới (null = server không phân giải được). */
    deliver(url: string, result: ResolveResult | null) {
      const key = normalizeUrl(url);
      if (result) install(key, result);
      const entry = pending.get(key);
      if (entry) {
        pending.delete(key);
        clearTimeout(entry.timer);
        entry.resolve(result);
      }
    },
    async navigate(url: string): Promise<{ route: ResolvedRoute; source: Source; waitedMs: number }> {
      const start = performance.now();
      const cached = lookup(url);
      if (cached) return { ...cached, waitedMs: 0 };
      const entry = pending.get(normalizeUrl(url));
      if (entry && maxWaitOnNavigate > 0) {
        const timeout = new Promise<null>((r) => setTimeout(() => r(null), maxWaitOnNavigate));
        const pushed = await Promise.race([entry.promise, timeout]);
        if (pushed) {
          const hit = lookup(url);
          if (hit) return { route: hit.route, source: 'pushed', waitedMs: performance.now() - start };
        }
      }
      const route = await fromNetwork(url);
      return { route, source: 'network', waitedMs: performance.now() - start };
    },
    /** Prefetch khi rê chuột lên link: chờ promise đặt trước tới khi nó hết hạn, giống `fetch` của bản gốc. */
    async prefetch(url: string): Promise<{ route: ResolvedRoute; source: Source }> {
      const cached = lookup(url);
      if (cached) return cached;
      const entry = pending.get(normalizeUrl(url));
      if (entry && (await entry.promise)) {
        const hit = lookup(url);
        if (hit) return { route: hit.route, source: 'pushed' };
      }
      return { route: await fromNetwork(url), source: 'network' };
    },
    invalidatePath(prefix: string) {
      const matches = (p: string) => p === prefix || p.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
      [...byPath.keys()].forEach((p) => matches(p) && byPath.delete(p));
      [...byUrl.keys()].forEach((u) => matches(u.split('?')[0]) && byUrl.delete(u));
    },
    stats: () => ({ urls: byUrl.size, paths: byPath.size, pending: pending.size }),
  };
}

export type RouteStore = ReturnType<typeof createRouteStore>;
ts

#Đi qua code

  • normalizeUrl bỏ param tracking, sắp lại query (để ?a=1&b=2 và ?b=2&a=1 là một), bỏ / cuối. Kết quả dùng làm khoá cho cả cache URL lẫn promise chờ sẵn.
  • install lưu kết quả vào nút của path, khoá bằng significantKey. Danh sách param có nghĩa lấy từ kết quả server trả về lần đầu cho path đó.
  • lookup tra cache URL trước, rồi cache theo path; gặp redirect thì đi tiếp, giới hạn độ sâu để tránh vòng lặp.
  • expect đặt promise chờ cho các URL chưa có trong cache. Timer hết hạn chỉ xoá đúng entry nó tạo.
  • deliver cài kết quả và giải quyết promise. null nghĩa là server không phân giải được; promise trả null, người đang chờ sẽ tự gọi mạng.
  • navigate dùng Promise.race giữa promise chờ sẵn và một timeout maxWaitOnNavigate (mặc định 300ms). Hết thời gian thì gọi mạng ngay. Đặt 0 để giống bản gốc. Promise chờ sẵn vẫn còn trong map; nếu payload tới muộn, nó vẫn được cài vào cache cho lần sau.
  • prefetch chờ promise tới khi nó kết thúc, giống fetch của bản gốc.
  • fromNetwork giữ một map các request đang bay theo URL đã chuẩn hoá. Hover (prefetch) rồi bấm ngay (điều hướng) cùng một link chỉ tạo một request.
  • invalidatePath so theo ranh giới /.

Khác bản gốc: không có tầng routeMatch lồng nhau, không có fetchForValidation, danh sách param có nghĩa là mảng tên thay vì object định nghĩa đầy đủ (kiểu, giá trị mặc định, tên cũ), có dedupe request đang bay, và điều hướng chờ route đang được đẩy tới trong một khoảng ngắn.

#Đánh đổi của việc chờ khi điều hướng

Đo trong demo (mạng giả lập 450ms), bấm link ngay sau khi tải feed:

Payload đẩy trước tới sau Bản gốc (không chờ) Chờ tối đa 300ms
200ms ~490ms, thêm 1 request ~190ms, 0 request
1500ms ~500ms, thêm 1 request ~800ms, thêm 1 request
không bao giờ tới ~500ms, thêm 1 request ~800ms, thêm 1 request

Chờ có lợi khi payload thường tới nhanh, và có hại tối đa maxWaitOnNavigate khi payload tới muộn hoặc không tới. Bản gốc chọn không bao giờ để người dùng chờ. Muốn chọn khác thì nên đo trước: payload route thường tới sau bao lâu so với lúc dữ liệu hiện lên màn hình.

#Tại sao không làm cách khác?

Gửi cả bảng route xuống client? Với vài chục route thì nên làm vậy (React Router, Next.js đều thế). Với vanity URL và URL cũ phụ thuộc dữ liệu, client không thể tự quyết /cocacola là trang cá nhân hay Page. Hỏi server là bắt buộc; câu hỏi chỉ còn là hỏi lúc nào và cache ra sao.

Cache theo URL đầy đủ thôi? Mỗi link trong feed mang __tn__, __cft__, ref khác nhau. Cache theo URL đầy đủ gần như không bao giờ trúng.

Chỉ preload route khi hover? Facebook có làm (BaseLink gọi preloadRoute qua useCometPreloader, xem bài preload khi hover). Đẩy kèm response GraphQL còn sớm hơn: route có thể đã nằm trong cache trước cả khi người dùng thấy link, và preload lúc hover thì dùng lại promise đang chờ thay vì gọi mạng thêm.

#Edge case và cạm bẫy

  • Server đổi định nghĩa route. Sau một lần deploy, param có nghĩa của một path có thể đổi. Cache cũ sẽ chọn sai mục. Cần một cơ chế như fetchForValidation của bản gốc, hoặc xoá cache khi phát hiện bản build mới.
  • Redirect vòng. /a → /b → /a. Giới hạn độ sâu (maxRedirects) là bắt buộc.
  • Param có nghĩa bị thiếu. /profile.php không có id nghĩa là "trang của tôi". Khoá vẫn hợp lệ vì significantKey ghi null cho param thiếu.
  • Hai URL cùng chờ một payload. Link /profile.php?id=1&ref=feed có ref không phải param tracking, nên khoá của nó khác khoá của URL server báo trước (/profile.php?id=1). Bản gốc cũng có giới hạn này. Muốn khớp được, server phải báo đúng URL như trong link.

#Dùng trong dự án

const routes = createRouteStore({
  resolve: (url) => fetch(`/api/resolve-route?url=${encodeURIComponent(url)}`).then((r) => r.json()),
  maxWaitOnNavigate: 250,
});

// response API danh sách bài viết kèm route của các link trong đó
const data = await api.get('/feed');
routes.expect(data.links);
data.routes && Object.entries(data.routes).forEach(([url, r]) => routes.deliver(url, r));

// trong router
async function onLinkClick(url: string) {
  const { route } = await routes.navigate(url);
  render(PAGES[route.name], route.params);
}
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { createRouteStore, normalizeUrl, type ResolveResult } from './routeCache';

const server = () =>
  vi.fn(async (url: string): Promise<ResolveResult> =>
    url.startsWith('/me')
      ? { type: 'redirect', to: '/profile.php?id=4' }
      : { type: 'route', name: 'ProfilePage', significant: ['id'] },
  );

describe('route cache', () => {
  it('bỏ param tracking và sắp lại query', () => {
    expect(normalizeUrl('/profile.php?__tn__=R&id=1&b=2')).toBe('/profile.php?b=2&id=1');
  });

  it('param không có nghĩa không tạo request mới, redirect giải ở client', async () => {
    const resolve = server();
    const store = createRouteStore({ resolve });
    expect((await store.navigate('/profile.php?id=1')).source).toBe('network');
    expect((await store.navigate('/profile.php?id=1&ref=feed&__tn__=R')).source).toBe('path-cache');
    await store.navigate('/me');
    expect((await store.navigate('/me')).route.params.id).toBe('4');
    expect(resolve).toHaveBeenCalledTimes(3); // id=1, /me, id=4
  });

  it('route được đẩy trước thì không gọi mạng', async () => {
    const resolve = server();
    const store = createRouteStore({ resolve });
    store.expect(['/profile.php?id=9']);
    setTimeout(() => store.deliver('/profile.php?id=9', { type: 'route', name: 'ProfilePage', significant: ['id'] }), 20);
    expect((await store.navigate('/profile.php?id=9&__cft__=x')).source).toBe('pushed');
    expect(resolve).not.toHaveBeenCalled();
  });

  it('đẩy trước không tới thì chỉ chờ tối đa maxWaitOnNavigate', async () => {
    const store = createRouteStore({ resolve: server(), maxWaitOnNavigate: 50 });
    store.expect(['/profile.php?id=7']);
    const r = await store.navigate('/profile.php?id=7');
    expect(r.source).toBe('network');
    expect(r.waitedMs).toBeLessThan(1000);
  });

  it('prefetch chờ route được đẩy; request đang bay được dùng chung', async () => {
    const resolve = server();
    const store = createRouteStore({ resolve });
    store.expect(['/profile.php?id=5']);
    setTimeout(() => store.deliver('/profile.php?id=5', { type: 'route', name: 'ProfilePage', significant: ['id'] }), 400);
    expect((await store.prefetch('/profile.php?id=5')).source).toBe('pushed');
    await Promise.all([store.prefetch('/profile.php?id=6'), store.navigate('/profile.php?id=6')]);
    expect(resolve).toHaveBeenCalledTimes(1);
  });
});
ts

#So với thư viện và chuẩn web

  • Router phổ biến (React Router, TanStack Router, Next.js) dùng bảng route khai báo ở client hoặc sinh từ thư mục lúc build. Next.js App Router lấy "RSC payload" của trang từ server khi điều hướng, gần với ý "hỏi server" nhưng route vẫn xác định từ cấu trúc thư mục.
  • Speculation Rules API cho phép khai báo URL nên prefetch hoặc prerender khi người dùng có khả năng bấm (Chromium hỗ trợ, tính tới 10/2026). Nó áp dụng cho điều hướng tải trang đầy đủ, không phải route trong SPA.
  • Promise.withResolvers() (có trong các trình duyệt hiện đại, tính tới 10/2026) thay được đoạn let resolve; new Promise((r) => (resolve = r)) khi đặt promise chờ sẵn.

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

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz