Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./auto-refresh.js";
export * from "./timestamp.js";
export * from "./table.js";
export * from "./facts.js";
export * from "./paged-requests.js";
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "../../platform/test/rtl.js";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "./test/rtl.js";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { QueueRequestsModel } from "../entity/index.js";
import { QueueRequests, StovepipeQueueProvider } from "./queue.js";
import { PagedRequests, RequestPagesProvider, type RequestPage } from "./paged-requests.js";

interface Row {
id: string;
state: string;
}

const nextHref = "/repo%2Fmain?page=opaque%2B%2F%3D";
function page(ids: string[], next: string | null = nextHref): QueueRequestsModel {
return { requests: ids.map(id => ({
id, href: `/repo%2Fmain/request/${id}`, changeUri: `git://repo/${id}`, baseUri: "", state: "accepted",
acceptedAtMs: "1000", updatedAtMs: "1000", outcomeReason: "",
})), nextHref: next };
function page(ids: string[], next: string | null = nextHref): RequestPage<Row> {
return { requests: ids.map(id => ({ id, state: "accepted" })), nextHref: next };
}
function RowList({ initial, routeKey }: { initial: RequestPage<Row>; routeKey: string }) {
return <PagedRequests initial={initial} routeKey={routeKey} requestKey={request => request.id}>
{requests => <ul aria-label="Requests">{requests.map(request =>
<li key={request.id}><a href={`/repo%2Fmain/request/${request.id}`}>{request.id}</a><span>{request.state}</span></li>)}</ul>}
</PagedRequests>;
}
function displayedIds() {
return within(screen.getByRole("table", { name: "Requests" })).getAllByRole("link").map(link => link.textContent);
return within(screen.getByRole("list", { name: "Requests" })).getAllByRole("link").map(link => link.textContent);
}
let intersect: () => void;
beforeEach(() => {
Expand All @@ -31,11 +38,11 @@ afterEach(() => {
});

it("loads on intersection once, appends unique requests, and stops at the end", async () => {
let resolve!: (result: QueueRequestsModel) => void;
const loadPage = vi.fn(() => new Promise<QueueRequestsModel>(complete => {
let resolve!: (result: RequestPage<Row>) => void;
const loadPage = vi.fn(() => new Promise<RequestPage<Row>>(complete => {
resolve = complete;
}));
render(<StovepipeQueueProvider loadPage={loadPage}><QueueRequests routeKey="queue" initial={page(["3", "2"])} /></StovepipeQueueProvider>);
render(<RequestPagesProvider loadPage={loadPage}><RowList routeKey="queue" initial={page(["3", "2"])} /></RequestPagesProvider>);
await act(async () => {
intersect();
intersect();
Expand All @@ -58,7 +65,7 @@ it("loads on intersection once, appends unique requests, and stops at the end",

it("retains rows after a failed load and retries the same continuation by button", async () => {
const loadPage = vi.fn().mockRejectedValueOnce(new Error("unavailable")).mockResolvedValueOnce(page(["1"], null));
render(<StovepipeQueueProvider loadPage={loadPage}><QueueRequests routeKey="queue" initial={page(["2"])} /></StovepipeQueueProvider>);
render(<RequestPagesProvider loadPage={loadPage}><RowList routeKey="queue" initial={page(["2"])} /></RequestPagesProvider>);
await act(async () => intersect());
expect(displayedIds()).toEqual(["2"]);
expect(screen.getByRole("alert")).toHaveTextContent("Could not load older requests");
Expand All @@ -72,17 +79,17 @@ it("retains rows after a failed load and retries the same continuation by button
it("aborts a departing queue, restores its loaded rows, and keeps new arrivals behind an explicit action", async () => {
const initial = page(["3", "2"]);
const loadPage = vi.fn().mockResolvedValueOnce(page(["1"], "/repo%2Fmain?page=last"));
function App({ visible = true, latest = initial }: { visible?: boolean; latest?: QueueRequestsModel }) {
return <StovepipeQueueProvider loadPage={loadPage}>{visible && <QueueRequests routeKey="queue" initial={latest} />}</StovepipeQueueProvider>;
function App({ visible = true, latest = initial }: { visible?: boolean; latest?: RequestPage<Row> }) {
return <RequestPagesProvider loadPage={loadPage}>{visible && <RowList routeKey="queue" initial={latest} />}</RequestPagesProvider>;
}
const { rerender } = render(<App />);
await act(async () => intersect());
expect(displayedIds()).toEqual(["3", "2", "1"]);
let signal: AbortSignal | undefined;
let finish!: (result: QueueRequestsModel) => void;
let finish!: (result: RequestPage<Row>) => void;
loadPage.mockImplementationOnce((_href: string, abort: AbortSignal) => {
signal = abort;
return new Promise<QueueRequestsModel>(resolve => {
return new Promise<RequestPage<Row>>(resolve => {
finish = resolve;
});
});
Expand Down
200 changes: 200 additions & 0 deletions web/platform/paged-requests.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"use client";

import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Block } from "baseui/block";
import { Button, KIND, SIZE } from "baseui/button";
import { StyledLink } from "baseui/link";
import { Spinner } from "baseui/spinner";
import { ParagraphSmall, ParagraphXSmall } from "baseui/typography";
import { useWebNavigation } from "./navigation.js";

const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;

/** One page of a request list ordered newest first. */
export interface RequestPage<T> {
/** Requests on this page, newest first. */
requests: T[];
/** Opaque in-app URL of the next page; null on the final page. */
nextHref: string | null;
}

/** Fetches a continuation through the host's authenticated transport. */
export type RequestPageLoader = (href: string, signal: AbortSignal) => Promise<RequestPage<unknown>>;

interface RequestSnapshot<T> extends RequestPage<T> {
latest: RequestPage<T>;
firstPageKeys: string[];
loadedHrefs: string[];
}
interface SavedRequests {
snapshot: RequestSnapshot<unknown>;
scrollY: number;
}
const RequestPagesContext = createContext<{
loadPage: RequestPageLoader;
pages: Map<string, SavedRequests>;
} | null>(null);

/** Mount above route changes to retain loaded request rows and scroll positions. */
export function RequestPagesProvider({ loadPage, children }: { loadPage: RequestPageLoader; children: ReactNode }) {
const pages = useRef(new Map<string, SavedRequests>());
const value = useMemo(() => ({ loadPage, pages: pages.current }), [loadPage]);
return <RequestPagesContext.Provider value={value}>{children}</RequestPagesContext.Provider>;
}

function createSnapshot<T>(page: RequestPage<T>, requestKey: (request: T) => string): RequestSnapshot<T> {
return { ...page, latest: page, firstPageKeys: page.requests.map(requestKey), loadedHrefs: [] };
}

/**
* Renders `initial` and appends older pages as the viewer scrolls near the end. A new `initial` updates displayed
* rows in place; arrivals stay behind an explicit action. Without a provider, continuation links navigate normally.
*/
export function PagedRequests<T>({ initial, routeKey, requestKey, children }: {
initial: RequestPage<T>;
/** Identity of the list for retained rows and scroll position; independent of refresh time. */
routeKey: string;
requestKey: (request: T) => string;
/** `complete` is true once no older page remains. */
children: (requests: T[], complete: boolean) => ReactNode;
}) {
const context = useContext(RequestPagesContext);
const { Link: HostLink } = useWebNavigation();
const [snapshot, setSnapshot] = useState(() =>
(context?.pages.get(routeKey)?.snapshot as RequestSnapshot<T> | undefined) ?? createSnapshot(initial, requestKey));
const [hydrated, setHydrated] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const sentinel = useRef<HTMLElement | null>(null);
const inFlight = useRef<AbortController | null>(null);
const saved = useRef<SavedRequests>({ snapshot, scrollY: context?.pages.get(routeKey)?.scrollY ?? 0 });
saved.current.snapshot = snapshot;
const keyOf = useRef(requestKey);
keyOf.current = requestKey;

if (snapshot.latest !== initial) {
const current = new Map(initial.requests.map(request => [requestKey(request), request]));
setSnapshot({ ...snapshot, latest: initial, requests: snapshot.requests.map(request => current.get(requestKey(request)) ?? request) });
}

useBrowserLayoutEffect(() => {
setHydrated(true);
const previous = context?.pages.get(routeKey);
const frame = previous ? requestAnimationFrame(() => window.scrollTo(0, previous.scrollY)) : null;
const recordScroll = () => {
saved.current.scrollY = window.scrollY;
};
window.addEventListener("scroll", recordScroll, { passive: true });
return () => {
if (frame !== null) cancelAnimationFrame(frame);
window.removeEventListener("scroll", recordScroll);
// Save before navigation can shrink the document and reset its scroll offset.
inFlight.current?.abort();
context?.pages.set(routeKey, saved.current);
};
}, [context, routeKey]);

const loadOlderRequests = useCallback(async () => {
const href = snapshot.nextHref;
if (!context || !href || inFlight.current) return;
const controller = new AbortController();
inFlight.current = controller;
setLoading(true);
setError(false);
try {
const next = await context.loadPage(href, controller.signal) as RequestPage<T>;
if (controller.signal.aborted) return;
if (next.nextHref && [href, ...snapshot.loadedHrefs].includes(next.nextHref)) throw new Error("Repeated continuation");
setSnapshot(current => {
if (current.nextHref !== href) return current;
const existing = new Set(current.requests.map(keyOf.current));
const additions = next.requests.filter(request => {
const key = keyOf.current(request);
if (existing.has(key)) return false;
existing.add(key);
return true;
});
return { ...current, requests: [...current.requests, ...additions], nextHref: next.nextHref, loadedHrefs: [...current.loadedHrefs, href] };
});
} catch {
if (!controller.signal.aborted) setError(true);
} finally {
if (inFlight.current === controller) {
inFlight.current = null;
setLoading(false);
}
}
}, [context, snapshot.nextHref, snapshot.loadedHrefs]);

useEffect(() => {
if (!context || !snapshot.nextHref || error || loading || !sentinel.current || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) void loadOlderRequests();
}, { rootMargin: "400px" });
observer.observe(sentinel.current);
return () => observer.disconnect();
}, [context, snapshot.nextHref, error, loading, loadOlderRequests]);

let paginationMessage: string;
if (loading) {
paginationMessage = "Loading older requests…";
} else if (error) {
paginationMessage = "Loading paused";
} else if (snapshot.nextHref && context && hydrated && typeof IntersectionObserver !== "undefined") {
paginationMessage = "Scroll for more requests";
} else if (snapshot.nextHref) {
paginationMessage = "More requests available";
} else if (snapshot.requests.length) {
paginationMessage = "All available requests loaded";
} else {
paginationMessage = "No requests to load";
}

const newRequests = initial.requests.some(request => !snapshot.firstPageKeys.includes(requestKey(request)));
return <>
{newRequests && <Block marginBottom="scale600">
<Button size={SIZE.compact} onClick={() => {
inFlight.current?.abort();
inFlight.current = null;
setLoading(false);
setError(false);
setSnapshot(createSnapshot(initial, requestKey));
window.scrollTo(0, 0);
}}>New requests available · Show latest requests</Button>
</Block>}
{children(snapshot.requests, snapshot.nextHref === null)}
<section ref={sentinel} aria-label="Queue pagination">
{error && <ParagraphSmall role="alert" color="contentNegative">Could not load older requests. Your displayed requests are unchanged.</ParagraphSmall>}
<Block display="flex" flexDirection="column" alignItems="center" gridGap="scale400" paddingTop="scale600" paddingBottom="scale600">
<Block role="status" display="flex" flexDirection="column" alignItems="center" gridGap="scale300">
<Block display="flex" alignItems="center" gridGap="scale300" height="scale900">
<Block width="scale800" height="scale800" display="flex" alignItems="center" justifyContent="center" $style={{ flexShrink: 0 }}>
{loading && <Block role="progressbar" aria-label="Loading older requests" aria-busy="true" $style={{
animationName: { from: { opacity: 0 }, to: { opacity: 1 } },
animationDuration: "160ms",
// Fast batches finish before the indicator becomes visible.
animationDelay: "120ms",
animationFillMode: "backwards",
animationTimingFunction: "ease-out",
"@media (prefers-reduced-motion: reduce)": { animationName: "none" },
}}>
<Spinner role="presentation" aria-hidden="true" $size="20px" $borderWidth="2px" $style={{
"@media (prefers-reduced-motion: reduce)": { animationName: "none" },
}} />
</Block>}
</Block>
<ParagraphSmall margin="0" color="contentSecondary">
{paginationMessage}
</ParagraphSmall>
</Block>
<ParagraphXSmall margin="0" color="contentSecondary">{snapshot.requests.length} displayed requests</ParagraphXSmall>
</Block>
<Block minHeight="scale1000">
{snapshot.nextHref && (context && hydrated
? <Button kind={KIND.tertiary} size={SIZE.compact} aria-disabled={loading} aria-busy={loading} onClick={() => void loadOlderRequests()}>{error ? "Retry loading older requests" : "Load more requests"}</Button>
: <StyledLink $as={HostLink ?? "a"} href={snapshot.nextHref}>Older requests →</StyledLink>)}
</Block>
</Block>
</section>
</>;
}
5 changes: 3 additions & 2 deletions web/platform/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ import type { ReactNode } from "react";

export function Table({ label, headers, children }: {
label: string;
headers: string[];
/** Column headings; strings, or nodes when a host styles them. */
headers: ReactNode[];
children: ReactNode;
}) {
return <StyledRoot tabIndex={0} role="region" aria-label={label}>
<StyledTable aria-label={label}>
<StyledTableHead><StyledTableHeadRow>
{headers.map(header => <StyledTableHeadCell key={header} scope="col">{header}</StyledTableHeadCell>)}
{headers.map((header, index) => <StyledTableHeadCell key={index} scope="col">{header}</StyledTableHeadCell>)}
</StyledTableHeadRow></StyledTableHead>
<StyledTableBody>{children}</StyledTableBody>
</StyledTable>
Expand Down
6 changes: 3 additions & 3 deletions web/service/stovepipe/src/next/queue-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"use client";

import type { ReactNode } from "react";
import { StovepipeQueueProvider, type QueuePageLoader } from "@submitqueue/web-stovepipe";
import { RequestPagesProvider, type RequestPageLoader } from "@submitqueue/web-stovepipe";

const loadPage: QueuePageLoader = async (href, signal) => {
const loadPage: RequestPageLoader = async (href, signal) => {
const continuation = new URL(href, window.location.origin);
const search = new URLSearchParams({ path: continuation.pathname, page: continuation.searchParams.get("page") ?? "" });
const response = await fetch(`/api/queue-requests?${search}`, { signal, cache: "no-store" });
Expand All @@ -12,5 +12,5 @@ const loadPage: QueuePageLoader = async (href, signal) => {
};

export function QueueProvider({ children }: { children: ReactNode }) {
return <StovepipeQueueProvider loadPage={loadPage}>{children}</StovepipeQueueProvider>;
return <RequestPagesProvider loadPage={loadPage}>{children}</RequestPagesProvider>;
}
4 changes: 2 additions & 2 deletions web/service/submitqueue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ A local/demo Next.js host for the SubmitQueue module, and the composition root t
| Path | Holds |
|---|---|
| `src/server/` | Wiring, free of Next.js: environment config, demo Basic auth, the Connect gateway client and transport, pino, opt-in Prometheus, and `resolveReferenceWebHost` |
| `src/next/` | Everything Next.js-specific: catch-all page and metadata, the per-request auth check, navigation provider (`next/link` without prefetch, transition-aware refresh), readiness route |
| `src/app/` | Routes: one optional catch-all page, `/healthz`, layouts, not-found and unauthorized pages |
| `src/next/` | Everything Next.js-specific: catch-all page and metadata, the per-request auth check, navigation provider (`next/link` without prefetch, transition-aware refresh), queue-page loader for older requests, readiness route |
| `src/app/` | Routes: one optional catch-all page, `/healthz`, the authenticated uncached `/api/queue/<queue>/requests` JSON route, layouts, not-found and unauthorized pages |
| `src/proxy.ts` | Challenges anonymous browsers with HTTP Basic auth |

Two Next.js specifics: `next.config.ts` sets `htmlLimitedBots: /.*/` so page titles resolve before streaming, and Next leaves pino unbundled, so the image's runtime layer declares it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const dynamic = "force-dynamic";
export const revalidate = 0;

const page = createNextPage({
shell: { environmentLabel: "Local demo · read-only", footer: "Read-only demo" },
shell: { environmentLabel: "Local demo" },
web: resolveReferenceWeb,
authenticate: requireAuthorization,
onLog: resolveReferenceLogCallback(),
Expand Down
Loading
Loading