Studio: make tab navigation feel immediate (#7271)

* Studio: make repeated tab switches feel immediate

* Keep cached Studio navigation data fresh

* Make first Studio tab visits responsive

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Serve range requests uncompressed for immutable assets (PR #7271)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: test <test@test.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
oobabooga 2026-07-21 02:39:43 -03:00 committed by GitHub
commit 27f3473c7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 516 additions and 107 deletions

View file

@ -289,6 +289,7 @@ from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, Response
from starlette.middleware.gzip import GZipMiddleware
from pathlib import Path
from datetime import datetime
@ -1509,6 +1510,34 @@ def _should_inject_bootstrap(request: Request) -> bool:
return _is_local_bootstrap_request(request)
_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
class ImmutableStaticFiles(StaticFiles):
"""Serve Vite's content-hashed assets without browser revalidation."""
def file_response(
self,
full_path,
stat_result,
scope,
status_code = 200,
):
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL
return response
class _AssetGZipMiddleware(GZipMiddleware):
"""Serve range requests uncompressed; gzip + 206 mislabels Content-Range."""
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and any(key == b"range" for key, _ in scope["headers"]):
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
def setup_frontend(app: FastAPI, build_path: Path):
"""Mount frontend static files (optional)"""
if not build_path.exists():
@ -1516,7 +1545,12 @@ def setup_frontend(app: FastAPI, build_path: Path):
assets_dir = build_path / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets")
assets_app = _AssetGZipMiddleware(
ImmutableStaticFiles(directory = assets_dir),
minimum_size = 1024,
compresslevel = 6,
)
app.mount("/assets", assets_app, name = "assets")
def _build_index_response(request: Request) -> Response:
content = (build_path / "index.html").read_bytes()

View file

@ -14,6 +14,7 @@ import pytest
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response
from fastapi.testclient import TestClient
from starlette.middleware.gzip import GZipMiddleware
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
@ -471,6 +472,71 @@ class TestSecurityHeadersMiddleware:
assert b"server" in names
class TestFrontendAssets:
def test_hashed_assets_are_compressed_and_cached(self, tmp_path, main_module):
content = b"export const value = 'responsive';\n" * 200
(tmp_path / "page-abc123.js").write_bytes(content)
app = FastAPI()
assets_app = GZipMiddleware(
main_module.ImmutableStaticFiles(directory = tmp_path),
minimum_size = 1024,
compresslevel = 6,
)
app.mount("/assets", assets_app, name = "assets")
response = TestClient(app).get(
"/assets/page-abc123.js",
headers = {"Accept-Encoding": "gzip"},
)
assert response.status_code == 200
assert response.content == content
assert response.headers["content-encoding"] == "gzip"
assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL)
assert "accept-encoding" in response.headers["vary"].lower()
def test_asset_revalidation_keeps_immutable_cache_header(self, tmp_path, main_module):
(tmp_path / "page-abc123.js").write_text("export {};", encoding = "utf-8")
app = FastAPI()
app.mount(
"/assets",
main_module.ImmutableStaticFiles(directory = tmp_path),
name = "assets",
)
client = TestClient(app)
first = client.get("/assets/page-abc123.js")
response = client.get(
"/assets/page-abc123.js",
headers = {"If-None-Match": first.headers["etag"]},
)
assert response.status_code == 304
assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL)
def test_range_request_is_not_compressed(self, tmp_path, main_module):
content = b"export const value = 'responsive';\n" * 200
(tmp_path / "page-abc123.js").write_bytes(content)
app = FastAPI()
assets_app = main_module._AssetGZipMiddleware(
main_module.ImmutableStaticFiles(directory = tmp_path),
minimum_size = 1024,
compresslevel = 6,
)
app.mount("/assets", assets_app, name = "assets")
response = TestClient(app).get(
"/assets/page-abc123.js",
headers = {"Accept-Encoding": "gzip", "Range": "bytes=0-99"},
)
assert response.status_code == 206
assert response.headers.get("content-encoding") != "gzip"
assert response.headers["content-range"] == f"bytes 0-99/{len(content)}"
assert response.content == content[:100]
assert response.headers["cache-control"] == (main_module._IMMUTABLE_ASSET_CACHE_CONTROL)
# /api/health auth gate

View file

@ -23,19 +23,47 @@ interface AuthStatus {
requires_password_change: boolean;
}
const AUTH_STATUS_TTL_MS = 30_000;
let authStatusCheckedAt = 0;
let authStatusRequest: Promise<AuthStatus> | null = null;
function hasFreshAuthStatus(): boolean {
return (
authStatusCheckedAt !== 0 &&
Date.now() - authStatusCheckedAt < AUTH_STATUS_TTL_MS
);
}
async function fetchAuthStatus(): Promise<AuthStatus> {
try {
const res = await fetch(apiUrl("/api/auth/status"));
if (!res.ok) return { initialized: true, requires_password_change: mustChangePassword() };
const status = (await res.json()) as AuthStatus;
// Server truth wins; keep localStorage in sync both ways.
if (status.requires_password_change !== mustChangePassword()) {
setMustChangePassword(status.requires_password_change);
if (authStatusRequest) return authStatusRequest;
const request = (async () => {
try {
const res = await fetch(apiUrl("/api/auth/status"));
if (!res.ok) {
return {
initialized: true,
requires_password_change: mustChangePassword(),
};
}
const status = (await res.json()) as AuthStatus;
authStatusCheckedAt = Date.now();
// Server truth wins; keep localStorage in sync both ways.
if (status.requires_password_change !== mustChangePassword()) {
setMustChangePassword(status.requires_password_change);
}
return status;
} catch {
return {
initialized: true,
requires_password_change: mustChangePassword(),
};
}
return status;
} catch {
return { initialized: true, requires_password_change: mustChangePassword() };
}
})().finally(() => {
authStatusRequest = null;
});
authStatusRequest = request;
return request;
}
function authRedirect(to: "/login" | "/change-password"): never {
@ -49,12 +77,17 @@ export async function requireAuth(): Promise<void> {
}
if (await hasActiveSession()) {
const { requires_password_change } = await fetchAuthStatus();
if (requires_password_change || mustChangePassword()) {
authRedirect("/change-password");
// Reconcile periodically so local-only routes cannot outlive a server-side
// password-change requirement, while nearby route switches stay local.
if (mustChangePassword() || !hasFreshAuthStatus()) {
const { requires_password_change } = await fetchAuthStatus();
if (requires_password_change || mustChangePassword()) {
authRedirect("/change-password");
}
}
return;
}
const status = await fetchAuthStatus();
if (status.requires_password_change || mustChangePassword()) {
authRedirect("/change-password");

View file

@ -281,7 +281,7 @@ function RootLayout() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
transition={{ duration: 0.06 }}
className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-visible"
>
<Suspense fallback={<RouteFallback />}>

View file

@ -1,15 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const DataRecipesPage = lazy(() =>
import("@/features/data-recipes").then((m) => ({
default: m.DataRecipesPage,
})),
const DataRecipesPage = lazyRouteComponent(
() => import("@/features/data-recipes"),
"DataRecipesPage",
);
export const Route = createRoute({

View file

@ -1,15 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ExportPage = lazy(() =>
import("@/features/export/export-page").then((m) => ({
default: m.ExportPage,
})),
const ExportPage = lazyRouteComponent(
() => import("@/features/export/export-page"),
"ExportPage",
);
export type ExportSearch = {

View file

@ -1,15 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ModelsPage = lazy(() =>
import("@/features/hub/hub-page").then((m) => ({
default: m.ModelsPage,
})),
const ModelsPage = lazyRouteComponent(
() => import("@/features/hub/hub-page"),
"ModelsPage",
);
export interface ModelsSearch {
@ -31,7 +29,11 @@ export const Route = createRoute({
const model = search.model;
if (typeof model === "string" && model.length > 0) next.model = model;
const section = search.section;
if (section === "trending" || section === "latest" || section === "finetune") {
if (
section === "trending" ||
section === "latest" ||
section === "finetune"
) {
next.section = section;
}
const kind = search.kind;

View file

@ -1,15 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ProjectsPage = lazy(() =>
import("@/features/chat/projects-page").then((m) => ({
default: m.ProjectsPage,
})),
const ProjectsPage = lazyRouteComponent(
() => import("@/features/chat/projects-page"),
"ProjectsPage",
);
export const Route = createRoute({

View file

@ -1,15 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const StudioPage = lazy(() =>
import("@/features/studio/studio-page").then((m) => ({
default: m.StudioPage,
})),
const StudioPage = lazyRouteComponent(
() => import("@/features/studio/studio-page"),
"StudioPage",
);
export const Route = createRoute({

View file

@ -93,7 +93,12 @@ import {
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { ChevronDown, Moon } from "lucide-react";
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
import {
Link,
useNavigate,
useRouter,
useRouterState,
} from "@tanstack/react-router";
import {
archiveChatItem,
ChatSearchDialog,
@ -256,6 +261,10 @@ function createNavigationNonce(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function preloadSilently(request: Promise<unknown>): void {
void request.catch(() => undefined);
}
function NavItem({
icon,
label,
@ -267,6 +276,7 @@ function NavItem({
className,
spinner,
tooltip,
onIntent,
}: {
icon: typeof ZapIcon;
label: string;
@ -277,6 +287,7 @@ function NavItem({
dataTour?: string;
className?: string;
spinner?: boolean;
onIntent?: () => void;
// Overrides the hover tooltip (defaults to `label`). Used to explain why a
// disabled item (e.g. Train/Export on a chat-only host) is greyed out.
tooltip?: string;
@ -288,6 +299,8 @@ function NavItem({
tooltip={tooltip ?? label}
disabled={disabled}
onClick={onClick}
onPointerEnter={disabled ? undefined : onIntent}
onFocus={disabled ? undefined : onIntent}
isActive={active}
data-tour={dataTour}
className="sidebar-nav-btn h-[33px] rounded-full gap-[8.5px] pl-3 pr-2.5 font-medium group-data-[collapsible=icon]:px-2.5 group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:mx-auto"
@ -324,6 +337,7 @@ export function AppSidebar() {
});
const { togglePinned, isMobile, setOpenMobile } = useSidebar();
const navigate = useNavigate();
const router = useRouter();
// Web update detection: `webUpdate` is non-null only when the installed
// (PyPI) version is behind the latest release, so the card is hidden by
@ -1218,6 +1232,9 @@ export function AppSidebar() {
navigate({ to: "/projects" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/projects" }));
}}
className="group/projects-item relative"
>
<button
@ -1248,6 +1265,9 @@ export function AppSidebar() {
navigate({ to: "/hub" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/hub" }));
}}
/>
{/* Train has a labelled section when expanded; plain icon here only when collapsed. */}
<NavItem
@ -1264,6 +1284,9 @@ export function AppSidebar() {
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/studio" }));
}}
className="hidden group-data-[collapsible=icon]:block"
/>
</SidebarMenu>
@ -1293,6 +1316,9 @@ export function AppSidebar() {
navigate({ to: "/studio" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/studio" }));
}}
/>
<NavItem
icon={ChefHatIcon}
@ -1302,6 +1328,16 @@ export function AppSidebar() {
navigate({ to: "/data-recipes" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(
router.preloadRoute({ to: "/data-recipes" }),
);
preloadSilently(
import("@/features/data-recipes").then((module) =>
module.preloadRecipes(),
),
);
}}
/>
<NavItem
icon={DownloadSquare01Icon}
@ -1312,6 +1348,14 @@ export function AppSidebar() {
navigate({ to: "/export" });
closeMobileIfOpen();
}}
onIntent={() => {
preloadSilently(router.preloadRoute({ to: "/export" }));
preloadSilently(
import(
"@/features/export/export-navigation-cache"
).then((module) => module.preloadExportData()),
);
}}
/>
</SidebarMenu>
</SidebarGroupContent>

View file

@ -33,6 +33,7 @@ import type {
} from "../types/api";
export const CHAT_HISTORY_UPDATED_EVENT = "unsloth-chat-history-updated";
export const CHAT_PROJECTS_UPDATED_EVENT = "unsloth-chat-projects-updated";
/**
* Thrown when the chat SSE stream ends without a terminal signal (`[DONE]` or a
@ -55,6 +56,13 @@ export function notifyChatHistoryUpdated(): void {
}
}
function notifyChatProjectsUpdated(): void {
notifyChatHistoryUpdated();
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(CHAT_PROJECTS_UPDATED_EVENT));
}
}
function parseErrorText(status: number, body: unknown): string {
if (body && typeof body === "object") {
const detail = (body as { detail?: unknown }).detail;
@ -644,7 +652,7 @@ export async function saveChatProject(
body: JSON.stringify(project),
});
const saved = await parseJsonOrThrow<ProjectRecord>(response);
notifyChatHistoryUpdated();
notifyChatProjectsUpdated();
return saved;
}
@ -661,7 +669,7 @@ export async function updateChatProject(
},
);
const project = await parseJsonOrThrow<ProjectRecord>(response);
notifyChatHistoryUpdated();
notifyChatProjectsUpdated();
return project;
}
@ -677,7 +685,7 @@ export async function deleteChatProject(
{ method: "DELETE" },
);
await parseJsonOrThrow<ProjectRecord>(response);
notifyChatHistoryUpdated();
notifyChatProjectsUpdated();
}
export async function listChatMessages(

View file

@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useEffect, useState } from "react";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import { useEffect, useState, useSyncExternalStore } from "react";
import { CHAT_PROJECTS_UPDATED_EVENT } from "../api/chat-api";
import type { ProjectRecord } from "../types";
import {
createStoredChatProject,
@ -15,32 +15,83 @@ import {
import type { SidebarItem } from "./use-chat-sidebar-items";
let cachedProjects: ProjectRecord[] = [];
let projectsLoaded = false;
let projectsRequest: Promise<ProjectRecord[]> | null = null;
let projectsRefreshPending = false;
let lastProjectsUpdateEvent: Event | null = null;
const projectSubscribers = new Set<() => void>();
function subscribeToProjects(onStoreChange: () => void): () => void {
projectSubscribers.add(onStoreChange);
return () => projectSubscribers.delete(onStoreChange);
}
function getProjectsSnapshot(): ProjectRecord[] {
return cachedProjects;
}
function publishProjects(projects: ProjectRecord[]): void {
cachedProjects = projects;
projectsLoaded = true;
for (const onStoreChange of projectSubscribers) onStoreChange();
}
function loadProjects(
force = false,
followUpIfPending = false,
): Promise<ProjectRecord[]> {
if (projectsRequest) {
if (followUpIfPending) projectsRefreshPending = true;
return projectsRequest;
}
if (!force && projectsLoaded) {
return Promise.resolve(cachedProjects);
}
async function run(): Promise<ProjectRecord[]> {
let nextProjects: ProjectRecord[] | null = null;
do {
projectsRefreshPending = false;
try {
const next = await listStoredChatProjects({ includeArchived: false });
nextProjects = Array.isArray(next) ? next : [];
} catch (error) {
if (!isExpectedBackgroundChatStorageError(error)) throw error;
nextProjects = null;
}
} while (projectsRefreshPending);
if (nextProjects !== null) publishProjects(nextProjects);
return cachedProjects;
}
const request = run().finally(() => {
projectsRequest = null;
});
projectsRequest = request;
return request;
}
export function useChatProjects(): {
projects: ProjectRecord[];
isLoading: boolean;
hasLoaded: boolean;
} {
// Stay null-safe even if the cache was poisoned by a bad response.
const cached = Array.isArray(cachedProjects) ? cachedProjects : [];
const [projects, setProjects] = useState<ProjectRecord[]>(cached);
const [isLoading, setIsLoading] = useState(cached.length === 0);
const [hasLoaded, setHasLoaded] = useState(cached.length > 0);
const projects = useSyncExternalStore(
subscribeToProjects,
getProjectsSnapshot,
getProjectsSnapshot,
);
const [isLoading, setIsLoading] = useState(!projectsLoaded);
const [hasLoaded, setHasLoaded] = useState(projectsLoaded);
useEffect(() => {
let cancelled = false;
async function load() {
if (!cancelled) setIsLoading(true);
async function refresh(force = false, followUpIfPending = false) {
if (!force && projectsLoaded) return;
if (!cancelled && !projectsLoaded) setIsLoading(true);
try {
const next = await listStoredChatProjects({ includeArchived: false });
cachedProjects = Array.isArray(next) ? next : [];
if (!cancelled) setProjects(cachedProjects);
} catch (error) {
if (isExpectedBackgroundChatStorageError(error)) {
return;
}
if (!cancelled) throw error;
await loadProjects(force, followUpIfPending);
} finally {
if (!cancelled) {
setHasLoaded(true);
@ -49,15 +100,18 @@ export function useChatProjects(): {
}
}
const onHistoryUpdated = () => {
void load();
const onProjectsUpdated = (event: Event) => {
const followUpIfPending = event !== lastProjectsUpdateEvent;
lastProjectsUpdateEvent = event;
void refresh(true, followUpIfPending);
};
void load();
window.addEventListener(CHAT_HISTORY_UPDATED_EVENT, onHistoryUpdated);
// Cached rows render immediately, then one shared request reconciles
// changes made by another browser tab or API client.
void refresh(projectsLoaded);
window.addEventListener(CHAT_PROJECTS_UPDATED_EVENT, onProjectsUpdated);
return () => {
cancelled = true;
window.removeEventListener(CHAT_HISTORY_UPDATED_EVENT, onHistoryUpdated);
window.removeEventListener(CHAT_PROJECTS_UPDATED_EVENT, onProjectsUpdated);
};
}, []);

View file

@ -16,11 +16,40 @@ db.version(1).stores({
});
const recentRecipeCache = new Map<string, RecipeRecord>();
let cachedRecipeList: RecipeRecord[] = [];
let recipeListReady = false;
let recipeListRequest: Promise<RecipeRecord[]> | null = null;
export function listRecipes(): Promise<RecipeRecord[]> {
return db.recipes.orderBy("updatedAt").reverse().toArray();
}
function cacheRecipeList(recipes: RecipeRecord[]): RecipeRecord[] {
for (const recipe of recipes) {
writeRecipeCache(recipe);
}
cachedRecipeList = recipes;
recipeListReady = true;
return recipes;
}
export function preloadRecipes(): Promise<RecipeRecord[]> {
if (recipeListReady) {
return Promise.resolve(cachedRecipeList);
}
if (recipeListRequest) {
return recipeListRequest;
}
const request = listRecipes()
.then(cacheRecipeList)
.finally(() => {
recipeListRequest = null;
});
recipeListRequest = request;
return request;
}
export function getRecipe(id: string): Promise<RecipeRecord | undefined> {
return db.recipes.get(id);
}
@ -55,12 +84,21 @@ export async function saveRecipe(
};
await db.recipes.put(record);
writeRecipeCache(record);
if (recipeListReady) {
cachedRecipeList = [
record,
...cachedRecipeList.filter((recipe) => recipe.id !== record.id),
].sort((a, b) => b.updatedAt - a.updatedAt);
}
return record;
}
export async function deleteRecipe(id: string): Promise<void> {
await db.recipes.delete(id);
recentRecipeCache.delete(id);
if (recipeListReady) {
cachedRecipeList = cachedRecipeList.filter((recipe) => recipe.id !== id);
}
}
export function createRecipeDraft(): Promise<RecipeRecord> {
@ -87,15 +125,13 @@ export function useRecipes(): {
recipes: RecipeRecord[];
ready: boolean;
} {
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
const [ready, setReady] = useState(false);
const [recipes, setRecipes] = useState<RecipeRecord[]>(cachedRecipeList);
const [ready, setReady] = useState(recipeListReady);
useEffect(() => {
const sub = liveQuery(() => listRecipes()).subscribe({
next: (value) => {
for (const recipe of value) {
writeRecipeCache(recipe);
}
cacheRecipeList(value);
setRecipes(value);
setReady(true);
},

View file

@ -3,3 +3,4 @@
export { DataRecipesPage } from "./pages/data-recipes-page";
export { EditRecipePage } from "./pages/edit-recipe-page";
export { preloadRecipes } from "./data/recipes-db";

View file

@ -0,0 +1,61 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { type LocalModelInfo, listLocalModels } from "@/features/training";
import { type ModelCheckpoints, fetchCheckpoints } from "./api/export-api";
let cachedCheckpoints: ModelCheckpoints[] | null = null;
let checkpointsRequest: Promise<ModelCheckpoints[]> | null = null;
let cachedLocalModels: LocalModelInfo[] | null = null;
let localModelsRequest: Promise<LocalModelInfo[]> | null = null;
export function getCachedCheckpoints(): ModelCheckpoints[] | null {
return cachedCheckpoints;
}
export function getCachedLocalModels(): LocalModelInfo[] | null {
return cachedLocalModels;
}
export function refreshCheckpoints(): Promise<ModelCheckpoints[]> {
if (checkpointsRequest) {
return checkpointsRequest;
}
const request = fetchCheckpoints()
.then((data) => {
cachedCheckpoints = data.models;
return data.models;
})
.finally(() => {
checkpointsRequest = null;
});
checkpointsRequest = request;
return request;
}
export function refreshLocalModels(): Promise<LocalModelInfo[]> {
if (localModelsRequest) {
return localModelsRequest;
}
const request = listLocalModels()
.then((models) => {
cachedLocalModels = models;
return models;
})
.finally(() => {
localModelsRequest = null;
});
localModelsRequest = request;
return request;
}
export async function preloadExportData(): Promise<void> {
const requests: Promise<unknown>[] = [];
if (cachedCheckpoints === null) {
requests.push(refreshCheckpoints());
}
if (cachedLocalModels === null) {
requests.push(refreshLocalModels());
}
await Promise.allSettled(requests);
}

View file

@ -48,7 +48,6 @@ import { prepareHfTokenForUse } from "@/features/hf-auth";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import {
type LocalModelInfo,
listLocalModels,
useTrainingConfigStore,
} from "@/features/training";
import { useDebouncedValue, useHfTokenValidation } from "@/hooks";
@ -67,7 +66,6 @@ import { useSearch } from "@tanstack/react-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import type { ModelCheckpoints } from "./api/export-api";
import { fetchCheckpoints } from "./api/export-api";
import { ExportRunPanel } from "./components/export-run-panel";
import { MethodPicker } from "./components/method-picker";
import { QuantPicker } from "./components/quant-picker";
@ -83,6 +81,12 @@ import {
mergedFormatPayload,
} from "./constants";
import { useExportSizeEstimate } from "./hooks/use-export-size-estimate";
import {
getCachedCheckpoints,
getCachedLocalModels,
refreshCheckpoints,
refreshLocalModels,
} from "./export-navigation-cache";
import {
isExportPanelActive,
useExportRuntimeStore,
@ -172,8 +176,12 @@ export function ExportPage() {
);
// ---- API-driven checkpoint state ----
const [models, setModels] = useState<ModelCheckpoints[]>([]);
const [loadingCheckpoints, setLoadingCheckpoints] = useState(true);
const [models, setModels] = useState<ModelCheckpoints[]>(
() => getCachedCheckpoints() ?? [],
);
const [loadingCheckpoints, setLoadingCheckpoints] = useState(
getCachedCheckpoints() === null,
);
const [checkpointError, setCheckpointError] = useState<string | null>(null);
const [selectedModelIdx, setSelectedModelIdx] = useState<string | null>(null);
@ -185,8 +193,12 @@ export function ExportPage() {
null,
);
const [localModelInput, setLocalModelInput] = useState("");
const [localModels, setLocalModels] = useState<LocalModelInfo[]>([]);
const [isLoadingLocalModels, setIsLoadingLocalModels] = useState(true);
const [localModels, setLocalModels] = useState<LocalModelInfo[]>(
() => getCachedLocalModels() ?? [],
);
const [isLoadingLocalModels, setIsLoadingLocalModels] = useState(
getCachedLocalModels() === null,
);
const [localModelsError, setLocalModelsError] = useState<string | null>(null);
const debouncedModelQuery = useDebouncedValue(modelInput);
const debouncedHfToken = useDebouncedValue(hfToken, 500);
@ -295,16 +307,15 @@ export function ExportPage() {
// ---- Fetch checkpoints on mount ----
useEffect(() => {
let cancelled = false;
setLoadingCheckpoints(true);
setCheckpointError(null);
fetchCheckpoints()
.then((data) => {
const hadCache = getCachedCheckpoints() !== null;
refreshCheckpoints()
.then((models) => {
if (!cancelled) {
setModels(data.models);
setModels(models);
}
})
.catch((err) => {
if (!cancelled) {
if (!cancelled && !hadCache) {
setCheckpointError(
err instanceof Error ? err.message : "Failed to load checkpoints",
);
@ -343,14 +354,15 @@ export function ExportPage() {
// ---- Fetch local models for direct export ----
useEffect(() => {
const controller = new AbortController();
void listLocalModels(controller.signal)
let cancelled = false;
const hadCache = getCachedLocalModels() !== null;
void refreshLocalModels()
.then((models) => {
if (controller.signal.aborted) return;
if (cancelled) return;
setLocalModels(models);
})
.catch((error) => {
if (controller.signal.aborted) return;
if (cancelled || hadCache) return;
setLocalModelsError(
error instanceof Error
? error.message
@ -358,10 +370,12 @@ export function ExportPage() {
);
})
.finally(() => {
if (controller.signal.aborted) return;
if (cancelled) return;
setIsLoadingLocalModels(false);
});
return () => controller.abort();
return () => {
cancelled = true;
};
}, []);
// ---- Derived state ----

View file

@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
interface HfPaginatedState<T> {
results: T[];
@ -12,6 +12,10 @@ interface HfPaginatedState<T> {
error: string | null;
}
interface InternalPaginatedState<T> extends HfPaginatedState<T> {
queryKey: object | null;
}
const INITIAL: HfPaginatedState<never> = {
results: [],
scannedCount: 0,
@ -74,10 +78,15 @@ export function useHubPaginatedSearch<T>(
options?: { enabled?: boolean },
): HfPaginatedState<T> & { fetchMore: () => boolean; retry: () => void } {
const enabled = options?.enabled ?? true;
const [state, setState] = useState<HfPaginatedState<T>>(
INITIAL as HfPaginatedState<T>,
);
const [retryNonce, setRetryNonce] = useState(0);
const queryKey = useMemo(
() => ({ createIter, mapItem, retryNonce }),
[createIter, mapItem, retryNonce],
);
const [state, setState] = useState<InternalPaginatedState<T>>({
...(INITIAL as HfPaginatedState<T>),
queryKey: null,
});
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
@ -187,6 +196,7 @@ export function useHubPaginatedSearch<T>(
setState({
...(INITIAL as HfPaginatedState<T>),
isLoading: true,
queryKey,
});
const iter = createIter(controller.signal);
@ -203,6 +213,7 @@ export function useHubPaginatedSearch<T>(
isLoadingMore: false,
hasMore: !done,
error: null,
queryKey,
});
})
.catch((err) => {
@ -214,6 +225,7 @@ export function useHubPaginatedSearch<T>(
isLoadingMore: false,
hasMore: false,
error: err instanceof Error ? err.message : "Search failed",
queryKey,
});
})
.finally(() => {
@ -226,7 +238,14 @@ export function useHubPaginatedSearch<T>(
return () => {
clearDeferredFetch();
};
}, [createIter, mapItem, enabled, retryNonce, clearDeferredFetch]);
}, [
createIter,
mapItem,
enabled,
retryNonce,
queryKey,
clearDeferredFetch,
]);
const retry = useCallback(() => {
setRetryNonce((n) => n + 1);
@ -358,5 +377,22 @@ export function useHubPaginatedSearch<T>(
};
}, [enabled, fetchMore]);
return { ...state, fetchMore, retry };
const visibleState: InternalPaginatedState<T> =
state.queryKey === queryKey
? state
: {
...(INITIAL as HfPaginatedState<T>),
isLoading: enabled,
queryKey,
};
return {
results: visibleState.results,
scannedCount: visibleState.scannedCount,
isLoading: visibleState.isLoading,
isLoadingMore: visibleState.isLoadingMore,
hasMore: visibleState.hasMore,
error: visibleState.error,
fetchMore,
retry,
};
}

View file

@ -23,6 +23,10 @@ import {
hfApiToken,
useHfTokenStore,
} from "@/features/hub/stores/hf-token-store";
import {
isChannelEntryFresh,
useHubFeedStore,
} from "./stores/hub-feed-store";
import {
getInferenceStatus,
isExternalModelId,
@ -80,6 +84,7 @@ import {
} from "./lib/hidden-models";
import { inventoryRowMatches, tokenizeQuery } from "./lib/inventory-search";
import { resolveOwnerProviderLogo } from "./lib/provider-logos";
import { fingerprintToken } from "./lib/token-fingerprint";
import {
buildDiscoverRows,
detectResultFormat,
@ -569,6 +574,10 @@ export function ModelsPage() {
const hfToken = useHfTokenStore((s) => s.token);
const debouncedHfToken = useDebouncedValue(hfToken, 500);
const apiHfToken = hfApiToken(debouncedHfToken);
const tokenFingerprint = useMemo(
() => fingerprintToken(apiHfToken),
[apiHfToken],
);
const deferredFormatFilter = useDeferredValue(formatFilter);
const deferredCapabilityFilter = useDeferredValue(capabilityFilter);
@ -633,8 +642,22 @@ export function ModelsPage() {
online,
});
const cachedListEntry = useHubFeedStore((state) =>
liveListChannel ? state.channels[liveListChannel.id] : undefined,
);
const visibleResults =
results.length === 0 &&
liveListChannel &&
isChannelEntryFresh(
cachedListEntry,
liveListChannel.id,
tokenFingerprint,
)
? (cachedListEntry?.results ?? results)
: results;
useFeedWriteBack({
channelId: isChannelListMode ? activeChannelId : null,
channelId: liveListChannel?.id ?? null,
results,
isLoading,
accessToken: apiHfToken,
@ -656,8 +679,13 @@ export function ModelsPage() {
[effectiveCachedRows, effectiveLocalRows],
);
const modelDiscoverRows = useMemo<DiscoverRow[]>(
() => buildDiscoverRows(results, effectiveCachedRows, effectiveLocalRows),
[results, modelDiscoveryInventorySignature],
() =>
buildDiscoverRows(
visibleResults,
effectiveCachedRows,
effectiveLocalRows,
),
[visibleResults, modelDiscoveryInventorySignature],
);
const datasetDiscoverRows = useMemo<DiscoverRow[]>(() => {
@ -784,7 +812,7 @@ export function ModelsPage() {
const selectionFilteredDiscoverRows = isFeedMode
? feedRows
: filteredDiscoverRows;
const selectionResults = isFeedMode ? feedResults : results;
const selectionResults = isFeedMode ? feedResults : visibleResults;
const inventoryTokens = useMemo(
() => (isDiscoverTab ? [] : tokenizeQuery(deferredDebouncedQuery)),