mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-29 22:02:46 -07:00
feat: add support for client only lazy loading with dynamic (#331)
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export const ROUTES_FOLDER = './src/routes/'
|
||||
export const LAYOUT_PATH_ID = '__layout'
|
||||
export const GENERATED_ROUTE_TREE = './.tuono/routeTree.gen.ts'
|
||||
export const DYNAMIC_FN = '__tuono__internal__lazyLoadRoute'
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ROUTES_FOLDER,
|
||||
LAYOUT_PATH_ID,
|
||||
GENERATED_ROUTE_TREE,
|
||||
DYNAMIC_FN,
|
||||
} from './constants'
|
||||
|
||||
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' : ''
|
||||
return `const ${
|
||||
node.variableName as string
|
||||
}Import = dynamic(() => import('./${replaceBackslash(
|
||||
}Import = ${DYNAMIC_FN}(() => import('./${replaceBackslash(
|
||||
removeExt(
|
||||
path.relative(
|
||||
path.dirname(config.generatedRouteTree),
|
||||
@@ -214,7 +215,7 @@ export async function routeGenerator(config = defaultConfig): Promise<void> {
|
||||
|
||||
const routeImports = [
|
||||
'// This file is auto-generated by Tuono',
|
||||
"import { createRoute, dynamic } from 'tuono'",
|
||||
`import { createRoute, ${DYNAMIC_FN} } from 'tuono'`,
|
||||
[
|
||||
`import RootLayoutImport from './${replaceBackslash(
|
||||
path.relative(
|
||||
|
||||
+5
-3
@@ -1,11 +1,13 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
|
||||
|
||||
import RootLayoutImport from './routes/__layout'
|
||||
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const PostscatchallImport = dynamic(
|
||||
const IndexImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/index'),
|
||||
)
|
||||
const PostscatchallImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/posts/[...catch_all]'),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
|
||||
|
||||
import RootLayoutImport from './routes/__layout'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about.mdx'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const AboutImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/about.mdx'),
|
||||
)
|
||||
const IndexImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/index'),
|
||||
)
|
||||
|
||||
const rootRoute = createRoute({ isRoot: true, component: RootLayoutImport })
|
||||
|
||||
|
||||
+19
-7
@@ -1,15 +1,27 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
|
||||
|
||||
import RootLayoutImport from './routes/__layout'
|
||||
|
||||
const PostslayoutImport = dynamic(() => import('./routes/posts/__layout'))
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const PostspostImport = dynamic(() => import('./routes/posts/[post]'))
|
||||
const PostsIndexImport = dynamic(() => import('./routes/posts/index'))
|
||||
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post'))
|
||||
const PostslayoutImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/posts/__layout'),
|
||||
)
|
||||
const AboutImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/about'),
|
||||
)
|
||||
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 })
|
||||
|
||||
|
||||
+10
-4
@@ -1,12 +1,18 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
|
||||
|
||||
import RootLayoutImport from './routes/__layout'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const PostsMyPostImport = dynamic(() => import('./routes/posts/my-post'))
|
||||
const AboutImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/about'),
|
||||
)
|
||||
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 })
|
||||
|
||||
|
||||
+7
-3
@@ -1,11 +1,15 @@
|
||||
// This file is auto-generated by Tuono
|
||||
|
||||
import { createRoute, dynamic } from 'tuono'
|
||||
import { createRoute, __tuono__internal__lazyLoadRoute } from 'tuono'
|
||||
|
||||
import RootLayoutImport from './routes/__layout'
|
||||
|
||||
const AboutImport = dynamic(() => import('./routes/about'))
|
||||
const IndexImport = dynamic(() => import('./routes/index'))
|
||||
const AboutImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/about'),
|
||||
)
|
||||
const IndexImport = __tuono__internal__lazyLoadRoute(
|
||||
() => import('./routes/index'),
|
||||
)
|
||||
|
||||
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',
|
||||
}),
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,5 +2,5 @@ export { RouterProvider } from './components/RouterProvider'
|
||||
export { default as Link } from './components/Link'
|
||||
export { createRouter } from './router'
|
||||
export { createRoute, createRootRoute } from './route'
|
||||
export { dynamic, __tuono__internal__lazyLoadComponent } from './dynamic'
|
||||
export { useRouter } from './hooks/useRouter'
|
||||
export type { RouteProps, RouteComponent } from './types'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ComponentType as ReactComponentType, ReactNode } from 'react'
|
||||
import type { ReactNode, ComponentType } from 'react'
|
||||
|
||||
export interface Segment {
|
||||
type: 'pathname' | 'param' | 'wildcard'
|
||||
@@ -23,6 +23,6 @@ export interface RouteProps<TData = unknown> {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export type RouteComponent = ReactComponentType<RouteProps> & {
|
||||
export type RouteComponent = ComponentType<RouteProps> & {
|
||||
preload: () => void
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"tuono-fs-router-vite-plugin": "workspace:*",
|
||||
"tuono-lazy-fn-vite-plugin": "workspace:*",
|
||||
"tuono-router": "workspace:*",
|
||||
"vite": "^5.2.11",
|
||||
"web-streams-polyfill": "^4.0.0"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { build, createServer, mergeConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import inject from '@rollup/plugin-inject'
|
||||
import { TuonoFsRouterPlugin } from 'tuono-fs-router-vite-plugin'
|
||||
import { TuonoLazyFnPlugin } from 'tuono-lazy-fn-vite-plugin'
|
||||
|
||||
import type { TuonoConfig } from '../config'
|
||||
|
||||
@@ -67,7 +66,6 @@ function createBaseViteConfigFromTuonoConfig(
|
||||
react({ include: pluginFilesInclude }),
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,9 +3,10 @@ export {
|
||||
createRootRoute,
|
||||
createRouter,
|
||||
Link,
|
||||
dynamic,
|
||||
useRouter,
|
||||
__tuono__internal__lazyLoadComponent,
|
||||
} from 'tuono-router'
|
||||
|
||||
export { RouteLazyLoading as __tuono__internal__lazyLoadRoute } from './dynamic/RouteLazyLoading'
|
||||
export { dynamic } from './dynamic/dynamic'
|
||||
|
||||
export type { TuonoProps } from './types'
|
||||
|
||||
Generated
-28
@@ -186,9 +186,6 @@ importers:
|
||||
tuono-fs-router-vite-plugin:
|
||||
specifier: workspace:*
|
||||
version: link:../tuono-fs-router-vite-plugin
|
||||
tuono-lazy-fn-vite-plugin:
|
||||
specifier: workspace:*
|
||||
version: link:../tuono-lazy-fn-vite-plugin
|
||||
tuono-router:
|
||||
specifier: workspace:*
|
||||
version: link:../tuono-router
|
||||
@@ -252,31 +249,6 @@ importers:
|
||||
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-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:
|
||||
dependencies:
|
||||
react-intersection-observer:
|
||||
|
||||
Reference in New Issue
Block a user