one
Version:
One is a new React Framework that makes Vite serve both native and web.
494 lines (434 loc) • 14.4 kB
text/typescript
import { useCallback, useSyncExternalStore } from 'react'
import { registerDevtoolsFunction } from './devtools/registry'
import { useParams, usePathname } from './hooks'
import { preloadedLoaderData, preloadingLoader } from './router/router'
import { getLoaderPath } from './utils/cleanUrl'
import { dynamicImport } from './utils/dynamicImport'
import { weakKey } from './utils/weakKey'
import { useServerContext } from './vite/one-server-only'
type LoaderStateEntry = {
data: any
error: any
promise?: Promise<void>
state: 'idle' | 'loading'
timestamp?: number
hasLoadedOnce?: boolean
}
// Timing data for loader waterfall devtool (dev only)
export type LoaderTimingEntry = {
path: string
startTime: number
moduleLoadTime?: number
executionTime?: number
totalTime?: number
error?: string
source: 'preload' | 'initial' | 'refetch'
}
// Store timing history for devtools - only populated in development
const loaderTimingHistory: LoaderTimingEntry[] = []
const MAX_TIMING_HISTORY = 50
function recordLoaderTiming(entry: LoaderTimingEntry) {
if (process.env.NODE_ENV !== 'development') return
loaderTimingHistory.unshift(entry)
if (loaderTimingHistory.length > MAX_TIMING_HISTORY) {
loaderTimingHistory.pop()
}
// Dispatch event for devtools
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('one-loader-timing', { detail: entry }))
// Also dispatch error event if there was an error
if (entry.error) {
window.dispatchEvent(
new CustomEvent('one-error', {
detail: {
error: {
message: entry.error,
name: 'LoaderError',
},
route: {
pathname: entry.path,
},
timestamp: Date.now(),
type: 'loader',
},
})
)
}
}
}
export function getLoaderTimingHistory(): LoaderTimingEntry[] {
return loaderTimingHistory
}
// Register with devtools registry so router.ts doesn't need to dynamically import us
registerDevtoolsFunction('getLoaderTimingHistory', getLoaderTimingHistory)
const loaderState: Record<string, LoaderStateEntry> = {}
const subscribers = new Set<() => void>()
function updateState(path: string, updates: Partial<LoaderStateEntry>) {
loaderState[path] = { ...loaderState[path], ...updates }
subscribers.forEach((callback) => {
callback()
})
}
function subscribe(callback: () => void) {
subscribers.add(callback)
return () => subscribers.delete(callback)
}
function getLoaderState(path: string, preloadedData?: any): LoaderStateEntry {
if (!loaderState[path]) {
loaderState[path] = {
data: preloadedData,
error: undefined,
promise: undefined,
state: 'idle',
hasLoadedOnce: !!preloadedData,
}
}
return loaderState[path]
}
/**
* Imperatively refetch loader data for a given path.
*
* @param pathname - The route path to refetch (e.g., '/users/123')
* @returns Promise that resolves when refetch completes
* @link https://onestack.dev/docs/api/hooks/useLoaderState#refetchloader
*
* @example
* ```tsx
* await refetchLoader('/users/123')
* ```
*/
export async function refetchLoader(pathname: string): Promise<void> {
const startTime = performance.now()
updateState(pathname, {
state: 'loading',
error: null,
})
try {
const cacheBust = `${Date.now()}`
const loaderJSUrl = getLoaderPath(pathname, true, cacheBust)
const moduleLoadStart = performance.now()
const module = await dynamicImport(loaderJSUrl)
const moduleLoadTime = performance.now() - moduleLoadStart
const executionStart = performance.now()
const result = await module.loader()
const executionTime = performance.now() - executionStart
const totalTime = performance.now() - startTime
updateState(pathname, {
data: result,
state: 'idle',
timestamp: Date.now(),
hasLoadedOnce: true,
})
recordLoaderTiming({
path: pathname,
startTime,
moduleLoadTime,
executionTime,
totalTime,
source: 'refetch',
})
} catch (err) {
const totalTime = performance.now() - startTime
updateState(pathname, {
error: err,
state: 'idle',
})
recordLoaderTiming({
path: pathname,
startTime,
totalTime,
error: err instanceof Error ? err.message : String(err),
source: 'refetch',
})
throw err
}
}
// Expose refetchLoader globally for HMR in development
if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {
;(window as any).__oneRefetchLoader = refetchLoader
}
/**
* Access loader data with full state control including refetch capability.
* Use this when you need loading state or refetch; use `useLoader` for just data.
*
* @param loader - The loader function (optional - omit for just refetch/state)
* @returns Object with data, state ('idle' | 'loading'), and refetch function
* @link https://onestack.dev/docs/api/hooks/useLoaderState
*
* @example
* ```tsx
* const { data, state, refetch } = useLoaderState(loader)
*
* return (
* <div>
* {state === 'loading' && <Spinner />}
* <button onClick={refetch}>Refresh</button>
* <pre>{JSON.stringify(data)}</pre>
* </div>
* )
* ```
*/
export function useLoaderState<
Loader extends Function = any,
Returned = Loader extends (p: any) => any ? ReturnType<Loader> : unknown,
>(
loader?: Loader
): Loader extends undefined
? { refetch: () => Promise<void>; state: 'idle' | 'loading' }
: {
data: Returned extends Promise<any> ? Awaited<Returned> : Returned
refetch: () => Promise<void>
state: 'idle' | 'loading'
} {
const {
loaderProps: loaderPropsFromServerContext,
loaderData: loaderDataFromServerContext,
} = useServerContext() || {}
const params = useParams()
const pathname = usePathname()
// use just the pathname for matching, don't use resolveHref which adds params as query string
// (the pathname is already resolved like /docs/getting-started, not /docs/[slug])
const currentPath = pathname.replace(/\/index$/, '').replace(/\/$/, '') || '/'
// server-side only - typeof window check ensures this never runs on client
if (typeof window === 'undefined' && loader) {
const serverData = useAsyncFn(
loader,
loaderPropsFromServerContext || {
path: pathname,
params,
}
)
return { data: serverData, refetch: async () => {}, state: 'idle' } as any
}
// preloaded data from SSR/SSG - only use if server context path matches current path
const serverContextPath = loaderPropsFromServerContext?.path
const preloadedData =
serverContextPath === currentPath ? loaderDataFromServerContext : undefined
const loaderStateEntry = useSyncExternalStore(
subscribe,
() => getLoaderState(currentPath, preloadedData),
() => getLoaderState(currentPath, preloadedData)
)
const refetch = useCallback(() => refetchLoader(currentPath), [currentPath])
// no loader, just return state/refetch for the path
if (!loader) {
return {
refetch,
state: loaderStateEntry.state,
} as any
}
// start initial load if needed
if (
!loaderStateEntry.data &&
!loaderStateEntry.promise &&
!loaderStateEntry.hasLoadedOnce &&
loader
) {
// check for already-resolved preloaded data first (synchronous)
const resolvedPreloadData = preloadedLoaderData[currentPath]
if (resolvedPreloadData !== undefined) {
// Data was preloaded and already resolved - use it directly
delete preloadedLoaderData[currentPath]
delete preloadingLoader[currentPath]
loaderStateEntry.data = resolvedPreloadData
loaderStateEntry.hasLoadedOnce = true
} else if (preloadingLoader[currentPath]) {
// Preload is in progress - wait for it
const preloadPromise = preloadingLoader[currentPath]!
const promise = preloadPromise
.then((val: any) => {
delete preloadingLoader[currentPath]
delete preloadedLoaderData[currentPath]
updateState(currentPath, {
data: val,
hasLoadedOnce: true,
promise: undefined,
})
})
.catch((err: any) => {
console.error(`Error running loader()`, err)
delete preloadingLoader[currentPath]
updateState(currentPath, {
error: err,
promise: undefined,
})
})
loaderStateEntry.promise = promise
} else {
// initial load
const loadData = async () => {
const startTime = performance.now()
try {
if (process.env.TAMAGUI_TARGET === 'native') {
const loaderJSUrl = getLoaderPath(currentPath, true)
const nativeLoaderJSUrl = `${loaderJSUrl}?platform=ios`
try {
const moduleLoadStart = performance.now()
const loaderJsCodeResp = await fetch(nativeLoaderJSUrl)
if (!loaderJsCodeResp.ok) {
throw new Error(`Response not ok: ${loaderJsCodeResp.status}`)
}
const loaderJsCode = await loaderJsCodeResp.text()
// biome-ignore lint/security/noGlobalEval: we need eval for native
const result = eval(
`() => { var exports = {}; ${loaderJsCode}; return exports; }`
)()
const moduleLoadTime = performance.now() - moduleLoadStart
if (typeof result.loader !== 'function') {
throw new Error("Loader code isn't exporting a `loader` function")
}
const executionStart = performance.now()
const data = await result.loader()
const executionTime = performance.now() - executionStart
const totalTime = performance.now() - startTime
updateState(currentPath, {
data,
hasLoadedOnce: true,
promise: undefined,
})
recordLoaderTiming({
path: currentPath,
startTime,
moduleLoadTime,
executionTime,
totalTime,
source: 'initial',
})
return
} catch (e) {
const totalTime = performance.now() - startTime
updateState(currentPath, {
data: {},
promise: undefined,
})
recordLoaderTiming({
path: currentPath,
startTime,
totalTime,
error: e instanceof Error ? e.message : String(e),
source: 'initial',
})
return
}
}
// web platform
const loaderJSUrl = getLoaderPath(currentPath, true)
const moduleLoadStart = performance.now()
const module = await dynamicImport(loaderJSUrl)
const moduleLoadTime = performance.now() - moduleLoadStart
const executionStart = performance.now()
const result = await module.loader()
const executionTime = performance.now() - executionStart
const totalTime = performance.now() - startTime
updateState(currentPath, {
data: result,
hasLoadedOnce: true,
promise: undefined,
})
recordLoaderTiming({
path: currentPath,
startTime,
moduleLoadTime,
executionTime,
totalTime,
source: 'initial',
})
} catch (err) {
const totalTime = performance.now() - startTime
updateState(currentPath, {
error: err,
promise: undefined,
})
recordLoaderTiming({
path: currentPath,
startTime,
totalTime,
error: err instanceof Error ? err.message : String(err),
source: 'initial',
})
}
}
const promise = loadData()
loaderStateEntry.promise = promise
}
}
// handle errors and suspension
if (loader) {
// only throw error on initial load
if (loaderStateEntry.error && !loaderStateEntry.hasLoadedOnce) {
throw loaderStateEntry.error
}
// only throw promise for suspension on initial load
if (
loaderStateEntry.data === undefined &&
loaderStateEntry.promise &&
!loaderStateEntry.hasLoadedOnce
) {
throw loaderStateEntry.promise
}
return {
data: loaderStateEntry.data,
refetch,
state: loaderStateEntry.state,
} as any
} else {
return {
refetch,
state: loaderStateEntry.state,
} as any
}
}
/**
* Load route data with SSR/SSG support. Returns the loader's data directly.
* For loading state and refetch capability, use `useLoaderState` instead.
*
* @param loader - The loader function exported from the route file
* @returns The awaited return value of your loader function
* @link https://onestack.dev/docs/api/hooks/useLoader
*
* @example
* ```tsx
* export async function loader({ params }) {
* return { user: await fetchUser(params.id) }
* }
*
* export default function UserPage() {
* const { user } = useLoader(loader)
* return <div>{user.name}</div>
* }
* ```
*/
export function useLoader<
Loader extends Function,
Returned = Loader extends (p: any) => any ? ReturnType<Loader> : unknown,
>(loader: Loader): Returned extends Promise<any> ? Awaited<Returned> : Returned {
const { data } = useLoaderState(loader)
return data
}
const results = new Map()
const started = new Map()
function useAsyncFn(val: any, props?: any) {
const key = (val ? weakKey(val) : '') + JSON.stringify(props)
if (val) {
if (!started.get(key)) {
started.set(key, true)
let next = val(props)
if (next instanceof Promise) {
next = next
.then((final) => {
results.set(key, final)
})
.catch((err) => {
console.error(`Error running loader()`, err)
results.set(key, undefined)
})
}
results.set(key, next)
}
}
const current = results.get(key)
if (current instanceof Promise) {
throw current
}
return current
}