这是indexloc提供的服务,不要输入任何密码
Skip to content

Preload chunks when links are visible in the viewport #24

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/tuono/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.8.4"
version = "0.9.0"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework"
Expand Down
4 changes: 2 additions & 2 deletions crates/tuono_lib/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.8.4"
version = "0.9.0"
edition = "2021"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "The react/rust fullstack framework"
Expand Down Expand Up @@ -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"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/tuono_lib_macros/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion packages/fs-router-vite-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/lazy-fn-vite-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/lazy-fn-vite-plugin/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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'
54 changes: 17 additions & 37 deletions packages/lazy-fn-vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 = {
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 3 additions & 4 deletions packages/lazy-fn-vite-plugin/test/transpileSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 2 additions & 1 deletion packages/router/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -42,6 +42,7 @@
"react-dom": ">=16.3.0"
},
"dependencies": {
"react-intersection-observer": "^9.13.0",
"vite": "^5.2.11",
"zustand": "4.4.7"
},
Expand Down
18 changes: 16 additions & 2 deletions packages/router/src/components/Link.tsx
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -14,7 +28,7 @@ export default function Link(
}

return (
<a {...props} onClick={handleTransition}>
<a {...props} ref={ref} onClick={handleTransition}>
{props.children}
</a>
)
Expand Down
57 changes: 2 additions & 55 deletions packages/router/src/components/Matches.tsx
Original file line number Diff line number Diff line change
@@ -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 />
Expand Down
6 changes: 3 additions & 3 deletions packages/router/src/components/RouteMatch.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
Expand All @@ -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(() => {
Expand Down
11 changes: 10 additions & 1 deletion packages/router/src/dynamic.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand All @@ -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
}
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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]',
)
})
Expand Down
Loading