diff --git a/.changeset/split-object-form-loader.md b/.changeset/split-object-form-loader.md
new file mode 100644
index 00000000000..69793cca0fe
--- /dev/null
+++ b/.changeset/split-object-form-loader.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/router-plugin': patch
+---
+
+Split only the `handler` of a loader in object form (`loader: { handler, staleReloadMode }`) when `loader` is code-split, and keep its other options on the route. Before, the split chunk failed with `Unexpected splitNode type ☝️: ObjectExpression`.
diff --git a/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md b/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md
index b3016930784..30f7e1c5659 100644
--- a/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md
+++ b/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md
@@ -870,6 +870,12 @@ Components use `lazyRouteComponent` (framework-provided). In React this
integrates with Suspense-compatible lazy loading. Loaders use `lazyFn` which
wraps the async function for deferred loading.
+A loader in object form (`{ handler, staleReloadMode }`) splits only its
+`handler`: the router reads the other keys from the route options, so they
+stay in the reference file. The loader stays whole in the reference file when
+a spread or computed key after `handler` may replace it, when there is no
+plain `handler`, or when a `handler() {}` method uses `super`.
+
### 11. Source Maps Are Preserved
All three compilers pass `sourceMaps: true` and `sourceFileName` to
diff --git a/packages/router-plugin/src/core/code-splitter/compilers.ts b/packages/router-plugin/src/core/code-splitter/compilers.ts
index 89cdae41add..172c44657b8 100644
--- a/packages/router-plugin/src/core/code-splitter/compilers.ts
+++ b/packages/router-plugin/src/core/code-splitter/compilers.ts
@@ -139,6 +139,88 @@ const allCreateRouteFns = [
...unsplittableCreateRouteFns,
]
+/**
+ * A loader can be an object, `{ handler, staleReloadMode }`. The router reads
+ * its other keys from the route options, so only `handler` is split. Returns
+ * the property to split, or `undefined` to keep the whole loader in the route
+ * file. A `handler() {}` method is replaced by a property holding the same
+ * function, so it splits like any other value.
+ */
+function getLoaderHandler(
+ loader: t.ObjectExpression,
+): t.ObjectProperty | undefined {
+ // The last `handler` wins, unless a spread or computed key after it may
+ // replace it
+ for (let index = loader.properties.length - 1; index >= 0; index--) {
+ const prop = loader.properties[index]!
+ if (t.isSpreadElement(prop) || prop.computed) {
+ return undefined
+ }
+ if (getObjectPropertyKeyName(prop) !== 'handler') {
+ continue
+ }
+ if (t.isObjectProperty(prop)) {
+ const value = prop.value
+ const isFallback =
+ t.isBooleanLiteral(value) ||
+ t.isNullLiteral(value) ||
+ (t.isIdentifier(value) && value.name === 'undefined')
+ return isFallback ? undefined : prop
+ }
+ // `super` is only valid in a method
+ if (prop.kind !== 'method' || containsSuper(prop.body)) {
+ return undefined
+ }
+ const fn = t.functionExpression(
+ null,
+ prop.params,
+ prop.body,
+ prop.generator,
+ prop.async,
+ )
+ fn.returnType = prop.returnType
+ fn.typeParameters = prop.typeParameters
+ const handler = t.objectProperty(t.identifier('handler'), fn)
+ loader.properties[index] = handler
+ return handler
+ }
+ return undefined
+}
+
+function containsSuper(node: t.Node) {
+ let found = false
+ t.traverseFast(node, (child) => {
+ if (t.isSuper(child)) found = true
+ })
+ return found
+}
+
+/**
+ * The parts of a route option and the group each one belongs to (-1 for the
+ * route file): the whole value, or, for a split loader in object form, its
+ * handler and the options that stay in the route file.
+ */
+function getRouteOptionParts(
+ key: string,
+ value: t.Node,
+ groupIndex: number,
+): Array<[node: t.Node, groupIndex: number]> {
+ if (key !== 'loader' || groupIndex === -1 || !t.isObjectExpression(value)) {
+ return [[value, groupIndex]]
+ }
+ const handler = getLoaderHandler(value)
+ if (!handler) {
+ return [[value, -1]]
+ }
+ const options = value.properties.filter((prop) => prop !== handler)
+ return options.length > 0
+ ? [
+ [handler.value, groupIndex],
+ [t.objectExpression(options), -1],
+ ]
+ : [[handler.value, groupIndex]]
+}
+
/**
* Computes module-level bindings that are shared between split and non-split
* route properties. These bindings need to be extracted into a shared virtual
@@ -211,11 +293,17 @@ export function computeSharedBindings(opts: {
if (!key) continue
if (key === 'codeSplitGroupings') continue
if (t.isIdentifier(prop.value) && prop.value.name === 'undefined') continue
- const groupIndex = findIndexForSplitNode(key) // -1 if non-split
- if (groupIndex === -1) {
- hasNonSplit = true
- } else {
- splitGroupsPresent.add(groupIndex)
+ const parts = getRouteOptionParts(
+ key,
+ prop.value,
+ findIndexForSplitNode(key), // -1 if non-split
+ )
+ for (const [, groupIndex] of parts) {
+ if (groupIndex === -1) {
+ hasNonSplit = true
+ } else {
+ splitGroupsPresent.add(groupIndex)
+ }
}
}
@@ -250,25 +338,31 @@ export function computeSharedBindings(opts: {
if (key === 'codeSplitGroupings') continue
- const groupIndex = findIndexForSplitNode(key) // -1 if non-split
-
- const directRefs = collectModuleLevelRefsFromNode(
+ const parts = getRouteOptionParts(
+ key,
prop.value,
- localModuleLevelBindings,
+ findIndexForSplitNode(key), // -1 if non-split
)
- // Expand transitively: if component references SharedComp which references
- // `shared`, then `shared` is also attributed to component's group.
- const allRefs = new Set(directRefs)
- expandTransitively(allRefs, depGraph)
+ for (const [node, groupIndex] of parts) {
+ const directRefs = collectModuleLevelRefsFromNode(
+ node,
+ localModuleLevelBindings,
+ )
+
+ // Expand transitively: if component references SharedComp which references
+ // `shared`, then `shared` is also attributed to component's group.
+ const allRefs = new Set(directRefs)
+ expandTransitively(allRefs, depGraph)
- for (const ref of allRefs) {
- let groups = refsByGroup.get(ref)
- if (!groups) {
- groups = new Set()
- refsByGroup.set(ref, groups)
+ for (const ref of allRefs) {
+ let groups = refsByGroup.get(ref)
+ if (!groups) {
+ groups = new Set()
+ refsByGroup.set(ref, groups)
+ }
+ groups.add(groupIndex)
}
- groups.add(groupIndex)
}
}
@@ -667,7 +761,15 @@ export function compileCodeSplitReferenceRoute(
addRouteHmr(insertionPath, routeOptions)
} else {
// if (splitNodeMeta.splitStrategy === 'lazyFn') {
- const value = prop.value
+ // A loader in object form splits only its handler
+ const target =
+ key === 'loader' && t.isObjectExpression(prop.value)
+ ? getLoaderHandler(prop.value)
+ : prop
+ if (!target) {
+ return
+ }
+ const value = target.value
let shouldSplit = true
@@ -725,7 +827,7 @@ export function compileCodeSplitReferenceRoute(
}
// Add the lazyFn call with the dynamic import to the prop value
- prop.value = template.expression(
+ target.value = template.expression(
`${LAZY_FN_IDENT}(${splitNodeMeta.localImporterIdent}, '${splitNodeMeta.exporterIdent}')`,
)()
}
@@ -932,7 +1034,19 @@ export function compileCodeSplitVirtualRoute(
return
}
- const value = prop.value
+ let value = prop.value
+
+ // A loader in object form splits only its handler
+ if (
+ splitType === 'loader' &&
+ t.isObjectExpression(value)
+ ) {
+ const handler = getLoaderHandler(value)
+ if (!handler) {
+ return
+ }
+ value = handler.value
+ }
// If the value for the `key` is `undefined`, then we don't need to include it
// in the split file, so we can just return, since it will kept in-place in the
@@ -957,7 +1071,7 @@ export function compileCodeSplitVirtualRoute(
} else {
const meta = SPLIT_NODES_CONFIG.get(splitType)!
trackedNodesToSplitByType[splitType] = {
- node: prop.value,
+ node: value,
meta,
}
}
diff --git a/packages/router-plugin/src/core/utils.ts b/packages/router-plugin/src/core/utils.ts
index 61ab1ca278f..3e520a4a7ad 100644
--- a/packages/router-plugin/src/core/utils.ts
+++ b/packages/router-plugin/src/core/utils.ts
@@ -23,7 +23,7 @@ export const routeFactoryCallCodeFilter = [
]
export function getObjectPropertyKeyName(
- prop: t.ObjectProperty,
+ prop: t.ObjectProperty | t.ObjectMethod,
): string | undefined {
if (prop.computed) {
return undefined
diff --git a/packages/router-plugin/tests/code-splitter-route-options.test.ts b/packages/router-plugin/tests/code-splitter-route-options.test.ts
index 84d24392d15..a1d782a12e9 100644
--- a/packages/router-plugin/tests/code-splitter-route-options.test.ts
+++ b/packages/router-plugin/tests/code-splitter-route-options.test.ts
@@ -1,12 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
+ buildAndRun,
compileRouteModules,
declarationOf,
expectValidModules,
+ exportedNames,
head,
importSources,
importedNames,
} from './regression-helpers'
+import type { CodeSplitGroupings } from '../src/core/constants'
describe('route options', () => {
it('are split when declared in a variable', async () => {
@@ -114,3 +117,114 @@ export const Route = createFileRoute('/')({
await expectValidModules(modules)
})
})
+
+describe('a loader in object form', () => {
+ const groupings: CodeSplitGroupings = [['loader'], ['component']]
+
+ /** Loads `/` with a real router and returns each match's loader data. */
+ const routerEntry = `import { createMemoryHistory, createRouter } from '@tanstack/react-router'
+import { routeTree } from './routeTree.gen'
+export { Route } from './routes/index'
+export async function load() {
+ const router = createRouter({
+ routeTree,
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ await router.load()
+ return router.state.matches.map((match) => match.loaderData ?? null)
+}`
+
+ it.each([
+ { name: 'an arrow handler', handler: `handler: async () => 'loaded'` },
+ {
+ name: 'a method handler',
+ handler: `async handler() {
+ return 'loaded'
+ }`,
+ },
+ {
+ name: 'an imported handler',
+ imports: `import { load } from '../load'\n`,
+ handler: `handler: load`,
+ },
+ ])(
+ 'loads route data and keeps its options with $name split',
+ async ({ imports = '', handler }) => {
+ const result = await buildAndRun(
+ `${head}${imports}export const Route = createFileRoute('/')({
+ loader: {
+ staleReloadMode: 'blocking',
+ ${handler},
+ },
+ component: () =>
index
,
+})`,
+ `return [await entry.load(), entry.Route.options.loader.staleReloadMode]`,
+ {
+ files: {
+ 'entry.ts': routerEntry,
+ 'load.ts': `export const load = async () => 'loaded'`,
+ },
+ groupings,
+ },
+ )
+ expect(result).toEqual([[null, 'loaded'], 'blocking'])
+ },
+ 30_000,
+ )
+
+ it.each([
+ {
+ name: 'a spread after the handler',
+ loader: `{ handler: () => 1, ...defaults }`,
+ },
+ {
+ name: 'a computed key after the handler',
+ loader: `{ handler: () => 1, [key]: () => 2 }`,
+ },
+ { name: 'no handler', loader: `{ ...defaults }` },
+ {
+ name: 'a getter handler',
+ loader: `{ get handler() { return () => 1 } }`,
+ },
+ {
+ name: 'a method handler that uses super',
+ loader: `{ handler() { return super.valueOf() } }`,
+ },
+ ])('stays in the route file with $name', async ({ loader }) => {
+ const { modules } = compileRouteModules(
+ `${head}import { defaults, key } from './defaults'
+export const Route = createFileRoute('/')({
+ loader: ${loader},
+ component: () => index
,
+})
+`,
+ { groupings },
+ )
+ expect(modules.reference).not.toContain('tsr-split=loader')
+ expect(exportedNames(modules['virtual loader']!)).toEqual([])
+ await expectValidModules(modules)
+ })
+
+ it('shares what its other options and a split option in the same chunk read', async () => {
+ const { modules, sharedBindings } = compileRouteModules(
+ `${head}const settings = { mode: 'blocking' as const }
+export const Route = createFileRoute('/')({
+ loader: {
+ staleReloadMode: settings.mode,
+ handler: () => settings,
+ },
+ component: () => index
,
+})
+`,
+ { groupings: [['loader', 'component']] },
+ )
+ // The handler moves to the chunk while `staleReloadMode` stays in the
+ // route file, so both read one `settings` object from the shared module.
+ expect(sharedBindings).toEqual(['settings'])
+ expect(modules.reference).not.toMatch(declarationOf('settings'))
+ expect(modules['virtual loader-component']).not.toMatch(
+ declarationOf('settings'),
+ )
+ await expectValidModules(modules)
+ })
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow.tsx
new file mode 100644
index 00000000000..0fc05a913c5
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow.tsx
@@ -0,0 +1,12 @@
+const $$splitComponentImporter = () =>
+ import('loader-object-arrow.tsx?tsr-split=component')
+import { lazyRouteComponent } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+import { fetchPost } from '../posts'
+export const Route = createFileRoute('/posts/$postId')({
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: async ({ params }) => fetchPost(params.postId),
+ },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@component.tsx
new file mode 100644
index 00000000000..3dfd8f4ebb6
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@component.tsx
@@ -0,0 +1,6 @@
+import { Route } from 'loader-object-arrow.tsx'
+function PostComponent() {
+ const post = Route.useLoaderData()
+ return {post.title}
+}
+export { PostComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@errorComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@notFoundComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-arrow@shared.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported.tsx
new file mode 100644
index 00000000000..06ed30141e8
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported.tsx
@@ -0,0 +1,9 @@
+const $$splitComponentImporter = () =>
+ import('loader-object-imported.tsx?tsr-split=component')
+import { lazyRouteComponent } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+import { loadPosts } from '../posts'
+export const Route = createFileRoute('/posts')({
+ loader: { handler: loadPosts, staleReloadMode: 'background' },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@component.tsx
new file mode 100644
index 00000000000..671675707b4
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@component.tsx
@@ -0,0 +1,2 @@
+const SplitComponent = () => posts
+export { SplitComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@errorComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@notFoundComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-imported@shared.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method.tsx
new file mode 100644
index 00000000000..a7219ece62c
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method.tsx
@@ -0,0 +1,16 @@
+const $$splitComponentImporter = () =>
+ import('loader-object-method.tsx?tsr-split=component')
+import { lazyRouteComponent } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+import { fetchPosts } from '../posts'
+const reloadMode = 'blocking'
+export const Route = createFileRoute('/posts')({
+ loader: {
+ staleReloadMode: reloadMode,
+ async handler({ abortController }) {
+ const posts = await fetchPosts({ signal: abortController.signal })
+ return { posts, reloadMode }
+ },
+ },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@component.tsx
new file mode 100644
index 00000000000..671675707b4
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@component.tsx
@@ -0,0 +1,2 @@
+const SplitComponent = () => posts
+export { SplitComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@errorComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@notFoundComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/loader-object-method@shared.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow.tsx
new file mode 100644
index 00000000000..9759abc7e2d
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow.tsx
@@ -0,0 +1,14 @@
+const $$splitComponentImporter = () =>
+ import('loader-object-arrow.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent')
+import { lazyRouteComponent } from '@tanstack/react-router'
+const $$splitLoaderImporter = () =>
+ import('loader-object-arrow.tsx?tsr-split=loader')
+import { lazyFn } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/posts/$postId')({
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: lazyFn($$splitLoaderImporter, 'loader'),
+ },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow@component---errorComponent---notFoundComponent---pendingComponent.tsx
new file mode 100644
index 00000000000..3dfd8f4ebb6
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow@component---errorComponent---notFoundComponent---pendingComponent.tsx
@@ -0,0 +1,6 @@
+import { Route } from 'loader-object-arrow.tsx'
+function PostComponent() {
+ const post = Route.useLoaderData()
+ return {post.title}
+}
+export { PostComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow@loader.tsx
new file mode 100644
index 00000000000..e39398f8b0e
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow@loader.tsx
@@ -0,0 +1,3 @@
+import { fetchPost } from '../posts'
+const SplitLoader = async ({ params }) => fetchPost(params.postId)
+export { SplitLoader as loader }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-arrow@shared.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported.tsx
new file mode 100644
index 00000000000..91de54e6684
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported.tsx
@@ -0,0 +1,14 @@
+const $$splitComponentImporter = () =>
+ import('loader-object-imported.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent')
+import { lazyRouteComponent } from '@tanstack/react-router'
+const $$splitLoaderImporter = () =>
+ import('loader-object-imported.tsx?tsr-split=loader')
+import { lazyFn } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/posts')({
+ loader: {
+ handler: lazyFn($$splitLoaderImporter, 'loader'),
+ staleReloadMode: 'background',
+ },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported@component---errorComponent---notFoundComponent---pendingComponent.tsx
new file mode 100644
index 00000000000..671675707b4
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported@component---errorComponent---notFoundComponent---pendingComponent.tsx
@@ -0,0 +1,2 @@
+const SplitComponent = () => posts
+export { SplitComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported@loader.tsx
new file mode 100644
index 00000000000..7d2bcc4aa00
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported@loader.tsx
@@ -0,0 +1,3 @@
+import { loadPosts } from '../posts'
+const SplitLoader = loadPosts
+export { SplitLoader as loader }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-imported@shared.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method.tsx
new file mode 100644
index 00000000000..da5a0e034df
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method.tsx
@@ -0,0 +1,15 @@
+import { reloadMode } from 'loader-object-method.tsx?tsr-shared=1'
+const $$splitComponentImporter = () =>
+ import('loader-object-method.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent')
+import { lazyRouteComponent } from '@tanstack/react-router'
+const $$splitLoaderImporter = () =>
+ import('loader-object-method.tsx?tsr-split=loader')
+import { lazyFn } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/posts')({
+ loader: {
+ staleReloadMode: reloadMode,
+ handler: lazyFn($$splitLoaderImporter, 'loader'),
+ },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@component---errorComponent---notFoundComponent---pendingComponent.tsx
new file mode 100644
index 00000000000..671675707b4
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@component---errorComponent---notFoundComponent---pendingComponent.tsx
@@ -0,0 +1,2 @@
+const SplitComponent = () => posts
+export { SplitComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@loader.tsx
new file mode 100644
index 00000000000..1dcdae71930
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@loader.tsx
@@ -0,0 +1,7 @@
+import { reloadMode } from 'loader-object-method.tsx?tsr-shared=1'
+import { fetchPosts } from '../posts'
+const SplitLoader = async function ({ abortController }) {
+ const posts = await fetchPosts({ signal: abortController.signal })
+ return { posts, reloadMode }
+}
+export { SplitLoader as loader }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@shared.tsx
new file mode 100644
index 00000000000..f9988bbf09a
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/loader-object-method@shared.tsx
@@ -0,0 +1,2 @@
+const reloadMode = 'blocking'
+export { reloadMode }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow.tsx
new file mode 100644
index 00000000000..8a4a2423613
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow.tsx
@@ -0,0 +1,14 @@
+const $$splitComponentImporter = () =>
+ import('loader-object-arrow.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent')
+import { lazyRouteComponent } from '@tanstack/react-router'
+const $$splitLoaderImporter = () =>
+ import('loader-object-arrow.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent')
+import { lazyFn } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/posts/$postId')({
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: lazyFn($$splitLoaderImporter, 'loader'),
+ },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow@component---loader---notFoundComponent---pendingComponent.tsx
new file mode 100644
index 00000000000..16c1f9dfa86
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow@component---loader---notFoundComponent---pendingComponent.tsx
@@ -0,0 +1,9 @@
+import { fetchPost } from '../posts'
+import { Route } from 'loader-object-arrow.tsx'
+function PostComponent() {
+ const post = Route.useLoaderData()
+ return {post.title}
+}
+const SplitLoader = async ({ params }) => fetchPost(params.postId)
+export { SplitLoader as loader }
+export { PostComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow@errorComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-arrow@shared.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported.tsx
new file mode 100644
index 00000000000..be42a03d382
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported.tsx
@@ -0,0 +1,14 @@
+const $$splitComponentImporter = () =>
+ import('loader-object-imported.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent')
+import { lazyRouteComponent } from '@tanstack/react-router'
+const $$splitLoaderImporter = () =>
+ import('loader-object-imported.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent')
+import { lazyFn } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/posts')({
+ loader: {
+ handler: lazyFn($$splitLoaderImporter, 'loader'),
+ staleReloadMode: 'background',
+ },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported@component---loader---notFoundComponent---pendingComponent.tsx
new file mode 100644
index 00000000000..f1380d7e759
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported@component---loader---notFoundComponent---pendingComponent.tsx
@@ -0,0 +1,5 @@
+import { loadPosts } from '../posts'
+const SplitLoader = loadPosts
+export { SplitLoader as loader }
+const SplitComponent = () => posts
+export { SplitComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported@errorComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-imported@shared.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method.tsx
new file mode 100644
index 00000000000..433c5ca006f
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method.tsx
@@ -0,0 +1,15 @@
+import { reloadMode } from 'loader-object-method.tsx?tsr-shared=1'
+const $$splitComponentImporter = () =>
+ import('loader-object-method.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent')
+import { lazyRouteComponent } from '@tanstack/react-router'
+const $$splitLoaderImporter = () =>
+ import('loader-object-method.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent')
+import { lazyFn } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
+export const Route = createFileRoute('/posts')({
+ loader: {
+ staleReloadMode: reloadMode,
+ handler: lazyFn($$splitLoaderImporter, 'loader'),
+ },
+ component: lazyRouteComponent($$splitComponentImporter, 'component'),
+})
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method@component---loader---notFoundComponent---pendingComponent.tsx
new file mode 100644
index 00000000000..df9d32640c3
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method@component---loader---notFoundComponent---pendingComponent.tsx
@@ -0,0 +1,9 @@
+import { reloadMode } from 'loader-object-method.tsx?tsr-shared=1'
+import { fetchPosts } from '../posts'
+const SplitLoader = async function ({ abortController }) {
+ const posts = await fetchPosts({ signal: abortController.signal })
+ return { posts, reloadMode }
+}
+export { SplitLoader as loader }
+const SplitComponent = () => posts
+export { SplitComponent as component }
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method@errorComponent.tsx
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method@shared.tsx
new file mode 100644
index 00000000000..f9988bbf09a
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/loader-object-method@shared.tsx
@@ -0,0 +1,2 @@
+const reloadMode = 'blocking'
+export { reloadMode }
diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-arrow.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-arrow.tsx
new file mode 100644
index 00000000000..893d390d067
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-arrow.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { fetchPost } from '../posts'
+
+export const Route = createFileRoute('/posts/$postId')({
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: async ({ params }) => fetchPost(params.postId),
+ },
+ component: PostComponent,
+})
+
+function PostComponent() {
+ const post = Route.useLoaderData()
+ return {post.title}
+}
diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-imported.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-imported.tsx
new file mode 100644
index 00000000000..7099264d944
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-imported.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { loadPosts } from '../posts'
+
+export const Route = createFileRoute('/posts')({
+ loader: { handler: loadPosts, staleReloadMode: 'background' },
+ component: () => posts
,
+})
diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-method.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-method.tsx
new file mode 100644
index 00000000000..a185368dab3
--- /dev/null
+++ b/packages/router-plugin/tests/code-splitter/test-files/react/loader-object-method.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { fetchPosts } from '../posts'
+
+const reloadMode = 'blocking'
+
+export const Route = createFileRoute('/posts')({
+ loader: {
+ staleReloadMode: reloadMode,
+ async handler({ abortController }) {
+ const posts = await fetchPosts({ signal: abortController.signal })
+ return { posts, reloadMode }
+ },
+ },
+ component: () => posts
,
+})
diff --git a/packages/router-plugin/tests/regression-helpers.ts b/packages/router-plugin/tests/regression-helpers.ts
index 3cca5f6cff7..10c217e2c0a 100644
--- a/packages/router-plugin/tests/regression-helpers.ts
+++ b/packages/router-plugin/tests/regression-helpers.ts
@@ -33,9 +33,9 @@ export { declarationOf } from './validate-module'
export const head = `import { createFileRoute } from '@tanstack/react-router'\n`
/**
- * Compiles a route file with the default groupings into every module the code
- * splitter emits for it: `reference`, one `virtual ` chunk per grouping
- * and, when bindings are shared, `shared`.
+ * Compiles a route file (with the default groupings unless `groupings` is
+ * given) into every module the code splitter emits for it: `reference`, one
+ * `virtual ` chunk per grouping and, when bindings are shared, `shared`.
*/
export function compileRouteModules(
code: string,
@@ -44,14 +44,15 @@ export function compileRouteModules(
targetFramework?: 'react' | 'solid'
/** Compile with route HMR and the framework's HMR compiler plugins. */
hmr?: boolean
+ groupings?: CodeSplitGroupings
} = {},
) {
const {
filename = 'route.tsx',
targetFramework = 'react',
hmr = false,
+ groupings = defaultCodeSplitGroupings,
} = options
- const groupings = defaultCodeSplitGroupings
const compilerPlugins = hmr
? getFrameworkHmrCompilerPlugins({ targetFramework })
: undefined