Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf: prefetch data

Hugo ed1dbf16 66904f21

+96 -21
+3 -2
packages/app/src/pages/sphere.tsx
··· 2 2 import { canDo } from "@exosphere/client/permissions"; 3 3 import { useQuery } from "@exosphere/client/hooks"; 4 4 import { spherePath } from "@exosphere/client/router"; 5 + import { Link } from "@exosphere/client/link"; 5 6 import * as ui from "@exosphere/client/ui.css"; 6 7 import { 7 8 getSphereModules, ··· 111 112 <div class={ui.card} key={mod.name}> 112 113 <div class={ui.row}> 113 114 <div> 114 - <a href={spherePath(`/${moduleLabels[mod.name]?.path ?? mod.name}`)}> 115 + <Link href={spherePath(`/${moduleLabels[mod.name]?.path ?? mod.name}`)}> 115 116 <strong>{moduleLabels[mod.name]?.label ?? mod.name}</strong> 116 - </a> 117 + </Link> 117 118 <span class={`${ui.muted} ${ui.inlineTag}`}>enabled</span> 118 119 </div> 119 120 {canDo("sphere", "disableModule") && (
+1 -1
packages/app/src/ssr-prefetch-orchestrator.ts
··· 1 - import { ssrPrefetch } from "./ssr-prefetch.ts"; 1 + import { ssrPrefetch } from "@exosphere/client/ssr-prefetch"; 2 2 3 3 export interface PrefetchOptions { 4 4 url: string;
packages/app/src/ssr-prefetch.ts packages/client/src/ssr-prefetch.ts
+2
packages/client/package.json
··· 11 11 "./permissions": "./src/permissions.ts", 12 12 "./hooks": "./src/hooks.ts", 13 13 "./ssr-data": "./src/ssr-data.ts", 14 + "./ssr-prefetch": "./src/ssr-prefetch.ts", 15 + "./link": "./src/link.tsx", 14 16 "./theme.css": "./src/theme.css.ts", 15 17 "./ui.css": "./src/ui.css.ts", 16 18 "./types": "./src/types.ts",
+22
packages/client/src/hooks.ts
··· 11 11 12 12 const LOADING_DELAY = 300; 13 13 const queryCache = new Map<string, unknown>(); 14 + const inflight = new Map<string, Promise<unknown>>(); 15 + 16 + /** 17 + * Pre-populate `queryCache` for a future `useQuery` call with the same cacheKey. 18 + * Used for prefetch-on-intent so navigation doesn't flash a skeleton. 19 + * No-op if cache is already warm or a request is already in flight. 20 + */ 21 + export function prefetch(cacheKey: string, apiUrl: string): void { 22 + if (queryCache.has(cacheKey) || inflight.has(cacheKey)) return; 23 + const p = fetch(apiUrl, { credentials: "same-origin" }) 24 + .then((r) => (r.ok ? r.json() : null)) 25 + .then((d) => { 26 + if (d !== null) queryCache.set(cacheKey, d); 27 + }) 28 + .catch(() => { 29 + /* silent — click still works, useQuery will retry */ 30 + }) 31 + .finally(() => { 32 + inflight.delete(cacheKey); 33 + }); 34 + inflight.set(cacheKey, p); 35 + } 14 36 15 37 export function useQuery<T>( 16 38 fetcher: () => Promise<T>,
+43
packages/client/src/link.tsx
··· 1 + import { useRef } from "preact/hooks"; 2 + import type { FocusEventHandler, JSX, PointerEventHandler, TouchEventHandler } from "preact"; 3 + import { sphereHandle } from "./sphere.ts"; 4 + import { ssrPrefetch } from "./ssr-prefetch.ts"; 5 + import { prefetch } from "./hooks.ts"; 6 + 7 + type AnchorProps = JSX.IntrinsicElements["a"]; 8 + 9 + /** 10 + * `<a>` wrapper that pre-warms `queryCache` for the target route on hover/focus/touch. 11 + * When the click lands, the destination page's `useQuery` finds the cached data and 12 + * skips its skeleton. Prefetches at most once per Link instance per target href. 13 + */ 14 + export function Link(props: AnchorProps) { 15 + const lastPrefetched = useRef<string | null>(null); 16 + 17 + const warm = () => { 18 + const href = props.href; 19 + if (typeof href !== "string" || lastPrefetched.current === href) return; 20 + lastPrefetched.current = href; 21 + const handle = sphereHandle.value ?? undefined; 22 + for (const p of ssrPrefetch(href, handle)) { 23 + prefetch(p.key, p.apiUrl); 24 + } 25 + }; 26 + 27 + const onPointerEnter: PointerEventHandler<HTMLAnchorElement> = (e) => { 28 + warm(); 29 + if (typeof props.onPointerEnter === "function") props.onPointerEnter(e); 30 + }; 31 + const onFocus: FocusEventHandler<HTMLAnchorElement> = (e) => { 32 + warm(); 33 + if (typeof props.onFocus === "function") props.onFocus(e); 34 + }; 35 + const onTouchStart: TouchEventHandler<HTMLAnchorElement> = (e) => { 36 + warm(); 37 + if (typeof props.onTouchStart === "function") props.onTouchStart(e); 38 + }; 39 + 40 + return ( 41 + <a {...props} onPointerEnter={onPointerEnter} onFocus={onFocus} onTouchStart={onTouchStart} /> 42 + ); 43 + }
+3 -2
packages/feature-requests/src/ui/components/request-card.tsx
··· 2 2 import type { ComponentChildren } from "preact"; 3 3 import * as ui from "@exosphere/client/ui.css"; 4 4 import { spherePath } from "@exosphere/client/router"; 5 + import { Link } from "@exosphere/client/link"; 5 6 import { LabelBadge } from "@exosphere/client/components/label-badge"; 6 7 import * as frUi from "../ui.css.ts"; 7 8 import { statusLabels, type FeatureRequestListItem, type Status } from "../api/index.ts"; ··· 70 71 </h1> 71 72 ) : ( 72 73 <h3 class={ui.cardTitle}> 73 - <a href={spherePath(`/infuse/${fr.number}`)}> 74 + <Link href={spherePath(`/infuse/${fr.number}`)}> 74 75 <span class={ui.muted}>#{fr.number}</span> {fr.title} 75 - </a> 76 + </Link> 76 77 </h3> 77 78 )} 78 79 {statusSlot ?? (
+7 -6
packages/feature-requests/src/ui/pages/feature-request.tsx
··· 1 1 import { useSignal, useComputed, batch } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 3 import { useLocation, useRoute, spherePath } from "@exosphere/client/router"; 4 + import { Link } from "@exosphere/client/link"; 4 5 import { useCanDo } from "@exosphere/client/permissions"; 5 6 import { useQuery } from "@exosphere/client/hooks"; 6 7 import * as ui from "@exosphere/client/ui.css"; ··· 509 510 <div class={ui.stackSm}> 510 511 {duplicates.value.map((d) => ( 511 512 <div key={d.id}> 512 - <a href={spherePath(`/infuse/${d.number}`)}> 513 + <Link href={spherePath(`/infuse/${d.number}`)}> 513 514 <span class={ui.muted}>#{d.number}</span> {d.title} 514 - </a> 515 + </Link> 515 516 </div> 516 517 ))} 517 518 </div> ··· 663 664 <div class={ui.container}> 664 665 <div class={ui.section}> 665 666 <div> 666 - <a href={spherePath("/infuse")} class={ui.muted}> 667 + <Link href={spherePath("/infuse")} class={ui.muted}> 667 668 &larr; All requests 668 - </a> 669 + </Link> 669 670 </div> 670 671 671 672 {pending ? ( ··· 742 743 {data.duplicateOf && ( 743 744 <p class={`${ui.muted} ${frUi.duplicateNotice}`}> 744 745 Duplicate of{" "} 745 - <a href={spherePath(`/infuse/${data.duplicateOf.number}`)}> 746 + <Link href={spherePath(`/infuse/${data.duplicateOf.number}`)}> 746 747 #{data.duplicateOf.number} &mdash; {data.duplicateOf.title} 747 - </a> 748 + </Link> 748 749 </p> 749 750 )} 750 751 </div>
+3 -2
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 3 import { spherePath, useLocation } from "@exosphere/client/router"; 4 + import { Link } from "@exosphere/client/link"; 4 5 import { useQuery } from "@exosphere/client/hooks"; 5 6 import * as ui from "@exosphere/client/ui.css"; 6 7 import * as frUi from "../ui.css.ts"; ··· 229 230 activeTab === tab ? ( 230 231 <span class={ui.tabNavActive}>{label}</span> 231 232 ) : ( 232 - <a href={spherePath(path)} class={ui.tabNavLink}> 233 + <Link href={spherePath(path)} class={ui.tabNavLink}> 233 234 {label} 234 - </a> 235 + </Link> 235 236 ), 236 237 )} 237 238 </nav>
+3 -2
packages/kanban/src/ui/components/task-card.tsx
··· 1 1 import { spherePath } from "@exosphere/client/router"; 2 + import { Link } from "@exosphere/client/link"; 2 3 import { LabelBadge } from "@exosphere/client/components/label-badge"; 3 4 import * as kbUi from "../ui.css.ts"; 4 5 import type { KanbanTaskListItem } from "../../types.ts"; ··· 21 22 if (isDragging) classes.push(kbUi.taskCardDragging); 22 23 23 24 return ( 24 - <a 25 + <Link 25 26 href={spherePath(`/flux/${task.number}`)} 26 27 class={classes.join(" ")} 27 28 data-task-id={task.id} ··· 50 51 </span> 51 52 )} 52 53 </div> 53 - </a> 54 + </Link> 54 55 ); 55 56 }
+3 -2
packages/kanban/src/ui/pages/board.tsx
··· 5 5 import { useQuery } from "@exosphere/client/hooks"; 6 6 import { ssrPageData } from "@exosphere/client/ssr-data"; 7 7 import { spherePath } from "@exosphere/client/router"; 8 + import { Link } from "@exosphere/client/link"; 8 9 import { Settings } from "lucide-preact"; 9 10 import * as ui from "@exosphere/client/ui.css"; 10 11 import * as kbUi from "../ui.css.ts"; ··· 70 71 <h1 class={ui.pageTitle}>Flux</h1> 71 72 <div class={kbUi.titleRowActions}> 72 73 {isAuthenticated && canManageSettings.value && ( 73 - <a href={spherePath("/flux/settings")} class={kbUi.iconBtn} title="Flux settings"> 74 + <Link href={spherePath("/flux/settings")} class={kbUi.iconBtn} title="Flux settings"> 74 75 <Settings size={18} /> 75 - </a> 76 + </Link> 76 77 )} 77 78 {isAuthenticated && canCreate.value && ( 78 79 <button class={ui.button} onClick={() => openForm()}>
+3 -2
packages/kanban/src/ui/pages/settings.tsx
··· 2 2 import { auth } from "@exosphere/client/auth"; 3 3 import { useCanDo } from "@exosphere/client/permissions"; 4 4 import { spherePath } from "@exosphere/client/router"; 5 + import { Link } from "@exosphere/client/link"; 5 6 import { useQuery } from "@exosphere/client/hooks"; 6 7 import { ssrPageData } from "@exosphere/client/ssr-data"; 7 8 import * as ui from "@exosphere/client/ui.css"; ··· 31 32 <div class={ui.container}> 32 33 <div class={ui.section}> 33 34 <div> 34 - <a href={spherePath("/flux")} class={ui.muted}> 35 + <Link href={spherePath("/flux")} class={ui.muted}> 35 36 &larr; Flux 36 - </a> 37 + </Link> 37 38 </div> 38 39 <p class={ui.muted}>You don't have permission to manage flux settings.</p> 39 40 </div>
+3 -2
packages/kanban/src/ui/pages/task.tsx
··· 1 1 import { useSignal, batch } from "@preact/signals"; 2 2 import { auth } from "@exosphere/client/auth"; 3 3 import { useLocation, useRoute, spherePath } from "@exosphere/client/router"; 4 + import { Link } from "@exosphere/client/link"; 4 5 import { useCanDo } from "@exosphere/client/permissions"; 5 6 import { useQuery } from "@exosphere/client/hooks"; 6 7 import { ssrPageData } from "@exosphere/client/ssr-data"; ··· 495 496 <div class={ui.container}> 496 497 <div class={ui.section}> 497 498 <div> 498 - <a href={spherePath("/flux")} class={ui.muted}> 499 + <Link href={spherePath("/flux")} class={ui.muted}> 499 500 &larr; Flux 500 - </a> 501 + </Link> 501 502 </div> 502 503 503 504 {pending ? (