diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6bd333f1..31c1509e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -15,6 +15,7 @@ env: jobs: build_and_test: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name name: Build and test rust crates runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 07635812..a6160660 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -12,18 +12,20 @@ on: jobs: build-and-test: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + timeout-minutes: 15 runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18.x, 20.x] - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + - name: Check out code + uses: actions/checkout@v4 with: - node-version: ${{ matrix.node-version }} + fetch-depth: 2 + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: 20 - name: Install pnpm run: npm i -g pnpm @@ -34,15 +36,19 @@ jobs: - name: Build project run: pnpm build + - name: Test project + run: pnpm test + fmt-lint-and-types: - runs-on: ubuntu-20.04 + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name + runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@v2 - - name: CI Checkout - uses: actions/setup-node@v2 + - name: Check out code + uses: actions/checkout@v4 with: - node-version: 20.x + fetch-depth: 2 - name: Install pnpm run: npm i -g pnpm diff --git a/crates/tuono/Cargo.toml b/crates/tuono/Cargo.toml index 17951068..836d8754 100644 --- a/crates/tuono/Cargo.toml +++ b/crates/tuono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono" -version = "0.1.4" +version = "0.1.5" edition = "2021" authors = ["V. Ageno "] description = "The react/rust fullstack framework" diff --git a/crates/tuono_lib/Cargo.toml b/crates/tuono_lib/Cargo.toml index c7f56bd1..5951a21d 100644 --- a/crates/tuono_lib/Cargo.toml +++ b/crates/tuono_lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib" -version = "0.1.4" +version = "0.1.5" edition = "2021" authors = ["V. Ageno "] description = "The react/rust fullstack framework" @@ -24,5 +24,5 @@ serde = { version = "1.0.202", features = ["derive"] } erased-serde = "0.4.5" serde_json = "1.0" -tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.4"} +tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.1.5"} diff --git a/crates/tuono_lib_macros/Cargo.toml b/crates/tuono_lib_macros/Cargo.toml index 226d6b90..2f9ea1e8 100644 --- a/crates/tuono_lib_macros/Cargo.toml +++ b/crates/tuono_lib_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib_macros" -version = "0.1.4" +version = "0.1.5" edition = "2021" description = "The react/rust fullstack framework" homepage = "https://github.com/Valerioageno/tuono" diff --git a/package.json b/package.json index f845926f..112d6b86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tuono", - "version": "0.1.4", + "version": "0.1.5", "description": "", "main": "src/index.js", "packageManager": "pnpm@9.1.1", @@ -9,8 +9,8 @@ "build": "turbo build", "lint": "turbo lint", "format": "turbo format", - "format:check": "turbo format:check", - "types": "turbo types", + "format:check": "turbo format:check", + "types": "turbo types", "test": "turbo test" }, "keywords": [], @@ -36,6 +36,6 @@ "vitest": "^1.5.2" }, "dependencies": { - "turbo": "^2.0.3" + "turbo": "^2.0.4" } } diff --git a/packages/lazy-fn-vite-plugin/.prettierignore b/packages/lazy-fn-vite-plugin/.prettierignore new file mode 100644 index 00000000..dee70d2f --- /dev/null +++ b/packages/lazy-fn-vite-plugin/.prettierignore @@ -0,0 +1,2 @@ +dist +pnpm-lock.yaml diff --git a/packages/lazy-fn-vite-plugin/README.md b/packages/lazy-fn-vite-plugin/README.md new file mode 100644 index 00000000..4f83ab5d --- /dev/null +++ b/packages/lazy-fn-vite-plugin/README.md @@ -0,0 +1 @@ +# tuono-lazy-fn-vite-plugin diff --git a/packages/lazy-fn-vite-plugin/package.json b/packages/lazy-fn-vite-plugin/package.json new file mode 100644 index 00000000..89a32dc2 --- /dev/null +++ b/packages/lazy-fn-vite-plugin/package.json @@ -0,0 +1,50 @@ +{ + "name": "tuono-lazy-fn-vite-plugin", + "version": "0.1.5", + "description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework", + "scripts": { + "dev": "vite build --watch", + "build": "vite build", + "lint": "eslint --ext .ts,.tsx ./src -c ../../.eslintrc", + "format": "prettier -u --write --ignore-unknown '**/*'", + "format:check": "prettier --check --ignore-unknown '**/*'", + "types": "tsc --noEmit", + "test": "vitest run" + }, + "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.11", + "@types/babel__core": "^7.20.5", + "prettier": "^3.2.4", + "vitest": "^1.5.2" + } +} diff --git a/packages/lazy-fn-vite-plugin/src/index.ts b/packages/lazy-fn-vite-plugin/src/index.ts new file mode 100644 index 00000000..a8024d2c --- /dev/null +++ b/packages/lazy-fn-vite-plugin/src/index.ts @@ -0,0 +1,116 @@ +import type { Plugin } from 'vite' +import * as babel from '@babel/core' +import type { PluginItem } from '@babel/core' + +import * as t from '@babel/types' + +import type { + Identifier, + ImportDeclaration, + CallExpression, + ArrowFunctionExpression, + StringLiteral, +} from '@babel/types' + +/** + * This plugin just removes the `lazy` imported function from any tuono import + */ +const RemoveTuonoLazyImport: PluginItem = { + name: 'remove-tuono-lazy-import-plugin', + visitor: { + ImportSpecifier: (path) => { + if ((path.node.imported as Identifier).name === 'lazy') { + if ( + (path.parentPath.node as ImportDeclaration).source.value === 'tuono' + ) { + path.remove() + } + } + }, + }, +} + +/** + * Import { lazy } from 'react' + */ +const ImportReactLazy: PluginItem = { + name: 'import-react-lazy-plugin', + visitor: { + Program: (path: any) => { + let isReactImported = false + + path.node.body.forEach((val: any) => { + if (val.type === 'ImportDeclaration' && val.source.value === 'react') { + isReactImported = true + // TODO: Handle also here case of already imported react + // Right now works just for the main routes file + } + }) + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!isReactImported) { + const importDeclaration = t.importDeclaration( + [t.importSpecifier(t.identifier('lazy'), t.identifier('lazy'))], + t.stringLiteral('react'), + ) + path.unshiftContainer('body', importDeclaration) + } + }, + }, +} + +/** + * For the server side we need to statically import the lazy loaded components + */ +const TurnLazyToStaticImport: PluginItem = { + name: 'turn-lazy-to-static-import-plugin', + visitor: { + VariableDeclaration: (path) => { + path.node.declarations.forEach((el) => { + const init = el.init as CallExpression + if ((init.callee as Identifier).name === 'lazy') { + const importName = (el.id as Identifier).name + const importPath = ( + ( + (init.arguments[0] as ArrowFunctionExpression) + .body as CallExpression + ).arguments[0] as StringLiteral + ).value + + if (importName && importPath) { + const importDeclaration = t.importDeclaration( + [t.importDefaultSpecifier(t.identifier(importName))], + t.stringLiteral(importPath), + ) + + path.replaceWith(importDeclaration) + } + } + }) + }, + }, +} + +export function LazyLoadingPlugin(): Plugin { + return { + name: 'vite-plugin-tuono-lazy-loading', + enforce: 'pre', + transform(code, _id, opts): string | undefined | null { + if (code.includes('lazy') && code.includes('tuono')) { + const res = babel.transformSync(code, { + plugins: [ + ['@babel/plugin-syntax-jsx', {}], + ['@babel/plugin-syntax-typescript', { isTSX: true }], + [RemoveTuonoLazyImport], + [!opts?.ssr ? ImportReactLazy : []], + [opts?.ssr ? TurnLazyToStaticImport : []], + ], + sourceMaps: true, + }) + + return res?.code + } + return code + }, + } +} diff --git a/packages/lazy-fn-vite-plugin/test/transpileSource.test.ts b/packages/lazy-fn-vite-plugin/test/transpileSource.test.ts new file mode 100644 index 00000000..70564fbd --- /dev/null +++ b/packages/lazy-fn-vite-plugin/test/transpileSource.test.ts @@ -0,0 +1,34 @@ +import { it, expect, describe } from 'vitest' +import { LazyLoadingPlugin } from '../src' + +const SOURCE_CODE = ` +import { createRoute, lazy } from 'tuono' + +const IndexImport = lazy(() => import('./../src/routes/index')) +const PokemonspokemonImport = lazy( + () => import('./../src/routes/pokemons/[pokemon]'), +) +` + +const CLIENT_RESULT = `import { lazy } from "react"; +import { createRoute } from 'tuono'; +const IndexImport = lazy(() => import('./../src/routes/index')); +const PokemonspokemonImport = lazy(() => import('./../src/routes/pokemons/[pokemon]'));` + +const SERVER_RESULT = `import { createRoute } from 'tuono'; +import IndexImport from "./../src/routes/index"; +import PokemonspokemonImport from "./../src/routes/pokemons/[pokemon]";` + +describe('Transpile tuono source', () => { + it('Into the client bundle', () => { + const bundle = LazyLoadingPlugin().transform?.(SOURCE_CODE, 'id') + expect(bundle).toBe(CLIENT_RESULT) + }) + + it('Into the server bundle', () => { + const bundle = LazyLoadingPlugin().transform?.(SOURCE_CODE, 'id', { + ssr: true, + }) + expect(bundle).toBe(SERVER_RESULT) + }) +}) diff --git a/packages/lazy-fn-vite-plugin/tsconfig.json b/packages/lazy-fn-vite-plugin/tsconfig.json new file mode 100644 index 00000000..b36d1bf9 --- /dev/null +++ b/packages/lazy-fn-vite-plugin/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "vite.config.ts"] +} diff --git a/packages/lazy-fn-vite-plugin/vite.config.ts b/packages/lazy-fn-vite-plugin/vite.config.ts new file mode 100644 index 00000000..c09134a3 --- /dev/null +++ b/packages/lazy-fn-vite-plugin/vite.config.ts @@ -0,0 +1,12 @@ +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', + }), +) diff --git a/packages/tuono/package.json b/packages/tuono/package.json index 8d4124de..8bdef4f5 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -1,6 +1,6 @@ { "name": "tuono", - "version": "0.1.4", + "version": "0.1.5", "description": "The react/rust fullstack framework", "scripts": { "dev": "vite build --watch", @@ -9,7 +9,7 @@ "format": "prettier -u --write --ignore-unknown '**/*'", "format:check": "prettier --check --ignore-unknown '**/*'", "types": "tsc --noEmit", - "test": "vitest" + "test": "vitest run" }, "type": "module", "types": "dist/esm/index.d.ts", @@ -88,6 +88,7 @@ "@vitejs/plugin-react-swc": "^3.7.0", "fast-text-encoding": "^1.0.6", "prettier": "^3.2.4", + "tuono-lazy-fn-vite-plugin": "workspace:*", "vite": "^5.2.11", "zustand": "4.4.7" }, diff --git a/packages/tuono/src/build/index.ts b/packages/tuono/src/build/index.ts index ff5e2490..457f1099 100644 --- a/packages/tuono/src/build/index.ts +++ b/packages/tuono/src/build/index.ts @@ -1,6 +1,7 @@ import { build, createServer, InlineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' import { ViteFsRouter } from './tuono-vite-plugin' +import { LazyLoadingPlugin } from 'tuono-lazy-fn-vite-plugin' const BASE_CONFIG: InlineConfig = { root: '.tuono', @@ -8,7 +9,7 @@ const BASE_CONFIG: InlineConfig = { publicDir: '../public', cacheDir: 'cache', envDir: '../', - plugins: [react(), ViteFsRouter()], + plugins: [react(), ViteFsRouter(), LazyLoadingPlugin()], } export function developmentSSRBundle() { diff --git a/packages/tuono/src/build/routes-generator/generator.ts b/packages/tuono/src/build/routes-generator/generator.ts index fc399aa8..cee1038a 100644 --- a/packages/tuono/src/build/routes-generator/generator.ts +++ b/packages/tuono/src/build/routes-generator/generator.ts @@ -13,7 +13,6 @@ import { removeGroups, removeUnderscores, removeLayoutSegments, - removeTrailingUnderscores, } from './utils' import type { Config, RouteNode, RouteSubNode } from './types' @@ -151,7 +150,6 @@ export async function routeGenerator(config = defaultConfig): Promise { const taskId = latestTask + 1 latestTask = taskId - const start = Date.now() const { routeNodes: beforeRouteNodes, rustHandlersNodes } = await getRouteNodes(config) @@ -232,7 +230,9 @@ export async function routeGenerator(config = defaultConfig): Promise { const imports = [ ...sortedRouteNodes.map((node) => { - return `import ${node.variableName}Import from './${replaceBackslash( + return `const ${ + node.variableName + }Import = lazy(() => import('./${replaceBackslash( removeExt( path.relative( path.dirname(config.generatedRouteTree), @@ -240,7 +240,7 @@ export async function routeGenerator(config = defaultConfig): Promise { ), false, ), - )}'` + )}'))` }), ].join('\n') @@ -253,9 +253,6 @@ export async function routeGenerator(config = defaultConfig): Promise { const createRouteUpdates = [ sortedRouteNodes .map((node) => { - const loaderNode = routePiecesByPath[node.routePath]?.loader - const lazyComponentNode = routePiecesByPath[node.routePath]?.lazy - return [ `const ${node.variableName}Route = ${node.variableName}.update({ ${[ @@ -267,30 +264,7 @@ export async function routeGenerator(config = defaultConfig): Promise { ] .filter(Boolean) .join(',')} - } as any)`, - // Verify if needed - loaderNode - ? `.updateLoader({ loader: lazyFn(() => import('./${replaceBackslash( - removeExt( - path.relative( - path.dirname(config.generatedRouteTree), - path.resolve(config.folderName, loaderNode.filePath), - ), - false, - ), - )}'), 'loader') })` - : '', - lazyComponentNode - ? `.lazy(() => import('./${replaceBackslash( - removeExt( - path.relative( - path.dirname(config.generatedRouteTree), - path.resolve(config.folderName, lazyComponentNode.filePath), - ), - false, - ), - )}').then((d) => d.Route))` - : '', + })`, ].join('') }) .join('\n\n'), @@ -298,7 +272,7 @@ export async function routeGenerator(config = defaultConfig): Promise { const routeImports = [ '// This file is auto-generated by Tuono', - "import { createRoute } from 'tuono'", + "import { createRoute, lazy } from 'tuono'", [ `import RootImport from './${replaceBackslash( path.relative( diff --git a/packages/tuono/src/index.ts b/packages/tuono/src/index.ts index 842eddcc..b426a1b7 100644 --- a/packages/tuono/src/index.ts +++ b/packages/tuono/src/index.ts @@ -4,8 +4,16 @@ import { createRouter, Link, RouterProvider, + lazy, } from './router' export type { TuonoProps } from './types' -export { createRoute, createRootRoute, createRouter, Link, RouterProvider } +export { + createRoute, + createRootRoute, + createRouter, + Link, + RouterProvider, + lazy, +} diff --git a/packages/tuono/src/router/components/RouteMatch.tsx b/packages/tuono/src/router/components/RouteMatch.tsx index 1fda3921..6a78147f 100644 --- a/packages/tuono/src/router/components/RouteMatch.tsx +++ b/packages/tuono/src/router/components/RouteMatch.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import type { Route } from '../route' import { useServerSideProps } from '../hooks/useServerSideProps' @@ -19,12 +20,19 @@ export const RouteMatch = ({ const { data, isLoading } = useServerSideProps(route, serverSideProps) if (!route.isRoot) { - return route.options.getParentRoute().component({ - children: route.options.component({ data, isLoading }), - data, - isLoading, - }) + const Root = route.options.getParentRoute() + return ( + + + + + + ) } - return route.options.component({ data, isLoading }) + return ( + + + + ) } diff --git a/packages/tuono/src/router/index.tsx b/packages/tuono/src/router/index.tsx index 97cdc705..1f7ff843 100644 --- a/packages/tuono/src/router/index.tsx +++ b/packages/tuono/src/router/index.tsx @@ -2,3 +2,4 @@ export { RouterProvider } from './components/RouterProvider' export { default as Link } from './components/Link' export { createRouter } from './router' export { createRoute, createRootRoute } from './route' +export { lazy } from './lazy' diff --git a/packages/tuono/src/router/lazy.tsx b/packages/tuono/src/router/lazy.tsx new file mode 100644 index 00000000..1038d292 --- /dev/null +++ b/packages/tuono/src/router/lazy.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' + +/** + * 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 lazy = (importFn: () => JSX.Element): 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 { lazy } from 'tuono' + * const MyComponent = lazy(() => 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' + * + */ + return <> +} diff --git a/turbo.json b/turbo.json index 84c0817d..773a6963 100644 --- a/turbo.json +++ b/turbo.json @@ -4,7 +4,11 @@ "lint": {}, "format": {}, "format:check": {}, - "types": {}, + "types": { + "dependsOn": [ + "^build" + ] + }, "test": {}, "build": { "outputs": [