diff --git a/crates/tuono/Cargo.toml b/crates/tuono/Cargo.toml index f9be796c..2bc2af48 100644 --- a/crates/tuono/Cargo.toml +++ b/crates/tuono/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono" -version = "0.8.4" +version = "0.9.0" 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 80a46049..ffd6f629 100644 --- a/crates/tuono_lib/Cargo.toml +++ b/crates/tuono_lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuono_lib" -version = "0.8.4" +version = "0.9.0" edition = "2021" authors = ["V. Ageno "] description = "The react/rust fullstack framework" @@ -32,7 +32,7 @@ regex = "1.10.5" either = "1.13.0" tower-http = {version = "0.5.2", features = ["fs"]} -tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.8.4"} +tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.9.0"} # Match the same version used by axum tokio-tungstenite = "0.21.0" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } diff --git a/crates/tuono_lib_macros/Cargo.toml b/crates/tuono_lib_macros/Cargo.toml index 8d1b9e46..04b1a129 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.8.4" +version = "0.9.0" edition = "2021" description = "The react/rust fullstack framework" keywords = [ "react", "typescript", "fullstack", "web", "ssr"] diff --git a/packages/fs-router-vite-plugin/package.json b/packages/fs-router-vite-plugin/package.json index ace1d93c..2b192575 100644 --- a/packages/fs-router-vite-plugin/package.json +++ b/packages/fs-router-vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "tuono-fs-router-vite-plugin", - "version": "0.8.4", + "version": "0.9.0", "description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework", "scripts": { "dev": "vite build --watch", diff --git a/packages/lazy-fn-vite-plugin/package.json b/packages/lazy-fn-vite-plugin/package.json index 3e5ad0a1..ff160dc2 100644 --- a/packages/lazy-fn-vite-plugin/package.json +++ b/packages/lazy-fn-vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "tuono-lazy-fn-vite-plugin", - "version": "0.8.4", + "version": "0.9.0", "description": "Plugin for the tuono's lazy fn. Tuono is the react/rust fullstack framework", "scripts": { "dev": "vite build --watch", diff --git a/packages/lazy-fn-vite-plugin/src/constants.ts b/packages/lazy-fn-vite-plugin/src/constants.ts index 43c931c5..87ebd54b 100644 --- a/packages/lazy-fn-vite-plugin/src/constants.ts +++ b/packages/lazy-fn-vite-plugin/src/constants.ts @@ -1,3 +1,3 @@ export const TUONO_DYNAMIC_FN_ID = 'dynamic' -export const REACT_LAZY_FN_ID = 'lazy' +export const TUONO_LAZY_FN_ID = 'lazyLoadComponent' export const TUONO_MAIN_PACKAGE = 'tuono' diff --git a/packages/lazy-fn-vite-plugin/src/index.ts b/packages/lazy-fn-vite-plugin/src/index.ts index 7db67fd7..5365aa81 100644 --- a/packages/lazy-fn-vite-plugin/src/index.ts +++ b/packages/lazy-fn-vite-plugin/src/index.ts @@ -5,7 +5,7 @@ import type { PluginItem } from '@babel/core' import { TUONO_MAIN_PACKAGE, TUONO_DYNAMIC_FN_ID, - REACT_LAZY_FN_ID, + TUONO_LAZY_FN_ID, } from './constants' import * as t from '@babel/types' @@ -19,6 +19,7 @@ import type { } from '@babel/types' /** + * [SERVER build] * This plugin just removes the `dynamic` imported function from any tuono import */ const RemoveTuonoLazyImport: PluginItem = { @@ -38,49 +39,28 @@ const RemoveTuonoLazyImport: PluginItem = { } /** - * This plugin adds: "Import { lazy } from 'react'" - * and translate dynamic call into a React.lazy call + * [CLIENT build] + * This plugin replace the `dynamic` function with the `lazyLoadComponent` one */ -const ImportReactLazy: PluginItem = { - name: 'import-react-lazy-plugin', +const ReplaceTuonoLazyImport: PluginItem = { + name: 'remove-tuono-lazy-import-plugin', visitor: { - // Add the import statement - 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 + ImportSpecifier: (path) => { + if ((path.node.imported as Identifier).name === TUONO_DYNAMIC_FN_ID) { + if ( + (path.parentPath.node as ImportDeclaration).source.value === + TUONO_MAIN_PACKAGE + ) { + ;(path.node.imported as Identifier).name = TUONO_LAZY_FN_ID } - }) - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!isReactImported) { - const importDeclaration = t.importDeclaration( - [ - t.importSpecifier( - t.identifier(REACT_LAZY_FN_ID), - t.identifier(REACT_LAZY_FN_ID), - ), - ], - t.stringLiteral('react'), - ) - path.unshiftContainer('body', importDeclaration) - } - }, - // Update lazy function name from `dynamic` to `lazy` - CallExpression: (path: any) => { - if (path.node.callee?.name === TUONO_DYNAMIC_FN_ID) { - path.node.callee.name = REACT_LAZY_FN_ID } }, }, } /** - * For the server side we need to statically import the lazy loaded components + * [SERVER build] + * This plugin statically imports the lazy loaded components */ const TurnLazyIntoStaticImport: PluginItem = { name: 'turn-lazy-into-static-import-plugin', @@ -124,8 +104,8 @@ export function LazyLoadingPlugin(): Plugin { plugins: [ ['@babel/plugin-syntax-jsx', {}], ['@babel/plugin-syntax-typescript', { isTSX: true }], - [RemoveTuonoLazyImport], - [!opts?.ssr ? ImportReactLazy : []], + [!opts?.ssr ? ReplaceTuonoLazyImport : []], + [opts?.ssr ? RemoveTuonoLazyImport : []], [opts?.ssr ? TurnLazyIntoStaticImport : []], ], sourceMaps: true, diff --git a/packages/lazy-fn-vite-plugin/test/transpileSource.test.ts b/packages/lazy-fn-vite-plugin/test/transpileSource.test.ts index eb48ef72..07301731 100644 --- a/packages/lazy-fn-vite-plugin/test/transpileSource.test.ts +++ b/packages/lazy-fn-vite-plugin/test/transpileSource.test.ts @@ -10,10 +10,9 @@ const PokemonspokemonImport = dynamic( ) ` -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 CLIENT_RESULT = `import { createRoute, lazyLoadComponent as dynamic } from 'tuono'; +const IndexImport = dynamic(() => import('./../src/routes/index')); +const PokemonspokemonImport = dynamic(() => import('./../src/routes/pokemons/[pokemon]'));` const SERVER_RESULT = `import { createRoute } from 'tuono'; import IndexImport from "./../src/routes/index"; diff --git a/packages/router/package.json b/packages/router/package.json index 44196ee1..2fa3c39e 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "tuono-router", - "version": "0.8.4", + "version": "0.9.0", "description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework", "scripts": { "dev": "vite build --watch", @@ -42,6 +42,7 @@ "react-dom": ">=16.3.0" }, "dependencies": { + "react-intersection-observer": "^9.13.0", "vite": "^5.2.11", "zustand": "4.4.7" }, diff --git a/packages/router/src/components/Link.tsx b/packages/router/src/components/Link.tsx index 0332ccbf..530d0def 100644 --- a/packages/router/src/components/Link.tsx +++ b/packages/router/src/components/Link.tsx @@ -1,11 +1,25 @@ import * as React from 'react' import { useRouter } from '../hooks/useRouter' import type { AnchorHTMLAttributes, MouseEvent } from 'react' +import useRoute from '../hooks/useRoute' +import { useInView } from 'react-intersection-observer' + +interface TuonoLinkProps { + preload?: boolean +} export default function Link( - props: AnchorHTMLAttributes, + componentProps: AnchorHTMLAttributes & TuonoLinkProps, ): JSX.Element { + const { preload = true, ...props } = componentProps const router = useRouter() + const route = useRoute(props.href) + const { ref } = useInView({ + onChange(inView) { + if (inView && preload) route?.component.preload() + }, + triggerOnce: true, + }) const handleTransition = (e: MouseEvent): void => { e.preventDefault() @@ -14,7 +28,7 @@ export default function Link( } return ( - + {props.children} ) diff --git a/packages/router/src/components/Matches.tsx b/packages/router/src/components/Matches.tsx index 278a8c8e..5b0f43a9 100644 --- a/packages/router/src/components/Matches.tsx +++ b/packages/router/src/components/Matches.tsx @@ -1,71 +1,18 @@ import * as React from 'react' -import { useInternalRouter } from '../hooks/useInternalRouter' import { useRouterStore } from '../hooks/useRouterStore' -import type { Route } from '../route' import { RouteMatch } from './RouteMatch' import NotFound from './NotFound' +import useRoute from '../hooks/useRoute' interface MatchesProps { // user defined props serverSideProps: any } -const DYNAMIC_PATH_REGEX = /\[(.*?)\]/ - -/* - * This function is also implemented on server side to match the bundle - * file to load at the first rendering. - * - * File: crates/tuono_lib/src/payload.rs - * - * Optimizations should occour on both - */ -export function getRouteByPathname(pathname: string): Route | undefined { - const { routesById } = useInternalRouter() - - if (routesById[pathname]) return routesById[pathname] - - const dynamicRoutes = Object.keys(routesById).filter((route) => - DYNAMIC_PATH_REGEX.test(route), - ) - - if (!dynamicRoutes.length) return - - const pathSegments = pathname.split('/').filter(Boolean) - - let match = undefined - - // TODO: Check algo efficiency - for (const dynamicRoute of dynamicRoutes) { - const dynamicRouteSegments = dynamicRoute.split('/').filter(Boolean) - - const routeSegmentsCollector: string[] = [] - - for (let i = 0; i < dynamicRouteSegments.length; i++) { - if ( - dynamicRouteSegments[i] === pathSegments[i] || - DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '') - ) { - routeSegmentsCollector.push(dynamicRouteSegments[i] ?? '') - } else { - break - } - } - - if (routeSegmentsCollector.length === pathSegments.length) { - match = `/${routeSegmentsCollector.join('/')}` - break - } - } - - if (!match) return - return routesById[match] -} - export function Matches({ serverSideProps }: MatchesProps): JSX.Element { const location = useRouterStore((st) => st.location) - const route = getRouteByPathname(location.pathname) + const route = useRoute(location.pathname) if (!route) { return diff --git a/packages/router/src/components/RouteMatch.spec.tsx b/packages/router/src/components/RouteMatch.spec.tsx index 9de7a97d..7d96ea21 100644 --- a/packages/router/src/components/RouteMatch.spec.tsx +++ b/packages/router/src/components/RouteMatch.spec.tsx @@ -14,7 +14,7 @@ const root = { component: ({ children }: Props) => (
root route {children}
), -} as Route +} as unknown as Route const parent = { component: ({ children }: Props) => ( @@ -23,14 +23,14 @@ const parent = { options: { getParentRoute: () => root, }, -} as Route +} as unknown as Route const route = { component: () =>

current route

, options: { getParentRoute: () => parent, }, -} as Route +} as unknown as Route describe('Test RouteMatch component', () => { afterEach(() => { diff --git a/packages/router/src/dynamic.tsx b/packages/router/src/dynamic.tsx index 12033bcc..ad32d94f 100644 --- a/packages/router/src/dynamic.tsx +++ b/packages/router/src/dynamic.tsx @@ -1,4 +1,7 @@ import * as React from 'react' +import type { RouteComponent } from './types' + +type ImportFn = () => Promise<{ default: React.ComponentType }> /** * Helper function to lazy load any component. @@ -9,7 +12,7 @@ import * as React from 'react' * 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: () => JSX.Element): JSX.Element => { +export const dynamic = (importFn: ImportFn): JSX.Element => { /** * * This function is just a placeholder. The real work is done by the bundler. @@ -35,3 +38,9 @@ export const dynamic = (importFn: () => JSX.Element): JSX.Element => { */ return <> } + +export const lazyLoadComponent = (factory: ImportFn): RouteComponent => { + const Component = React.lazy(factory) as unknown as RouteComponent + Component.preload = factory + return Component +} diff --git a/packages/router/src/components/Matches.spec.tsx b/packages/router/src/hooks/useRoute.spec.tsx similarity index 52% rename from packages/router/src/components/Matches.spec.tsx rename to packages/router/src/hooks/useRoute.spec.tsx index 3051524c..8ec6452b 100644 --- a/packages/router/src/components/Matches.spec.tsx +++ b/packages/router/src/hooks/useRoute.spec.tsx @@ -1,14 +1,14 @@ import { afterEach, describe, expect, test, vi } from 'vitest' -import { getRouteByPathname } from './Matches' +import useRoute from './useRoute' import { cleanup } from '@testing-library/react' -describe('Test getRouteByPathname fn', () => { +describe('Test useRoute fn', () => { afterEach(() => { cleanup() }) test('match routes by ids', () => { - vi.mock('../hooks/useInternalRouter.tsx', () => ({ + vi.mock('./useInternalRouter.tsx', () => ({ useInternalRouter: (): { routesById: Record } => { return { routesById: { @@ -23,15 +23,13 @@ describe('Test getRouteByPathname fn', () => { }, })) - expect(getRouteByPathname('/')?.id).toBe('/') - expect(getRouteByPathname('/not-found')?.id).toBe(undefined) - expect(getRouteByPathname('/about')?.id).toBe('/about') - expect(getRouteByPathname('/posts/')?.id).toBe('/posts/') - expect(getRouteByPathname('/posts/dynamic-post')?.id).toBe('/posts/[post]') - expect(getRouteByPathname('/posts/defined-post')?.id).toBe( - '/posts/defined-post', - ) - expect(getRouteByPathname('/posts/dynamic-post/dynamic-comment')?.id).toBe( + expect(useRoute('/')?.id).toBe('/') + expect(useRoute('/not-found')?.id).toBe(undefined) + expect(useRoute('/about')?.id).toBe('/about') + expect(useRoute('/posts/')?.id).toBe('/posts/') + expect(useRoute('/posts/dynamic-post')?.id).toBe('/posts/[post]') + expect(useRoute('/posts/defined-post')?.id).toBe('/posts/defined-post') + expect(useRoute('/posts/dynamic-post/dynamic-comment')?.id).toBe( '/posts/[post]/[comment]', ) }) diff --git a/packages/router/src/hooks/useRoute.tsx b/packages/router/src/hooks/useRoute.tsx new file mode 100644 index 00000000..3124057d --- /dev/null +++ b/packages/router/src/hooks/useRoute.tsx @@ -0,0 +1,56 @@ +import type { Route } from '../route' +import { useInternalRouter } from './useInternalRouter' + +const DYNAMIC_PATH_REGEX = /\[(.*?)\]/ + +/* + * This hook is also implemented on server side to match the bundle + * file to load at the first rendering. + * + * File: crates/tuono_lib/src/payload.rs + * + * Optimizations should occour on both + */ +export default function useRoute(pathname?: string): Route | undefined { + if (!pathname) return + + const { routesById } = useInternalRouter() + + if (routesById[pathname]) return routesById[pathname] + + const dynamicRoutes = Object.keys(routesById).filter((route) => + DYNAMIC_PATH_REGEX.test(route), + ) + + if (!dynamicRoutes.length) return + + const pathSegments = pathname.split('/').filter(Boolean) + + let match = undefined + + // TODO: Check algo efficiency + for (const dynamicRoute of dynamicRoutes) { + const dynamicRouteSegments = dynamicRoute.split('/').filter(Boolean) + + const routeSegmentsCollector: string[] = [] + + for (let i = 0; i < dynamicRouteSegments.length; i++) { + if ( + dynamicRouteSegments[i] === pathSegments[i] || + DYNAMIC_PATH_REGEX.test(dynamicRouteSegments[i] || '') + ) { + routeSegmentsCollector.push(dynamicRouteSegments[i] ?? '') + } else { + break + } + } + + if (routeSegmentsCollector.length === pathSegments.length) { + match = `/${routeSegmentsCollector.join('/')}` + break + } + } + + if (!match) return + return routesById[match] +} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index e0a902fc..f618dc32 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -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 } from './dynamic' +export { dynamic, lazyLoadComponent } from './dynamic' export { useRouter } from './hooks/useRouter' diff --git a/packages/router/src/route.ts b/packages/router/src/route.ts index 159e9c57..ae6665d8 100644 --- a/packages/router/src/route.ts +++ b/packages/router/src/route.ts @@ -6,7 +6,7 @@ interface RouteOptions { isRoot?: boolean getParentRoute?: () => Route path?: string - component: () => JSX.Element + component: RouteComponent } export function createRoute(options: RouteOptions): Route { diff --git a/packages/router/src/types.ts b/packages/router/src/types.ts index 285241f1..913b0b44 100644 --- a/packages/router/src/types.ts +++ b/packages/router/src/types.ts @@ -17,4 +17,6 @@ export interface RouteProps { isLoading: boolean } -export type RouteComponent = (props: RouteProps) => JSX.Element +export type RouteComponent = ((props: RouteProps) => JSX.Element) & { + preload: () => void +} diff --git a/packages/tuono/package.json b/packages/tuono/package.json index 3768569c..622b92eb 100644 --- a/packages/tuono/package.json +++ b/packages/tuono/package.json @@ -1,6 +1,6 @@ { "name": "tuono", - "version": "0.8.4", + "version": "0.9.0", "description": "The react/rust fullstack framework", "scripts": { "dev": "vite build --watch", diff --git a/packages/tuono/src/index.ts b/packages/tuono/src/index.ts index 55ade937..db6cf60a 100644 --- a/packages/tuono/src/index.ts +++ b/packages/tuono/src/index.ts @@ -1,23 +1,16 @@ import Head from 'react-meta-tags' -import { + +export { createRoute, createRootRoute, createRouter, Link, RouterProvider, dynamic, + lazyLoadComponent, useRouter, } from 'tuono-router' -export type { TuonoProps } from './types' +export { Head } -export { - createRoute, - createRootRoute, - createRouter, - Link, - RouterProvider, - dynamic, - useRouter, - Head, -} +export type { TuonoProps } from './types'