From 5a1959098fb1b6cf49d2202db256e9bc43e0703e Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 12:18:08 +0530 Subject: [PATCH 1/9] chore: add @tanstack/react-query dependency Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 5 +++++ packages/chronicle/package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/bun.lock b/bun.lock index 244ae2c..a1cc1a2 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@radix-ui/react-icons": "^1.3.2", "@raystack/apsara": "1.0.0-rc.7", "@shikijs/rehype": "^4.0.2", + "@tanstack/react-query": "^5.100.10", "@vitejs/plugin-react": "^6.0.1", "chalk": "^5.6.2", "class-variance-authority": "^0.7.1", @@ -311,6 +312,10 @@ "@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@8.19.4", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="], diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index 88f4e62..1c3f337 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -49,6 +49,7 @@ "@radix-ui/react-icons": "^1.3.2", "@raystack/apsara": "1.0.0-rc.7", "@shikijs/rehype": "^4.0.2", + "@tanstack/react-query": "^5.100.10", "@vitejs/plugin-react": "^6.0.1", "chalk": "^5.6.2", "class-variance-authority": "^0.7.1", From bd53b78b311f17c88c8c4373689b94630440b24c Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 12:18:12 +0530 Subject: [PATCH 2/9] feat: add page data preload utility with react-query Singleton QueryClient with prefetchPageData for cache-aware preloading of /api/page responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/preload.ts | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 packages/chronicle/src/lib/preload.ts diff --git a/packages/chronicle/src/lib/preload.ts b/packages/chronicle/src/lib/preload.ts new file mode 100644 index 0000000..23d14e9 --- /dev/null +++ b/packages/chronicle/src/lib/preload.ts @@ -0,0 +1,32 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + refetchOnWindowFocus: false, + }, + }, +}); + +export function pageDataQueryKey(pathname: string) { + const slug = pathname.split('/').filter(Boolean); + const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(','); + return ['pageData', key] as const; +} + +async function fetchPageDataByPathname(pathname: string) { + const slug = pathname.split('/').filter(Boolean); + const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(','); + const apiPath = key ? `/api/page?slug=${key}` : '/api/page'; + const res = await fetch(apiPath); + if (!res.ok) throw new Error(String(res.status)); + return res.json(); +} + +export function prefetchPageData(pathname: string) { + queryClient.prefetchQuery({ + queryKey: pageDataQueryKey(pathname), + queryFn: () => fetchPageDataByPathname(pathname), + }); +} From cff56adac7b6905b6565b88d674d75d093918a9b Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 12:18:16 +0530 Subject: [PATCH 3/9] feat: use queryClient.fetchQuery in PageProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchPageData now goes through react-query cache — prefetched data is served instantly without duplicate network requests. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/page-context.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index 74d33bb..f49180d 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -13,6 +13,7 @@ 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, PageNavLink, Root, TableOfContents } from '@/types'; +import { queryClient } from '@/lib/preload'; export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>; @@ -114,12 +115,16 @@ export function PageProvider({ } const fetchPageData = useCallback(async (slug: string[]): Promise => { - const apiPath = slug.length === 0 - ? '/api/page' - : `/api/page?slug=${slug.map(s => encodeURIComponent(s)).join(',')}`; - const res = await fetch(apiPath); - if (!res.ok) throw new Error(String(res.status)); - return res.json(); + const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(','); + const apiPath = key ? `/api/page?slug=${key}` : '/api/page'; + return queryClient.fetchQuery({ + queryKey: ['pageData', key], + queryFn: async () => { + 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 }) => { From c61f1054ca2e9d7c68fbe5245889152f03516c40 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 12:18:21 +0530 Subject: [PATCH 4/9] feat: add PrefetchProvider with hover, focus, and viewport triggers Global event delegation prefetches page data on mouseover/focusin. IntersectionObserver + MutationObserver handle viewport-visible and dynamically added links. Wired up in entry-client.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/PrefetchProvider.tsx | 63 +++++++++++++++++++ .../chronicle/src/server/entry-client.tsx | 35 ++++++----- 2 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 packages/chronicle/src/components/PrefetchProvider.tsx diff --git a/packages/chronicle/src/components/PrefetchProvider.tsx b/packages/chronicle/src/components/PrefetchProvider.tsx new file mode 100644 index 0000000..921f5e0 --- /dev/null +++ b/packages/chronicle/src/components/PrefetchProvider.tsx @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import { prefetchPageData } from '@/lib/preload'; + +function isInternalLink(href: string | null): href is string { + return !!href && !href.startsWith('http') && !href.startsWith('#') && !href.startsWith('mailto:'); +} + +export function PrefetchProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + const handleMouseOver = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement).closest?.('a[href]'); + if (!anchor) return; + const href = anchor.getAttribute('href'); + if (isInternalLink(href)) prefetchPageData(href); + }; + + const handleFocusIn = (e: FocusEvent) => { + const anchor = (e.target as HTMLElement).closest?.('a[href]'); + if (!anchor) return; + const href = anchor.getAttribute('href'); + if (isInternalLink(href)) prefetchPageData(href); + }; + + document.addEventListener('mouseover', handleMouseOver); + document.addEventListener('focusin', handleFocusIn); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const href = (entry.target as HTMLAnchorElement).getAttribute('href'); + if (isInternalLink(href)) prefetchPageData(href); + observer.unobserve(entry.target); + } + } + }, + { rootMargin: '200px' }, + ); + + const observeLinks = () => { + document.querySelectorAll('a[href]:not([data-prefetch-observed])').forEach((link) => { + const href = link.getAttribute('href'); + if (isInternalLink(href)) { + link.setAttribute('data-prefetch-observed', ''); + observer.observe(link); + } + }); + }; + + const mutationObserver = new MutationObserver(observeLinks); + mutationObserver.observe(document.body, { childList: true, subtree: true }); + observeLinks(); + + return () => { + document.removeEventListener('mouseover', handleMouseOver); + document.removeEventListener('focusin', handleFocusIn); + observer.disconnect(); + mutationObserver.disconnect(); + }; + }, []); + + return children; +} diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index 985cce3..c4ef2f9 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -3,9 +3,12 @@ import React from 'react'; import { hydrateRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router'; import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; +import { QueryClientProvider } from '@tanstack/react-query'; import { mdxComponents } from '@/components/mdx'; +import { PrefetchProvider } from '@/components/PrefetchProvider'; import { getApiConfigsForVersion } from '@/lib/config'; import { PageProvider } from '@/lib/page-context'; +import { queryClient } from '@/lib/preload'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import { resolveVersionFromUrl, type VersionContext } from '@/lib/version-source'; import type { ChronicleConfig, Frontmatter, PageNavLink, Root, TableOfContents } from '@/types'; @@ -93,20 +96,24 @@ async function hydrate() { hydrateRoot( document.getElementById('root') as HTMLElement, - - - - - - - + + + + + + + + + + + ); } catch (err) { console.error('Hydration failed:', err); From 48b8b0f1b96d68875ac309fcacb430efe3911a1a Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 12:26:09 +0530 Subject: [PATCH 5/9] fix: skip prefetching for API routes API pages use /api/specs not /api/page, so prefetch requests for /apis/* paths were returning errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/preload.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/chronicle/src/lib/preload.ts b/packages/chronicle/src/lib/preload.ts index 23d14e9..ee55289 100644 --- a/packages/chronicle/src/lib/preload.ts +++ b/packages/chronicle/src/lib/preload.ts @@ -24,7 +24,12 @@ async function fetchPageDataByPathname(pathname: string) { return res.json(); } +function isApisRoute(pathname: string): boolean { + return pathname === '/apis' || pathname.startsWith('/apis/'); +} + export function prefetchPageData(pathname: string) { + if (isApisRoute(pathname)) return; queryClient.prefetchQuery({ queryKey: pageDataQueryKey(pathname), queryFn: () => fetchPageDataByPathname(pathname), From 742b77808c252563493c2604deee4e1d8ff9b4ae Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 13:16:04 +0530 Subject: [PATCH 6/9] fix: normalize href via URL() before prefetching Raw getAttribute('href') can include hash fragments, query strings, or relative paths that leak into the slug and cause bad API requests. Resolve via new URL(href, location.href) and use pathname only. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/PrefetchProvider.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/chronicle/src/components/PrefetchProvider.tsx b/packages/chronicle/src/components/PrefetchProvider.tsx index 921f5e0..17c6a2a 100644 --- a/packages/chronicle/src/components/PrefetchProvider.tsx +++ b/packages/chronicle/src/components/PrefetchProvider.tsx @@ -1,8 +1,15 @@ import { useEffect } from 'react'; import { prefetchPageData } from '@/lib/preload'; -function isInternalLink(href: string | null): href is string { - return !!href && !href.startsWith('http') && !href.startsWith('#') && !href.startsWith('mailto:'); +function resolvePathname(href: string | null): string | null { + if (!href) return null; + try { + const url = new URL(href, location.href); + if (url.origin !== location.origin) return null; + return url.pathname; + } catch { + return null; + } } export function PrefetchProvider({ children }: { children: React.ReactNode }) { @@ -10,15 +17,15 @@ export function PrefetchProvider({ children }: { children: React.ReactNode }) { const handleMouseOver = (e: MouseEvent) => { const anchor = (e.target as HTMLElement).closest?.('a[href]'); if (!anchor) return; - const href = anchor.getAttribute('href'); - if (isInternalLink(href)) prefetchPageData(href); + const pathname = resolvePathname(anchor.getAttribute('href')); + if (pathname) prefetchPageData(pathname); }; const handleFocusIn = (e: FocusEvent) => { const anchor = (e.target as HTMLElement).closest?.('a[href]'); if (!anchor) return; - const href = anchor.getAttribute('href'); - if (isInternalLink(href)) prefetchPageData(href); + const pathname = resolvePathname(anchor.getAttribute('href')); + if (pathname) prefetchPageData(pathname); }; document.addEventListener('mouseover', handleMouseOver); @@ -28,8 +35,8 @@ export function PrefetchProvider({ children }: { children: React.ReactNode }) { (entries) => { for (const entry of entries) { if (entry.isIntersecting) { - const href = (entry.target as HTMLAnchorElement).getAttribute('href'); - if (isInternalLink(href)) prefetchPageData(href); + const pathname = resolvePathname((entry.target as HTMLAnchorElement).getAttribute('href')); + if (pathname) prefetchPageData(pathname); observer.unobserve(entry.target); } } @@ -39,8 +46,8 @@ export function PrefetchProvider({ children }: { children: React.ReactNode }) { const observeLinks = () => { document.querySelectorAll('a[href]:not([data-prefetch-observed])').forEach((link) => { - const href = link.getAttribute('href'); - if (isInternalLink(href)) { + const pathname = resolvePathname(link.getAttribute('href')); + if (pathname) { link.setAttribute('data-prefetch-observed', ''); observer.observe(link); } From f941f9d14e6f441a605882a3fbd690dee599b525 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 13:19:03 +0530 Subject: [PATCH 7/9] chore: pin @tanstack/react-query to exact version Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/chronicle/package.json b/packages/chronicle/package.json index 1c3f337..1c862c7 100644 --- a/packages/chronicle/package.json +++ b/packages/chronicle/package.json @@ -49,7 +49,7 @@ "@radix-ui/react-icons": "^1.3.2", "@raystack/apsara": "1.0.0-rc.7", "@shikijs/rehype": "^4.0.2", - "@tanstack/react-query": "^5.100.10", + "@tanstack/react-query": "5.100.10", "@vitejs/plugin-react": "^6.0.1", "chalk": "^5.6.2", "class-variance-authority": "^0.7.1", From 685ce343bfc26f9642b302a4fe5c3f6f30427e2d Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 13:36:43 +0530 Subject: [PATCH 8/9] refactor: move PrefetchProvider to components/ui, separate from QueryClientProvider QueryClientProvider stays in entry-client.tsx at app root. PrefetchProvider moved to src/components/ui/ to match project structure. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/components/{ => ui}/PrefetchProvider.tsx | 0 packages/chronicle/src/server/entry-client.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/chronicle/src/components/{ => ui}/PrefetchProvider.tsx (100%) diff --git a/packages/chronicle/src/components/PrefetchProvider.tsx b/packages/chronicle/src/components/ui/PrefetchProvider.tsx similarity index 100% rename from packages/chronicle/src/components/PrefetchProvider.tsx rename to packages/chronicle/src/components/ui/PrefetchProvider.tsx diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index c4ef2f9..a7b5926 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -5,7 +5,7 @@ import { BrowserRouter } from 'react-router'; import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; import { QueryClientProvider } from '@tanstack/react-query'; import { mdxComponents } from '@/components/mdx'; -import { PrefetchProvider } from '@/components/PrefetchProvider'; +import { PrefetchProvider } from '@/components/ui/PrefetchProvider'; import { getApiConfigsForVersion } from '@/lib/config'; import { PageProvider } from '@/lib/page-context'; import { queryClient } from '@/lib/preload'; From dc97ecc55e0e3fc989070237db56151707a985c0 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 13:40:08 +0530 Subject: [PATCH 9/9] refactor: scope PrefetchProvider to DocsLayout only Prefetching page data only relevant for docs content pages, not API pages or landing page. Move from app root to DocsLayout which wraps sidebar + content area. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/pages/DocsLayout.tsx | 19 +++++++++++-------- .../chronicle/src/server/entry-client.tsx | 5 +---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/chronicle/src/pages/DocsLayout.tsx b/packages/chronicle/src/pages/DocsLayout.tsx index 26cb0b7..62ca893 100644 --- a/packages/chronicle/src/pages/DocsLayout.tsx +++ b/packages/chronicle/src/pages/DocsLayout.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { useLocation } from 'react-router'; +import { PrefetchProvider } from '@/components/ui/PrefetchProvider'; import { usePageContext } from '@/lib/page-context'; import { getActiveContentDir } from '@/lib/navigation'; import { @@ -27,13 +28,15 @@ export function DocsLayout({ children, hideSidebar }: DocsLayoutProps) { ); return ( - - {children} - + + + {children} + + ); } diff --git a/packages/chronicle/src/server/entry-client.tsx b/packages/chronicle/src/server/entry-client.tsx index a7b5926..fa83e99 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -5,7 +5,6 @@ import { BrowserRouter } from 'react-router'; import { ReactRouterProvider } from 'fumadocs-core/framework/react-router'; import { QueryClientProvider } from '@tanstack/react-query'; import { mdxComponents } from '@/components/mdx'; -import { PrefetchProvider } from '@/components/ui/PrefetchProvider'; import { getApiConfigsForVersion } from '@/lib/config'; import { PageProvider } from '@/lib/page-context'; import { queryClient } from '@/lib/preload'; @@ -107,9 +106,7 @@ async function hydrate() { initialVersion={version} loadMdx={loadMdxModule} > - - - +