Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/split-object-form-loader.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 6 additions & 0 deletions packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
160 changes: 137 additions & 23 deletions packages/router-plugin/src/core/code-splitter/compilers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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}')`,
)()
}
Expand Down Expand Up @@ -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
Expand All @@ -957,7 +1071,7 @@ export function compileCodeSplitVirtualRoute(
} else {
const meta = SPLIT_NODES_CONFIG.get(splitType)!
trackedNodesToSplitByType[splitType] = {
node: prop.value,
node: value,
meta,
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/router-plugin/src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions packages/router-plugin/tests/code-splitter-route-options.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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: () => <p>index</p>,
})`,
`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: () => <p>index</p>,
})
`,
{ 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: () => <p>index</p>,
})
`,
{ 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)
})
})
Original file line number Diff line number Diff line change
@@ -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'),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Route } from 'loader-object-arrow.tsx'
function PostComponent() {
const post = Route.useLoaderData()
return <div>{post.title}</div>
}
export { PostComponent as component }
Loading