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..1c862c7 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", diff --git a/packages/chronicle/src/components/ui/PrefetchProvider.tsx b/packages/chronicle/src/components/ui/PrefetchProvider.tsx new file mode 100644 index 0000000..17c6a2a --- /dev/null +++ b/packages/chronicle/src/components/ui/PrefetchProvider.tsx @@ -0,0 +1,70 @@ +import { useEffect } from 'react'; +import { prefetchPageData } from '@/lib/preload'; + +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 }) { + useEffect(() => { + const handleMouseOver = (e: MouseEvent) => { + const anchor = (e.target as HTMLElement).closest?.('a[href]'); + if (!anchor) return; + 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 pathname = resolvePathname(anchor.getAttribute('href')); + if (pathname) prefetchPageData(pathname); + }; + + document.addEventListener('mouseover', handleMouseOver); + document.addEventListener('focusin', handleFocusIn); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const pathname = resolvePathname((entry.target as HTMLAnchorElement).getAttribute('href')); + if (pathname) prefetchPageData(pathname); + observer.unobserve(entry.target); + } + } + }, + { rootMargin: '200px' }, + ); + + const observeLinks = () => { + document.querySelectorAll('a[href]:not([data-prefetch-observed])').forEach((link) => { + const pathname = resolvePathname(link.getAttribute('href')); + if (pathname) { + 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/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 }) => { diff --git a/packages/chronicle/src/lib/preload.ts b/packages/chronicle/src/lib/preload.ts new file mode 100644 index 0000000..ee55289 --- /dev/null +++ b/packages/chronicle/src/lib/preload.ts @@ -0,0 +1,37 @@ +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(); +} + +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), + }); +} 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 985cce3..fa83e99 100644 --- a/packages/chronicle/src/server/entry-client.tsx +++ b/packages/chronicle/src/server/entry-client.tsx @@ -3,9 +3,11 @@ 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 { 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 +95,22 @@ async function hydrate() { hydrateRoot( document.getElementById('root') as HTMLElement, - - - - - - - + + + + + + + + + ); } catch (err) { console.error('Hydration failed:', err);