mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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>
84 lines
3.0 KiB
JavaScript
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);
|
|
}
|