Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/one/src/useLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useParams, usePathname } from './hooks'
import { findNearestNotFoundRoute, setNotFoundState } from './notFoundState'
import { router } from './router/imperative-api'
import { preloadedLoaderData, preloadingLoader, routeNode } from './router/router'
import { getClientMatchesSnapshot, updateMatchLoaderData } from './useMatches'
import { getLoaderPath } from './utils/cleanUrl'
import { dynamicImport } from './utils/dynamicImport'
import { weakKey } from './utils/weakKey'
Expand Down Expand Up @@ -225,6 +226,28 @@ if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {
;(window as any).__oneRefetchLoader = refetchLoader
}

/**
* Refetch the loader data for a specific match (identified by routeId) by fetching
* the current page's loader JS and updating only that match entry in clientMatches.
*
* Note: the server's loader JS endpoint runs the *page* loader, so for layout
* routeIds this fetches fresh page data and stores it on the layout match. A
* dedicated per-layout refetch endpoint would be needed to truly re-run a layout
* loader in isolation; that can be added in a follow-up.
*/
export async function refetchMatchLoader(routeId: string, currentPath: string): Promise<void> {
const cacheBust = `${Date.now()}`
const loaderJSUrl = getLoaderPath(currentPath, true, cacheBust)

const module = await dynamicImport(loaderJSUrl)?.catch(() => null)
if (!module?.loader) return

const result = await module.loader()
if (result?.__oneRedirect || result?.__oneError) return

updateMatchLoaderData(routeId, result)
}

/**
* Access loader data with full state control including refetch capability.
* Use this when you need loading state or refetch; use `useLoader` for just data.
Expand Down Expand Up @@ -281,6 +304,23 @@ export function useLoaderState<
return { data: serverData, refetch: async () => {}, state: 'idle' } as any
}

// client-side fast path: if the loader stub returns a routeId string (set by
// clientTreeShakePlugin), look up the data directly from the matches array
// instead of fetching the loader JS file. This is how layout loaders are accessed.
if (loader) {
const loaderResult = loader()
if (typeof loaderResult === 'string' && loaderResult.startsWith('./')) {
const routeId = loaderResult
const matches = getClientMatchesSnapshot()
const match = matches.find((m) => m.routeId === routeId)
const refetch = useCallback(
() => refetchMatchLoader(routeId, currentPath),
[routeId, currentPath]
)
return { data: match?.loaderData, refetch, state: 'idle' } as any
}
}

// preloaded data from SSR/SSG - only use if server context path matches current path
const serverContextPath = loaderPropsFromServerContext?.path
const preloadedData =
Expand Down
13 changes: 12 additions & 1 deletion packages/one/src/useMatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function subscribeToClientMatches(callback: () => void) {
return () => clientMatchesListeners.delete(callback)
}

function getClientMatchesSnapshot() {
export function getClientMatchesSnapshot() {
return clientMatches
}

Expand All @@ -32,6 +32,17 @@ export function setClientMatches(matches: RouteMatch[]) {
}
}

/**
* Update the loaderData for a single match by routeId, leaving all other matches intact.
* @internal
*/
export function updateMatchLoaderData(routeId: string, loaderData: unknown) {
clientMatches = clientMatches.map((m) => (m.routeId === routeId ? { ...m, loaderData } : m))
for (const listener of clientMatchesListeners) {
listener()
}
}

/**
* Returns an array of all matched routes from root to the current page.
* Each match contains the route's loader data, params, and route ID.
Expand Down
4 changes: 4 additions & 0 deletions packages/one/src/vite/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export const EMPTY_LOADER_STRING = `export function loader() {return "__vxrn__loader__"};`

export function makeLoaderRouteIdStub(routeId: string): string {
return `export function loader() {return ${JSON.stringify(routeId)}};`
}

export const LoaderDataCache = {}

export const SERVER_CONTEXT_POST_RENDER_STRING = `_one_post_render_data_`
58 changes: 58 additions & 0 deletions packages/one/src/vite/plugins/clientTreeShakePlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,64 @@ export default function Page() {
expect(result).toBeUndefined()
})

it('should embed routeId in loader stub when root is provided', async () => {
const code = `
import { serverOnlyModule } from 'server-only-pkg'
import { useLoader } from 'one'

export async function loader() {
return serverOnlyModule()
}

export default function Layout() {
const data = useLoader(loader)
return data
}
`
const result = await transformTreeShakeClient(code, '/project/app/_layout.tsx', '/project')
expect(result).toBeDefined()
expect(result!.code).toContain('export function loader() {return "./app/_layout.tsx"}')
expect(result!.code).not.toContain('__vxrn__loader__')
})

it('should embed routeId with render mode suffix in loader stub', async () => {
const code = `
import { serverOnlyModule } from 'server-only-pkg'
import { useLoader } from 'one'

export async function loader() {
return serverOnlyModule()
}

export default function Layout() {
const data = useLoader(loader)
return data
}
`
const result = await transformTreeShakeClient(code, '/project/app/user+ssr.tsx', '/project')
expect(result).toBeDefined()
expect(result!.code).toContain('export function loader() {return "./app/user+ssr.tsx"}')
})

it('should fall back to __vxrn__loader__ stub when no root provided', async () => {
const code = `
import { serverOnlyModule } from 'server-only-pkg'
import { useLoader } from 'one'

export async function loader() {
return serverOnlyModule()
}

export default function Page() {
const data = useLoader(loader)
return data
}
`
const result = await transformTreeShakeClient(code, '/app/index.tsx')
expect(result).toBeDefined()
expect(result!.code).toContain('__vxrn__loader__')
})

it('should preserve type-only imports during tree shaking', async () => {
const code = `
import type { SomeType } from 'types-pkg'
Expand Down
10 changes: 7 additions & 3 deletions packages/one/src/vite/plugins/clientTreeShakePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
findReferencedIdentifiers,
} from 'babel-dead-code-elimination'
import type { Plugin } from 'vite'
import { EMPTY_LOADER_STRING } from '../constants'
import { EMPTY_LOADER_STRING, makeLoaderRouteIdStub } from '../constants'

const traverse = (BabelTraverse['default'] || BabelTraverse) as typeof BabelTraverse
const generate = (BabelGenerate['default'] ||
Expand Down Expand Up @@ -74,15 +74,15 @@ export const clientTreeShakePlugin = (): Plugin => {
return
}

const out = await transformTreeShakeClient(code, id)
const out = await transformTreeShakeClient(code, id, process.cwd())

return out
},
},
} satisfies Plugin
}

export async function transformTreeShakeClient(code: string, id: string) {
export async function transformTreeShakeClient(code: string, id: string, root?: string) {
if (!/generateStaticParams|loader/.test(code)) {
return
}
Expand Down Expand Up @@ -188,6 +188,10 @@ export async function transformTreeShakeClient(code: string, id: string) {
removedFunctions
.map((key) => {
if (key === 'loader') {
if (root) {
const routeId = './' + relative(root, id).replace(/\\/g, '/')
return makeLoaderRouteIdStub(routeId)
}
return EMPTY_LOADER_STRING
}

Expand Down
Loading