view react_games/src/API_works/pagination.tsx @ 189:14cc84ba35a0

[BenchMark] Updated results
author MrJuneJune <me@mrjunejune.com>
date Sat, 24 Jan 2026 06:37:43 -0800
parents fb9bcd3145cb
children
line wrap: on
line source

import { CSSProperties, startTransition, useCallback, useEffect, useReducer, useRef } from "react";
import ReactDOM from "react-dom/client";

const API_URL = "https://swapi.py4e.com/api/people/";

/* ---------- Types ---------- */
interface PeopleJSON { name: string; birth_year: string; }
interface APIResponse { next: string | null; previous: string | null; results: PeopleJSON[]; }
interface PageData extends APIResponse { isLoading: boolean; curr: string; }

type Dir = "next" | "prev";
type Mode = "none" | "page" | "offset" | "cursor" | "link";
type UrlParams =
  | { mode: "none" }
  | { mode: "page"; page: number; limit?: number }
  | { mode: "offset"; offset: number; limit?: number }
  | { mode: "cursor"; cursor: string; limit?: number; dir?: Dir }
  | { mode: "link"; curr: string; next?: string; prev?: string };

/* ---------- URL <-> Args ---------- */
function parseArgsFromSearch(): UrlParams {
  const sp = new URLSearchParams(location.search);
  const mode = (sp.get("mode") as Mode) || "none";
  switch (mode) {
    case "none":   return { mode };
    case "page":   return { mode, page: Number(sp.get("page") || 1), limit: sp.get("limit") ? Number(sp.get("limit")) : undefined };
    case "offset": return { mode, offset: Number(sp.get("offset") || 0), limit: sp.get("limit") ? Number(sp.get("limit")) : undefined };
    case "cursor": return { mode, cursor: sp.get("cursor") || "", limit: sp.get("limit") ? Number(sp.get("limit")) : undefined, dir: (sp.get("dir") as Dir) || undefined };
    case "link":   return { mode, curr: sp.get("curr") || API_URL, next: sp.get("next") || undefined, prev: sp.get("prev") || undefined };
  }
}

function uiUrl(basePath: string, args: UrlParams): string {
  const sp = new URLSearchParams();
  switch (args.mode) {
    case "none":
      return basePath;
    case "page":
      sp.set("mode", "page");
      sp.set("page", String(args.page));
      if (args.limit != null) sp.set("limit", String(args.limit));
      break;
    case "offset":
      sp.set("mode", "offset");
      sp.set("offset", String(args.offset));
      if (args.limit != null) sp.set("limit", String(args.limit));
      break;
    case "cursor":
      sp.set("mode", "cursor");
      sp.set("cursor", args.cursor);
      if (args.limit != null) sp.set("limit", String(args.limit));
      if (args.dir) sp.set("dir", args.dir);
      break;
    case "link":
      sp.set("mode", "link");
      sp.set("curr", args.curr);
      if (args.next) sp.set("next", args.next);
      if (args.prev) sp.set("prev", args.prev);
      break;
  }
  const q = sp.toString();
  return q ? `${basePath}?${q}` : basePath;
}

/** For this API, upstream URL comes from args. */
function toUpstreamUrl(args: UrlParams): string {
  switch (args.mode) {
    case "none":   return API_URL;
    case "link":   return args.curr || API_URL;
    case "page":   return `${API_URL}?page=${args.page}${args.limit != null ? `&limit=${args.limit}` : ""}`;
    case "offset": return `${API_URL}?offset=${args.offset}${args.limit != null ? `&limit=${args.limit}` : ""}`;
    case "cursor": return `${API_URL}?cursor=${args.cursor}${args.limit != null ? `&limit=${args.limit}` : ""}${args.dir ? `&dir=${args.dir}` : ""}`;
  }
}

function argsFromResponse(currUrl: string, resp: APIResponse): UrlParams {
  // Prefer LINK mode when API provides next/previous URLs
  return { mode: "link", curr: currUrl, next: resp.next ?? undefined, prev: resp.previous ?? undefined };
}

/* ---------- In-flight dedupe (not a cache) ---------- */
const inflight = new Map<string, Promise<PageData>>();

async function fetchPage(url: string, signal?: AbortSignal): Promise<PageData> {
  if (inflight.has(url)) return inflight.get(url)!;

  const p = (async () => {
    const res = await fetch(url, { signal });
    console.log(res.headers.get("content-type"));
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const json: APIResponse = await res.json();
    return { ...json, isLoading: false, curr: url } as PageData;
  })().finally(() => inflight.delete(url));

  inflight.set(url, p);
  return p;
}

/* ---------- Derive next/prev target args (mode-agnostic) ---------- */
function deriveNav(current: UrlParams, pd: PageData) {
  let nextArgs: UrlParams | undefined;
  let prevArgs: UrlParams | undefined;

  switch (current.mode) {
    case "none":
    case "link": {
      nextArgs = pd.next ? { mode: "link", curr: pd.next, next: pd.next, prev: pd.prev || undefined } : undefined;
      prevArgs = pd.previous ? { mode: "link", curr: pd.previous, prev: pd.previous, next: pd.next || undefined } : undefined;
      break;
    }
    case "page": {
      const limit = current.limit;
      nextArgs = { mode: "page", page: current.page + 1, limit };
      prevArgs = current.page > 1 ? { mode: "page", page: current.page - 1, limit } : undefined;
      break;
    }
    case "offset": {
      const limit = current.limit ?? 10;
      nextArgs = { mode: "offset", offset: current.offset + limit, limit };
      prevArgs = current.offset - limit >= 0 ? { mode: "offset", offset: current.offset - limit, limit } : undefined;
      break;
    }
    case "cursor": {
      // needs server-provided cursors; we’d stash them in pd if available
      // For this API we don't have cursors, so fall back to link behavior above.
      break;
    }
  }
  return { nextArgs, prevArgs };
}

/* ---------- State ---------- */
enum ActionType { HYDRATE, LOADING, ERROR }
type Action =
  | { type: ActionType.HYDRATE; data: PageData }
  | { type: ActionType.LOADING }
  | { type: ActionType.ERROR; error: string };

const INITIAL: PageData = { next: null, previous: null, results: [], isLoading: false, curr: API_URL };

function reducer(state: PageData, action: Action): PageData {
  switch (action.type) {
    case ActionType.HYDRATE: return action.data;
    case ActionType.LOADING: return { ...state, isLoading: true };
    case ActionType.ERROR:   return { ...state, isLoading: false };
    default:                 return state;
  }
}

/* ---------- Styles & Row ---------- */
const styles: { row: CSSProperties; toolbar: CSSProperties } = {
  row: { display: "grid", gridTemplateColumns: "repeat(2, 1fr)" },
  toolbar: { display: "flex", gap: 8, marginBottom: 8 },
};

const PersonRow = ({ p }: { p: PeopleJSON }) => (
  <div style={styles.row}>
    <p>{p.name}</p>
    <p>{p.birth_year}</p>
  </div>
);

/* ---------- Component ---------- */
const People = () => {
  const [page, dispatch] = useReducer(reducer, INITIAL);
  const ctrlRef = useRef<AbortController | null>(null);

  const load = useCallback((args: UrlParams, pushUrl = true) => {
    dispatch({ type: ActionType.LOADING });

    startTransition(async () => {
      ctrlRef.current?.abort();
      const ctrl = new AbortController();
      ctrlRef.current = ctrl;

      try {
        const upstream = toUpstreamUrl(args);
        const data = await fetchPage(upstream, ctrl.signal);

        // Auto-detect LINK and sync UI URL *from the response* (URL is the source of truth)
        const linkArgs = argsFromResponse(upstream, data);
        if (pushUrl) history.pushState(null, "", uiUrl(location.pathname, linkArgs));

        dispatch({ type: ActionType.HYDRATE, data });
      } catch (e: any) {
        if (e?.name !== "AbortError") dispatch({ type: ActionType.ERROR, error: String(e?.message ?? e) });
      }
    });
  }, []);

  // Initial load + back/forward
  useEffect(() => {
    const applyFromLocation = () => load(parseArgsFromSearch(), false);
    window.addEventListener("popstate", applyFromLocation);
    applyFromLocation();
    return () => window.removeEventListener("popstate", applyFromLocation);
  }, [load]);

  // Click handlers: derive target solely from current URL + current page data
  const navigate = useCallback((dir: Dir) => {
    const current = parseArgsFromSearch();
    const { nextArgs, prevArgs } = deriveNav(current, page);
    const target = dir === "next" ? nextArgs : prevArgs;
    if (!target) return;
    // push new URL first so URL remains the source of truth
    history.pushState(null, "", uiUrl(location.pathname, target));
    load(target, false); // do not push again inside load
  }, [page, load]);

  const { nextArgs, prevArgs } = deriveNav(parseArgsFromSearch(), page);

  return (
    <div>
      <div style={styles.toolbar}>
        <button disabled={!prevArgs} onClick={() => navigate("prev")}>prev</button>
        <button disabled={!nextArgs} onClick={() => navigate("next")}>next</button>
      </div>

      {page.isLoading && <p>Loading…</p>}

      {!page.isLoading && (
        <>
          <div style={{ ...styles.row, borderBottom: "1px solid #000" }}>
            <strong>Name</strong>
            <strong>Birth Year</strong>
          </div>
          {page.results.map((p) => <PersonRow key={p.name} p={p} />)}
        </>
      )}
    </div>
  );
};

export {
  People,
}