Skip to content

Commit

Pages no longer blink as they refresh: a README stays put through a push, coloured diffs keep their colours, and production's picture is replaced rather than blanked. Markdown made its components for each commit (a README's pictures point at the commit shown), so every push to the branch had React take the whole README down and put it back; the components are now made once and read where links and pictures point from context, so a new commit only updates those addresses. A file in a diff (a commit, a pull request's changes, a comparison) dropped its colours on every refresh, since a refresh hands over a new file with the same lines, and drew them again when it scrolled near; it keeps them while its lines are the same. The production picture on a project's overview went back to its empty frame and "Taking a screenshot…" whenever its address changed (a new deploy) until the new one loaded; the old picture now stays under the new one until it has faded in. In Chat, every one of @g1t's answers was drawn afresh whenever a message arrived, since the repository they link into was compared as an object; it is compared by name. Router updates already run in a transition (React Router's RouterProvider wraps them in startTransition), so a refresh never shows a skeleton again once a page has loaded; that is measured below rather than changed. Checked: the site's typecheck, its 904 tests and a production build, and a harness that mounts the real routes with g1t.sh's own responses for flagon-io/g1t, decoded by React Router as the site does, watching the DOM over revalidations. Elements removed per 5 refreshes with the same data, before → after: commit page 2,495 → 0, every other page measured (overview, Code, a folder, a file, Commits, pull requests, issues, a pull request, an issue, Work, Workflows, a run, Deployments, and the same with the signed-in app shell) 0 → 0, with no skeleton shown again and no layout shift, also with deferred data taking 2.5 s. A push (a new head commit), per 4 refreshes: Code 1,077 → 0 (the README), overview's picture 18 removed and a skeleton shown → a 2-element cross-fade and none.

syntaqxcommitted Parentce5f447Browse files
4 files+167−1040/4 viewed
+8−1
220220 const g1tBusy = withG1t && anyAnswering(messages);
221221 // References in g1t's answers point into the repository the latest question was asked from, if any.
222222 const askedRepo = useMemo(() => [...messages].reverse().find((m) => m.page)?.page?.repo ?? null, [messages]);
223− const answerContext = useMemo<ResponseContext>(() => ({ slug, repo: askedRepo }), [slug, askedRepo]);
223+ // Kept while it names the same repository: each answer draws itself again
224+ // (and React replaces all of it) whenever this changes, so a new message
225+ // or a refresh, which hand over new objects, must not change it.
226+ const answerContext = useMemo<ResponseContext>(
227+ () => ({ slug, repo: askedRepo }),
228+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the repository by name, not the object it came in
229+ [slug, askedRepo?.namespace, askedRepo?.name],
230+ );
224231 const lastAnswer = withG1t ? ([...messages].reverse().find((m) => !m.deleted_at && !m.pending) ?? null) : null;
225232 const g1tSend = useCallback(
226233 async (intent: "g1t_retry" | "g1t_stop" | "g1t_reset") => {
+23−9
1616
1717 import type { Comment, Comparison, DiffLine } from "@g1t/contracts";
1818
19−import type {
20− HighlightedFile,
21− HighlightedLine,
19+import {
20+ type FileHtml,
21+ type HighlightedFile,
22+ type HighlightedLine,
23+ diffContent,
24+ htmlOfFile,
25+ withHtml,
2226 } from "../lib/diff";
2327 import { Markdown } from "./markdown";
2428 import { EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui";
398402 // Shown plain at once, and highlighted in the browser when it comes near
399403 // the screen: the page never waits for colours.
400404 const section = useRef<HTMLElement>(null);
401− const [highlighted, setHighlighted] = useState<HighlightedFile | null>(null);
405+ const [highlighted, setHighlighted] = useState<{ content: string; html: FileHtml } | null>(null);
406+ // What the colours depend on. A refresh hands the page a new `file` with
407+ // the same lines: it keeps its colours rather than going plain and being
408+ // highlighted again, which flashed every coloured diff on the page.
409+ const content = useMemo(() => `${file.path}\n${diffContent(file)}`, [file]);
410+ const latest = useRef(file);
411+ latest.current = file;
412+ const kept = highlighted?.content === content ? highlighted.html : null;
413+ const drawn = useMemo(() => (kept ? withHtml(file, kept) : file), [file, kept]);
402414 useEffect(() => {
403− setHighlighted(null);
404415 const element = section.current;
416+ const file = latest.current;
405417 // Highlighted by the server already: one of the change's first files.
406418 const done = file.hunks.some((hunk) => hunk.lines.some((line) => (line as HighlightedLine).html != null));
407− if (!element || collapsed || done) return;
419+ if (!element || collapsed || done || kept) return;
408420 let cancelled = false;
409421 const observer = new IntersectionObserver(
410422 ([entry]) => {
413425 void import("../lib/shiki")
414426 .then(({ highlightFile }) => highlightFile(file))
415427 .then((result) => {
416− if (!cancelled && result) setHighlighted(result);
428+ if (!cancelled && result) setHighlighted({ content, html: htmlOfFile(result) });
417429 })
418430 .catch(() => {});
419431 },
424436 cancelled = true;
425437 observer.disconnect();
426438 };
427− }, [file, collapsed]);
428− const props = { file: highlighted ?? file, comments, canComment: review?.canComment ?? false };
439+ // `kept` is whether `content` is highlighted already: the lines, not the object they came in.
440+ // eslint-disable-next-line react-hooks/exhaustive-deps
441+ }, [content, collapsed, kept != null]);
442+ const props = { file: drawn, comments, canComment: review?.canComment ?? false };
429443 return (
430444 <Card asChild tone="plain" className="scroll-mt-[calc(4rem+var(--diff-toolbar,3rem))]">
431445 <section
+111−93
1010 } from "lucide-react";
1111 import type { Root } from "hast";
1212 import type { Components } from "react-markdown";
13−import { type ReactNode, isValidElement, useEffect, useMemo, useRef, useState } from "react";
13+import { type ReactNode, createContext, isValidElement, useContext, useEffect, useMemo, useRef, useState } from "react";
1414 import { Link } from "react-router";
1515 import rehypeRaw from "rehype-raw";
1616 import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
206206 }) {
207207 // The same text renders to the same elements with the same components, so
208208 // a page that revalidates (a live refresh) leaves the text as it is
209− // rather than mounting it again and reloading its pictures.
209+ // rather than mounting it again and reloading its pictures. The
210+ // components never change: where links and pictures point comes from
211+ // context, so a new commit (a README's pictures are that commit's files)
212+ // updates their addresses in place rather than replacing every element.
210213 const tree = useMemo(() => treeOf(source, repo), [source, repo?.namespace, repo?.name]);
211− const components = useMemo(() => markdownComponents(base, rawBase), [base, rawBase]);
212− const rendered = useMemo(() => renderMarkdownTree(tree, components), [tree, components]);
214+ const rendered = useMemo(() => renderMarkdownTree(tree, COMPONENTS), [tree]);
215+ const bases = useMemo(() => ({ base, rawBase }), [base, rawBase]);
213216 return (
214− // A long word or address breaks rather than widening the page.
215− <div className="prose wrap-break-word">{rendered}</div>
217+ <MarkdownBases.Provider value={bases}>
218+ {/* A long word or address breaks rather than widening the page. */}
219+ <div className="prose wrap-break-word">{rendered}</div>
220+ </MarkdownBases.Provider>
216221 );
217222 }
218223
219−/** How each element is drawn: links relative to `base`, pictures under `rawBase`. */
220−function markdownComponents(base: string | undefined, rawBase: string | undefined): Components {
221− return {
222− h1: ({ children }) => <Heading level={1}>{children}</Heading>,
223− h2: ({ children }) => <Heading level={2}>{children}</Heading>,
224− h3: ({ children }) => <Heading level={3}>{children}</Heading>,
225− h4: ({ children }) => <Heading level={4}>{children}</Heading>,
226− a({ href = "", children, node }) {
227− const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef;
228− if (ref === "mention") {
229− // `@name` may be a person (with a card) or a workspace (none).
230− const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, "");
231− return (
232− <UserCard username={name}>
233− <Link to={href} prefetch="intent" className="font-medium">
234− {children}
235− </Link>
236− </UserCard>
237− );
238− }
239− if (ref) {
240− return (
241− <Link
242− to={href}
243− prefetch="intent"
244− className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""}
245− >
246− {children}
247− </Link>
248− );
249− }
250− if (href.startsWith("#")) return <a href={href}>{children}</a>;
251− if (isExternal(href)) {
252− return (
253− <a href={href} rel="noreferrer nofollow ugc" target="_blank">
254− {children}
255− </a>
256− );
257− }
258− // A link within the site, or relative to the document's folder.
259− const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`;
260− return <Link to={to}>{children}</Link>;
261− },
262− blockquote({ children, node }) {
263− const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert;
264− if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>;
265− const alert = ALERT[kind];
266− return (
267− <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}>
268− <p className="alert-title flex items-center gap-2 text-sm font-medium">
269− {alert.icon}
270− {alert.title}
271− </p>
272− <div className="mt-1 [&>*+*]:mt-3">{children}</div>
273− </div>
274− );
275− },
276− pre({ children }) {
277− const code = Array.isArray(children) ? children[0] : children;
278− if (isValidElement<{ className?: string; children?: ReactNode }>(code)) {
279− const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null;
280− return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />;
281− }
282− return <pre>{children}</pre>;
283− },
284− // A wide table scrolls on its own, inside the text, not the page.
285− table: ({ children }) => (
286− <div className="overflow-x-auto">
287− <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table>
224+/** Where a document's relative links (`base`) and pictures (`rawBase`) point. */
225+const MarkdownBases = createContext<{ base?: string; rawBase?: string }>({});
226+
227+/** A link: a reference or mention within the site, an anchor, an address elsewhere, or a file beside the document. */
228+function MarkdownLink({ href = "", children, node }: { href?: string; children?: ReactNode; node?: { properties?: unknown } }) {
229+ const { base } = useContext(MarkdownBases);
230+ const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef;
231+ if (ref === "mention") {
232+ // `@name` may be a person (with a card) or a workspace (none).
233+ const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, "");
234+ return (
235+ <UserCard username={name}>
236+ <Link to={href} prefetch="intent" className="font-medium">
237+ {children}
238+ </Link>
239+ </UserCard>
240+ );
241+ }
242+ if (ref) {
243+ return (
244+ <Link
245+ to={href}
246+ prefetch="intent"
247+ className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""}
248+ >
249+ {children}
250+ </Link>
251+ );
252+ }
253+ if (href.startsWith("#")) return <a href={href}>{children}</a>;
254+ if (isExternal(href)) {
255+ return (
256+ <a href={href} rel="noreferrer nofollow ugc" target="_blank">
257+ {children}
258+ </a>
259+ );
260+ }
261+ // A link within the site, or relative to the document's folder.
262+ const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`;
263+ return <Link to={to}>{children}</Link>;
264+}
265+
266+/** A picture; a relative one is the document's commit's file. */
267+function MarkdownImage({ src, alt }: { src?: unknown; alt?: string }) {
268+ const { rawBase } = useContext(MarkdownBases);
269+ const at = typeof src === "string" ? imageSource(src, rawBase) : undefined;
270+ return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />;
271+}
272+
273+/**
274+ * How each element is drawn, made once: a component that is the same on
275+ * every render keeps the elements it drew, where a new one (even an
276+ * identical function) has React take them down and put them back.
277+ */
278+const COMPONENTS: Components = {
279+ h1: ({ children }) => <Heading level={1}>{children}</Heading>,
280+ h2: ({ children }) => <Heading level={2}>{children}</Heading>,
281+ h3: ({ children }) => <Heading level={3}>{children}</Heading>,
282+ h4: ({ children }) => <Heading level={4}>{children}</Heading>,
283+ a: MarkdownLink,
284+ blockquote({ children, node }) {
285+ const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert;
286+ if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>;
287+ const alert = ALERT[kind];
288+ return (
289+ <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}>
290+ <p className="alert-title flex items-center gap-2 text-sm font-medium">
291+ {alert.icon}
292+ {alert.title}
293+ </p>
294+ <div className="mt-1 [&>*+*]:mt-3">{children}</div>
288295 </div>
289− ),
290− input({ type, checked, disabled }) {
291− // Task list boxes: shown, not editable.
292− return type === "checkbox" ? (
293− <Checkbox
294− checked={checked === true}
295− disabled={disabled !== false}
296− aria-label={checked ? "Done" : "Not done"}
297− className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100"
298− />
299− ) : null;
300− },
301− img({ src, alt }) {
302− const at = typeof src === "string" ? imageSource(src, rawBase) : undefined;
303− return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />;
304− },
305− } satisfies Components;
306−}
296+ );
297+ },
298+ pre({ children }) {
299+ const code = Array.isArray(children) ? children[0] : children;
300+ if (isValidElement<{ className?: string; children?: ReactNode }>(code)) {
301+ const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null;
302+ return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />;
303+ }
304+ return <pre>{children}</pre>;
305+ },
306+ // A wide table scrolls on its own, inside the text, not the page.
307+ table: ({ children }) => (
308+ <div className="overflow-x-auto">
309+ <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table>
310+ </div>
311+ ),
312+ input({ type, checked, disabled }) {
313+ // Task list boxes: shown, not editable.
314+ return type === "checkbox" ? (
315+ <Checkbox
316+ checked={checked === true}
317+ disabled={disabled !== false}
318+ aria-label={checked ? "Done" : "Not done"}
319+ className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100"
320+ />
321+ ) : null;
322+ },
323+ img: MarkdownImage,
324+};
+25−1
3636 const image = useRef<HTMLImageElement>(null);
3737 const [at, setAt] = useState(0);
3838 const [loaded, setLoaded] = useState(false);
39+ // The picture last shown, kept under the next one until that has loaded
40+ // and faded in: a new deploy's screenshot replaces the old one rather
41+ // than the frame going blank while it comes.
42+ const [shown, setShown] = useState<CardSource | null>(null);
3943 const key = sources.map((source) => source.src).join("\n");
4044 const source = sources[at] ?? null;
45+ const under = source && shown && shown.src !== source.src ? shown : null;
4146
4247 // Other sources (another deploy, a new upload): from the first again.
4348 useEffect(() => {
5358 else setAt((index) => index + 1);
5459 }, [source?.src]);
5560
61+ // Once the new picture has faded in over it (300 ms), the old one goes.
62+ useEffect(() => {
63+ if (!loaded || !source || shown?.src === source.src) return;
64+ const timer = setTimeout(() => setShown(source), 350);
65+ return () => clearTimeout(timer);
66+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `source` is the one `src` names
67+ }, [loaded, source?.src, shown?.src]);
68+
5669 const empty = source == null;
5770 const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null;
5871
6376 href && "transition-colors group-hover:border-line-strong",
6477 )}
6578 >
66− {!loaded && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
79+ {!loaded && !under && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />}
80+ {under && (
81+ <img
82+ key={under.src}
83+ src={under.src}
84+ alt=""
85+ aria-hidden="true"
86+ width={1280}
87+ height={800}
88+ className="absolute inset-0 size-full object-cover object-top"
89+ />
90+ )}
6791 {source && (
6892 <img
6993 key={source.src}