feat: add support for client only lazy loading with dynamic (#331)

This commit is contained in:
Valerio Ageno
2025-01-16 12:34:06 +01:00
committed by GitHub
parent 1dfb83b1aa
commit 1a8231851d
33 changed files with 170 additions and 552 deletions
@@ -1,3 +1,4 @@
export const ROUTES_FOLDER = './src/routes/' export const ROUTES_FOLDER = './src/routes/'
export const LAYOUT_PATH_ID = '__layout' export const LAYOUT_PATH_ID = '__layout'
export const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts' export const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
export const DYNAMIC_FN = '__tuono__internal__lazyLoadRoute'
@@ -22,6 +22,7 @@ import {
ROUTES_FOLDER, ROUTES_FOLDER,
LAYOUT_PATH_ID, LAYOUT_PATH_ID,
GENERATED_ROUTE_TREE, GENERATED_ROUTE_TREE,
DYNAMIC_FN,
} from './constants' } from './constants'
import { sortRouteNodes } from './sort-route-nodes' import { sortRouteNodes } from './sort-route-nodes'
@@ -168,7 +169,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
const extension = node.filePath.endsWith('mdx') ? '.mdx' : '' const extension = node.filePath.endsWith('mdx') ? '.mdx' : ''
return `const ${ return `const ${
node.variableName as string node.variableName as string
}Import = dynamic(() => import('./${replaceBackslash( }Import = ${DYNAMIC_FN}(() => import('./${replaceBackslash(
removeExt( removeExt(
path.relative( path.relative(
path.dirname(config.generatedRouteTree), path.dirname(config.generatedRouteTree),
@@ -214,7 +215,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
const routeImports = [ const routeImports = [
'// This file is auto-generated by Tuono', '// This file is auto-generated by Tuono',
"import { createRoute, dynamic } from 'tuono'", `import { createRoute, ${DYNAMIC_FN} } from 'tuono'`,
[ [
`import RootLayoutImport from './${replaceBackslash( `import RootLayoutImport from './${replaceBackslash(
path.relative( path.relative(
@@ -1,11 +1,13 @@
// This file is auto-generated by Tuono // This file is auto-generated by Tuono
import { createRoute, dynamic } from 'tuono' import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
import RootLayoutImport from './routes/__layout' import RootLayoutImport from './routes/__layout'
const IndexImport = dynamic(() => import('./routes/index')) const IndexImport = __tuono__internal__lazyLoadRoute(
const PostscatchallImport = dynamic( () => import('./routes/index'),
)
const PostscatchallImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/posts/[...catch_all]'), () => import('./routes/posts/[...catch_all]'),
) )
@@ -1,11 +1,15 @@
// This file is auto-generated by Tuono // This file is auto-generated by Tuono
import { createRoute, dynamic } from 'tuono' import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
import RootLayoutImport from './routes/__layout' import RootLayoutImport from './routes/__layout'
const AboutImport = dynamic(() => import('./routes/about.mdx')) const AboutImport = __tuono__internal__lazyLoadRoute(
const IndexImport = dynamic(() => import('./routes/index')) () => import('./routes/about.mdx'),
)
const IndexImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/index'),
)
const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport }) const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport })
@@ -1,15 +1,27 @@
// This file is auto-generated by Tuono // This file is auto-generated by Tuono
import { createRoute, dynamic } from 'tuono' import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
import RootLayoutImport from './routes/__layout' import RootLayoutImport from './routes/__layout'
const PostslayoutImport = dynamic(() => import('./routes/posts/__layout')) const PostslayoutImport = __tuono__internal__lazyLoadRoute(
const AboutImport = dynamic(() => import('./routes/about')) () => import('./routes/posts/__layout'),
const IndexImport = dynamic(() => import('./routes/index')) )
const PostspostImport = dynamic(() => import('./routes/posts/[post]')) const AboutImport = __tuono__internal__lazyLoadRoute(
const PostsIndexImport = dynamic(() => import('./routes/posts/index')) () => import('./routes/about'),
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post')) )
const IndexImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/index'),
)
const PostspostImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/posts/[post]'),
)
const PostsIndexImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/posts/index'),
)
const PostsMyPostImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/posts/my-post'),
)
const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport }) const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport })
@@ -1,12 +1,18 @@
// This file is auto-generated by Tuono // This file is auto-generated by Tuono
import { createRoute, dynamic } from 'tuono' import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
import RootLayoutImport from './routes/__layout' import RootLayoutImport from './routes/__layout'
const AboutImport = dynamic(() => import('./routes/about')) const AboutImport = __tuono__internal__lazyLoadRoute(
const IndexImport = dynamic(() => import('./routes/index')) () => import('./routes/about'),
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post')) )
const IndexImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/index'),
)
const PostsMyPostImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/posts/my-post'),
)
const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport }) const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport })
@@ -1,11 +1,15 @@
// This file is auto-generated by Tuono // This file is auto-generated by Tuono
import { createRoute, dynamic } from 'tuono' import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
import RootLayoutImport from './routes/__layout' import RootLayoutImport from './routes/__layout'
const AboutImport = dynamic(() => import('./routes/about')) const AboutImport = __tuono__internal__lazyLoadRoute(
const IndexImport = dynamic(() => import('./routes/index')) () => import('./routes/about'),
)
const IndexImport = __tuono__internal__lazyLoadRoute(
() => import('./routes/index'),
)
const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport }) const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport })
@@ -1,8 +0,0 @@
# tuono-lazy-fn-vite-plugin
This is a vite plugin for [tuono](https://github.com/tuono-labs/tuono).
This package specifically handles the transpiling of the `dynamic` function
allowing custom componenents to be lazy loaded but also server side rendered.
Check [tuono](https://github.com/tuono-labs/tuono) for more.
@@ -1,57 +0,0 @@
{
"name": "tuono-lazy-fn-vite-plugin",
"version": "0.17.0",
"description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"lint": "eslint .",
"format": "prettier --write --ignore-unknown --ignore-path ../../.prettierignore .",
"format:check": "prettier --check --ignore-unknown --ignore-path ../../.prettierignore .",
"types": "tsc --noEmit",
"test:watch": "vitest",
"test": "vitest run"
},
"repository": {
"type": "git",
"url": "git+https://github.com/tuono-labs/tuono.git",
"directory": "packages/tuono-lazy-fn-vite-plugin"
},
"keywords": [],
"author": "Valerio Ageno",
"license": "MIT",
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"files": [
"dist",
"src",
"README.md"
],
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./package.json": "./package.json"
},
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/types": "^7.24.0",
"vite": "^5.2.11"
},
"devDependencies": {
"@tanstack/config": "0.7.13",
"@types/babel__core": "^7.20.5",
"prettier": "^3.2.4",
"vitest": "2.1.8"
}
}
@@ -1,3 +0,0 @@
export const TUONO_DYNAMIC_FN_ID = 'dynamic'
export const TUONO_LAZY_FN_ID = '__tuono__internal__lazyLoadComponent'
export const TUONO_MAIN_PACKAGE = 'tuono'
@@ -1,169 +0,0 @@
import { transformSync } from '@babel/core'
import type { PluginItem as BabelPluginItem } from '@babel/core'
import * as BabelTypes from '@babel/types'
import { createFilter } from 'vite'
import type { Plugin as VitePlugin, Rollup, FilterPattern } from 'vite'
import {
TUONO_MAIN_PACKAGE,
TUONO_DYNAMIC_FN_ID,
TUONO_LAZY_FN_ID,
} from './constants'
import { isTuonoDynamicFnImported } from './utils'
/**
* [SERVER build]
* This plugin just removes the `dynamic` imported function from any tuono import
*/
const RemoveTuonoLazyImport: BabelPluginItem = {
name: 'remove-tuono-lazy-import-plugin',
visitor: {
ImportDeclaration: (path) => {
const importNode = path.node
if (importNode.source.value !== TUONO_MAIN_PACKAGE) return
path.traverse({
ImportSpecifier: (importSpecifierPath) => {
if (isTuonoDynamicFnImported(importSpecifierPath)) {
importSpecifierPath.remove()
}
},
})
// If there are no specifiers left after traverse
// remove the import to avoid unwanted side effects
if (importNode.specifiers.length === 0) {
path.remove()
}
},
},
}
/**
* [CLIENT build]
* This plugin replace the `dynamic` function with the `__tuono__internal__lazyLoadComponent` one
*/
const ReplaceTuonoLazyImport: BabelPluginItem = {
name: 'replace-tuono-lazy-import-plugin',
visitor: {
ImportSpecifier: (path) => {
if (
BabelTypes.isIdentifier(path.node.imported) &&
isTuonoDynamicFnImported(path)
) {
path.node.imported.name = TUONO_LAZY_FN_ID
}
},
},
}
const turnLazyIntoStatic = {
VariableDeclaration: (
path: babel.NodePath<BabelTypes.VariableDeclaration>,
): void => {
path.node.declarations.forEach((variableDeclarationNode) => {
const init = variableDeclarationNode.init
if (
BabelTypes.isCallExpression(init) &&
// ensures that the method call is `TUONO_DYNAMIC_FN_ID`
BabelTypes.isIdentifier(init.callee, { name: TUONO_DYNAMIC_FN_ID }) &&
// import name must be an identifier
BabelTypes.isIdentifier(variableDeclarationNode.id) &&
// check that the first function parameter is an arrow function
BabelTypes.isArrowFunctionExpression(init.arguments[0])
) {
const cmpImportFn = init.arguments[0]
// ensures that the first parameter is a call expression (may be a block statement)
if (!BabelTypes.isCallExpression(cmpImportFn.body)) return
// ensures that the first parameter is a string literal (the import path)
if (!BabelTypes.isStringLiteral(cmpImportFn.body.arguments[0])) return
const importName = variableDeclarationNode.id.name
const importPath = cmpImportFn.body.arguments[0].value
if (importName && importPath) {
const importDeclaration = BabelTypes.importDeclaration(
[
BabelTypes.importDefaultSpecifier(
BabelTypes.identifier(importName),
),
],
BabelTypes.stringLiteral(importPath),
)
path.replaceWith(importDeclaration)
}
}
})
},
}
/**
* [SERVER build]
* This plugin statically imports the lazy loaded components
*/
const TurnLazyIntoStaticImport: BabelPluginItem = {
name: 'turn-lazy-into-static-import-plugin',
visitor: {
Program: (path) => {
path.traverse({
ImportSpecifier: (subPath) => {
if (isTuonoDynamicFnImported(subPath)) {
path.traverse(turnLazyIntoStatic)
}
},
})
},
},
}
interface TuonoLazyFnPluginOptions {
include: FilterPattern
}
export function TuonoLazyFnPlugin(
options: TuonoLazyFnPluginOptions,
): VitePlugin {
const { include } = options
const filter = createFilter(include)
return {
name: 'vite-plugin-tuono-lazy-loading',
enforce: 'pre',
transform(code, id, opts): Rollup.TransformResult {
if (!filter(id)) return
/**
* @todo we should exclude non tsx files from this transformation
* this might benefit build time avoiding running `includes` on non-tsx files.
* This can be executed using `_id` parameter
* which is the filepath that is being processed
*/
if (
code.includes(TUONO_DYNAMIC_FN_ID) &&
code.includes(TUONO_MAIN_PACKAGE)
) {
const plugins: Array<BabelPluginItem> = [
['@babel/plugin-syntax-jsx', {}],
['@babel/plugin-syntax-typescript', { isTSX: true }],
]
if (opts?.ssr) {
plugins.push(RemoveTuonoLazyImport, TurnLazyIntoStaticImport)
} else {
plugins.push(ReplaceTuonoLazyImport)
}
const res = transformSync(code, { plugins })
return res?.code
}
return code
},
}
}
@@ -1,25 +0,0 @@
import {
isIdentifier,
isImportDeclaration,
isImportSpecifier,
} from '@babel/types'
import { TUONO_MAIN_PACKAGE, TUONO_DYNAMIC_FN_ID } from './constants'
/**
* By a given AST Node path returns true if the path involves an import specifier
* importing {@link TUONO_DYNAMIC_FN_ID}
*/
export const isTuonoDynamicFnImported = (path: babel.NodePath): boolean => {
// If the node isn't an import declaration there is no need to process it
if (!isImportDeclaration(path.parentPath?.node)) return false
// if the import doesn't import from 'tuono' we don't need to process it
if (path.parentPath.node.source.value !== TUONO_MAIN_PACKAGE) return false
// ensure that we are processing an import specifier
if (!isImportSpecifier(path.node)) return false
// finally check if the imported item is `TUONO_DYNAMIC_FN_ID`
return isIdentifier(path.node.imported, { name: TUONO_DYNAMIC_FN_ID })
}
@@ -1,9 +0,0 @@
import React, { Suspense, type JSX } from "react";
import { __tuono__internal__lazyLoadComponent as dynamic } from "tuono";
const DynamicComponent = dynamic(() => import("../components/DynamicComponent"));
const Loading = (): JSX.Element => <>Loading</>;
export default function IndexPage(): JSX.Element {
return <Suspense fallback={<Loading />}>
<DynamicComponent />
</Suspense>;
}
@@ -1,8 +0,0 @@
import React, { Suspense, type JSX } from "react";
import DynamicComponent from "../components/DynamicComponent";
const Loading = (): JSX.Element => <>Loading</>;
export default function IndexPage(): JSX.Element {
return <Suspense fallback={<Loading />}>
<DynamicComponent />
</Suspense>;
}
@@ -1,14 +0,0 @@
import React, { Suspense, type JSX } from "react";
import { dynamic } from "tuono";
const DynamicComponent = dynamic(() => import("../components/DynamicComponent"))
const Loading = (): JSX.Element => <>Loading</>
export default function IndexPage(): JSX.Element {
return (
<Suspense fallback={<Loading />}>
<DynamicComponent />
</Suspense>
);
}
@@ -1,4 +0,0 @@
import { createRoute } from 'tuono';
import { dynamic } from 'external-lib';
const IndexImport = dynamic(() => import('./../src/routes/index'));
const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));
@@ -1,4 +0,0 @@
import { createRoute } from 'tuono';
import { dynamic } from 'external-lib';
const IndexImport = dynamic(() => import('./../src/routes/index'));
const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));
@@ -1,7 +0,0 @@
import { createRoute } from 'tuono'
import { dynamic } from 'external-lib'
const IndexImport = dynamic(() => import('./../src/routes/index'))
const PokemonspokemonImport = dynamic(
() => import('./../src/routes/pokemons/[pokemon]'),
)
@@ -1,3 +0,0 @@
import { createRoute, __tuono__internal__lazyLoadComponent as dynamic } from 'tuono';
const IndexImport = dynamic(() => import('./../src/routes/index'));
const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));
@@ -1,3 +0,0 @@
import { createRoute } from 'tuono';
import IndexImport from "./../src/routes/index";
import PokemonspokemonImport from "./../src/routes/pokemons/[pokemon]";
@@ -1,6 +0,0 @@
import { createRoute, dynamic } from 'tuono'
const IndexImport = dynamic(() => import('./../src/routes/index'))
const PokemonspokemonImport = dynamic(
() => import('./../src/routes/pokemons/[pokemon]'),
)
@@ -1,94 +0,0 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import { it, expect, describe } from 'vitest'
import type { Plugin } from 'vite'
import { TuonoLazyFnPlugin } from '../src'
type ViteTransformHandler = Exclude<
Plugin['transform'],
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
Function | undefined
>['handler']
// Create a type-safe transform method
function getTransform(): (...args: Parameters<ViteTransformHandler>) => string {
/** @warning Keep in sync with {@link createBaseViteConfigFromTuonoConfig} */
const pluginFilesInclude = /\.(jsx|js|mdx|md|tsx|ts)$/
return TuonoLazyFnPlugin({ include: pluginFilesInclude }).transform as never
}
describe('"dynamic" sources', async () => {
const folderNames = await fs.readdir(`${process.cwd()}/tests/sources`)
describe.each(folderNames)('%s', async (folderName) => {
const testDirPath = `${process.cwd()}/tests/sources/${folderName}`
const sourceFilePath = `${testDirPath}/source.tsx`
const sourceRaw = await fs.readFile(sourceFilePath, 'utf-8')
/**
* When adding `packages/lazy-fn-vite-plugin/tests/sources/dynamic-only` only
* the test involving that fixture were broken on Windows... but not the one in the other fixtures:
* - packages/lazy-fn-vite-plugin/tests/sources/vanilla
* - packages/lazy-fn-vite-plugin/tests/sources/external-dynamic
*
* Awkwardly this doesn't happen on `packages/fs-router-vite-plugin/tests/generator.spec.ts`
*
* Too much pain and sadness to investigate this right now.
* Might worth creating an utility function in the future if this happens again
*/
const source = sourceRaw.replace(new RegExp(os.EOL, 'g'), '\n')
it('should generate file for client', async () => {
const pluginTransform = getTransform()
const clientBundle = pluginTransform(source, sourceFilePath)
const expectedClientSrc = `${testDirPath}/client.expected.tsx`
await expect(clientBundle).toMatchFileSnapshot(
expectedClientSrc,
`${testDirPath} client build should be equal to ${expectedClientSrc}`,
)
})
it('should generate file for server', async () => {
const pluginTransform = getTransform()
const serverBundle = pluginTransform(source, sourceFilePath, {
ssr: true,
})
const expectedServerSrc = `${testDirPath}/server.expected.tsx`
await expect(serverBundle).toMatchFileSnapshot(
expectedServerSrc,
`${testDirPath} server build should be equal to ${expectedServerSrc}`,
)
})
})
})
describe('not valid file should not be processed', () => {
const notValidFiles: Array<string> = [
'src/styles/module.css',
'src/pages/file-without-ext',
'src/pages/file-with-invalid-ext',
'src/components/fileWithInvalidExt',
'src/components/sidebar/sidebar-link.module.css',
]
it.each(notValidFiles)('"%s"', (fileName) => {
const pluginTransform = getTransform()
const code = [
"import { createRoute, dynamic } from 'tuono'",
"const IndexImport = dynamic(() => import('./../src/routes/index'))",
].join('\n')
const result = pluginTransform(code, fileName)
expect(result).toBeUndefined()
})
})
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "tests", "vite.config.ts"],
"exclude": ["tests/sources"]
}
@@ -1,12 +0,0 @@
import { defineConfig, mergeConfig } from 'vitest/config'
import { tanstackBuildConfig } from '@tanstack/config/build'
const config = defineConfig({})
export default mergeConfig(
config,
tanstackBuildConfig({
entry: './src/index.ts',
srcDir: './src',
}),
)
-63
View File
@@ -1,63 +0,0 @@
import * as React from 'react'
import type { ReactElement } from 'react'
import type { RouteComponent } from './types'
type ImportFn = () => Promise<{ default: RouteComponent }>
/**
* Helper function to lazy load any component.
*
* The function acts exactly like React.lazy function but also renders the component on the server.
* If you want to just load the component client side use directly the react's lazy function.
*
* It can be wrapped within a React.Suspense component in order to handle its loading state.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const dynamic = (importFn: ImportFn): React.JSX.Element => {
/**
*
* This function is just a placeholder. The real work is done by the bundler.
* The custom babel plugin will create two different bundles for the client and the server.
*
* The client will import the React's lazy function while the server will statically
* import the file.
*
* Example:
*
* // User code
* import { dynamic } from 'tuono'
* const MyComponent = dynamic(() => import('./my-component'))
*
* // Client side generated code
* import { lazy } from 'react'
* const MyComponent = lazy(() => import('./my-component'))
*
* // Server side generated code
* import MyComponent from './my-component'
*
* Check the `lazy-fn-vite-plugin` package for more
*/
return <></>
}
export const __tuono__internal__lazyLoadComponent = (
factory: ImportFn,
): RouteComponent => {
let LoadedComponent: RouteComponent | undefined
const LazyComponent = React.lazy(factory) as unknown as RouteComponent
const loadComponent = (): Promise<void> =>
factory().then((module) => {
LoadedComponent = module.default
})
const Component = (
props: React.ComponentProps<RouteComponent>,
): ReactElement =>
React.createElement(LoadedComponent || LazyComponent, props)
Component.preload = loadComponent
return Component
}
+1 -1
View File
@@ -2,5 +2,5 @@ export { RouterProvider } from './components/RouterProvider'
export { default as Link } from './components/Link' export { default as Link } from './components/Link'
export { createRouter } from './router' export { createRouter } from './router'
export { createRoute, createRootRoute } from './route' export { createRoute, createRootRoute } from './route'
export { dynamic, __tuono__internal__lazyLoadComponent } from './dynamic'
export { useRouter } from './hooks/useRouter' export { useRouter } from './hooks/useRouter'
export type { RouteProps, RouteComponent } from './types'
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ComponentType as ReactComponentType, ReactNode } from 'react' import type { ReactNode, ComponentType } from 'react'
export interface Segment { export interface Segment {
type: 'pathname' | 'param' | 'wildcard' type: 'pathname' | 'param' | 'wildcard'
@@ -23,6 +23,6 @@ export interface RouteProps<TData = unknown> {
children?: ReactNode children?: ReactNode
} }
export type RouteComponent = ReactComponentType<RouteProps> & { export type RouteComponent = ComponentType<RouteProps> & {
preload: () => void preload: () => void
} }
-1
View File
@@ -99,7 +99,6 @@
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.0",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"tuono-fs-router-vite-plugin": "workspace:*", "tuono-fs-router-vite-plugin": "workspace:*",
"tuono-lazy-fn-vite-plugin": "workspace:*",
"tuono-router": "workspace:*", "tuono-router": "workspace:*",
"vite": "^5.2.11", "vite": "^5.2.11",
"web-streams-polyfill": "^4.0.0" "web-streams-polyfill": "^4.0.0"
-2
View File
@@ -3,7 +3,6 @@ import { build, createServer, mergeConfig } from 'vite'
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import inject from '@rollup/plugin-inject' import inject from '@rollup/plugin-inject'
import { TuonoFsRouterPlugin } from 'tuono-fs-router-vite-plugin' import { TuonoFsRouterPlugin } from 'tuono-fs-router-vite-plugin'
import { TuonoLazyFnPlugin } from 'tuono-lazy-fn-vite-plugin'
import type { TuonoConfig } from '../config' import type { TuonoConfig } from '../config'
@@ -67,7 +66,6 @@ function createBaseViteConfigFromTuonoConfig(
react({ include: pluginFilesInclude }), react({ include: pluginFilesInclude }),
TuonoFsRouterPlugin(), TuonoFsRouterPlugin(),
TuonoLazyFnPlugin({ include: pluginFilesInclude }),
], ],
} }
@@ -0,0 +1,24 @@
import { lazy, createElement } from 'react'
import type { ReactElement } from 'react'
import type { RouteComponent } from 'tuono-router'
type ImportFn = () => Promise<{ default: RouteComponent }>
export const RouteLazyLoading = (factory: ImportFn): RouteComponent => {
let LoadedComponent: RouteComponent | undefined
const LazyComponent = lazy<RouteComponent>(factory)
const loadComponent = (): Promise<void> =>
factory().then((module) => {
LoadedComponent = module.default
})
const Component = (
props: React.ComponentProps<RouteComponent>,
): ReactElement => createElement(LoadedComponent || LazyComponent, props)
Component.preload = loadComponent
return Component
}
+88
View File
@@ -0,0 +1,88 @@
/**
* This component is heavily inspired by Next.js dynamic function
* Link: https://github.com/vercel/next.js/blob/1df81bcea62800198884438a2bb27ba14c9d506a/packages/next/src/shared/lib/dynamic.tsx
*/
import { lazy, Suspense, Fragment } from 'react'
import type { ComponentType } from 'react'
const isServerSide = typeof window === 'undefined'
interface ComponentModule<T> {
default: React.ComponentType<T>
}
interface DynamicOptions {
ssr?: boolean
loading?: React.ComponentType<unknown> | null
}
type Loader<T = object> = () => Promise<
React.ComponentType<T> | ComponentModule<T>
>
interface LoadableOptions<T> extends DynamicOptions {
loader: Loader<T>
}
type LoadableFn = <T = object>(options: LoadableOptions<T>) => ComponentType<T>
const defaultLoaderOptions: LoadableOptions<object> = {
ssr: true,
loading: null,
loader: () => Promise.resolve(() => null),
}
function noSSR<T = object>(
LoadableInitializer: LoadableFn,
loadableOptions: LoadableOptions<T>,
): React.ComponentType<T> {
if (!isServerSide) {
return LoadableInitializer(loadableOptions)
}
if (!loadableOptions.loading) return () => null
const Loading = loadableOptions.loading
// This will only be rendered on the server side
function NoSSRLoading(): React.JSX.Element {
return <Loading />
}
return NoSSRLoading
}
function Loadable<T = object>(options: LoadableOptions<T>): ComponentType<T> {
const opts = { ...defaultLoaderOptions, ...options }
const Lazy = lazy(() => opts.loader().then())
const Loading = opts.loading
function LoadableComponent(props: T): React.JSX.Element {
const fallbackElement = Loading ? <Loading /> : null
const Wrap = Loading ? Suspense : Fragment
const wrapProps = Loading ? { fallback: fallbackElement } : {}
// TODO: In case ssr = false handle also the assets preloading
return (
<Wrap {...wrapProps}>
<Lazy {...props} />
</Wrap>
)
}
LoadableComponent.displayName = 'LoadableComponent'
return LoadableComponent
}
/**
* This function lets you dynamically import a component.
* It uses [React.lazy()](https://react.dev/reference/react/lazy) with [Suspense](https://react.dev/reference/react/Suspense) under the hood.
*/
export function dynamic<T = object>(
importFn: Loader<T>,
opts?: DynamicOptions,
): ComponentType<T> {
if (typeof opts?.ssr === 'boolean' && !opts.ssr) {
return noSSR<T>(Loadable, { ...opts, loader: importFn })
}
return Loadable<T>({ ...opts, loader: importFn })
}
+3 -2
View File
@@ -3,9 +3,10 @@ export {
createRootRoute, createRootRoute,
createRouter, createRouter,
Link, Link,
dynamic,
useRouter, useRouter,
__tuono__internal__lazyLoadComponent,
} from 'tuono-router' } from 'tuono-router'
export { RouteLazyLoading as __tuono__internal__lazyLoadRoute } from './dynamic/RouteLazyLoading'
export { dynamic } from './dynamic/dynamic'
export type { TuonoProps } from './types' export type { TuonoProps } from './types'
-28
View File
@@ -186,9 +186,6 @@ importers:
tuono-fs-router-vite-plugin: tuono-fs-router-vite-plugin:
specifier: workspace:* specifier: workspace:*
version: link:../tuono-fs-router-vite-plugin version: link:../tuono-fs-router-vite-plugin
tuono-lazy-fn-vite-plugin:
specifier: workspace:*
version: link:../tuono-lazy-fn-vite-plugin
tuono-router: tuono-router:
specifier: workspace:* specifier: workspace:*
version: link:../tuono-router version: link:../tuono-router
@@ -252,31 +249,6 @@ importers:
specifier: 2.1.8 specifier: 2.1.8
version: 2.1.8(@types/node@22.10.6)(jsdom@25.0.1)(sugarss@4.0.1(postcss@8.5.0)) version: 2.1.8(@types/node@22.10.6)(jsdom@25.0.1)(sugarss@4.0.1(postcss@8.5.0))
packages/tuono-lazy-fn-vite-plugin:
dependencies:
'@babel/core':
specifier: ^7.24.4
version: 7.26.0
'@babel/types':
specifier: ^7.24.0
version: 7.26.3
vite:
specifier: ^5.2.11
version: 5.4.11(@types/node@22.10.6)(sugarss@4.0.1(postcss@8.5.0))
devDependencies:
'@tanstack/config':
specifier: 0.7.13
version: 0.7.13(@types/node@22.10.6)(esbuild@0.21.5)(rollup@4.25.0)(typescript@5.7.3)(vite@5.4.11(@types/node@22.10.6)(sugarss@4.0.1(postcss@8.5.0)))
'@types/babel__core':
specifier: ^7.20.5
version: 7.20.5
prettier:
specifier: ^3.2.4
version: 3.4.2
vitest:
specifier: 2.1.8
version: 2.1.8(@types/node@22.10.6)(jsdom@25.0.1)(sugarss@4.0.1(postcss@8.5.0))
packages/tuono-router: packages/tuono-router:
dependencies: dependencies:
react-intersection-observer: react-intersection-observer: