From 2a451f297e02acb8dc3d2f492bf3b25e43443b5f Mon Sep 17 00:00:00 2001 From: Ofer Sadgat Date: Tue, 24 Feb 2026 20:04:46 -0800 Subject: [PATCH] Update useLoader to return the data from the passed in loader --- packages/one/src/useLoader.ts | 40 +++++++++++++ packages/one/src/useMatches.ts | 13 ++++- packages/one/src/vite/constants.ts | 4 ++ .../plugins/clientTreeShakePlugin.test.ts | 58 +++++++++++++++++++ .../src/vite/plugins/clientTreeShakePlugin.ts | 10 +++- 5 files changed, 121 insertions(+), 4 deletions(-) diff --git a/packages/one/src/useLoader.ts b/packages/one/src/useLoader.ts index 543d0f57ae..e521b6c64e 100644 --- a/packages/one/src/useLoader.ts +++ b/packages/one/src/useLoader.ts @@ -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' @@ -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 { + 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. @@ -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 = diff --git a/packages/one/src/useMatches.ts b/packages/one/src/useMatches.ts index 091ce95dc5..31ade83043 100644 --- a/packages/one/src/useMatches.ts +++ b/packages/one/src/useMatches.ts @@ -16,7 +16,7 @@ function subscribeToClientMatches(callback: () => void) { return () => clientMatchesListeners.delete(callback) } -function getClientMatchesSnapshot() { +export function getClientMatchesSnapshot() { return clientMatches } @@ -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. diff --git a/packages/one/src/vite/constants.ts b/packages/one/src/vite/constants.ts index 6c415a7c79..311c0dd9c3 100644 --- a/packages/one/src/vite/constants.ts +++ b/packages/one/src/vite/constants.ts @@ -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_` diff --git a/packages/one/src/vite/plugins/clientTreeShakePlugin.test.ts b/packages/one/src/vite/plugins/clientTreeShakePlugin.test.ts index faee27a7dc..0781820641 100644 --- a/packages/one/src/vite/plugins/clientTreeShakePlugin.test.ts +++ b/packages/one/src/vite/plugins/clientTreeShakePlugin.test.ts @@ -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' diff --git a/packages/one/src/vite/plugins/clientTreeShakePlugin.ts b/packages/one/src/vite/plugins/clientTreeShakePlugin.ts index 5d76a3e447..e4e39037d8 100644 --- a/packages/one/src/vite/plugins/clientTreeShakePlugin.ts +++ b/packages/one/src/vite/plugins/clientTreeShakePlugin.ts @@ -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'] || @@ -74,7 +74,7 @@ export const clientTreeShakePlugin = (): Plugin => { return } - const out = await transformTreeShakeClient(code, id) + const out = await transformTreeShakeClient(code, id, process.cwd()) return out }, @@ -82,7 +82,7 @@ export const clientTreeShakePlugin = (): Plugin => { } 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 } @@ -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 }