Compare commits

...

10 Commits

Author SHA1 Message Date
Valerio Ageno 07351587f3 chore: bump version (0.19.6) (#771) 2025-05-11 19:11:40 +02:00
Valerio Ageno 3027b828a8 fix: improve critical css generation (#770) 2025-05-11 19:11:30 +02:00
Valerio Ageno eef2cd26e0 chore: bump ssr_rs (0.8.3) (#769) 2025-05-10 20:15:13 +02:00
Valerio Ageno 8190915e3a chore: bump version (0.19.5) (#768) 2025-05-09 17:02:38 +02:00
Valerio Ageno dd157d6ef9 feat: load critical CSS on development (#765) 2025-05-09 17:02:18 +02:00
Valerio Ageno 58f4ecb260 fix: tailwindcss template (#767) 2025-05-08 20:52:42 +02:00
Valerio Ageno ab441d1fa7 chore: dedupe pnpm dependencies (#766) 2025-05-07 20:47:26 +02:00
renovate[bot] 7538031261 chore(deps): update dependency typescript to v5.8.3 (#751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 10:23:37 +02:00
renovate[bot] 18f66ff79b chore(deps): update react monorepo to v19.1.3 (#746)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 10:23:24 +02:00
Valerio Ageno b48f7244b7 refactor: turn tuono-fs-router-vite-plugin into tuono-react-vite-plugin (#756) 2025-04-28 20:54:40 +02:00
70 changed files with 554 additions and 169 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono"
version = "0.19.4"
version = "0.19.6"
edition = "2024"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
@@ -24,6 +24,7 @@ tracing-subscriber = {version = "0.3.19", features = ["env-filter"]}
miette = "7.2.0"
colored = "2.1.0"
once_cell = "1.19.0"
watchexec = "5.0.0"
watchexec-signals = "4.0.0"
watchexec-events = "4.0.0"
@@ -36,7 +37,7 @@ reqwest = { version = "0.12.4", features = ["blocking", "json"] }
serde_json = "1.0"
fs_extra = "1.3.0"
http = "1.1.0"
tuono_internal = {path = "../tuono_internal", version = "0.19.4"}
tuono_internal = {path = "../tuono_internal", version = "0.19.6"}
spinners = "4.1.1"
console = "0.15.10"
convert_case = "0.8.0"
+15 -1
View File
@@ -1,3 +1,6 @@
use once_cell::sync::Lazy;
use regex::Regex;
use std::borrow::Cow;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
@@ -19,6 +22,12 @@ use crate::source_builder::SourceBuilder;
use console::Term;
use spinners::{Spinner, Spinners};
fn is_css_module(file_name: Cow<str>) -> bool {
static RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^.*\.module\.(css|scss|sass|less|styl|stylus)$").unwrap());
RE.is_match(&file_name)
}
fn ssr_reload_needed(path: &Path) -> bool {
let file_name_starts_with_env = path
.file_name()
@@ -27,7 +36,12 @@ fn ssr_reload_needed(path: &Path) -> bool {
let file_path = path.to_string_lossy();
file_name_starts_with_env || file_path.ends_with("sx") || file_path.ends_with("mdx")
file_name_starts_with_env
|| file_path.ends_with("sx")
|| file_path.ends_with("mdx")
// When a CSS module is modified
// also the class names get modified.
|| is_css_module(file_path)
}
#[allow(
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_internal"
version = "0.19.4"
version = "0.19.6"
edition = "2024"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
+4 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib"
version = "0.19.4"
version = "0.19.6"
edition = "2024"
authors = ["V. Ageno <valerioageno@yahoo.it>"]
description = "Superfast React fullstack framework"
@@ -17,7 +17,7 @@ include = [
[dependencies]
ssr_rs = "0.8.2"
ssr_rs = "0.8.3"
axum = {version = "0.8.1", features = ["json", "ws"]}
axum-extra = {version = "0.10.0", features = ["cookie"]}
tokio = { version = "1.37.0", features = ["full"] }
@@ -32,8 +32,8 @@ either = "1.13.0"
tower-http = {version = "0.6.0", features = ["fs"]}
colored = "3.0.0"
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.4"}
tuono_internal = {path = "../tuono_internal", version = "0.19.4"}
tuono_lib_macros = {path = "../tuono_lib_macros", version = "0.19.6"}
tuono_internal = {path = "../tuono_internal", version = "0.19.6"}
# Match the same version used by axum
tokio-tungstenite = "0.26.0"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
+24 -3
View File
@@ -1,12 +1,16 @@
use crate::config::GLOBAL_CONFIG;
use axum::body::Body;
use axum::extract::Path;
use axum::extract::{Path, Query};
use std::collections::HashMap;
use axum::http::{HeaderName, HeaderValue};
use axum::response::{IntoResponse, Response};
use reqwest::Client;
pub async fn vite_reverse_proxy(Path(path): Path<String>) -> impl IntoResponse {
pub async fn vite_reverse_proxy(
Path(path): Path<String>,
query: Query<HashMap<String, String>>,
) -> impl IntoResponse {
let client = Client::new();
let config = GLOBAL_CONFIG
@@ -19,7 +23,24 @@ pub async fn vite_reverse_proxy(Path(path): Path<String>) -> impl IntoResponse {
config.server.port + 1
);
match client.get(format!("{vite_url}/{path}")).send().await {
let query_string = query
.0
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let query_string = if query_string.is_empty() {
String::new()
} else {
format!("?{}", query_string)
};
match client
.get(format!("{vite_url}/{path}{query_string}"))
.send()
.await
{
Ok(res) => {
let mut response_builder = Response::builder().status(res.status().as_u16());
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuono_lib_macros"
version = "0.19.4"
version = "0.19.6"
edition = "2024"
description = "Superfast React fullstack framework"
homepage = "https://tuono.dev"
+1 -2
View File
@@ -31,7 +31,7 @@ const tuonoEslintConfig = tseslint.config(
// #endregion shared
// #region package-specific
'packages/tuono-fs-router-vite-plugin/tests/generator/**',
'packages/tuono-react-vite-plugin/tests/generator/**',
'packages/tuono-lazy-fn-vite-plugin/tests/sources/**',
@@ -69,7 +69,6 @@ const tuonoEslintConfig = tseslint.config(
{
files: [REACT_FILES_MATCH],
plugins: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
'react-hooks': eslintPluginReactHooks,
},
rules: {
+1 -1
View File
@@ -1 +1 @@
@import 'tailwindcss';
@import 'tailwindcss' source('../..');
@@ -1,47 +0,0 @@
import { normalize } from 'node:path'
import type { Plugin } from 'vite'
import { routeGenerator } from './generator'
const ROUTES_DIRECTORY_PATH = './src/routes'
let lock = false
export function TuonoFsRouterPlugin(): Plugin {
const generate = async (): Promise<void> => {
if (lock) return
lock = true
try {
await routeGenerator()
} catch (err) {
console.error(err)
} finally {
lock = false
}
}
const handleFile = async (file: string): Promise<void> => {
const filePath = normalize(file)
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
await generate()
}
}
return {
name: 'vite-plugin-tuono-fs-router',
configResolved: async (): Promise<void> => {
await generate()
},
watchChange: async (
file: string,
context: { event: string },
): Promise<void> => {
if (['create', 'update', 'delete'].includes(context.event)) {
await handleFile(file)
}
},
}
}
@@ -1,6 +1,6 @@
{
"name": "tuono-fs-router-vite-plugin",
"version": "0.19.4",
"name": "tuono-react-vite-plugin",
"version": "0.19.6",
"description": "Plugin for the tuono's file system router. Tuono is the react/rust fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
@@ -16,7 +16,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/tuono-labs/tuono.git",
"directory": "packages/tuono-fs-router-vite-plugin"
"directory": "packages/tuono-react-vite-plugin"
},
"keywords": [],
"author": "Valerio Ageno",
@@ -1,5 +1,6 @@
import type { RouteNode } from '../types'
import { spaces } from './utils'
import type { RouteNode } from './types'
export function buildRouteConfig(nodes: Array<RouteNode>, depth = 1): string {
const children = nodes.map((node) => {
@@ -3,6 +3,8 @@ import path from 'path'
import { format } from 'prettier'
import type { Config, RouteNode } from '../types'
import { buildRouteConfig } from './build-route-config'
import { hasParentRoute } from './has-parent-route'
import {
@@ -17,7 +19,6 @@ import {
removeUnderscores,
} from './utils'
import type { Config, RouteNode } from './types'
import {
ROUTES_FOLDER,
LAYOUT_PATH_ID,
@@ -26,7 +27,7 @@ import {
} from './constants'
import { sortRouteNodes } from './sort-route-nodes'
import isDefaultExported from './utils/is-default-exported'
import isDefaultExported from './is-default-exported'
let latestTask = 0
@@ -206,6 +207,7 @@ export async function routeGenerator(
rustHandlersNodes.includes(node.path || '')
? 'hasHandler: true'
: '',
`filePath: '${node.path || '/'}'`,
]
.filter(Boolean)
.join(',')}
@@ -1,6 +1,7 @@
import type { RouteNode } from '../types'
import { LAYOUT_PATH_ID } from './constants'
import { multiSortBy } from './utils'
import type { RouteNode } from './types'
export function hasParentRoute(
routes: Array<RouteNode>,
@@ -1,4 +1,5 @@
import type { RouteNode } from './types'
import type { RouteNode } from '../types'
import { multiSortBy } from './utils'
import { LAYOUT_PATH_ID } from './constants'
@@ -1,4 +1,4 @@
import type { RouteNode } from './types'
import type { RouteNode } from '../types'
export function removeExt(d: string, keepExtension: boolean = false): string {
return keepExtension ? d : d.substring(0, d.lastIndexOf('.')) || d
@@ -0,0 +1 @@
export { TuonoReactPlugin } from './plugin'
@@ -0,0 +1,83 @@
import { normalize } from 'node:path'
import type { Plugin, ViteDevServer } from 'vite'
import { routeGenerator } from './fs-routing/generator'
import { getStylesForComponentId, isCssModulesFile } from './styles'
const CRITICAL_CSS_PATH = '/vite-server/tuono_internal__critical_css'
const ROUTES_DIRECTORY_PATH = './src/routes'
let lock = false
export function TuonoReactPlugin(): Plugin {
const generate = async (): Promise<void> => {
if (lock) return
lock = true
try {
await routeGenerator()
} catch (err) {
console.error(err)
} finally {
lock = false
}
}
const handleFile = async (file: string): Promise<void> => {
const filePath = normalize(file)
if (filePath.startsWith(ROUTES_DIRECTORY_PATH)) {
await generate()
}
}
// This manifest is used to store the CSS modules contents in dev mode
// { [filePath]: cssContent }
const cssModulesManifest: Record<string, string> = {}
return {
name: 'vite-plugin-tuono-react',
configResolved: async (): Promise<void> => {
await generate()
},
watchChange: async (
file: string,
context: { event: string },
): Promise<void> => {
if (['create', 'update', 'delete'].includes(context.event)) {
await handleFile(file)
}
},
transform: (code, id): void => {
if (isCssModulesFile(id)) {
cssModulesManifest[id] = code
}
},
configureServer: (server: ViteDevServer): void => {
// Using middlewares in order to take advantage of async requests out of
// the box
// eslint-disable-next-line @typescript-eslint/no-misused-promises
server.middlewares.use(async (req, res, next): Promise<void> => {
const url = new URL(req.url || '', `http://${req.headers.host || ''}`)
// Give the request handler access to the critical CSS in dev to avoid a
// flash of unstyled content since Vite injects CSS file contents via JS
if (url.pathname === CRITICAL_CSS_PATH) {
const componentId = url.searchParams.get('componentId')
const css = await getStylesForComponentId(
server,
componentId,
cssModulesManifest,
)
res.writeHead(200, { 'Content-Type': 'text/css' })
res.end(css)
return
}
next()
})
},
}
}
@@ -0,0 +1,215 @@
/**
* This module is strongly inspired by the remix project.
*
* source: https://github.com/remix-run/remix/blob/main/packages/remix-dev/vite/styles.ts
*/
import path from 'path'
import type { ModuleNode, ViteDevServer } from 'vite'
const isCssFile = (file: string): boolean => cssFileRegExp.test(file)
const cssFileRegExp =
/\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/
const cssModulesRegExp = new RegExp(`\\.module${cssFileRegExp.source}`)
const routesFolder = path.relative(process.cwd(), 'src/routes')
const injectQuery = (url: string, query: string): string =>
url.includes('?') ? url.replace('?', `?${query}&`) : `${url}?${query}`
export const isCssModulesFile = (file: string): boolean =>
cssModulesRegExp.test(file)
const cssUrlParamsWithoutSideEffects = ['url', 'inline', 'raw', 'inline-css']
const isCssUrlWithoutSideEffects = (url: string): boolean => {
const queryString = url.split('?')[1]
if (!queryString) {
return false
}
const params = new URLSearchParams(queryString)
for (const paramWithoutSideEffects of cssUrlParamsWithoutSideEffects) {
if (
// Parameter is blank and not explicitly set, i.e. "?url", not "?url="
params.get(paramWithoutSideEffects) === '' &&
!url.includes(`?${paramWithoutSideEffects}=`) &&
!url.includes(`&${paramWithoutSideEffects}=`)
) {
return true
}
}
return false
}
const normalizePath = (modulePath: string): string => {
return modulePath.startsWith('node_modules')
? path.join(process.cwd(), modulePath)
: modulePath
}
export const getStylesForModule = async (
viteDevServer: ViteDevServer,
moduleUrl: string,
/**
* All the CSS modules are preloaded and saved in this manifest
*/
cssModulesManifest: Record<string, string>,
): Promise<string | undefined> => {
const styles: Record<string, string> = {}
const deps: Set<ModuleNode> = new Set()
const moduleFilePath = normalizePath(moduleUrl)
try {
let node: ModuleNode | undefined =
await viteDevServer.moduleGraph.getModuleByUrl(moduleFilePath)
// If the module is only present in the client module graph, the module
// won't have been found on the first request to the server. If so, we
// request the module so it's in the module graph, then try again.
if (!node) {
try {
await viteDevServer.transformRequest(moduleFilePath)
} catch (err) {
console.error(err)
}
node = await viteDevServer.moduleGraph.getModuleByUrl(moduleFilePath)
}
if (!node) {
console.error(`Could not resolve module for file: ${moduleFilePath}`)
return
}
await findNodeDependencies(viteDevServer, node, deps)
} catch (error) {
console.error(error)
}
for (const dep of deps) {
if (
dep.file &&
isCssFile(dep.file) &&
!isCssUrlWithoutSideEffects(dep.url) // Ignore styles that resolved as URLs, inline or raw. These shouldn't get injected.
) {
try {
const css = isCssModulesFile(dep.file)
? cssModulesManifest[dep.file]
: ((
await viteDevServer.ssrLoadModule(
// We need the ?inline query in Vite v6 when loading CSS in SSR
// since it does not expose the default export for CSS in a
// server environment.
injectQuery(normalizePath(dep.file), 'inline'),
)
).default as string)
if (css === undefined) {
throw new Error()
}
styles[dep.url] = css
} catch {
// this can happen with dynamically imported modules
console.warn(`Could not load ${dep.file}`)
}
}
}
return (
Object.entries(styles)
.map(([fileName, css]) => [
`\n/* ${fileName
// Escape comment syntax in file paths
.replace(/\/\*/g, '/\\*')
.replace(/\*\//g, '*\\/')} */`,
css,
])
.flat()
.join('\n') || undefined
)
}
/**
* This function transform the componentId into a file path.
* File extension is not required for the vite.moduleGraph URL search.
*/
function findFileFromComponentId(id: string): string {
if (id.endsWith('/')) {
return id + 'index'
}
if (id.includes('__root__')) {
return id.replaceAll('__root__', '__layout')
}
return id
}
export const getStylesForComponentId = async (
viteDevServer: ViteDevServer,
/**
* The route name (should match tuono-router specs)
*/
componentId: string | null,
/**
* All the CSS modules are preloaded and saved in this manifest
*/
cssModulesManifest: Record<string, string>,
): Promise<string | undefined> => {
const relativeFilePath = path.join(
routesFolder,
findFileFromComponentId(componentId || ''),
)
const fileUrl = path.join(process.cwd(), relativeFilePath)
return await getStylesForModule(viteDevServer, fileUrl, cssModulesManifest)
}
/**
* This function is used to find all the dependencies of a module node.
* The starting node is always a route.
*/
const findNodeDependencies = async (
vite: ViteDevServer,
node: ModuleNode,
deps: Set<ModuleNode>,
): Promise<void> => {
// since `ssrTransformResult.deps` contains URLs instead of `ModuleNode`s, this process is asynchronous.
// instead of using `await`, we resolve all branches in parallel.
const branches: Array<Promise<void>> = []
async function addFromNode(innerNode: ModuleNode): Promise<void> {
if (!deps.has(innerNode)) {
deps.add(innerNode)
await findNodeDependencies(vite, innerNode, deps)
}
}
async function addFromUrl(url: string): Promise<void> {
const innerNode = await vite.moduleGraph.getModuleByUrl(url)
if (innerNode) {
await addFromNode(innerNode)
}
}
if (node.ssrTransformResult) {
if (node.ssrTransformResult.deps) {
node.ssrTransformResult.deps.forEach((url) =>
branches.push(addFromUrl(url)),
)
}
} else {
node.importedModules.forEach((innerNode: ModuleNode) =>
branches.push(addFromNode(innerNode)),
)
}
await Promise.all(branches)
}
@@ -2,7 +2,7 @@ import fs from 'fs/promises'
import { describe, it, expect } from 'vitest'
import { routeGenerator } from '../src/generator'
import { routeGenerator } from '../src/fs-routing/generator'
describe('generator works', async () => {
const folderNames = await fs.readdir(`${process.cwd()}/tests/generator`)
@@ -21,12 +21,14 @@ const Postscatchall = createRoute({ component: PostscatchallImport })
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
const PostscatchallRoute = Postscatchall.update({
path: '/posts/[...catch_all]',
getParentRoute: () => rootRoute,
hasHandler: true,
filePath: '/posts/[...catch_all]',
})
// Create and export the route tree
@@ -21,11 +21,13 @@ const Index = createRoute({ component: IndexImport })
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
filePath: '/about',
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
// Create and export the route tree
@@ -36,31 +36,37 @@ const PostsMyPost = createRoute({ component: PostsMyPostImport })
const PostslayoutRoute = Postslayout.update({
getParentRoute: () => rootRoute,
filePath: '/posts/__layout',
})
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
filePath: '/about',
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
const PostspostRoute = Postspost.update({
path: '/posts/[post]',
getParentRoute: () => PostslayoutRoute,
filePath: '/posts/[post]',
})
const PostsIndexRoute = PostsIndex.update({
path: '/posts',
getParentRoute: () => PostslayoutRoute,
filePath: '/posts/',
})
const PostsMyPostRoute = PostsMyPost.update({
path: '/posts/my-post',
getParentRoute: () => PostslayoutRoute,
filePath: '/posts/my-post',
})
// Create and export the route tree
@@ -25,16 +25,19 @@ const PostsMyPost = createRoute({ component: PostsMyPostImport })
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
filePath: '/about',
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
const PostsMyPostRoute = PostsMyPost.update({
path: '/posts/my-post',
getParentRoute: () => rootRoute,
filePath: '/posts/my-post',
})
// Create and export the route tree
@@ -21,11 +21,13 @@ const Index = createRoute({ component: IndexImport })
const AboutRoute = About.update({
path: '/about',
getParentRoute: () => rootRoute,
filePath: '/about',
})
const IndexRoute = Index.update({
path: '/',
getParentRoute: () => rootRoute,
filePath: '/',
})
// Create and export the route tree
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "tuono-router",
"version": "0.19.4",
"version": "0.19.6",
"description": "React routing component for the framework tuono. Tuono is the react/rust fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
@@ -45,7 +45,7 @@
},
"devDependencies": {
"@testing-library/react": "16.3.0",
"@types/react": "19.1.1",
"@types/react": "19.1.3",
"@vitejs/plugin-react-swc": "3.8.1",
"happy-dom": "17.4.4",
"react": "19.1.0",
@@ -0,0 +1,33 @@
import type { JSX } from 'react'
import type { Mode } from '../types'
const VITE_PROXY_PATH = '/vite-server'
const CRITICAL_CSS_PATH = VITE_PROXY_PATH + '/tuono_internal__critical_css'
interface CriticalCssProps {
routeFilePath?: string
mode?: Mode
}
/**
* Returns the critical CSS for the given route
* This is required in order to avoid FOUC during development
* since vite does not support CSS injection without JS waterfall
*/
export function CriticalCss({
routeFilePath,
mode,
}: CriticalCssProps): JSX.Element | null {
if (!routeFilePath || mode !== 'Dev') {
return null
}
return (
<link
href={`${CRITICAL_CSS_PATH}?componentId=${routeFilePath}`}
precedence="high"
rel="stylesheet"
/>
)
}
@@ -2,6 +2,8 @@ import type { JSX } from 'react'
import { useRoute } from '../hooks/useRoute'
import type { Mode } from '../types'
import { RouteMatch } from './RouteMatch'
import { NotFound } from './NotFound'
import { useRouterContext } from './RouterContext'
@@ -9,16 +11,26 @@ import { useRouterContext } from './RouterContext'
interface MatchesProps<TServerPayloadData = unknown> {
// user defined props
serverInitialData: TServerPayloadData
mode?: Mode
}
export function Matches({ serverInitialData }: MatchesProps): JSX.Element {
export function Matches({
serverInitialData,
mode,
}: MatchesProps): JSX.Element {
const { location } = useRouterContext()
const route = useRoute(location.pathname)
if (!route) {
return <NotFound />
return <NotFound mode={mode} />
}
return <RouteMatch route={route} serverInitialData={serverInitialData} />
return (
<RouteMatch
route={route}
mode={mode}
serverInitialData={serverInitialData}
/>
)
}
@@ -3,17 +3,25 @@ import type { JSX } from 'react'
import { useRouterContext } from '../components/RouterContext'
import { ROOT_ROUTE_ID } from '../route'
import type { Mode } from '../types'
import { RouteMatch } from './RouteMatch'
import { NotFoundDefaultContent } from './NotFoundDefaultContent'
import { CriticalCss } from './CriticalCss'
export function NotFound(): JSX.Element | null {
export function NotFound({ mode }: { mode?: Mode }): JSX.Element | null {
const { router } = useRouterContext()
const custom404Route = router.routesById['/404']
// Check if exists a custom 404 error page
if (custom404Route) {
return <RouteMatch route={custom404Route} serverInitialData={{}} />
return (
<>
<CriticalCss routeFilePath={custom404Route.filePath} mode={mode} />
<RouteMatch route={custom404Route} mode={mode} serverInitialData={{}} />
</>
)
}
const RootLayout = router.routesById[ROOT_ROUTE_ID]?.component
@@ -22,6 +30,7 @@ export function NotFound(): JSX.Element | null {
return (
<RootLayout data={null} isLoading={false}>
<CriticalCss routeFilePath="__root__" mode={mode} />
<NotFoundDefaultContent />
</RootLayout>
)
@@ -1,16 +1,19 @@
import type { JSX } from 'react'
import { memo, Suspense, useMemo } from 'react'
import type { Mode } from '../types'
import type { Route } from '../route'
import { useServerPayloadData } from '../hooks/useServerPayloadData'
import { useRouterContext } from './RouterContext'
import { CriticalCss } from './CriticalCss'
interface RouteMatchProps<TServerPayloadData = unknown> {
route: Route
// User defined server side props
serverInitialData: TServerPayloadData
mode?: Mode
}
/**
@@ -21,6 +24,7 @@ interface RouteMatchProps<TServerPayloadData = unknown> {
export const RouteMatch = ({
route,
serverInitialData,
mode,
}: RouteMatchProps): JSX.Element => {
const { data } = useServerPayloadData(route, serverInitialData)
const { isTransitioning } = useRouterContext()
@@ -35,8 +39,10 @@ export const RouteMatch = ({
routes={routes}
data={routeData}
isLoading={isTransitioning}
mode={mode}
>
<Suspense>
<CriticalCss routeFilePath={route.filePath} mode={mode} />
<route.component data={routeData} isLoading={isTransitioning} />
</Suspense>
</TraverseRootComponents>
@@ -49,6 +55,7 @@ interface TraverseRootComponentsProps<TData = unknown> {
isLoading: boolean
children?: React.ReactNode
index?: number
mode?: Mode
}
/**
@@ -63,18 +70,26 @@ const TraverseRootComponents = memo(
data,
isLoading,
index = 0,
mode,
children,
}: TraverseRootComponentsProps): React.JSX.Element => {
if (routes.length > index) {
const Parent = (routes[index] as Route).component
const route = routes[index] as Route
const Parent = route.component
// Fallback to the route id if the filePath is not defined
// as is the case for the root route
const routeFilePath = route.filePath || route.id
return (
<Parent data={data} isLoading={isLoading}>
<CriticalCss routeFilePath={routeFilePath} mode={mode} />
<TraverseRootComponents
routes={routes}
data={data}
isLoading={isLoading}
index={index + 1}
mode={mode}
>
{children}
</TraverseRootComponents>
@@ -1,6 +1,6 @@
import type { JSX } from 'react'
import type { ServerInitialLocation } from '../types'
import type { ServerInitialLocation, Mode } from '../types'
import type { Router } from '../router'
import { RouterContextProvider } from './RouterContext'
@@ -10,19 +10,21 @@ interface RouterProviderProps {
router: Router
serverInitialLocation: ServerInitialLocation
serverInitialData: unknown
mode?: Mode
}
export function RouterProvider({
router,
serverInitialLocation,
serverInitialData,
mode,
}: RouterProviderProps): JSX.Element {
return (
<RouterContextProvider
router={router}
serverInitialLocation={serverInitialLocation}
>
<Matches serverInitialData={serverInitialData} />
<Matches serverInitialData={serverInitialData} mode={mode} />
</RouterContextProvider>
)
}
+3 -1
View File
@@ -16,7 +16,9 @@ export function sanitizePathname(pathname: string): string {
return pathname
}
/*
/**
* Returns the route that matches the given pathname
*
* This hook is also implemented on server side to match the bundle
* file to load at the first rendering.
*
+20 -3
View File
@@ -6,6 +6,7 @@ interface RouteOptions {
isRoot?: boolean
getParentRoute?: () => Route
path?: string
filePath?: string
component: RouteComponent
hasHandler?: boolean
}
@@ -19,11 +20,28 @@ export const ROOT_ROUTE_ID = '__root__'
export class Route {
options: RouteOptions
/**
* The route id is used to identify the route in the router
* and is used to match the route with the URL.
*
* For now is the `path`
*/
id?: string
isRoot: boolean
/**
* Used for identify the route by matching the URL
*/
path?: string
fullPath!: string
/**
* Utility to identify the route in the file system
* Used i.e. for finding the criticalCss to load
*
* The path does not include the file extension
*/
filePath?: string
children?: Array<Route>
parentRoute?: Route
originalIndex?: number
@@ -70,11 +88,10 @@ export class Route {
id = joinPaths(['/', id])
}
const fullPath = id === ROOT_ROUTE_ID ? '/' : path
this.filePath = this.options.filePath
this.path = path
this.id = id
this.fullPath = fullPath || ''
this.fullPath = path || ''
}
addChildren(routes: Array<Route>): this {
+2
View File
@@ -1,5 +1,7 @@
import type { ReactNode, ComponentType } from 'react'
export type Mode = 'Dev' | 'Prod'
export interface Segment {
type: 'pathname' | 'param' | 'wildcard'
value: string
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "tuono",
"version": "0.19.4",
"version": "0.19.6",
"description": "Superfast React fullstack framework",
"homepage": "https://tuono.dev",
"scripts": {
@@ -71,7 +71,7 @@
"@rollup/plugin-inject": "^5.0.5",
"@vitejs/plugin-react-swc": "^3.8.0",
"fast-text-encoding": "^1.0.6",
"tuono-fs-router-vite-plugin": "workspace:*",
"tuono-react-vite-plugin": "workspace:*",
"tuono-router": "workspace:*",
"url-search-params-polyfill": "^8.2.5",
"vite": "^6.1.1",
@@ -81,8 +81,8 @@
"@types/babel__core": "7.20.5",
"@types/babel__traverse": "7.20.7",
"@types/node": "22.14.1",
"@types/react": "19.1.1",
"@types/react-dom": "19.1.2",
"@types/react": "19.1.3",
"@types/react-dom": "19.1.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"vite-config": "workspace:*",
+2 -2
View File
@@ -2,7 +2,7 @@ import type { InlineConfig, Plugin } from 'vite'
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 { TuonoReactPlugin } from 'tuono-react-vite-plugin'
import type { TuonoConfig } from '../config'
@@ -71,7 +71,7 @@ function createBaseViteConfigFromTuonoConfig(
// @ts-expect-error see above comment
react({ include: pluginFilesInclude }),
TuonoFsRouterPlugin(),
TuonoReactPlugin(),
],
}
+1 -1
View File
@@ -2,8 +2,8 @@ import type { JSX } from 'react'
import type { TuonoConfigServer } from '../config'
const VITE_PROXY_PATH = '/vite-server'
const DEFAULT_SERVER_CONFIG = { host: 'localhost', origin: null, port: 3000 }
const VITE_PROXY_PATH = '/vite-server'
interface DevResourcesProps {
devServerConfig?: TuonoConfigServer
@@ -2,10 +2,13 @@ import type { JSX } from 'react'
import { RouterProvider } from 'tuono-router'
import type { RouterInstanceType } from 'tuono-router'
import type { Mode } from '../types'
import { useTuonoContextServerPayload } from './TuonoContext'
interface RouterContextProviderWrapperProps {
router: RouterInstanceType
mode?: Mode
}
/**
@@ -17,6 +20,7 @@ interface RouterContextProviderWrapperProps {
*/
export function RouterContextProviderWrapper({
router,
mode,
}: RouterContextProviderWrapperProps): JSX.Element {
const serverPayload = useTuonoContextServerPayload()
@@ -25,6 +29,7 @@ export function RouterContextProviderWrapper({
router={router}
serverInitialLocation={serverPayload.location}
serverInitialData={serverPayload.data}
mode={mode}
/>
)
}
@@ -19,7 +19,10 @@ export function TuonoEntryPoint({
return (
<StrictMode>
<TuonoContextProvider serverPayload={serverPayload}>
<RouterContextProviderWrapper router={router} />
<RouterContextProviderWrapper
router={router}
mode={serverPayload?.mode}
/>
</TuonoContextProvider>
</StrictMode>
)
-1
View File
@@ -1 +0,0 @@
export type Mode = 'Dev' | 'Prod'
+2
View File
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react'
import type { TuonoConfigServer } from './config'
export type Mode = 'Dev' | 'Prod'
/**
* Provided by the rust server and used in the ssr env
* @see tuono-router {@link ServerInitialLocation}
+51 -74
View File
@@ -80,10 +80,10 @@ importers:
devDependencies:
'@types/react':
specifier: ^19.0.2
version: 19.1.0
version: 19.1.3
'@types/react-dom':
specifier: ^19.0.2
version: 19.1.1(@types/react@19.1.0)
version: 19.1.3(@types/react@19.1.3)
typescript:
specifier: ^5.6.3
version: 5.7.3
@@ -102,10 +102,10 @@ importers:
devDependencies:
'@types/react':
specifier: ^19.0.2
version: 19.1.0
version: 19.1.3
'@types/react-dom':
specifier: ^19.0.2
version: 19.1.1(@types/react@19.1.0)
version: 19.1.3(@types/react@19.1.3)
typescript:
specifier: ^5.6.3
version: 5.7.3
@@ -124,10 +124,10 @@ importers:
devDependencies:
'@types/react':
specifier: ^19.0.2
version: 19.1.0
version: 19.1.3
'@types/react-dom':
specifier: ^19.0.2
version: 19.1.1(@types/react@19.1.0)
version: 19.1.3(@types/react@19.1.3)
typescript:
specifier: ^5.6.3
version: 5.7.3
@@ -136,7 +136,7 @@ importers:
dependencies:
'@mdx-js/react':
specifier: ^3.1.0
version: 3.1.0(@types/react@19.1.0)(react@19.1.0)
version: 3.1.0(@types/react@19.1.3)(react@19.1.0)
react:
specifier: ^19.0.0
version: 19.1.0
@@ -152,10 +152,10 @@ importers:
version: 3.1.0(acorn@8.14.1)(rollup@4.38.0)
'@types/react':
specifier: ^19.0.2
version: 19.1.0
version: 19.1.3
'@types/react-dom':
specifier: ^19.0.2
version: 19.1.1(@types/react@19.1.0)
version: 19.1.3(@types/react@19.1.3)
typescript:
specifier: ^5.6.3
version: 5.7.3
@@ -180,10 +180,10 @@ importers:
devDependencies:
'@types/react':
specifier: ^19.0.2
version: 19.1.0
version: 19.1.3
'@types/react-dom':
specifier: ^19.0.2
version: 19.1.1(@types/react@19.1.0)
version: 19.1.3(@types/react@19.1.3)
typescript:
specifier: ^5.6.3
version: 5.7.3
@@ -208,9 +208,9 @@ importers:
fast-text-encoding:
specifier: ^1.0.6
version: 1.0.6
tuono-fs-router-vite-plugin:
tuono-react-vite-plugin:
specifier: workspace:*
version: link:../tuono-fs-router-vite-plugin
version: link:../tuono-react-vite-plugin
tuono-router:
specifier: workspace:*
version: link:../tuono-router
@@ -234,11 +234,11 @@ importers:
specifier: 22.14.1
version: 22.14.1
'@types/react':
specifier: 19.1.1
version: 19.1.1
specifier: 19.1.3
version: 19.1.3
'@types/react-dom':
specifier: 19.1.2
version: 19.1.2(@types/react@19.1.1)
specifier: 19.1.3
version: 19.1.3(@types/react@19.1.3)
react:
specifier: 19.1.0
version: 19.1.0
@@ -252,7 +252,7 @@ importers:
specifier: 3.1.1
version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.1)(happy-dom@17.4.4)(jiti@2.4.2)(lightningcss@1.29.1)(sugarss@4.0.1(postcss@8.5.3))
packages/tuono-fs-router-vite-plugin:
packages/tuono-react-vite-plugin:
dependencies:
'@babel/core':
specifier: ^7.24.4
@@ -285,10 +285,10 @@ importers:
devDependencies:
'@testing-library/react':
specifier: 16.3.0
version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.2(@types/react@19.1.1))(@types/react@19.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.3(@types/react@19.1.3))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@types/react':
specifier: 19.1.1
version: 19.1.1
specifier: 19.1.3
version: 19.1.3
'@vitejs/plugin-react-swc':
specifier: 3.8.1
version: 3.8.1(vite@6.1.3(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.1)(sugarss@4.0.1(postcss@8.5.3)))
@@ -314,8 +314,8 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@babel/code-frame@7.26.2':
resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.26.2':
@@ -352,8 +352,8 @@ packages:
resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.25.9':
resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.25.9':
@@ -381,8 +381,8 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.27.0':
resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==}
'@babel/runtime@7.27.1':
resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==}
engines: {node: '>=6.9.0'}
'@babel/template@7.25.9':
@@ -1194,21 +1194,13 @@ packages:
'@types/node@22.14.1':
resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==}
'@types/react-dom@19.1.1':
resolution: {integrity: sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==}
'@types/react-dom@19.1.3':
resolution: {integrity: sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==}
peerDependencies:
'@types/react': ^19.0.0
'@types/react-dom@19.1.2':
resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==}
peerDependencies:
'@types/react': ^19.0.0
'@types/react@19.1.0':
resolution: {integrity: sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==}
'@types/react@19.1.1':
resolution: {integrity: sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==}
'@types/react@19.1.3':
resolution: {integrity: sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -2587,9 +2579,6 @@ packages:
resolution: {integrity: sha512-r0Ay04Snci87djAsI4U+WNRcSw5S4pOH7qFjd/veA5gC7TbqESR3tcj28ia95L/fYUDw11JKP7uqUKUAfVvV5Q==}
engines: {node: '>= 0.4'}
regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
regexp.prototype.flags@1.5.3:
resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==}
engines: {node: '>= 0.4'}
@@ -3094,9 +3083,9 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
'@babel/code-frame@7.26.2':
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.25.9
'@babel/helper-validator-identifier': 7.27.1
js-tokens: 4.0.0
picocolors: 1.1.1
@@ -3105,7 +3094,7 @@ snapshots:
'@babel/core@7.26.0':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/code-frame': 7.26.2
'@babel/code-frame': 7.27.1
'@babel/generator': 7.26.3
'@babel/helper-compilation-targets': 7.25.9
'@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0)
@@ -3149,7 +3138,7 @@ snapshots:
dependencies:
'@babel/core': 7.26.0
'@babel/helper-module-imports': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@babel/helper-validator-identifier': 7.27.1
'@babel/traverse': 7.26.4
transitivePeerDependencies:
- supports-color
@@ -3158,7 +3147,7 @@ snapshots:
'@babel/helper-string-parser@7.25.9': {}
'@babel/helper-validator-identifier@7.25.9': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-option@7.25.9': {}
@@ -3181,19 +3170,17 @@ snapshots:
'@babel/core': 7.26.0
'@babel/helper-plugin-utils': 7.25.9
'@babel/runtime@7.27.0':
dependencies:
regenerator-runtime: 0.14.1
'@babel/runtime@7.27.1': {}
'@babel/template@7.25.9':
dependencies:
'@babel/code-frame': 7.26.2
'@babel/code-frame': 7.27.1
'@babel/parser': 7.26.3
'@babel/types': 7.26.3
'@babel/traverse@7.26.4':
dependencies:
'@babel/code-frame': 7.26.2
'@babel/code-frame': 7.27.1
'@babel/generator': 7.26.3
'@babel/parser': 7.26.3
'@babel/template': 7.25.9
@@ -3206,7 +3193,7 @@ snapshots:
'@babel/types@7.26.3':
dependencies:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@babel/helper-validator-identifier': 7.27.1
'@emnapi/core@1.4.0':
dependencies:
@@ -3407,10 +3394,10 @@ snapshots:
- acorn
- supports-color
'@mdx-js/react@3.1.0(@types/react@19.1.0)(react@19.1.0)':
'@mdx-js/react@3.1.0(@types/react@19.1.3)(react@19.1.0)':
dependencies:
'@types/mdx': 2.0.13
'@types/react': 19.1.0
'@types/react': 19.1.3
react: 19.1.0
'@mdx-js/rollup@3.1.0(acorn@8.14.1)(rollup@4.38.0)':
@@ -3738,8 +3725,8 @@ snapshots:
'@testing-library/dom@10.4.0':
dependencies:
'@babel/code-frame': 7.26.2
'@babel/runtime': 7.27.0
'@babel/code-frame': 7.27.1
'@babel/runtime': 7.27.1
'@types/aria-query': 5.0.4
aria-query: 5.3.0
chalk: 4.1.2
@@ -3747,15 +3734,15 @@ snapshots:
lz-string: 1.5.0
pretty-format: 27.5.1
'@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.2(@types/react@19.1.1))(@types/react@19.1.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
'@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.3(@types/react@19.1.3))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@babel/runtime': 7.27.0
'@babel/runtime': 7.27.1
'@testing-library/dom': 10.4.0
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.1
'@types/react-dom': 19.1.2(@types/react@19.1.1)
'@types/react': 19.1.3
'@types/react-dom': 19.1.3(@types/react@19.1.3)
'@tybys/wasm-util@0.9.0':
dependencies:
@@ -3819,19 +3806,11 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/react-dom@19.1.1(@types/react@19.1.0)':
'@types/react-dom@19.1.3(@types/react@19.1.3)':
dependencies:
'@types/react': 19.1.0
'@types/react': 19.1.3
'@types/react-dom@19.1.2(@types/react@19.1.1)':
dependencies:
'@types/react': 19.1.1
'@types/react@19.1.0':
dependencies:
csstype: 3.1.3
'@types/react@19.1.1':
'@types/react@19.1.3':
dependencies:
csstype: 3.1.3
@@ -5632,8 +5611,6 @@ snapshots:
gopd: 1.2.0
which-builtin-type: 1.2.1
regenerator-runtime@0.14.1: {}
regexp.prototype.flags@1.5.3:
dependencies:
call-bind: 1.0.8