Preload chunks when links are visible in the viewport (#24)

* feat: preload chunk when links are visible in the viewport

* feat: update version to v0.9.0
This commit is contained in:
Valerio Ageno
2024-08-14 20:50:47 +02:00
committed by GitHub
parent 58c4890122
commit 341fba6ca9
20 changed files with 138 additions and 139 deletions
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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'
+17 -37
View File
@@ -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,
@@ -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";
+2 -1
View File
@@ -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"
},
+16 -2
View File
@@ -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<HTMLAnchorElement>,
componentProps: AnchorHTMLAttributes<HTMLAnchorElement> & 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<HTMLAnchorElement>): void => {
e.preventDefault()
@@ -14,7 +28,7 @@ export default function Link(
}
return (
<a {...props} onClick={handleTransition}>
<a {...props} ref={ref} onClick={handleTransition}>
{props.children}
</a>
)
+2 -55
View File
@@ -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 <NotFound />
@@ -14,7 +14,7 @@ const root = {
component: ({ children }: Props) => (
<div data-testid="root">root route {children}</div>
),
} 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: () => <p data-testid="route">current route</p>,
options: {
getParentRoute: () => parent,
},
} as Route
} as unknown as Route
describe('Test RouteMatch component', () => {
afterEach(() => {
+10 -1
View File
@@ -1,4 +1,7 @@
import * as React from 'react'
import type { RouteComponent } from './types'
type ImportFn = () => Promise<{ default: React.ComponentType<any> }>
/**
* 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
}
@@ -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<string, any> } => {
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]',
)
})
+56
View File
@@ -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]
}
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -6,7 +6,7 @@ interface RouteOptions {
isRoot?: boolean
getParentRoute?: () => Route
path?: string
component: () => JSX.Element
component: RouteComponent
}
export function createRoute(options: RouteOptions): Route {
+3 -1
View File
@@ -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
}
+1 -1
View File
@@ -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",
+6 -13
View File
@@ -1,15 +1,4 @@
import Head from 'react-meta-tags'
import {
createRoute,
createRootRoute,
createRouter,
Link,
RouterProvider,
dynamic,
useRouter,
} from 'tuono-router'
export type { TuonoProps } from './types'
export {
createRoute,
@@ -18,6 +7,10 @@ export {
Link,
RouterProvider,
dynamic,
lazyLoadComponent,
useRouter,
Head,
}
} from 'tuono-router'
export { Head }
export type { TuonoProps } from './types'