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.

fix: flashing between page transitions

Hugo ac0705f9 878572fe

+36 -48
+14 -6
packages/client/src/hooks.ts
··· 10 10 } 11 11 12 12 const LOADING_DELAY = 400; 13 + const queryCache = new Map<string, unknown>(); 13 14 14 15 export function useQuery<T>( 15 16 fetcher: () => Promise<T>, 16 17 deps: unknown[] = [], 17 - options?: { initialData?: T }, 18 + options?: { initialData?: T; cacheKey?: string }, 18 19 ): QueryResult<T> { 20 + const cached = options?.cacheKey 21 + ? (queryCache.get(options.cacheKey) as T | undefined) 22 + : undefined; 19 23 const hasInitial = options?.initialData !== undefined; 20 - const data = useSignal<T | null>(options?.initialData ?? null); 24 + const data = useSignal<T | null>(options?.initialData ?? cached ?? null); 21 25 const pending = useSignal(!hasInitial); 22 - // When there's no initial data, show the loading indicator immediately 23 - // instead of waiting LOADING_DELAY ms. The delay only helps during 24 - // re-fetches where existing data stays visible. 25 - const loading = useSignal(!hasInitial); 26 + // Always start false — the LOADING_DELAY timer is the sole trigger, 27 + // which avoids a brief "Loading..." flash for fast fetches. 28 + const loading = useSignal(false); 26 29 const error = useSignal(""); 27 30 const skipNext = useSignal(hasInitial); 28 31 29 32 const run = () => { 30 33 if (skipNext.value) { 31 34 skipNext.value = false; 35 + // Seed cache with SSR initial data so it's available on re-navigation 36 + if (options?.cacheKey && options.initialData !== undefined) { 37 + queryCache.set(options.cacheKey, options.initialData); 38 + } 32 39 return; 33 40 } 34 41 pending.value = true; ··· 39 46 fetcher() 40 47 .then((d) => { 41 48 data.value = d; 49 + if (options?.cacheKey) queryCache.set(options.cacheKey, d); 42 50 }) 43 51 .catch((e) => { 44 52 error.value = e.message || "Something went wrong.";
+5 -8
packages/feature-requests/src/client.ssr.ts
··· 1 1 import type { ClientModule } from "@exosphere/client/types"; 2 - import { 3 - FeatureRequestsActivePage, 4 - FeatureRequestsDonePage, 5 - FeatureRequestsNotPlannedPage, 6 - } from "./ui/pages/feature-requests.tsx"; 2 + import { FeatureRequestsListPage } from "./ui/pages/feature-requests.tsx"; 7 3 import { FeatureRequestPage } from "./ui/pages/feature-request.tsx"; 8 4 9 5 export const featureRequestsModule: ClientModule = { 10 6 name: "infuse", 11 7 // Static paths before parameterized — preact-iso matches first hit 8 + // Tab routes share the same component so the router keeps it mounted across tab switches 12 9 routes: [ 13 - { path: "/infuse", component: FeatureRequestsActivePage }, 14 - { path: "/infuse/done", component: FeatureRequestsDonePage }, 15 - { path: "/infuse/not-planned", component: FeatureRequestsNotPlannedPage }, 10 + { path: "/infuse", component: FeatureRequestsListPage }, 11 + { path: "/infuse/done", component: FeatureRequestsListPage }, 12 + { path: "/infuse/not-planned", component: FeatureRequestsListPage }, 16 13 { path: "/infuse/:number", component: FeatureRequestPage }, 17 14 ], 18 15 };
+6 -13
packages/feature-requests/src/client.ts
··· 1 1 import { lazy } from "@exosphere/client/router"; 2 2 import type { ClientModule } from "@exosphere/client/types"; 3 3 4 - const FeatureRequestsActivePage = lazy(() => 5 - import("./ui/pages/feature-requests.tsx").then((m) => m.FeatureRequestsActivePage), 4 + const FeatureRequestsListPage = lazy(() => 5 + import("./ui/pages/feature-requests.tsx").then((m) => m.FeatureRequestsListPage), 6 6 ); 7 7 8 8 const FeatureRequestPage = lazy(() => 9 9 import("./ui/pages/feature-request.tsx").then((m) => m.FeatureRequestPage), 10 10 ); 11 11 12 - const FeatureRequestsDonePage = lazy(() => 13 - import("./ui/pages/feature-requests.tsx").then((m) => m.FeatureRequestsDonePage), 14 - ); 15 - 16 - const FeatureRequestsNotPlannedPage = lazy(() => 17 - import("./ui/pages/feature-requests.tsx").then((m) => m.FeatureRequestsNotPlannedPage), 18 - ); 19 - 20 12 export const featureRequestsModule: ClientModule = { 21 13 name: "infuse", 22 14 // Static paths before parameterized — preact-iso matches first hit 15 + // Tab routes share the same component so the router keeps it mounted across tab switches 23 16 routes: [ 24 - { path: "/infuse", component: FeatureRequestsActivePage }, 25 - { path: "/infuse/done", component: FeatureRequestsDonePage }, 26 - { path: "/infuse/not-planned", component: FeatureRequestsNotPlannedPage }, 17 + { path: "/infuse", component: FeatureRequestsListPage }, 18 + { path: "/infuse/done", component: FeatureRequestsListPage }, 19 + { path: "/infuse/not-planned", component: FeatureRequestsListPage }, 27 20 { path: "/infuse/:number", component: FeatureRequestPage }, 28 21 ], 29 22 };
+11 -21
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 { sphereState } from "@exosphere/client/sphere"; 4 - import { spherePath } from "@exosphere/client/router"; 4 + import { spherePath, useLocation } from "@exosphere/client/router"; 5 5 import { useQuery } from "@exosphere/client/hooks"; 6 6 import * as ui from "@exosphere/client/ui.css"; 7 7 import * as frUi from "../ui.css.ts"; ··· 135 135 136 136 type ActiveTab = "requests" | "done" | "not-planned"; 137 137 138 - export function FeatureRequestsDonePage() { 139 - return <FeatureRequestsPage statuses={["done"]} activeTab="done" />; 140 - } 141 - 142 - export function FeatureRequestsNotPlannedPage() { 143 - return <FeatureRequestsPage statuses={["not-planned"]} activeTab="not-planned" />; 144 - } 145 - 146 - export function FeatureRequestsActivePage() { 147 - return <FeatureRequestsPage />; 138 + function useActiveTab(): { activeTab: ActiveTab; statuses: string[] } { 139 + const { path } = useLocation(); 140 + if (path.endsWith("/done")) return { activeTab: "done", statuses: ["done"] }; 141 + if (path.endsWith("/not-planned")) return { activeTab: "not-planned", statuses: ["not-planned"] }; 142 + return { activeTab: "requests", statuses: activeStatuses }; 148 143 } 149 144 150 - function FeatureRequestsPage({ 151 - statuses = activeStatuses, 152 - activeTab = "requests", 153 - }: { 154 - statuses?: string[]; 155 - activeTab?: ActiveTab; 156 - } = {}) { 145 + export function FeatureRequestsListPage() { 146 + const { activeTab, statuses } = useActiveTab(); 157 147 const showForm = useSignal(false); 158 148 const votedIds = useSignal<Set<string>>(new Set()); 159 149 const { sortBy, sortOrder } = useSortParams(); ··· 170 160 const { data, pending, loading, error, refetch } = useQuery( 171 161 () => getFeatureRequests(statuses, sortBy.value, sortOrder.value), 172 162 [activeTab, sortBy.value, sortOrder.value], 173 - prefetched ? { initialData: prefetched } : undefined, 163 + prefetched ? { initialData: prefetched, cacheKey: prefetchKey } : { cacheKey: prefetchKey }, 174 164 ); 175 165 176 166 const isAuthenticated = auth.value.authenticated; ··· 267 257 <div class={ui.section}> 268 258 <div class={frUi.titleRow}> 269 259 <h1 class={ui.pageTitle}>Infuse</h1> 270 - {isAuthenticated && !showForm.value && ( 260 + {activeTab === "requests" && isAuthenticated && !showForm.value && ( 271 261 <button class={ui.button} onClick={() => (showForm.value = true)}> 272 262 New request 273 263 </button> ··· 298 288 )} 299 289 </nav> 300 290 301 - {showForm.value && ( 291 + {activeTab === "requests" && showForm.value && ( 302 292 <div class={ui.card}> 303 293 <SubmitForm onCreated={onCreated} /> 304 294 </div>