From 87896afff01787866a0b29c5868640c4197d825e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 7 May 2026 13:35:11 +0530 Subject: [PATCH 1/6] feat: add isLoading state to PageProvider for skeleton loaders Extract fetch logic into useCallback (fetchApiSpecs, fetchPageData). Both callbacks own their loading state. Use ref for currentPath to avoid stale dependency. DocsPage renders loading placeholder when isLoading is true. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/page-context.tsx | 114 ++++++++++++-------- packages/chronicle/src/pages/DocsPage.tsx | 4 +- 2 files changed, 73 insertions(+), 45 deletions(-) diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index a5069afb..95dc0f7d 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -1,8 +1,10 @@ import { createContext, type ReactNode, + useCallback, useContext, useEffect, + useRef, useState } from 'react'; import { useLocation } from 'react-router'; @@ -10,7 +12,7 @@ import type { ApiSpec } from '@/lib/openapi'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import type { VersionContext } from '@/lib/version-source'; import { LATEST_CONTEXT } from '@/lib/version-source'; -import type { ChronicleConfig, Frontmatter, Page, Root, TableOfContents } from '@/types'; +import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types'; export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>; @@ -18,6 +20,7 @@ interface PageContextValue { config: ChronicleConfig; tree: Root; page: Page | null; + isLoading: boolean; errorStatus: number | null; apiSpecs: ApiSpec[]; version: VersionContext; @@ -36,6 +39,7 @@ export function usePageContext(): PageContextValue { }, tree: { name: 'root', children: [] } as Root, page: null, + isLoading: false, errorStatus: null, apiSpecs: [], version: LATEST_CONTEXT, @@ -82,11 +86,52 @@ export function PageProvider({ const [errorStatus, setErrorStatus] = useState(getInitialErrorStatus(initialPage, initialConfig, pathname)); const [apiSpecs, setApiSpecs] = useState(initialApiSpecs); const [version, setVersion] = useState(initialVersion); - const [currentPath, setCurrentPath] = useState(pathname); + const [isLoading, setIsLoading] = useState(false); + const currentPathRef = useRef(pathname); + + const fetchApiSpecs = useCallback(async (route: { version: VersionContext }, cancelled: { current: boolean }) => { + setIsLoading(true); + try { + const specsUrl = route.version.dir + ? `/api/specs?version=${encodeURIComponent(route.version.dir)}` + : '/api/specs'; + const res = await fetch(specsUrl); + const specs = await res.json(); + if (!cancelled.current) setApiSpecs(specs); + } catch { + // best-effort on client nav + } finally { + setIsLoading(false); + } + }, []); + + interface PageData { + frontmatter: Frontmatter; + relativePath: string; + originalPath?: string; + prev?: PageNavLink | null; + next?: PageNavLink | null; + } + + const fetchPageData = useCallback(async (slug: string[]): Promise => { + setIsLoading(true); + try { + const apiPath = slug.length === 0 + ? '/api/page' + : `/api/page?slug=${slug.join(',')}`; + const res = await fetch(apiPath); + if (!res.ok) throw new Error(String(res.status)); + return await res.json(); + } catch (err) { + throw err; + } finally { + setIsLoading(false); + } + }, []); useEffect(() => { - if (pathname === currentPath) return; - setCurrentPath(pathname); + if (pathname === currentPathRef.current) return; + currentPathRef.current = pathname; const route = resolveRoute(pathname, initialConfig); if (route.type !== RouteType.Redirect) setVersion(route.version); @@ -96,17 +141,7 @@ export function PageProvider({ if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) { setPage(null); setErrorStatus(null); - const specsUrl = route.version.dir - ? `/api/specs?version=${encodeURIComponent(route.version.dir)}` - : '/api/specs'; - fetch(specsUrl) - .then(res => res.json()) - .then(specs => { - if (!cancelled.current) setApiSpecs(specs); - }) - .catch(() => { - // swallow — api specs are best-effort on client nav - }); + fetchApiSpecs(route, cancelled); return () => { cancelled.current = true; }; } @@ -116,41 +151,34 @@ export function PageProvider({ return () => { cancelled.current = true; }; } - const apiPath = route.slug.length === 0 - ? '/api/page' - : `/api/page?slug=${route.slug.join(',')}`; - - fetch(apiPath) - .then(res => { - if (!res.ok) { - if (!cancelled.current) { - setPage(null); - setErrorStatus(res.status); - } - return; - } - return res.json(); - }) - .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string; prev?: Page['prev']; next?: Page['next'] } | undefined) => { - if (cancelled.current || !data) return; + (async () => { + try { + const data = await fetchPageData(route.slug); + if (cancelled.current) return; const { content, toc } = await loadMdx(data.originalPath || data.relativePath); if (cancelled.current) return; setErrorStatus(null); - setPage({ slug: route.slug, frontmatter: data.frontmatter, content, toc, prev: data.prev, next: data.next }); - }) - .catch(() => { - if (!cancelled.current) { - setPage(null); - setErrorStatus(500); - } - }); - + setPage({ + slug: route.slug, + frontmatter: data.frontmatter, + content, + toc, + prev: data.prev ?? null, + next: data.next ?? null, + }); + } catch (err) { + if (cancelled.current) return; + const status = Number((err as Error).message) || 500; + setPage(null); + setErrorStatus(status); + } + })(); return () => { cancelled.current = true; }; - }, [pathname]); + }, [pathname, initialConfig, fetchApiSpecs, fetchPageData, loadMdx]); return ( {children} diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index 8dfb6adc..73e89ba0 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -8,11 +8,11 @@ interface DocsPageProps { } export function DocsPage({ slug }: DocsPageProps) { - const { config, tree, page, errorStatus } = usePageContext(); + const { config, tree, page, isLoading, errorStatus } = usePageContext(); if (errorStatus === 404) return ; if (errorStatus) return ; - if (!page) return null; + if (isLoading || !page) return
Loading...
; const { Page } = getTheme(config.theme?.name); const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined; From 8908d220935047455c9558a71659b8ff0f924963 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 7 May 2026 14:13:19 +0530 Subject: [PATCH 2/6] feat: add skeleton loaders for default and paper themes Add Skeleton component to Theme interface. Each theme provides its own skeleton matching its content layout. DocsPage renders theme skeleton when isLoading is true. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/pages/DocsPage.tsx | 4 +-- .../chronicle/src/themes/default/Skeleton.tsx | 27 +++++++++++++++++++ .../chronicle/src/themes/default/index.ts | 6 +++-- .../src/themes/paper/Page.module.css | 10 +++++++ .../chronicle/src/themes/paper/Skeleton.tsx | 23 ++++++++++++++++ packages/chronicle/src/themes/paper/index.ts | 4 ++- packages/chronicle/src/types/theme.ts | 1 + 7 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 packages/chronicle/src/themes/default/Skeleton.tsx create mode 100644 packages/chronicle/src/themes/paper/Skeleton.tsx diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index 73e89ba0..344b9f03 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -12,9 +12,9 @@ export function DocsPage({ slug }: DocsPageProps) { if (errorStatus === 404) return ; if (errorStatus) return ; - if (isLoading || !page) return
Loading...
; + const { Page, Skeleton } = getTheme(config.theme?.name); - const { Page } = getTheme(config.theme?.name); + if (isLoading || !page) return ; const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined; const markdownHref = `/${slug.join('/')}.md`; diff --git a/packages/chronicle/src/themes/default/Skeleton.tsx b/packages/chronicle/src/themes/default/Skeleton.tsx new file mode 100644 index 00000000..b8cde1ea --- /dev/null +++ b/packages/chronicle/src/themes/default/Skeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from '@raystack/apsara'; +import { Flex } from '@raystack/apsara'; +import styles from './Page.module.css'; + +export function PageSkeleton() { + return ( + +
+ + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/packages/chronicle/src/themes/default/index.ts b/packages/chronicle/src/themes/default/index.ts index c0811972..795b13ef 100644 --- a/packages/chronicle/src/themes/default/index.ts +++ b/packages/chronicle/src/themes/default/index.ts @@ -1,11 +1,13 @@ import type { Theme } from '@/types'; import { Layout } from './Layout'; import { Page } from './Page'; +import { PageSkeleton } from './Skeleton'; import { Toc } from './Toc'; export const defaultTheme: Theme = { Layout, - Page + Page, + Skeleton: PageSkeleton, }; -export { Layout, Page, Toc }; +export { Layout, Page, PageSkeleton, Toc }; diff --git a/packages/chronicle/src/themes/paper/Page.module.css b/packages/chronicle/src/themes/paper/Page.module.css index 92bcad75..0c0764b4 100644 --- a/packages/chronicle/src/themes/paper/Page.module.css +++ b/packages/chronicle/src/themes/paper/Page.module.css @@ -160,6 +160,7 @@ 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04); margin-bottom: var(--rs-space-9); + min-height: calc(100vh - var(--rs-space-12)); } .content h1, @@ -236,3 +237,12 @@ padding-left: 1rem; border-left: 3px solid var(--rs-color-border-base-primary); } + +.headerLoader { + align-items: center; + margin-bottom: var(--rs-space-5) +} + +.loader { + margin-bottom: var(--rs-space-3) +} diff --git a/packages/chronicle/src/themes/paper/Skeleton.tsx b/packages/chronicle/src/themes/paper/Skeleton.tsx new file mode 100644 index 00000000..9aa3c69e --- /dev/null +++ b/packages/chronicle/src/themes/paper/Skeleton.tsx @@ -0,0 +1,23 @@ +import { Flex, Skeleton } from '@raystack/apsara'; +import styles from './Page.module.css'; + +export function PageSkeleton() { + return ( +
+
+
+ + + +
+
+ { + [...new Array(30)].map((_, i) => { + return + }) + } +
+
+
+ ); +} diff --git a/packages/chronicle/src/themes/paper/index.ts b/packages/chronicle/src/themes/paper/index.ts index 65543a58..c10679c2 100644 --- a/packages/chronicle/src/themes/paper/index.ts +++ b/packages/chronicle/src/themes/paper/index.ts @@ -1,8 +1,10 @@ import type { Theme } from '@/types'; import { Layout } from './Layout'; import { Page } from './Page'; +import { PageSkeleton } from './Skeleton'; export const paperTheme: Theme = { Layout, - Page + Page, + Skeleton: PageSkeleton, }; diff --git a/packages/chronicle/src/types/theme.ts b/packages/chronicle/src/types/theme.ts index cfcdd68d..627a8dd2 100644 --- a/packages/chronicle/src/types/theme.ts +++ b/packages/chronicle/src/types/theme.ts @@ -20,5 +20,6 @@ export interface ThemePageProps { export interface Theme { Layout: React.ComponentType Page: React.ComponentType + Skeleton: React.ComponentType className?: string } From 5f9af64194dd15f220aabd3c9e9e0dd0cfd790fe Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 7 May 2026 14:16:05 +0530 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20lint=20errors=20=E2=80=94=20unused?= =?UTF-8?q?=20import=20and=20useless=20catch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/page-context.tsx | 1 + packages/chronicle/src/themes/paper/Skeleton.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 95dc0f7d..329428eb 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -123,6 +123,7 @@ export function PageProvider({ if (!res.ok) throw new Error(String(res.status)); return await res.json(); } catch (err) { + console.error('Failed to fetch page data:', err); throw err; } finally { setIsLoading(false); diff --git a/packages/chronicle/src/themes/paper/Skeleton.tsx b/packages/chronicle/src/themes/paper/Skeleton.tsx index 9aa3c69e..bf715ce5 100644 --- a/packages/chronicle/src/themes/paper/Skeleton.tsx +++ b/packages/chronicle/src/themes/paper/Skeleton.tsx @@ -1,4 +1,4 @@ -import { Flex, Skeleton } from '@raystack/apsara'; +import { Skeleton } from '@raystack/apsara'; import styles from './Page.module.css'; export function PageSkeleton() { From 7248b74f8b5fbffecb1fd777c75b77a56bcbbf5c Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 7 May 2026 14:45:30 +0530 Subject: [PATCH 4/6] fix: clear stale page/error before docs fetch for consistent skeleton Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/page-context.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 329428eb..cfbbb76a 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -152,6 +152,8 @@ export function PageProvider({ return () => { cancelled.current = true; }; } + setPage(null); + setErrorStatus(null); (async () => { try { const data = await fetchPageData(route.slug); From 2ce4d026096b9724ce4013d04c65e9b9b0fae5d0 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Thu, 7 May 2026 14:48:42 +0530 Subject: [PATCH 5/6] refactor: extract IIFE into loadDocsPage useCallback Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/page-context.tsx | 48 +++++++++++---------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index cfbbb76a..c899c3c8 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -130,6 +130,29 @@ export function PageProvider({ } }, []); + const loadDocsPage = useCallback(async (slug: string[], cancelled: { current: boolean }) => { + try { + const data = await fetchPageData(slug); + if (cancelled.current) return; + const { content, toc } = await loadMdx(data.originalPath || data.relativePath); + if (cancelled.current) return; + setErrorStatus(null); + setPage({ + slug, + frontmatter: data.frontmatter, + content, + toc, + prev: data.prev ?? null, + next: data.next ?? null, + }); + } catch (err) { + if (cancelled.current) return; + const status = Number((err as Error).message) || 500; + setPage(null); + setErrorStatus(status); + } + }, [fetchPageData, loadMdx]); + useEffect(() => { if (pathname === currentPathRef.current) return; currentPathRef.current = pathname; @@ -154,30 +177,9 @@ export function PageProvider({ setPage(null); setErrorStatus(null); - (async () => { - try { - const data = await fetchPageData(route.slug); - if (cancelled.current) return; - const { content, toc } = await loadMdx(data.originalPath || data.relativePath); - if (cancelled.current) return; - setErrorStatus(null); - setPage({ - slug: route.slug, - frontmatter: data.frontmatter, - content, - toc, - prev: data.prev ?? null, - next: data.next ?? null, - }); - } catch (err) { - if (cancelled.current) return; - const status = Number((err as Error).message) || 500; - setPage(null); - setErrorStatus(status); - } - })(); + loadDocsPage(route.slug, cancelled); return () => { cancelled.current = true; }; - }, [pathname, initialConfig, fetchApiSpecs, fetchPageData, loadMdx]); + }, [pathname, initialConfig, fetchApiSpecs, loadDocsPage]); return ( Date: Thu, 7 May 2026 14:55:57 +0530 Subject: [PATCH 6/6] refactor: move isLoading to loadDocsPage, keep fetchPageData pure Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/page-context.tsx | 23 ++++++++------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index c899c3c8..e075974e 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -114,23 +114,16 @@ export function PageProvider({ } const fetchPageData = useCallback(async (slug: string[]): Promise => { - setIsLoading(true); - try { - const apiPath = slug.length === 0 - ? '/api/page' - : `/api/page?slug=${slug.join(',')}`; - const res = await fetch(apiPath); - if (!res.ok) throw new Error(String(res.status)); - return await res.json(); - } catch (err) { - console.error('Failed to fetch page data:', err); - throw err; - } finally { - setIsLoading(false); - } + const apiPath = slug.length === 0 + ? '/api/page' + : `/api/page?slug=${slug.join(',')}`; + const res = await fetch(apiPath); + if (!res.ok) throw new Error(String(res.status)); + return res.json(); }, []); const loadDocsPage = useCallback(async (slug: string[], cancelled: { current: boolean }) => { + setIsLoading(true); try { const data = await fetchPageData(slug); if (cancelled.current) return; @@ -150,6 +143,8 @@ export function PageProvider({ const status = Number((err as Error).message) || 500; setPage(null); setErrorStatus(status); + } finally { + if (!cancelled.current) setIsLoading(false); } }, [fetchPageData, loadMdx]);