Files
buzz/desktop/test-dom-loader-hooks.mjs
T
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6wandTaylor Ho 374c829619 perf(timeline): virtualize the main message timeline
Phase 2: render only the viewport (+overscan) of the main timeline instead of
all ~200+ rows, lifting the ~2,000-row ceiling. Main timeline ONLY — the thread
pane (and its useTimelineScrollManager) is untouched (Phase 3).

What changed:

- MessageTimeline now drives @tanstack/react-virtual off the same deferred
  snapshot the rows render from, flattening day-grouped entries into
  index-addressable rows via buildVirtualTimelineRows. Rows are built from the
  FILTERED main-timeline entries (buildMainTimelineEntries), so dropped thread
  replies never desync the row->entry mapping.

- useVirtualTimelineScroll REPLACES the bespoke 427-line useTimelineScrollManager
  for the main timeline. The virtualizer owns the scroll container and all
  measurement/anchoring; this hook is a thin wrapper keeping sticky-bottom
  autoscroll, accent smooth-scroll, the newMessageCount pill, isAtBottom, and
  deep-link + find-in-page jumps (via findVirtualRowIndexForMessage ->
  scrollToIndex). No scrollTop locking, no ResizeObserver re-pinning.

- useLoadOlderOnScroll is gutted to a trigger: the double-requestAnimationFrame
  scrollTop correction and restoreScrollPosition plumbing are DELETED. Stable
  per-row keys (getItemKey) let the virtualizer hold scroll position on prepend
  natively — the band-aid is gone at the root.

- cmd+F find drives scroll-to-row through the same scrollToIndex bridge; a
  render-all-while-searching escape hatch exists as an off-by-default fallback
  (the in-app path is the default, keeping the perf win).

- The per-entry render (3 row variants + video-review context) is extracted to
  renderTimelineEntry + useVideoReviewContextById, a single source of truth
  injected into VirtualizedTimelineList. The old nested TimelineMessageList is
  deleted (orphaned by the rewire).

Must-keeps preserved: sticky-bottom autoscroll, day dividers (first-class
variable-height flat rows), jump-to-message deep links, no-tearing (same
deferredMessages snapshot drives both rows and scroll logic).

Coverage: 8 jsdom/testing-library DOM tests cover row dispatch (divider vs
message, flat-index->entry mapping), init autoscroll, accent smooth-scroll, and
deep-link scrollToIndex. jsdom has no layout engine, so react-virtual's pixel
measurement/scroll math stays on a manual/visual verification pass.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-06-15 19:35:40 -07:00

84 lines
3.0 KiB
JavaScript

// DOM test loader — extends the pure-logic resolver in `test-loader-hooks.mjs`
// with a `load` hook so JSX/TSX components can be rendered under jsdom.
//
// The default `pnpm test` lane (node --test over *.test.mjs) runs pure logic and
// imports .ts via `--experimental-strip-types`, which strips types but does NOT
// transform JSX. The DOM lane needs real component rendering, so it routes .ts
// and .tsx through esbuild (TS + JSX -> JS, automatic React runtime). This keeps
// the net thin: one transform dep, no second test runner.
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { transform } from "esbuild";
const srcRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"src",
);
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
// Extensionless `@/` and relative specifiers resolve .tsx first (components),
// then fall back to .ts (logic) — so both lanes of source resolve under jsdom.
export function resolve(specifier, context, nextResolve) {
if (specifier === "@features-manifest") {
const resolved = path.join(repoRoot, "preview-features.json");
return nextResolve(resolved, context);
}
if (specifier.startsWith("@/")) {
const stripped = specifier.slice(2);
if (path.extname(stripped)) {
return nextResolve(`${srcRoot}/${stripped}`, context);
}
return nextResolve(`${srcRoot}/${stripped}.tsx`, context).catch(() =>
nextResolve(`${srcRoot}/${stripped}.ts`, context),
);
}
if (
(specifier.startsWith("./") || specifier.startsWith("../")) &&
!path.extname(specifier) &&
context.parentURL
) {
const tsx = new URL(`${specifier}.tsx`, context.parentURL).href;
return nextResolve(tsx, context).catch(() =>
nextResolve(new URL(`${specifier}.ts`, context.parentURL).href, context),
);
}
return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
if (url.endsWith(".json")) {
// Node's ESM loader requires an explicit `with { type: "json" }` import
// attribute for JSON, but esbuild emits bare `import x from "./f.json"`.
// Rather than rewrite every importer, synthesize an ESM module that
// default-exports the parsed JSON so the bare import resolves.
const filename = fileURLToPath(url);
const json = await readFile(filename, "utf8");
return {
format: "module",
source: `export default ${json};`,
shortCircuit: true,
};
}
if (url.endsWith(".ts") || url.endsWith(".tsx")) {
const filename = fileURLToPath(url);
const source = await readFile(filename, "utf8");
const { code } = await transform(source, {
loader: url.endsWith(".tsx") ? "tsx" : "ts",
jsx: "automatic",
format: "esm",
target: "es2022",
sourcemap: "inline",
sourcefile: filename,
});
return { format: "module", source: code, shortCircuit: true };
}
return nextLoad(url, context);
}