From ad513bf40eb0c50164e513eed5d88233b428f5fd Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Sun, 21 Sep 2025 09:14:00 +0200 Subject: [PATCH 01/20] fix(ci): wasmpack pnpm warning --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a451d2f794..c0702dd340a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -285,7 +285,7 @@ jobs: - run: pnpm install - if: matrix.settings.wasm - run: pnpm install wasm-pack + run: pnpm install -w wasm-pack - name: Lint check if: matrix.settings.wasm From e94dc523bee86d408c6511584198decf9c49da62 Mon Sep 17 00:00:00 2001 From: Varixo Date: Tue, 8 Jul 2025 15:45:20 +0200 Subject: [PATCH 02/20] chore: change route data and router config names --- .../docs/src/routes/api/qwik-router/api.json | 2 +- .../docs/src/routes/api/qwik-router/index.mdx | 4 +-- .../src/adapters/shared/vite/index.ts | 4 +-- packages/qwik-router/src/buildtime/build.ts | 18 +++++----- packages/qwik-router/src/buildtime/context.ts | 6 ++-- .../src/buildtime/markdown/frontmatter.ts | 4 +-- .../qwik-router/src/buildtime/markdown/mdx.ts | 4 +-- .../src/buildtime/markdown/menu.ts | 4 +-- .../src/buildtime/markdown/rehype.ts | 8 ++--- .../buildtime/routing/resolve-source-file.ts | 24 ++++++------- .../src/buildtime/routing/sort-routes.ts | 4 +-- .../src/buildtime/routing/sort-routes.unit.ts | 4 +-- .../buildtime/routing/walk-server-plugins.ts | 4 +-- .../runtime-generation/generate-entries.ts | 6 ++-- .../runtime-generation/generate-menus.ts | 9 +++-- .../generate-qwik-router-config.ts | 4 +-- .../runtime-generation/generate-routes.ts | 34 ++++++++++--------- .../generate-server-plugins.ts | 4 +-- .../generate-service-worker.ts | 4 +-- packages/qwik-router/src/buildtime/types.ts | 26 +++++++------- .../src/buildtime/vite/dev-middleware.ts | 8 ++--- .../src/buildtime/vite/get-route-imports.ts | 4 +-- .../buildtime/vite/get-route-imports.unit.ts | 12 +++---- .../qwik-router/src/buildtime/vite/plugin.ts | 8 ++--- .../qwik-router/src/buildtime/vite/types.ts | 8 ++--- .../resolve-request-handlers.ts | 34 ++++++------------- .../runtime/src/qwik-router.runtime.api.md | 7 ++-- .../qwik-router/src/runtime/src/routing.ts | 6 ++-- .../src/runtime/src/server-functions.ts | 1 + packages/qwik-router/src/runtime/src/types.ts | 22 +++++++++--- packages/qwik-router/src/utils/format.ts | 6 ++-- packages/qwik-router/src/utils/test-suite.ts | 18 +++++----- packages/qwik/src/optimizer/core/src/parse.rs | 1 + .../qwik/src/optimizer/core/src/transform.rs | 1 + .../qwik/src/optimizer/src/plugins/plugin.ts | 1 + 35 files changed, 165 insertions(+), 149 deletions(-) diff --git a/packages/docs/src/routes/api/qwik-router/api.json b/packages/docs/src/routes/api/qwik-router/api.json index dcba8cae5ed..659c327c524 100644 --- a/packages/docs/src/routes/api/qwik-router/api.json +++ b/packages/docs/src/routes/api/qwik-router/api.json @@ -796,7 +796,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type RouteData = [routeName: string, loaders: ModuleLoader[]] | [\n routeName: string,\n loaders: ModuleLoader[],\n originalPathname: string,\n routeBundleNames: string[]\n];\n```", + "content": "```typescript\nexport type RouteData = [\n routeName: string,\n moduleLoaders: ModuleLoader[]\n] | [\n routeName: string,\n moduleLoaders: ModuleLoader[],\n originalPathname: string,\n routeBundleNames: string[]\n];\n```", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.routedata.md" }, diff --git a/packages/docs/src/routes/api/qwik-router/index.mdx b/packages/docs/src/routes/api/qwik-router/index.mdx index 57aa7db932d..8301a0c13ee 100644 --- a/packages/docs/src/routes/api/qwik-router/index.mdx +++ b/packages/docs/src/routes/api/qwik-router/index.mdx @@ -1854,10 +1854,10 @@ routeAction$: ActionConstructor; ```typescript export type RouteData = - | [routeName: string, loaders: ModuleLoader[]] + | [routeName: string, moduleLoaders: ModuleLoader[]] | [ routeName: string, - loaders: ModuleLoader[], + moduleLoaders: ModuleLoader[], originalPathname: string, routeBundleNames: string[], ]; diff --git a/packages/qwik-router/src/adapters/shared/vite/index.ts b/packages/qwik-router/src/adapters/shared/vite/index.ts index 708e54926b4..abd2707cd42 100644 --- a/packages/qwik-router/src/adapters/shared/vite/index.ts +++ b/packages/qwik-router/src/adapters/shared/vite/index.ts @@ -3,7 +3,7 @@ import type { StaticGenerateOptions, SsgRenderOptions } from 'packages/qwik-rout import type { QwikRouterPlugin } from '@qwik.dev/router/vite'; import { basename, dirname, join, resolve } from 'node:path'; import type { Plugin, UserConfig } from 'vite'; -import type { BuildRoute } from '../../../buildtime/types'; +import type { BuiltRoute } from '../../../buildtime/types'; import { postBuild } from './post-build'; /** @@ -262,7 +262,7 @@ interface ViteAdapterPluginOptions { clientPublicOutDir: string; serverOutDir: string; basePathname: string; - routes: BuildRoute[]; + routes: BuiltRoute[]; assetsDir?: string; warn: (message: string) => void; error: (message: string) => void; diff --git a/packages/qwik-router/src/buildtime/build.ts b/packages/qwik-router/src/buildtime/build.ts index 6ff46893494..27de62b5a54 100644 --- a/packages/qwik-router/src/buildtime/build.ts +++ b/packages/qwik-router/src/buildtime/build.ts @@ -2,11 +2,11 @@ import { addError, addWarning } from '../utils/format'; import { resolveSourceFiles } from './routing/resolve-source-file'; import { walkRoutes } from './routing/walk-routes-dir'; import { walkServerPlugins } from './routing/walk-server-plugins'; -import type { BuildContext, BuildRoute, RewriteRouteOption } from './types'; +import type { RoutingContext, BuiltRoute, RewriteRouteOption } from './types'; -export async function build(ctx: BuildContext) { +export async function parseRoutesDir(ctx: RoutingContext) { try { - await updateBuildContext(ctx); + await updateRoutingContext(ctx); validateBuild(ctx); } catch (e) { addError(ctx, e); @@ -21,7 +21,7 @@ export async function build(ctx: BuildContext) { } } -export async function updateBuildContext(ctx: BuildContext) { +export async function updateRoutingContext(ctx: RoutingContext) { if (!ctx.activeBuild) { ctx.activeBuild = new Promise((resolve, reject) => { walkServerPlugins(ctx.opts) @@ -47,12 +47,12 @@ export async function updateBuildContext(ctx: BuildContext) { return ctx.activeBuild; } -function rewriteRoutes(ctx: BuildContext, resolvedFiles: ReturnType) { +function rewriteRoutes(ctx: RoutingContext, resolvedFiles: ReturnType) { if (!ctx.opts.rewriteRoutes || !resolvedFiles.routes) { return; } - const translatedRoutes: BuildRoute[] = []; + const translatedRoutes: BuiltRoute[] = []; let segmentsToTranslate = ctx.opts.rewriteRoutes.flatMap((rewriteConfig) => { return Object.keys(rewriteConfig.paths || {}); @@ -95,10 +95,10 @@ function rewriteRoutes(ctx: BuildContext, resolvedFiles: ReturnType (config.paths || {})[part] ?? part; const pathnamePrefix = config.prefix ? '/' + config.prefix : ''; @@ -156,7 +156,7 @@ function translateRoute( return routeToPush; } -function validateBuild(ctx: BuildContext) { +function validateBuild(ctx: RoutingContext) { const pathnames = Array.from(new Set(ctx.routes.map((r) => r.pathname))).sort(); for (const pathname of pathnames) { diff --git a/packages/qwik-router/src/buildtime/context.ts b/packages/qwik-router/src/buildtime/context.ts index 14e7b2bc9e5..0386db30551 100644 --- a/packages/qwik-router/src/buildtime/context.ts +++ b/packages/qwik-router/src/buildtime/context.ts @@ -1,6 +1,6 @@ import { isAbsolute, resolve } from 'node:path'; import { normalizePath } from '../utils/fs'; -import type { BuildContext, NormalizedPluginOptions, PluginOptions } from './types'; +import type { RoutingContext, NormalizedPluginOptions, PluginOptions } from './types'; export function createBuildContext( rootDir: string, @@ -9,7 +9,7 @@ export function createBuildContext( target?: 'ssr' | 'client', dynamicImports?: boolean ) { - const ctx: BuildContext = { + const ctx: RoutingContext = { rootDir: normalizePath(rootDir), opts: normalizeOptions(rootDir, viteBasePath, userOpts), routes: [], @@ -28,7 +28,7 @@ export function createBuildContext( return ctx; } -export function resetBuildContext(ctx: BuildContext | null) { +export function resetBuildContext(ctx: RoutingContext | null) { if (ctx) { ctx.routes.length = 0; ctx.layouts.length = 0; diff --git a/packages/qwik-router/src/buildtime/markdown/frontmatter.ts b/packages/qwik-router/src/buildtime/markdown/frontmatter.ts index 9cf16941044..5e90b608b77 100644 --- a/packages/qwik-router/src/buildtime/markdown/frontmatter.ts +++ b/packages/qwik-router/src/buildtime/markdown/frontmatter.ts @@ -1,12 +1,12 @@ import type { Transformer } from 'unified'; -import type { BuildContext, FrontmatterAttrs } from '../types'; +import type { RoutingContext, FrontmatterAttrs } from '../types'; import { normalizePath } from '../../utils/fs'; import { visit } from 'unist-util-visit'; import { parse as parseYaml } from 'yaml'; import type { ResolvedDocumentHead } from '../../runtime/src'; import type { DocumentMeta, Editable } from '../../runtime/src/types'; -export function parseFrontmatter(ctx: BuildContext): Transformer { +export function parseFrontmatter(ctx: RoutingContext): Transformer { return (mdast, vfile) => { const attrs: FrontmatterAttrs = {}; diff --git a/packages/qwik-router/src/buildtime/markdown/mdx.ts b/packages/qwik-router/src/buildtime/markdown/mdx.ts index c527791fe5b..b97ef52f8c9 100644 --- a/packages/qwik-router/src/buildtime/markdown/mdx.ts +++ b/packages/qwik-router/src/buildtime/markdown/mdx.ts @@ -1,12 +1,12 @@ import type { CompileOptions } from '@mdx-js/mdx'; import { SourceMapGenerator } from 'source-map'; import { getExtension } from '../../utils/fs'; -import type { BuildContext } from '../types'; +import type { RoutingContext } from '../types'; import { parseFrontmatter } from './frontmatter'; import { rehypePage, rehypeSlug, renameClassname, wrapTableWithDiv } from './rehype'; import { rehypeSyntaxHighlight } from './syntax-highlight'; -export async function createMdxTransformer(ctx: BuildContext): Promise { +export async function createMdxTransformer(ctx: RoutingContext): Promise { const { compile } = await import('@mdx-js/mdx'); const { default: remarkFrontmatter } = await import('remark-frontmatter'); const { default: remarkGfm } = await import('remark-gfm'); diff --git a/packages/qwik-router/src/buildtime/markdown/menu.ts b/packages/qwik-router/src/buildtime/markdown/menu.ts index 50527560fe2..0fb6760444b 100644 --- a/packages/qwik-router/src/buildtime/markdown/menu.ts +++ b/packages/qwik-router/src/buildtime/markdown/menu.ts @@ -1,10 +1,10 @@ -import type { NormalizedPluginOptions, BuildMenu, ParsedMenuItem, RouteSourceFile } from '../types'; +import type { NormalizedPluginOptions, BuiltMenu, ParsedMenuItem, RouteSourceFile } from '../types'; import { marked } from 'marked'; import { createFileId, getMenuPathname } from '../../utils/fs'; import { getMarkdownRelativeUrl } from './markdown-url'; export function createMenu(opts: NormalizedPluginOptions, filePath: string) { - const menu: BuildMenu = { + const menu: BuiltMenu = { pathname: getMenuPathname(opts, filePath), filePath, }; diff --git a/packages/qwik-router/src/buildtime/markdown/rehype.ts b/packages/qwik-router/src/buildtime/markdown/rehype.ts index 27bbf47bc41..a7577e50dd1 100644 --- a/packages/qwik-router/src/buildtime/markdown/rehype.ts +++ b/packages/qwik-router/src/buildtime/markdown/rehype.ts @@ -7,7 +7,7 @@ import { headingRank } from 'hast-util-heading-rank'; import { toString } from 'hast-util-to-string'; import { visit } from 'unist-util-visit'; import type { ContentHeading } from '../../runtime/src'; -import type { BuildContext, NormalizedPluginOptions } from '../types'; +import type { RoutingContext, NormalizedPluginOptions } from '../types'; import { getExtension, isMarkdownExt, normalizePath } from '../../utils/fs'; import { frontmatterAttrsToDocumentHead } from './frontmatter'; import { isSameOriginUrl } from '../../utils/pathname'; @@ -31,7 +31,7 @@ export function rehypeSlug(): Transformer { }; } -export function rehypePage(ctx: BuildContext): Transformer { +export function rehypePage(ctx: RoutingContext): Transformer { return (ast, vfile) => { const mdast = ast as Root; const sourcePath = normalizePath(vfile.path); @@ -96,12 +96,12 @@ function updateContentLinks(mdast: Root, opts: NormalizedPluginOptions, sourcePa }); } -function exportFrontmatter(ctx: BuildContext, mdast: Root, sourcePath: string) { +function exportFrontmatter(ctx: RoutingContext, mdast: Root, sourcePath: string) { const attrs = ctx.frontmatter.get(sourcePath); createExport(mdast, 'frontmatter', attrs); } -function exportContentHead(ctx: BuildContext, mdast: Root, sourcePath: string) { +function exportContentHead(ctx: RoutingContext, mdast: Root, sourcePath: string) { const attrs = ctx.frontmatter.get(sourcePath); const head = frontmatterAttrsToDocumentHead(attrs); if (head) { diff --git a/packages/qwik-router/src/buildtime/routing/resolve-source-file.ts b/packages/qwik-router/src/buildtime/routing/resolve-source-file.ts index c7ffd80fc0a..74a016c8cc2 100644 --- a/packages/qwik-router/src/buildtime/routing/resolve-source-file.ts +++ b/packages/qwik-router/src/buildtime/routing/resolve-source-file.ts @@ -1,10 +1,10 @@ import { dirname } from 'node:path'; import { resolveMenu } from '../markdown/menu'; import type { - BuildEntry, - BuildLayout, - BuildRoute, - BuildServerPlugin, + BuiltEntry, + BuiltLayout, + BuiltRoute, + BuiltServerPlugin, NormalizedPluginOptions, RouteSourceFile, } from '../types'; @@ -93,7 +93,7 @@ export function resolveLayout(opts: NormalizedPluginOptions, layoutSourceFile: R layoutName = ''; } - const layout: BuildLayout = { + const layout: BuiltLayout = { id: createFileId(opts.routesDir, filePath), filePath, dirPath, @@ -110,11 +110,11 @@ const LAYOUT_TOP_SUFFIX = '!'; export function resolveRoute( opts: NormalizedPluginOptions, - appLayouts: BuildLayout[], + appLayouts: BuiltLayout[], sourceFile: RouteSourceFile ) { const filePath = sourceFile.filePath; - const layouts: BuildLayout[] = []; + const layouts: BuiltLayout[] = []; const routesDir = opts.routesDir; const { layoutName, layoutStop } = parseRouteIndexName(sourceFile.extlessName); let pathname = getPathnameFromDirPath(opts, sourceFile.dirPath); @@ -129,7 +129,7 @@ export function resolveRoute( const hasNamedLayout = layoutName !== ''; for (let i = 0; i < 20; i++) { - let layout: BuildLayout | undefined = undefined; + let layout: BuiltLayout | undefined = undefined; if (hasNamedLayout && !hasFoundNamedLayout) { layout = appLayouts.find((l) => l.dirPath === currentDir && l.layoutName === layoutName); @@ -155,7 +155,7 @@ export function resolveRoute( } } - const buildRoute: BuildRoute = { + const buildRoute: BuiltRoute = { id: createFileId(opts.routesDir, filePath, 'Route'), filePath, pathname, @@ -169,7 +169,7 @@ export function resolveRoute( export function resolveServerPlugin(opts: NormalizedPluginOptions, sourceFile: RouteSourceFile) { const filePath = sourceFile.filePath; - const buildRoute: BuildServerPlugin = { + const buildRoute: BuiltServerPlugin = { id: createFileId(opts.serverPluginsDir, filePath, 'Plugin'), filePath, ext: sourceFile.ext, @@ -181,7 +181,7 @@ function resolveEntry(opts: NormalizedPluginOptions, sourceFile: RouteSourceFile const pathname = getPathnameFromDirPath(opts, sourceFile.dirPath); const chunkFileName = pathname.slice(opts.basePathname.length); - const buildEntry: BuildEntry = { + const buildEntry: BuiltEntry = { id: createFileId(opts.routesDir, sourceFile.filePath, 'Route'), filePath: sourceFile.filePath, chunkFileName, @@ -196,7 +196,7 @@ function resolveServiceWorkerEntry(opts: NormalizedPluginOptions, sourceFile: Ro const pathname = dirPathname + sourceFile.extlessName + '.js'; const chunkFileName = pathname.slice(opts.basePathname.length); - const buildEntry: BuildEntry = { + const buildEntry: BuiltEntry = { id: createFileId(opts.routesDir, sourceFile.filePath, 'ServiceWorker'), filePath: sourceFile.filePath, chunkFileName, diff --git a/packages/qwik-router/src/buildtime/routing/sort-routes.ts b/packages/qwik-router/src/buildtime/routing/sort-routes.ts index 76aad9656dc..588ef7863fd 100644 --- a/packages/qwik-router/src/buildtime/routing/sort-routes.ts +++ b/packages/qwik-router/src/buildtime/routing/sort-routes.ts @@ -1,6 +1,6 @@ -import type { BuildRoute } from '../types'; +import type { BuiltRoute } from '../types'; -export function routeSortCompare(a: BuildRoute, b: BuildRoute) { +export function routeSortCompare(a: BuiltRoute, b: BuiltRoute) { const maxSegments = Math.max(a.segments.length, b.segments.length); for (let i = 0; i < maxSegments; i += 1) { diff --git a/packages/qwik-router/src/buildtime/routing/sort-routes.unit.ts b/packages/qwik-router/src/buildtime/routing/sort-routes.unit.ts index 4aa7606faf1..1cc27d285b2 100644 --- a/packages/qwik-router/src/buildtime/routing/sort-routes.unit.ts +++ b/packages/qwik-router/src/buildtime/routing/sort-routes.unit.ts @@ -1,4 +1,4 @@ -import type { BuildRoute } from '../types'; +import type { BuiltRoute } from '../types'; import { createFileId } from '../../utils/fs'; import { parseRoutePathname } from './parse-pathname'; import { routeSortCompare } from './sort-routes'; @@ -44,7 +44,7 @@ test('routeSortCompare', () => { function route(r: TestRoute) { const pathname = r.pathname || '/'; - const route: BuildRoute = { + const route: BuiltRoute = { id: createFileId('', pathname, 'Route'), filePath: pathname, pathname, diff --git a/packages/qwik-router/src/buildtime/routing/walk-server-plugins.ts b/packages/qwik-router/src/buildtime/routing/walk-server-plugins.ts index 199d908b81d..4a4d09db3a0 100644 --- a/packages/qwik-router/src/buildtime/routing/walk-server-plugins.ts +++ b/packages/qwik-router/src/buildtime/routing/walk-server-plugins.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { join } from 'node:path'; -import type { BuildServerPlugin, NormalizedPluginOptions } from '../types'; +import type { BuiltServerPlugin, NormalizedPluginOptions } from '../types'; import { createFileId, getExtension, @@ -14,7 +14,7 @@ import { export async function walkServerPlugins(opts: NormalizedPluginOptions) { const dirPath = opts.serverPluginsDir; const dirItemNames = await fs.promises.readdir(dirPath); - const sourceFiles: BuildServerPlugin[] = []; + const sourceFiles: BuiltServerPlugin[] = []; await Promise.all( dirItemNames.map(async (itemName) => { const itemPath = normalizePath(join(dirPath, itemName)); diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-entries.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-entries.ts index 2dbcf67c86a..fe950c014d1 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-entries.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-entries.ts @@ -1,6 +1,6 @@ -import type { BuildContext } from '../types'; +import type { RoutingContext } from '../types'; -export function createEntries(ctx: BuildContext, c: string[]) { +export function createEntries(ctx: RoutingContext, c: string[]) { const isClient = ctx.target === 'client'; const entries = [...ctx.entries, ...ctx.serviceWorkers]; @@ -14,7 +14,7 @@ export function createEntries(ctx: BuildContext, c: string[]) { } } -export function generateQwikRouterEntries(ctx: BuildContext) { +export function generateQwikRouterEntries(ctx: RoutingContext) { // generate @qwik-router-entries const c: string[] = []; diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-menus.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-menus.ts index c64fb414a66..2c203d1ee10 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-menus.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-menus.ts @@ -1,8 +1,13 @@ import { createFileId } from '../../utils/fs'; -import type { BuildContext } from '../types'; +import type { RoutingContext } from '../types'; import { getImportPath } from './utils'; -export function createMenus(ctx: BuildContext, c: string[], esmImports: string[], isSSR: boolean) { +export function createMenus( + ctx: RoutingContext, + c: string[], + esmImports: string[], + isSSR: boolean +) { c.push(`\n/** Qwik Router Menus (${ctx.menus.length}) */`); c.push(`export const menus = [`); diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts index 9dc2089d678..a7eb83bf6fe 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts @@ -1,5 +1,5 @@ import type { QwikVitePlugin } from '@qwik.dev/core/optimizer'; -import type { BuildContext } from '../types'; +import type { RoutingContext } from '../types'; import { createEntries } from './generate-entries'; import { createMenus } from './generate-menus'; import { createRoutes } from './generate-routes'; @@ -7,7 +7,7 @@ import { createServerPlugins } from './generate-server-plugins'; /** Generates the Qwik Router Config runtime code */ export function generateQwikRouterConfig( - ctx: BuildContext, + ctx: RoutingContext, qwikPlugin: QwikVitePlugin, isSSR: boolean ) { diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-routes.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-routes.ts index 4efed351d6b..f06bae80212 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-routes.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-routes.ts @@ -1,10 +1,10 @@ import type { QwikManifest, QwikVitePlugin } from '@qwik.dev/core/optimizer'; import { isModuleExt, isPageExt, removeExtension } from '../../utils/fs'; -import type { BuildContext, BuildRoute } from '../types'; +import type { RoutingContext, BuiltRoute } from '../types'; import { getImportPath } from './utils'; export function createRoutes( - ctx: BuildContext, + ctx: RoutingContext, qwikPlugin: QwikVitePlugin, c: string[], esmImports: string[], @@ -30,33 +30,33 @@ export function createRoutes( c.push(`export const routes = [`); for (const route of ctx.routes) { - const loaders = []; + const layouts = []; if (isPageExt(route.ext)) { // page module or markdown for (const layout of route.layouts) { - loaders.push(layout.id); + layouts.push(layout.id); } const importPath = getImportPath(route.filePath); if (dynamicImports) { - loaders.push(`()=>import(${JSON.stringify(importPath)})`); + layouts.push(`()=>import(${JSON.stringify(importPath)})`); } else { esmImports.push(`import * as ${route.id} from ${JSON.stringify(importPath)};`); - loaders.push(`()=>${route.id}`); + layouts.push(`()=>${route.id}`); } } else if (includeEndpoints && isModuleExt(route.ext)) { // include endpoints, and this is a module const importPath = getImportPath(route.filePath); esmImports.push(`import * as ${route.id} from ${JSON.stringify(importPath)};`); for (const layout of route.layouts) { - loaders.push(layout.id); + layouts.push(layout.id); } - loaders.push(`()=>${route.id}`); + layouts.push(`()=>${route.id}`); } - if (loaders.length > 0) { - c.push(` ${createRouteData(qwikPlugin, route, loaders, isSSR)},`); + if (layouts.length > 0) { + c.push(` ${createRouteData(qwikPlugin, route, layouts, isSSR)},`); } } @@ -65,12 +65,12 @@ export function createRoutes( function createRouteData( qwikPlugin: QwikVitePlugin, - r: BuildRoute, - loaders: string[], + r: BuiltRoute, + layouts: string[], isSsr: boolean ) { const routeName = JSON.stringify(r.routeName); - const moduleLoaders = `[ ${loaders.join(', ')} ]`; + const moduleLayouts = `[ ${layouts.join(', ')} ]`; // Use RouteData interface @@ -79,14 +79,16 @@ function createRouteData( const clientBundleNames = JSON.stringify(getClientRouteBundleNames(qwikPlugin, r)); // SSR also adds the originalPathname and clientBundleNames to the RouteData - return `[ ${routeName}, ${moduleLoaders}, ${originalPathname}, ${clientBundleNames} ]`; + // ["qwikrouter-test/loaders-serialization/", [Layout, () => __vitePreload(() => import("./index58.js"), true ? [] : void 0)]], + return `[ ${routeName}, ${moduleLayouts}, ${originalPathname}, ${clientBundleNames} ]`; } // simple RouteData, only route name and module loaders - return `[ ${routeName}, ${moduleLoaders} ]`; + return `[ ${routeName}, ${moduleLayouts} ]`; } -function getClientRouteBundleNames(qwikPlugin: QwikVitePlugin, r: BuildRoute) { +// TODO is this still used? We have the preloader now. Maybe this is what generates the data for it? +function getClientRouteBundleNames(qwikPlugin: QwikVitePlugin, r: BuiltRoute) { const bundlesNames: string[] = []; const manifest: QwikManifest = qwikPlugin.api.getManifest()!; diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-server-plugins.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-server-plugins.ts index 2b475ba98e0..52358ffb70f 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-server-plugins.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-server-plugins.ts @@ -1,9 +1,9 @@ import type { QwikVitePlugin } from '@qwik.dev/core/optimizer'; -import type { BuildContext } from '../types'; +import type { RoutingContext } from '../types'; import { getImportPath } from './utils'; export function createServerPlugins( - ctx: BuildContext, + ctx: RoutingContext, _qwikPlugin: QwikVitePlugin, c: string[], esmImports: string[], diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-service-worker.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-service-worker.ts index 383c1b4f75e..8488f54d698 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-service-worker.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-service-worker.ts @@ -1,6 +1,6 @@ -import type { BuildContext } from '../types'; +import type { RoutingContext } from '../types'; -export function generateServiceWorkerRegister(ctx: BuildContext, swRegister: string) { +export function generateServiceWorkerRegister(ctx: RoutingContext, swRegister: string) { let swReg: string; let swUrl = '/service-worker.js'; diff --git a/packages/qwik-router/src/buildtime/types.ts b/packages/qwik-router/src/buildtime/types.ts index 39dc4ace9aa..50c045fb0c6 100644 --- a/packages/qwik-router/src/buildtime/types.ts +++ b/packages/qwik-router/src/buildtime/types.ts @@ -1,14 +1,14 @@ import type { SerializationStrategy } from '@qwik.dev/core/internal'; -export interface BuildContext { +export interface RoutingContext { rootDir: string; opts: NormalizedPluginOptions; - routes: BuildRoute[]; - serverPlugins: BuildServerPlugin[]; - layouts: BuildLayout[]; - entries: BuildEntry[]; - serviceWorkers: BuildEntry[]; - menus: BuildMenu[]; + routes: BuiltRoute[]; + serverPlugins: BuiltServerPlugin[]; + layouts: BuiltLayout[]; + entries: BuiltEntry[]; + serviceWorkers: BuiltEntry[]; + menus: BuiltMenu[]; frontmatter: Map; diagnostics: Diagnostic[]; target: 'ssr' | 'client' | undefined; @@ -45,7 +45,7 @@ export interface RouteSourceFileName { export type RouteSourceType = 'route' | 'layout' | 'entry' | 'menu' | 'service-worker'; -export interface BuildRoute extends ParsedPathname { +export interface BuiltRoute extends ParsedPathname { /** Unique id built from its relative file system path */ id: string; /** Local file system path */ @@ -53,10 +53,10 @@ export interface BuildRoute extends ParsedPathname { ext: string; /** URL Pathname */ pathname: string; - layouts: BuildLayout[]; + layouts: BuiltLayout[]; } -export interface BuildServerPlugin { +export interface BuiltServerPlugin { /** Unique id built from its relative file system path */ id: string; /** Local file system path */ @@ -79,7 +79,7 @@ export interface PathnameSegmentPart { rest: boolean; } -export interface BuildLayout { +export interface BuiltLayout { filePath: string; dirPath: string; id: string; @@ -87,13 +87,13 @@ export interface BuildLayout { layoutName: string; } -export interface BuildEntry extends ParsedPathname { +export interface BuiltEntry extends ParsedPathname { id: string; chunkFileName: string; filePath: string; } -export interface BuildMenu { +export interface BuiltMenu { pathname: string; filePath: string; } diff --git a/packages/qwik-router/src/buildtime/vite/dev-middleware.ts b/packages/qwik-router/src/buildtime/vite/dev-middleware.ts index 18b251e347b..ff332c08029 100644 --- a/packages/qwik-router/src/buildtime/vite/dev-middleware.ts +++ b/packages/qwik-router/src/buildtime/vite/dev-middleware.ts @@ -1,13 +1,13 @@ import type { Render } from '@qwik.dev/core/server'; import type { RendererOptions } from '@qwik.dev/router'; import type { Connect, ModuleNode, ViteDevServer } from 'vite'; -import { build } from '../build'; -import type { BuildContext } from '../types'; +import { updateRoutingContext } from '../build'; +import type { RoutingContext } from '../types'; import { formatError } from './format-error'; import { wrapResponseForHtmlTransform } from './html-transform-wrapper'; export const makeRouterDevMiddleware = - (server: ViteDevServer, ctx: BuildContext): Connect.NextHandleFunction => + (server: ViteDevServer, ctx: RoutingContext): Connect.NextHandleFunction => async (req, res, next) => { // This middleware is the fallback for Vite dev mode; it renders the application @@ -20,7 +20,7 @@ export const makeRouterDevMiddleware = } const renderer = mod.default; if (ctx!.isDirty) { - await build(ctx!); + await updateRoutingContext(ctx!); ctx!.isDirty = false; } diff --git a/packages/qwik-router/src/buildtime/vite/get-route-imports.ts b/packages/qwik-router/src/buildtime/vite/get-route-imports.ts index 5b17131691e..1e6d3976837 100644 --- a/packages/qwik-router/src/buildtime/vite/get-route-imports.ts +++ b/packages/qwik-router/src/buildtime/vite/get-route-imports.ts @@ -1,9 +1,9 @@ import type { QwikBundle, QwikManifest } from '@qwik.dev/core/optimizer'; import { removeExtension } from '../../utils/fs'; -import type { BuildRoute } from '../types'; +import type { BuiltRoute } from '../types'; import { QWIK_ROUTER_CONFIG_ID } from './plugin'; -export function getRouteImports(routes: BuildRoute[], manifest: QwikManifest) { +export function getRouteImports(routes: BuiltRoute[], manifest: QwikManifest) { const result: Record = {}; routes.forEach((route) => { const routePath = removeExtension(route.filePath); diff --git a/packages/qwik-router/src/buildtime/vite/get-route-imports.unit.ts b/packages/qwik-router/src/buildtime/vite/get-route-imports.unit.ts index 424a6424c7a..832c348f65c 100644 --- a/packages/qwik-router/src/buildtime/vite/get-route-imports.unit.ts +++ b/packages/qwik-router/src/buildtime/vite/get-route-imports.unit.ts @@ -1,6 +1,6 @@ import { type QwikBundle, type QwikManifest } from '@qwik.dev/core/optimizer'; import { describe, expect, test } from 'vitest'; -import type { BuildLayout, BuildRoute } from '../types'; +import type { BuiltLayout, BuiltRoute } from '../types'; import { getRouteImports } from './get-route-imports'; describe('modifyBundleGraph', () => { @@ -33,7 +33,7 @@ describe('modifyBundleGraph', () => { } as Record, } as QwikManifest; - const fakeRoutes: BuildRoute[] = [ + const fakeRoutes: BuiltRoute[] = [ { routeName: '/', filePath: '/home/qwik-app/src/routes/index.tsx', @@ -45,9 +45,9 @@ describe('modifyBundleGraph', () => { { filePath: '/home/qwik-app/src/routes/layout.tsx', }, - ] as BuildLayout[], + ] as BuiltLayout[], }, - ] as BuildRoute[]; + ] as BuiltRoute[]; const actualResult = getRouteImports(fakeRoutes, fakeManifest); expect(actualResult).toMatchInlineSnapshot(` @@ -87,12 +87,12 @@ describe('modifyBundleGraph', () => { } as Record, } as QwikManifest; - const fakeRoutes: BuildRoute[] = [ + const fakeRoutes: BuiltRoute[] = [ { routeName: '/', filePath: '/home/qwik-app/src/routes/index.tsx', }, - ] as BuildRoute[]; + ] as BuiltRoute[]; const actualResult = getRouteImports(fakeRoutes, fakeManifest); diff --git a/packages/qwik-router/src/buildtime/vite/plugin.ts b/packages/qwik-router/src/buildtime/vite/plugin.ts index 19d2babf251..be2ca2b670e 100644 --- a/packages/qwik-router/src/buildtime/vite/plugin.ts +++ b/packages/qwik-router/src/buildtime/vite/plugin.ts @@ -5,14 +5,14 @@ import { basename, extname, join, resolve } from 'node:path'; import type { Plugin, PluginOption, Rollup, UserConfig, ViteDevServer } from 'vite'; import { loadEnv } from 'vite'; import { isMenuFileName, normalizePath, removeExtension } from '../../utils/fs'; -import { build } from '../build'; +import { parseRoutesDir } from '../build'; import { createBuildContext, resetBuildContext } from '../context'; import { createMdxTransformer, type MdxTransform } from '../markdown/mdx'; import { transformMenu } from '../markdown/menu'; import { generateQwikRouterEntries } from '../runtime-generation/generate-entries'; import { generateQwikRouterConfig } from '../runtime-generation/generate-qwik-router-config'; import { generateServiceWorkerRegister } from '../runtime-generation/generate-service-worker'; -import type { BuildContext } from '../types'; +import type { RoutingContext } from '../types'; import { getRouteImports } from './get-route-imports'; import { imagePlugin } from './image-jsx'; import type { @@ -42,7 +42,7 @@ export function qwikRouter(userOpts?: QwikRouterVitePluginOptions): PluginOption } function qwikRouterPlugin(userOpts?: QwikRouterVitePluginOptions): any { - let ctx: BuildContext | null = null; + let ctx: RoutingContext | null = null; let mdxTransform: MdxTransform | null = null; let rootDir: string | null = null; let qwikPlugin: QwikVitePlugin | null; @@ -223,7 +223,7 @@ function qwikRouterPlugin(userOpts?: QwikRouterVitePluginOptions): any { const isSwRegister = id.endsWith(QWIK_ROUTER_SW_REGISTER); if (isRouterConfig || isSwRegister) { if (ctx.isDirty) { - await build(ctx); + await parseRoutesDir(ctx); ctx.isDirty = false; ctx.diagnostics.forEach((d) => { diff --git a/packages/qwik-router/src/buildtime/vite/types.ts b/packages/qwik-router/src/buildtime/vite/types.ts index 53fd701a047..8bad3a40649 100644 --- a/packages/qwik-router/src/buildtime/vite/types.ts +++ b/packages/qwik-router/src/buildtime/vite/types.ts @@ -3,7 +3,7 @@ import type { Config as SVGOConfig } from 'svgo'; import type { BuiltinsWithOptionalParams as SVGOBuiltinPluginsWithOptionalParams } from 'svgo/plugins/plugins-types'; import type { Plugin as VitePlugin } from 'vite'; import type { MdxTransform } from '../markdown/mdx'; -import type { BuildContext, BuildEntry, BuildRoute, MdxPlugins, PluginOptions } from '../types'; +import type { RoutingContext, BuiltEntry, BuiltRoute, MdxPlugins, PluginOptions } from '../types'; /** @public */ export interface ImageOptimizationOptions { @@ -52,7 +52,7 @@ export type MdxOptions = CompileOptions; /** @deprecated Not being used anywhere. Will be removed in V3. */ export interface PluginContext { - buildCtx: BuildContext | null; + buildCtx: RoutingContext | null; rootDir: string; cityPlanCode: string | null; mdxTransform: MdxTransform | null; @@ -74,8 +74,8 @@ export type QwikCityPlugin = QwikRouterPlugin; /** @public */ export interface QwikRouterPluginApi { getBasePathname: () => string; - getRoutes: () => BuildRoute[]; - getServiceWorkers: () => BuildEntry[]; + getRoutes: () => BuiltRoute[]; + getServiceWorkers: () => BuiltEntry[]; } /** diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts index af3dd9b1381..95d45cd6bf7 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts @@ -57,6 +57,15 @@ export const resolveRequestHandlers = ( } if (route) { + const routeModules = route[LoadedRouteProp.Mods]; + _resolveRequestHandlers( + routeLoaders, + routeActions, + requestHandlers, + routeModules, + isPageRoute, + method + ); const routeName = route[LoadedRouteProp.RouteName]; if ( checkOrigin && @@ -77,16 +86,8 @@ export const resolveRequestHandlers = ( requestHandlers.push(fixTrailingSlash); requestHandlers.push(renderQData); } - const routeModules = route[LoadedRouteProp.Mods]; requestHandlers.push(handleRedirect); - _resolveRequestHandlers( - routeLoaders, - routeActions, - requestHandlers, - routeModules, - isPageRoute, - method - ); + if (isPageRoute) { requestHandlers.push((ev) => { // Set the current route name @@ -236,20 +237,7 @@ export function loadersMiddleware(routeLoaders: LoaderInternal[]): RequestHandle const loaders = getRequestLoaders(requestEv); const isDev = getRequestMode(requestEv) === 'dev'; if (routeLoaders.length > 0) { - let currentLoaders: LoaderInternal[] = []; - if (requestEv.query.has(QLOADER_KEY)) { - const selectedLoaderIds = requestEv.query.getAll(QLOADER_KEY); - for (const loader of routeLoaders) { - if (selectedLoaderIds.includes(loader.__id)) { - currentLoaders.push(loader); - } else { - loaders[loader.__id] = _UNINITIALIZED; - } - } - } else { - currentLoaders = routeLoaders; - } - const resolvedLoadersPromises = currentLoaders.map((loader) => + const resolvedLoadersPromises = routeLoaders.map((loader) => getRouteLoaderPromise(loader, loaders, requestEv, isDev) ); await Promise.all(resolvedLoadersPromises); diff --git a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md index d6ae2ba798d..645ee16d2b8 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md +++ b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md @@ -416,9 +416,12 @@ export const routeActionQrl: ActionConstructorQRL; // Warning: (ae-forgotten-export) The symbol "ModuleLoader" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export type RouteData = [routeName: string, loaders: ModuleLoader[]] | [ +export type RouteData = [ routeName: string, -loaders: ModuleLoader[], +moduleLoaders: ModuleLoader[] +] | [ +routeName: string, +moduleLoaders: ModuleLoader[], originalPathname: string, routeBundleNames: string[] ]; diff --git a/packages/qwik-router/src/runtime/src/routing.ts b/packages/qwik-router/src/runtime/src/routing.ts index bf14faebf3f..521d5047de5 100644 --- a/packages/qwik-router/src/runtime/src/routing.ts +++ b/packages/qwik-router/src/runtime/src/routing.ts @@ -29,12 +29,12 @@ export const loadRoute = async ( if (!params) { continue; } - const loaders = routeData[RouteDataProp.Loaders]; + const moduleLoaders = routeData[RouteDataProp.ModuleLoaders]; const routeBundleNames = routeData[RouteDataProp.RouteBundleNames]; - const modules: RouteModule[] = new Array(loaders.length); + const modules: RouteModule[] = new Array(moduleLoaders.length); const pendingLoads: Promise[] = []; - loaders.forEach((moduleLoader, i) => { + moduleLoaders.forEach((moduleLoader, i) => { loadModule( moduleLoader, pendingLoads, diff --git a/packages/qwik-router/src/runtime/src/server-functions.ts b/packages/qwik-router/src/runtime/src/server-functions.ts index 323486d30a1..32e6c196b48 100644 --- a/packages/qwik-router/src/runtime/src/server-functions.ts +++ b/packages/qwik-router/src/runtime/src/server-functions.ts @@ -227,6 +227,7 @@ export const routeLoaderQrl = (( loader.__validators = validators; loader.__id = id; loader.__serializationStrategy = serializationStrategy; + loader.__expires = -1; // -1 means no expiration Object.freeze(loader); return loader; diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index fb0a0c8e4a2..83d3c835734 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -252,22 +252,34 @@ export interface ContentHeading { export type ContentModuleLoader = () => Promise; export type EndpointModuleLoader = () => Promise; -export type ModuleLoader = ContentModuleLoader | EndpointModuleLoader; +// export type RouteLoaderLoader = () => Promise; +export type ModuleLoader = ContentModuleLoader | EndpointModuleLoader; //| RouteLoaderLoader; export type MenuModuleLoader = () => Promise; +export type RouteLoaderInfo = [qrl: string, expires: number, live?: true]; /** @public */ export type RouteData = - | [routeName: string, loaders: ModuleLoader[]] + // SSR side | [ routeName: string, - loaders: ModuleLoader[], + moduleLoaders: ModuleLoader[], + // , routeLoaderModules: ModuleLoader[] + ] + // Client side + | [ + routeName: string, + moduleLoaders: ModuleLoader[], + // routeLoaderModules: ModuleLoader[], + /** The actual src/routes pathname, not rewritten */ originalPathname: string, + /** The bundles that contain the loaders */ routeBundleNames: string[], ]; export const enum RouteDataProp { RouteName, - Loaders, + ModuleLoaders, + // RouteLoaderModules, OriginalPathname, RouteBundleNames, } @@ -812,6 +824,8 @@ export interface LoaderInternal extends Loader { __id: string; __validators: DataValidator[] | undefined; __serializationStrategy: SerializationStrategy; + __expires: number; + // __live: boolean; (): LoaderSignal; } diff --git a/packages/qwik-router/src/utils/format.ts b/packages/qwik-router/src/utils/format.ts index 8bf6f60fab3..6030aa1e3ea 100644 --- a/packages/qwik-router/src/utils/format.ts +++ b/packages/qwik-router/src/utils/format.ts @@ -1,4 +1,4 @@ -import type { BuildContext } from '../buildtime/types'; +import type { RoutingContext } from '../buildtime/types'; export function toTitleCase(str: string) { return str.replace(/\w\S*/g, (txt) => { @@ -6,14 +6,14 @@ export function toTitleCase(str: string) { }); } -export function addError(ctx: BuildContext, e: any) { +export function addError(ctx: RoutingContext, e: any) { ctx.diagnostics.push({ type: 'error', message: e ? String(e.stack || e) : 'Error', }); } -export function addWarning(ctx: BuildContext, message: string) { +export function addWarning(ctx: RoutingContext, message: string) { ctx.diagnostics.push({ type: 'warn', message: String(message), diff --git a/packages/qwik-router/src/utils/test-suite.ts b/packages/qwik-router/src/utils/test-suite.ts index df64a9b05fe..a4a6e3669f2 100644 --- a/packages/qwik-router/src/utils/test-suite.ts +++ b/packages/qwik-router/src/utils/test-suite.ts @@ -3,12 +3,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { assert, beforeAll, test, type TestAPI } from 'vitest'; -import { build } from '../buildtime/build'; +import { parseRoutesDir } from '../buildtime/build'; import { createBuildContext } from '../buildtime/context'; import type { - BuildContext, - BuildLayout, - BuildRoute, + RoutingContext, + BuiltLayout, + BuiltRoute, MarkdownAttributes, PluginOptions, } from '../buildtime/types'; @@ -39,7 +39,7 @@ export function testAppSuite( title: string, userOpts?: PluginOptions ): TestAPI { - let buildCtx: BuildContext; + let buildCtx: RoutingContext; beforeAll(async (testCtx) => { const testAppRootDir = join( @@ -58,7 +58,7 @@ export function testAppSuite( assert.equal(normalizePath(testAppRootDir), ctx.rootDir); assert.equal(normalizePath(join(testAppRootDir, 'src', 'routes')), ctx.opts.routesDir); - await build(ctx); + await parseRoutesDir(ctx); assert.deepEqual(ctx.diagnostics, []); @@ -98,12 +98,12 @@ export function testAppSuite( } export interface TestAppBuildContext extends TestContext { - assertRoute: (pathname: string) => BuildRoute; - assertLayout: (id: string) => BuildLayout; + assertRoute: (pathname: string) => BuiltRoute; + assertLayout: (id: string) => BuiltLayout; } export interface TestContext { - ctx: BuildContext; + ctx: RoutingContext; filePath: string; attrs: MarkdownAttributes; } diff --git a/packages/qwik/src/optimizer/core/src/parse.rs b/packages/qwik/src/optimizer/core/src/parse.rs index 5e95c3ba8a5..0392eba6dfe 100644 --- a/packages/qwik/src/optimizer/core/src/parse.rs +++ b/packages/qwik/src/optimizer/core/src/parse.rs @@ -388,6 +388,7 @@ pub fn transform_code(config: TransformCodeOptions) -> Result QwikTransform<'a> { true } + // TODO export segment data for the noop qrl fn create_noop_qrl( &mut self, symbol_name: &swc_atoms::Atom, diff --git a/packages/qwik/src/optimizer/src/plugins/plugin.ts b/packages/qwik/src/optimizer/src/plugins/plugin.ts index fc443eb41f0..6bfc9a2702a 100644 --- a/packages/qwik/src/optimizer/src/plugins/plugin.ts +++ b/packages/qwik/src/optimizer/src/plugins/plugin.ts @@ -813,6 +813,7 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { } const deps = new Set(); for (const mod of newOutput.modules) { + // TODO handle noop modules if (mod !== module) { const key = normalizePath(path.join(srcDir, mod.path)); debug(`transform(${count})`, `segment ${key}`, mod.segment!.displayName); From f5c31e6b3807d4c58b013a701622205cb8d5a9df Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Sun, 13 Jul 2025 09:24:54 +0200 Subject: [PATCH 03/20] chore(router): lint++ --- packages/qwik-router/src/buildtime/build.ts | 51 ++++++------ .../src/buildtime/routing/sort-routes.ts | 1 + .../request-handler/request-event.ts | 11 +-- .../request-handler/request-handler.ts | 2 +- .../request-handler/user-response.ts | 82 +++++++++++-------- packages/qwik-router/src/utils/fs.ts | 16 ++-- 6 files changed, 83 insertions(+), 80 deletions(-) diff --git a/packages/qwik-router/src/buildtime/build.ts b/packages/qwik-router/src/buildtime/build.ts index 27de62b5a54..100f3d21265 100644 --- a/packages/qwik-router/src/buildtime/build.ts +++ b/packages/qwik-router/src/buildtime/build.ts @@ -1,5 +1,6 @@ import { addError, addWarning } from '../utils/format'; import { resolveSourceFiles } from './routing/resolve-source-file'; +import { routeSortCompare } from './routing/sort-routes'; import { walkRoutes } from './routing/walk-routes-dir'; import { walkServerPlugins } from './routing/walk-server-plugins'; import type { RoutingContext, BuiltRoute, RewriteRouteOption } from './types'; @@ -21,35 +22,29 @@ export async function parseRoutesDir(ctx: RoutingContext) { } } -export async function updateRoutingContext(ctx: RoutingContext) { - if (!ctx.activeBuild) { - ctx.activeBuild = new Promise((resolve, reject) => { - walkServerPlugins(ctx.opts) - .then((serverPlugins) => { - ctx.serverPlugins = serverPlugins; - return walkRoutes(ctx.opts.routesDir); - }) - .then((sourceFiles) => { - const resolved = resolveSourceFiles(ctx.opts, sourceFiles); - rewriteRoutes(ctx, resolved); - ctx.layouts = resolved.layouts; - ctx.routes = resolved.routes; - ctx.entries = resolved.entries; - ctx.serviceWorkers = resolved.serviceWorkers; - ctx.menus = resolved.menus; - resolve(); - }, reject) - .finally(() => { - ctx.activeBuild = null; - }); - }); - } +export function updateRoutingContext(ctx: RoutingContext) { + ctx.activeBuild ||= _updateRoutingContext(ctx).finally(() => { + ctx.activeBuild = null; + }); return ctx.activeBuild; } -function rewriteRoutes(ctx: RoutingContext, resolvedFiles: ReturnType) { - if (!ctx.opts.rewriteRoutes || !resolvedFiles.routes) { - return; +async function _updateRoutingContext(ctx: RoutingContext) { + const serverPlugins = await walkServerPlugins(ctx.opts); + const sourceFiles = await walkRoutes(ctx.opts.routesDir); + const resolved = resolveSourceFiles(ctx.opts, sourceFiles); + resolved.routes = rewriteRoutes(ctx, resolved.routes); + ctx.serverPlugins = serverPlugins; + ctx.layouts = resolved.layouts; + ctx.routes = resolved.routes; + ctx.entries = resolved.entries; + ctx.serviceWorkers = resolved.serviceWorkers; + ctx.menus = resolved.menus; +} + +function rewriteRoutes(ctx: RoutingContext, routes: BuiltRoute[]) { + if (!ctx.opts.rewriteRoutes) { + return routes; } const translatedRoutes: BuiltRoute[] = []; @@ -60,7 +55,7 @@ function rewriteRoutes(ctx: RoutingContext, resolvedFiles: ReturnType { + routes.forEach((route) => { // always push the original route translatedRoutes.push(route); @@ -91,7 +86,7 @@ function rewriteRoutes(ctx: RoutingContext, resolvedFiles: ReturnType { + const exit = ( + message: T = new AbortMessage() as T + ) => { routeModuleIndex = ABORT_INDEX; - return new AbortMessage(); + return message; }; const loaders: Record | undefined> = {}; @@ -252,8 +254,7 @@ export function createRequestEvent( if (statusCode > 301) { headers.set('Cache-Control', 'no-store'); } - exit(); - return new RedirectMessage(); + return exit(new RedirectMessage()); }, rewrite: (pathname: string) => { @@ -262,7 +263,7 @@ export function createRequestEvent( throw new Error('Rewrite does not support absolute urls'); } sharedMap.set(RequestEvIsRewrite, true); - return new RewriteMessage(pathname.replace(/\/+/g, '/')); + return exit(new RewriteMessage(pathname.replace(/\/+/g, '/'))); }, defer: (returnData) => { diff --git a/packages/qwik-router/src/middleware/request-handler/request-handler.ts b/packages/qwik-router/src/middleware/request-handler/request-handler.ts index be1c45552a9..fffa0b6a162 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-handler.ts @@ -11,7 +11,7 @@ import { getRouteMatchPathname, runQwikRouter, type QwikRouterRun } from './user */ let qwikRouterConfigActual: QwikRouterConfig; /** - * The request handler for QwikRouter. Called by every integration. + * The request handler for QwikRouter. Called by every adapter. * * @public */ diff --git a/packages/qwik-router/src/middleware/request-handler/user-response.ts b/packages/qwik-router/src/middleware/request-handler/user-response.ts index aeb525b771a..88e283fcaae 100644 --- a/packages/qwik-router/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-router/src/middleware/request-handler/user-response.ts @@ -8,7 +8,6 @@ import type { import { getErrorHtml } from './error-handler'; import { createRequestEvent, getRequestMode, type RequestEventInternal } from './request-event'; import { encoder } from './resolve-request-handlers'; -import type { ServerRequestEvent, StatusCodes } from './types'; // Import separately to avoid duplicate imports in the vite dev server import { AbortMessage, @@ -16,11 +15,24 @@ import { RewriteMessage, ServerError, } from '@qwik.dev/router/middleware/request-handler'; +import type { QwikSerializer, ServerRequestEvent, StatusCodes } from './types'; export interface QwikRouterRun { + /** + * The response to the request, if any. If there is no response, there might have been an error, + * or the request was aborted. + */ response: Promise; requestEv: RequestEvent; - completion: Promise; + /** + * Promise for the completion of the request. + * + * If it returns a RedirectMessage, it means the request must be redirected. + * + * If it returns an Error, it means there was an error, and if possible, the response already + * includes the error. The error is informational only. + */ + completion: Promise; } let asyncStore: AsyncStore | undefined; @@ -67,7 +79,7 @@ async function runNext( requestEv: RequestEventInternal, rebuildRouteInfo: RebuildRouteInfoInternal, resolve: (value: any) => void -) { +): Promise { try { const isValidURL = (url: URL) => new URL(url.pathname + url.search, url); isValidURL(requestEv.originalUrl); @@ -90,9 +102,10 @@ async function runNext( if (e instanceof RedirectMessage) { const stream = requestEv.getWritableStream(); await stream.close(); + return e; } else if (e instanceof RewriteMessage) { if (rewriteAttempt > 50) { - throw new Error(`Infinite rewrite loop`); + return new Error(`Infinite rewrite loop`); } rewriteAttempt += 1; @@ -101,48 +114,47 @@ async function runNext( const { loadedRoute, requestHandlers } = await rebuildRouteInfo(url); requestEv.resetRoute(loadedRoute, requestHandlers, url); return await _runNext(); - } else if (e instanceof ServerError) { - if (!requestEv.headersSent) { - const status = e.status as StatusCodes; - const accept = requestEv.request.headers.get('Accept'); - if (accept && !accept.includes('text/html')) { - requestEv.headers.set('Content-Type', 'application/qwik-json'); - requestEv.send(status, await _serialize([e.data])); - } else { - const html = getErrorHtml(e.status, e.data); - requestEv.html(status, html); - } + } else if (e instanceof AbortMessage) { + return; + } else if (e instanceof ServerError && !requestEv.headersSent) { + const status = e.status as StatusCodes; + const accept = requestEv.request.headers.get('Accept'); + if (accept && !accept.includes('text/html')) { + requestEv.headers.set('Content-Type', 'application/qwik-json'); + requestEv.send(status, await _serialize([e.data])); + } else { + // TODO render the custom error route + requestEv.html(status, getErrorHtml(status, e.data)); } - } else if (!(e instanceof AbortMessage)) { - if (getRequestMode(requestEv) !== 'dev') { - try { - if (!requestEv.headersSent) { - requestEv.headers.set('content-type', 'text/html; charset=utf-8'); - requestEv.cacheControl({ noCache: true }); - requestEv.status(500); - } - const stream = requestEv.getWritableStream(); - if (!stream.locked) { - const writer = stream.getWriter(); - await writer.write(encoder.encode(getErrorHtml(500, 'Internal Server Error'))); - await writer.close(); - } - } catch { - console.error('Unable to render error page'); + return e; + } + if (getRequestMode(requestEv) !== 'dev') { + try { + if (!requestEv.headersSent) { + requestEv.headers.set('content-type', 'text/html; charset=utf-8'); + requestEv.cacheControl({ noCache: true }); + requestEv.status(500); + } + const stream = requestEv.getWritableStream(); + if (!stream.locked) { + const writer = stream.getWriter(); + await writer.write(encoder.encode(getErrorHtml(500, 'Internal Server Error'))); + await writer.close(); } + } catch { + console.error('Unable to render error page'); } - - return e; } - } - return undefined; + return e as Error; + } } try { return await _runNext(); } finally { if (!requestEv.isDirty()) { + // The request didn't get handled, so we need to resolve with null. resolve(null); } } diff --git a/packages/qwik-router/src/utils/fs.ts b/packages/qwik-router/src/utils/fs.ts index 4ee726f340c..df49fe345ae 100644 --- a/packages/qwik-router/src/utils/fs.ts +++ b/packages/qwik-router/src/utils/fs.ts @@ -79,7 +79,7 @@ export function normalizePath(path: string) { export function normalizePathSlash(path: string) { // MIT https://github.com/sindresorhus/slash/blob/main/license // Convert Windows backslash paths to slash paths: foo\\bar ➔ foo/bar - const isExtendedLengthPath = /^\\\\\?\\/.test(path); + const isExtendedLengthPath = path.startsWith('\\\\?\\'); const hasNonAscii = /[^\u0000-\u0080]+/.test(path); // eslint-disable-line no-control-regex if (isExtendedLengthPath || hasNonAscii) { @@ -139,17 +139,17 @@ export function createFileId( const PAGE_MODULE_EXTS: { [type: string]: boolean } = { '.tsx': true, '.jsx': true, -}; +} as const; const MODULE_EXTS: { [type: string]: boolean } = { '.ts': true, '.js': true, -}; +} as const; const MARKDOWN_EXTS: { [type: string]: boolean } = { '.md': true, '.mdx': true, -}; +} as const; export function isIndexModule(extlessName: string) { return /^index(|!|@.+)$/.test(extlessName); @@ -192,13 +192,7 @@ export function isEntryName(extlessName: string) { } export function isErrorName(extlessName: string) { - try { - const statusCode = parseInt(extlessName, 10); - return statusCode >= 400 && statusCode <= 599; - } catch (e) { - // - } - return false; + return /^[45][0-9]{2}$/.test(extlessName); } export function isGroupedLayoutName(dirName: string, warn = true) { From 7493fd53228df91c4fba22799b3d0b62e0922ce0 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Thu, 18 Sep 2025 20:22:15 +0200 Subject: [PATCH 04/20] chore(router): make spa-init more readable this also improves compressed size also, remove the resolve for spaInit, the preloader takes care of that --- .../src/runtime/src/qwik-router-component.tsx | 3 - .../qwik-router/src/runtime/src/spa-init.ts | 318 +++++++++--------- 2 files changed, 152 insertions(+), 169 deletions(-) diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index ba17aeb4d41..18f453ecb29 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -708,9 +708,6 @@ export const useQwikRouter = (props?: QwikRouterProps) => { removeEventListener('scroll', win._qRouterInitScroll!); win._qRouterInitScroll = undefined; - - // Cache SPA recovery script. - spaInit.resolve(); } if (navType !== 'popstate') { diff --git a/packages/qwik-router/src/runtime/src/spa-init.ts b/packages/qwik-router/src/runtime/src/spa-init.ts index 8bb920f38e7..4dde8af15bc 100644 --- a/packages/qwik-router/src/runtime/src/spa-init.ts +++ b/packages/qwik-router/src/runtime/src/spa-init.ts @@ -16,197 +16,183 @@ import { event$, isDev } from '@qwik.dev/core'; // ! DO NOT IMPORT OR USE ANY EXTERNAL REFERENCES IN THIS SCRIPT. export default event$((_: Event, el: Element) => { const win: ClientSPAWindow = window; - const spa = '_qRouterSPA'; - const initPopstate = '_qRouterInitPopstate'; - const initAnchors = '_qRouterInitAnchors'; - const initVisibility = '_qRouterInitVisibility'; - const initScroll = '_qRouterInitScroll'; - if ( - !win[spa] && - !win[initPopstate] && - !win[initAnchors] && - !win[initVisibility] && - !win[initScroll] - ) { - const currentPath = location.pathname + location.search; - - const historyPatch = '_qRouterHistoryPatch'; - const scrollEnabled = '_qRouterScrollEnabled'; - const debounceTimeout = '_qRouterScrollDebounce'; - const scrollHistory = '_qRouterScroll'; - - const checkAndScroll = (scrollState: ScrollState | undefined) => { - if (scrollState) { - win.scrollTo(scrollState.x, scrollState.y); - } - }; + if (win._qRouterInitPopstate) { + return; + } + const currentPath = location.pathname + location.search; - const currentScrollState = (): ScrollState => { - const elm = document.documentElement; - return { - x: elm.scrollLeft, - y: elm.scrollTop, - w: Math.max(elm.scrollWidth, elm.clientWidth), - h: Math.max(elm.scrollHeight, elm.clientHeight), - }; + const checkAndScroll = (scrollState: ScrollState | undefined) => { + if (scrollState) { + win.scrollTo(scrollState.x, scrollState.y); + } + }; + + const currentScrollState = (): ScrollState => { + const elm = document.documentElement; + return { + x: elm.scrollLeft, + y: elm.scrollTop, + w: Math.max(elm.scrollWidth, elm.clientWidth), + h: Math.max(elm.scrollHeight, elm.clientHeight), }; + }; - const saveScrollState = (scrollState?: ScrollState) => { - const state: ScrollHistoryState = history.state || {}; - state[scrollHistory] = scrollState || currentScrollState(); - history.replaceState(state, ''); - }; + const saveScrollState = (scrollState?: ScrollState) => { + const state: ScrollHistoryState = history.state || {}; + state._qRouterScroll = scrollState || currentScrollState(); + history.replaceState(state, ''); + }; - saveScrollState(); + saveScrollState(); - win[initPopstate] = () => { - if (win[spa]) { - return; - } + // Also used in qwik-router-component.tsx to unregister + win._qRouterInitPopstate = () => { + if (win._qRouterSPA) { + return; + } - // Disable scroll handler eagerly to prevent overwriting history.state. - win[scrollEnabled] = false; - clearTimeout(win[debounceTimeout]); + // Disable scroll handler eagerly to prevent overwriting history.state. + win._qRouterScrollEnabled = false; + clearTimeout(win._qRouterScrollDebounce); - if (currentPath !== location.pathname + location.search) { - const getContainer = (el: Element) => - el.closest('[q\\:container]:not([q\\:container=html]):not([q\\:container=text])'); + if (currentPath !== location.pathname + location.search) { + const getContainer = (el: Element) => + el.closest('[q\\:container]:not([q\\:container=html]):not([q\\:container=text])'); - const container = getContainer(el); - const domContainer = (container as _ContainerElement).qContainer as DomContainer; - const hostElement = domContainer.vNodeLocate(el); + const container = getContainer(el); + const domContainer = (container as _ContainerElement).qContainer as DomContainer; + const hostElement = domContainer.vNodeLocate(el); - const nav = domContainer?.resolveContext(hostElement, { - id: 'qc--n', - } as ContextId); + const nav = domContainer?.resolveContext(hostElement, { + id: 'qc--n', + } as ContextId); - if (nav) { - nav(location.href, { type: 'popstate' }); - } else { - // No useNavigate ctx available, fallback to reload. - location.reload(); - } + if (nav) { + nav(location.href, { type: 'popstate' }); } else { - if (history.scrollRestoration === 'manual') { - const scrollState = (history.state as ScrollHistoryState)?.[scrollHistory]; - checkAndScroll(scrollState); - win[scrollEnabled] = true; + // No useNavigate ctx available, fallback to reload. + location.reload(); + } + } else { + if (history.scrollRestoration === 'manual') { + const scrollState = (history.state as ScrollHistoryState)?._qRouterScroll; + checkAndScroll(scrollState); + win._qRouterScrollEnabled = true; + } + } + }; + + if (!win._qRouterHistoryPatch) { + win._qRouterHistoryPatch = true; + const pushState = history.pushState; + const replaceState = history.replaceState; + + const prepareState = (state: any) => { + if (state === null || typeof state === 'undefined') { + state = {}; + } else if (state?.constructor !== Object) { + state = { _data: state }; + + if (isDev) { + console.warn( + 'In a Qwik SPA context, `history.state` is used to store scroll state. ' + + 'Direct calls to `pushState()` and `replaceState()` must supply an actual Object type. ' + + 'We need to be able to automatically attach the scroll state to your state object. ' + + 'A new state object has been created, your data has been moved to: `history.state._data`' + ); } } - }; - if (!win[historyPatch]) { - win[historyPatch] = true; - const pushState = history.pushState; - const replaceState = history.replaceState; - - const prepareState = (state: any) => { - if (state === null || typeof state === 'undefined') { - state = {}; - } else if (state?.constructor !== Object) { - state = { _data: state }; - - if (isDev) { - console.warn( - 'In a Qwik SPA context, `history.state` is used to store scroll state. ' + - 'Direct calls to `pushState()` and `replaceState()` must supply an actual Object type. ' + - 'We need to be able to automatically attach the scroll state to your state object. ' + - 'A new state object has been created, your data has been moved to: `history.state._data`' - ); - } - } + state._qRouterScroll = state._qRouterScroll || currentScrollState(); + return state; + }; - state._qRouterScroll = state._qRouterScroll || currentScrollState(); - return state; - }; + history.pushState = (state, title, url) => { + state = prepareState(state); + return pushState.call(history, state, title, url); + }; - history.pushState = (state, title, url) => { - state = prepareState(state); - return pushState.call(history, state, title, url); - }; + history.replaceState = (state, title, url) => { + state = prepareState(state); + return replaceState.call(history, state, title, url); + }; + } - history.replaceState = (state, title, url) => { - state = prepareState(state); - return replaceState.call(history, state, title, url); - }; + // We need this handler in init because Firefox destroys states w/ anchor tags. + win._qRouterInitAnchors = (event: MouseEvent) => { + if (win._qRouterSPA || event.defaultPrevented) { + return; } - // We need this handler in init because Firefox destroys states w/ anchor tags. - win[initAnchors] = (event: MouseEvent) => { - if (win[spa] || event.defaultPrevented) { - return; - } - - const target = (event.target as HTMLElement).closest('a[href]'); - - if (target && !target.hasAttribute('preventdefault:click')) { - const href = target.getAttribute('href')!; - const prev = new URL(location.href); - const dest = new URL(href, prev); - const sameOrigin = dest.origin === prev.origin; - const samePath = dest.pathname + dest.search === prev.pathname + prev.search; - // Patch only same-page anchors. - if (sameOrigin && samePath) { - event.preventDefault(); - - // Check href because empty hashes don't register. - if (dest.href !== prev.href) { - history.pushState(null, '', dest); - } + const target = (event.target as HTMLElement).closest('a[href]'); + + if (target && !target.hasAttribute('preventdefault:click')) { + const href = target.getAttribute('href')!; + const prev = new URL(location.href); + const dest = new URL(href, prev); + const sameOrigin = dest.origin === prev.origin; + const samePath = dest.pathname + dest.search === prev.pathname + prev.search; + // Patch only same-page anchors. + if (sameOrigin && samePath) { + event.preventDefault(); + + // Check href because empty hashes don't register. + if (dest.href !== prev.href) { + history.pushState(null, '', dest); + } - if (!dest.hash) { - if (dest.href.endsWith('#')) { - window.scrollTo(0, 0); - } else { - // Simulate same-page (no hash) anchor reload. - // history.scrollRestoration = 'manual' makes these not scroll. - win[scrollEnabled] = false; - clearTimeout(win[debounceTimeout]); - saveScrollState({ ...currentScrollState(), x: 0, y: 0 }); - location.reload(); - } + if (!dest.hash) { + if (dest.href.endsWith('#')) { + window.scrollTo(0, 0); } else { - const elmId = dest.hash.slice(1); - const elm = document.getElementById(elmId); - if (elm) { - elm.scrollIntoView(); - } + // Simulate same-page (no hash) anchor reload. + // history.scrollRestoration = 'manual' makes these not scroll. + win._qRouterScrollEnabled = false; + clearTimeout(win._qRouterScrollDebounce); + saveScrollState({ ...currentScrollState(), x: 0, y: 0 }); + location.reload(); + } + } else { + const elmId = dest.hash.slice(1); + const elm = document.getElementById(elmId); + if (elm) { + elm.scrollIntoView(); } } } - }; - - win[initVisibility] = () => { - if (!win[spa] && win[scrollEnabled] && document.visibilityState === 'hidden') { - saveScrollState(); - } - }; - - win[initScroll] = () => { - if (win[spa] || !win[scrollEnabled]) { - return; - } - - clearTimeout(win[debounceTimeout]); - win[debounceTimeout] = setTimeout(() => { - saveScrollState(); - // Needed for e2e debounceDetector. - win[debounceTimeout] = undefined; - }, 200); - }; + } + }; - win[scrollEnabled] = true; + win._qRouterInitVisibility = () => { + if (!win._qRouterSPA && win._qRouterScrollEnabled && document.visibilityState === 'hidden') { + saveScrollState(); + } + }; - setTimeout(() => { - win.addEventListener('popstate', win[initPopstate]!); - win.addEventListener('scroll', win[initScroll]!, { passive: true }); - document.body.addEventListener('click', win[initAnchors]!); + win._qRouterInitScroll = () => { + if (win._qRouterSPA || !win._qRouterScrollEnabled) { + return; + } - if (!(win as any).navigation) { - document.addEventListener('visibilitychange', win[initVisibility]!, { - passive: true, - }); - } - }, 0); - } + clearTimeout(win._qRouterScrollDebounce); + win._qRouterScrollDebounce = setTimeout(() => { + saveScrollState(); + // Needed for e2e debounceDetector. + win._qRouterScrollDebounce = undefined; + }, 200); + }; + + win._qRouterScrollEnabled = true; + + setTimeout(() => { + win.addEventListener('popstate', win._qRouterInitPopstate!); + win.addEventListener('scroll', win._qRouterInitScroll!, { passive: true }); + document.body.addEventListener('click', win._qRouterInitAnchors!); + + if (!(win as any).navigation) { + document.addEventListener('visibilitychange', win._qRouterInitVisibility!, { + passive: true, + }); + } + }, 0); }); From c249d02b1cda524937c2196a520f021db40bd54b Mon Sep 17 00:00:00 2001 From: Varixo Date: Thu, 18 Sep 2025 23:18:00 +0200 Subject: [PATCH 05/20] feat: split loaders from qdata.json --- .../qwik-router/src/buildtime/vite/plugin.ts | 2 +- .../request-handler/loader-endpoints.ts | 164 ++++++++++++++++++ .../request-handler/qdata-endpoints.ts | 50 ++++++ .../request-handler/request-event.ts | 35 +++- .../resolve-request-handlers.ts | 146 ++-------------- .../request-handler/user-response.ts | 31 +++- .../src/runtime/src/link-component.tsx | 2 +- .../src/runtime/src/qwik-router-component.tsx | 13 +- .../src/runtime/src/use-endpoint.ts | 163 +++++++++-------- packages/qwik-router/src/runtime/src/utils.ts | 8 +- 10 files changed, 386 insertions(+), 228 deletions(-) create mode 100644 packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts create mode 100644 packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts diff --git a/packages/qwik-router/src/buildtime/vite/plugin.ts b/packages/qwik-router/src/buildtime/vite/plugin.ts index be2ca2b670e..77c049037c4 100644 --- a/packages/qwik-router/src/buildtime/vite/plugin.ts +++ b/packages/qwik-router/src/buildtime/vite/plugin.ts @@ -13,6 +13,7 @@ import { generateQwikRouterEntries } from '../runtime-generation/generate-entrie import { generateQwikRouterConfig } from '../runtime-generation/generate-qwik-router-config'; import { generateServiceWorkerRegister } from '../runtime-generation/generate-service-worker'; import type { RoutingContext } from '../types'; +import { getRouterIndexTags, makeRouterDevMiddleware } from './dev-middleware'; import { getRouteImports } from './get-route-imports'; import { imagePlugin } from './image-jsx'; import type { @@ -21,7 +22,6 @@ import type { QwikRouterVitePluginOptions, } from './types'; import { validatePlugin } from './validate-plugin'; -import { getRouterIndexTags, makeRouterDevMiddleware } from './dev-middleware'; export const QWIK_ROUTER_CONFIG_ID = '@qwik-router-config'; const QWIK_ROUTER_ENTRIES_ID = '@qwik-router-entries'; diff --git a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts new file mode 100644 index 00000000000..b7c888c96cd --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts @@ -0,0 +1,164 @@ +import { _serialize, _UNINITIALIZED } from '@qwik.dev/core/internal'; +import type { + DataValidator, + LoaderInternal, + RequestHandler, + ValidatorReturn, +} from '../../runtime/src/types'; +import { + getRequestLoaders, + getRequestLoaderSerializationStrategyMap, + getRequestMode, +} from './request-event'; +import { measure, verifySerializable } from './resolve-request-handlers'; +import type { RequestEvent } from './types'; +import { IsQLoader, IsQLoaderData, QLoaderId } from './user-response'; + +export function loaderDataHandler(routeLoaders: LoaderInternal[]): RequestHandler { + return async (requestEvent: RequestEvent) => { + const requestEv = requestEvent as RequestEventInternal; + + const isQLoaderData = requestEv.sharedMap.has(IsQLoaderData); + if (!isQLoaderData) { + return; + } + + if (requestEv.headersSent || requestEv.exited) { + return; + } + + // Set cache headers - aggressive for loaders + requestEv.cacheControl({ + maxAge: 300, // 5 minutes + staleWhileRevalidate: 3600, // 1 hour + }); + + // return loader ids + const loaderIds = routeLoaders.map((l) => l.__id); + return requestEv.json(200, { loaderIds }); + }; +} + +export function singleLoaderHandler(routeLoaders: LoaderInternal[]): RequestHandler { + return async (requestEvent: RequestEvent) => { + const requestEv = requestEvent as RequestEventInternal; + + const isQLoader = requestEv.sharedMap.has(IsQLoader); + if (!isQLoader) { + return; + } + + if (requestEv.headersSent || requestEv.exited) { + return; + } + const loaderId = requestEv.sharedMap.get(QLoaderId); + + try { + // Execute just this loader + const loaders = getRequestLoaders(requestEv); + const isDev = getRequestMode(requestEv) === 'dev'; + + let loader: LoaderInternal | undefined; + for (const routeLoader of routeLoaders) { + if (routeLoader.__id === loaderId) { + loader = routeLoader; + } else if (!loaders[routeLoader.__id]) { + loaders[routeLoader.__id] = _UNINITIALIZED; + } + } + + if (!loader) { + return requestEv.json(404, { error: 'Loader not found' }); + } + + await executeLoader(loader, loaders, requestEv, isDev); + + // Set cache headers - aggressive for loaders + requestEv.cacheControl({ + maxAge: 300, // 5 minutes + staleWhileRevalidate: 3600, // 1 hour + }); + + const data = await _serialize([loaders[loaderId]]); + + requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); + + // Return just this loader's result + return requestEv.send(200, data); + } catch (error) { + console.error(`Loader ${loaderId} failed:`, error); + return requestEv.json(500, { error: 'Loader execution failed' }); + } + }; +} + +export async function executeLoader( + loader: LoaderInternal, + loaders: Record, + requestEv: RequestEventInternal, + isDev: boolean +) { + const loaderId = loader.__id; + loaders[loaderId] = runValidators( + requestEv, + loader.__validators, + undefined, // data + isDev + ) + .then((res) => { + if (res.success) { + if (isDev) { + return measure>(requestEv, loader.__qrl.getHash(), () => + loader.__qrl.call(requestEv, requestEv) + ); + } else { + return loader.__qrl.call(requestEv, requestEv); + } + } else { + return requestEv.fail(res.status ?? 500, res.error); + } + }) + .then((resolvedLoader) => { + if (typeof resolvedLoader === 'function') { + loaders[loaderId] = resolvedLoader(); + } else { + if (isDev) { + verifySerializable(resolvedLoader, loader.__qrl); + } + loaders[loaderId] = resolvedLoader; + } + return resolvedLoader; + }); + const loadersSerializationStrategy = getRequestLoaderSerializationStrategyMap(requestEv); + loadersSerializationStrategy.set(loaderId, loader.__serializationStrategy); + return loaders[loaderId]; +} + +export async function runValidators( + requestEv: RequestEvent, + validators: DataValidator[] | undefined, + data: unknown, + isDev: boolean +) { + let lastResult: ValidatorReturn = { + success: true, + data, + }; + if (validators) { + for (const validator of validators) { + if (isDev) { + lastResult = await measure(requestEv, `validator$`, () => + validator.validate(requestEv, data) + ); + } else { + lastResult = await validator.validate(requestEv, data); + } + if (!lastResult.success) { + return lastResult; + } else { + data = lastResult.data; + } + } + } + return lastResult; +} diff --git a/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts new file mode 100644 index 00000000000..3fc4fcdba46 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts @@ -0,0 +1,50 @@ +// requestEv.sharedMap.get(RequestEvSharedActionId) + +import type { RequestEvent } from '@qwik.dev/router'; +import { _serialize } from 'packages/qwik/core-internal'; +import { RequestEvIsRewrite, RequestEvSharedActionId } from './request-event'; +import { getPathname } from './resolve-request-handlers'; +import { IsQData } from './user-response'; + +export interface QData { + status: number; + href: string; + action?: string; + redirect?: string; + isRewrite?: boolean; +} + +export async function qDataHandler(requestEv: RequestEvent) { + const isPageDataReq = requestEv.sharedMap.has(IsQData); + if (!isPageDataReq) { + return; + } + + if (requestEv.headersSent || requestEv.exited) { + return; + } + + const status = requestEv.status(); + const redirectLocation = requestEv.headers.get('Location'); + + requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); + + const qData: QData = { + status, + href: getPathname(requestEv.url), + action: requestEv.sharedMap.get(RequestEvSharedActionId), + redirect: redirectLocation ?? undefined, + isRewrite: requestEv.sharedMap.get(RequestEvIsRewrite), + }; + + // Set cache headers + requestEv.cacheControl({ + maxAge: 300, // 5 minutes + staleWhileRevalidate: 3600, // 1 hour + }); + + // write just the page json data to the response body + const data = await _serialize([qData]); + + requestEv.send(200, data); +} diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index a894cd1fed6..9c0bff567d3 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -19,7 +19,7 @@ import { ServerError, RewriteMessage, } from '@qwik.dev/router/middleware/request-handler'; -import { encoder, getRouteLoaderPromise } from './resolve-request-handlers'; +import { encoder } from './resolve-request-handlers'; import type { CacheControl, CacheControlTarget, @@ -31,7 +31,18 @@ import type { ServerRequestEvent, ServerRequestMode, } from './types'; -import { IsQData, QDATA_JSON, QDATA_JSON_LEN } from './user-response'; +import { + IsQData, + IsQLoader, + IsQLoaderData, + Q_LOADER_DATA_JSON, + Q_LOADER_DATA_JSON_LEN, + QDATA_JSON, + QDATA_JSON_LEN, + QLoaderId, + SINGLE_LOADER_REGEX, +} from './user-response'; +import { executeLoader } from './loader-endpoints'; const RequestEvLoaders = Symbol('RequestEvLoaders'); const RequestEvMode = Symbol('RequestEvMode'); @@ -61,12 +72,26 @@ export function createRequestEvent( const cookie = new Cookie(request.headers.get('cookie')); const headers = new Headers(); const url = new URL(request.url); - if (url.pathname.endsWith(QDATA_JSON)) { - url.pathname = url.pathname.slice(0, -QDATA_JSON_LEN); + + const trimEnd = (length: number) => { + url.pathname = url.pathname.slice(0, -length); if (!globalThis.__NO_TRAILING_SLASH__ && !url.pathname.endsWith('/')) { url.pathname += '/'; } + }; + + if (url.pathname.endsWith(QDATA_JSON)) { + trimEnd(QDATA_JSON_LEN); sharedMap.set(IsQData, true); + } else if (url.pathname.endsWith(Q_LOADER_DATA_JSON)) { + trimEnd(Q_LOADER_DATA_JSON_LEN); + sharedMap.set(IsQLoaderData, true); + } + const loaderMatch = url.pathname.match(SINGLE_LOADER_REGEX); + if (loaderMatch) { + trimEnd(loaderMatch[0].length); + sharedMap.set(IsQLoader, true); + sharedMap.set(QLoaderId, loaderMatch[1]); // Store which loader was requested } let routeModuleIndex = -1; @@ -211,7 +236,7 @@ export function createRequestEvent( } if (loaders[id] === _UNINITIALIZED) { const isDev = getRequestMode(requestEv) === 'dev'; - await getRouteLoaderPromise(loaderOrAction, loaders, requestEv, isDev); + await executeLoader(loaderOrAction, loaders, requestEv, isDev); } } diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts index 95d45cd6bf7..903edfe389a 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts @@ -1,27 +1,23 @@ import { type QRL } from '@qwik.dev/core'; import { _serialize, _UNINITIALIZED, _verifySerializable } from '@qwik.dev/core/internal'; import type { Render, RenderToStringResult } from '@qwik.dev/core/server'; -import { QACTION_KEY, QFN_KEY, QLOADER_KEY } from '../../runtime/src/constants'; +import { QACTION_KEY, QFN_KEY } from '../../runtime/src/constants'; import { LoadedRouteProp, type ActionInternal, type ClientPageData, - type DataValidator, type JSONObject, type LoadedRoute, type LoaderInternal, type PageModule, type RouteModule, - type ValidatorReturn, } from '../../runtime/src/types'; import { HttpStatus } from './http-status-codes'; import { - RequestEvIsRewrite, RequestEvShareQData, RequestEvShareServerTiming, RequestEvSharedActionId, RequestRouteName, - getRequestLoaderSerializationStrategyMap, getRequestLoaders, getRequestMode, type RequestEventInternal, @@ -29,6 +25,13 @@ import { import { getQwikRouterServerData } from './response-page'; import type { ErrorCodes, RequestEvent, RequestEventBase, RequestHandler } from './types'; import { IsQData, QDATA_JSON } from './user-response'; +import { + executeLoader, + singleLoaderHandler, + runValidators, + loaderDataHandler, +} from './loader-endpoints'; +import { qDataHandler } from './qdata-endpoints'; // Import separately to avoid duplicate imports in the vite dev server import { RedirectMessage, ServerError } from '@qwik.dev/router/middleware/request-handler'; @@ -84,7 +87,9 @@ export const resolveRequestHandlers = ( } requestHandlers.push(fixTrailingSlash); - requestHandlers.push(renderQData); + requestHandlers.push(loaderDataHandler(routeLoaders)); + requestHandlers.push(singleLoaderHandler(routeLoaders)); + requestHandlers.push(qDataHandler); } requestHandlers.push(handleRedirect); @@ -238,84 +243,13 @@ export function loadersMiddleware(routeLoaders: LoaderInternal[]): RequestHandle const isDev = getRequestMode(requestEv) === 'dev'; if (routeLoaders.length > 0) { const resolvedLoadersPromises = routeLoaders.map((loader) => - getRouteLoaderPromise(loader, loaders, requestEv, isDev) + executeLoader(loader, loaders, requestEv, isDev) ); await Promise.all(resolvedLoadersPromises); } }; } -export async function getRouteLoaderPromise( - loader: LoaderInternal, - loaders: Record, - requestEv: RequestEventInternal, - isDev: boolean -) { - const loaderId = loader.__id; - loaders[loaderId] = runValidators( - requestEv, - loader.__validators, - undefined, // data - isDev - ) - .then((res) => { - if (res.success) { - if (isDev) { - return measure>(requestEv, loader.__qrl.getHash(), () => - loader.__qrl.call(requestEv, requestEv) - ); - } else { - return loader.__qrl.call(requestEv, requestEv); - } - } else { - return requestEv.fail(res.status ?? 500, res.error); - } - }) - .then((resolvedLoader) => { - if (typeof resolvedLoader === 'function') { - loaders[loaderId] = resolvedLoader(); - } else { - if (isDev) { - verifySerializable(resolvedLoader, loader.__qrl); - } - loaders[loaderId] = resolvedLoader; - } - return resolvedLoader; - }); - const loadersSerializationStrategy = getRequestLoaderSerializationStrategyMap(requestEv); - loadersSerializationStrategy.set(loaderId, loader.__serializationStrategy); - return loaders[loaderId]; -} - -async function runValidators( - requestEv: RequestEvent, - validators: DataValidator[] | undefined, - data: unknown, - isDev: boolean -) { - let lastResult: ValidatorReturn = { - success: true, - data, - }; - if (validators) { - for (const validator of validators) { - if (isDev) { - lastResult = await measure(requestEv, `validator$`, () => - validator.validate(requestEv, data) - ); - } else { - lastResult = await validator.validate(requestEv, data); - } - if (!lastResult.success) { - return lastResult; - } else { - data = lastResult.data; - } - } - } - return lastResult; -} - function isAsyncIterator(obj: unknown): obj is AsyncIterable { return obj ? typeof obj === 'object' && Symbol.asyncIterator in obj : false; } @@ -568,62 +502,6 @@ export async function handleRedirect(requestEv: RequestEvent) { } } -export async function renderQData(requestEv: RequestEvent) { - const isPageDataReq = requestEv.sharedMap.has(IsQData); - if (!isPageDataReq) { - return; - } - await requestEv.next(); - - if (requestEv.headersSent || requestEv.exited) { - return; - } - - const status = requestEv.status(); - const redirectLocation = requestEv.headers.get('Location'); - - const requestHeaders: Record = {}; - requestEv.request.headers.forEach((value, key) => (requestHeaders[key] = value)); - requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); - - let loaders = getRequestLoaders(requestEv); - const selectedLoaderIds = requestEv.query.getAll(QLOADER_KEY); - - const hasCustomLoaders = selectedLoaderIds.length > 0; - - if (hasCustomLoaders) { - const selectedLoaders: Record = {}; - for (const loaderId of selectedLoaderIds) { - const loader = loaders[loaderId]; - selectedLoaders[loaderId] = loader; - } - loaders = selectedLoaders; - } - - const qData: ClientPageData = hasCustomLoaders - ? { - // send minimal data to the client - loaders, - status: status !== 200 ? status : 200, - href: getPathname(requestEv.url), - } - : { - loaders, - action: requestEv.sharedMap.get(RequestEvSharedActionId), - status: status !== 200 ? status : 200, - href: getPathname(requestEv.url), - redirect: redirectLocation ?? undefined, - isRewrite: requestEv.sharedMap.get(RequestEvIsRewrite), - }; - const writer = requestEv.getWritableStream().getWriter(); - // write just the page json data to the response body - const data = await _serialize([qData]); - writer.write(encoder.encode(data)); - requestEv.sharedMap.set(RequestEvShareQData, qData); - - writer.close(); -} - function makeQDataPath(href: string) { if (href.startsWith('/')) { const append = QDATA_JSON; diff --git a/packages/qwik-router/src/middleware/request-handler/user-response.ts b/packages/qwik-router/src/middleware/request-handler/user-response.ts index 88e283fcaae..22a30cae736 100644 --- a/packages/qwik-router/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-router/src/middleware/request-handler/user-response.ts @@ -15,7 +15,7 @@ import { RewriteMessage, ServerError, } from '@qwik.dev/router/middleware/request-handler'; -import type { QwikSerializer, ServerRequestEvent, StatusCodes } from './types'; +import type { ServerRequestEvent, StatusCodes } from './types'; export interface QwikRouterRun { /** @@ -165,8 +165,10 @@ async function runNext( * be treated as a pathname without it. */ export function getRouteMatchPathname(pathname: string) { - if (pathname.endsWith(QDATA_JSON)) { - const trimEnd = pathname.length - QDATA_JSON_LEN + (globalThis.__NO_TRAILING_SLASH__ ? 0 : 1); + const frameworkSpecificFileLength = getFrameworkSpecificFileLength(pathname); + if (frameworkSpecificFileLength) { + const trimEnd = + pathname.length - frameworkSpecificFileLength + (globalThis.__NO_TRAILING_SLASH__ ? 0 : 1); pathname = pathname.slice(0, trimEnd); if (pathname === '') { pathname = '/'; @@ -175,6 +177,29 @@ export function getRouteMatchPathname(pathname: string) { return pathname; } +function getFrameworkSpecificFileLength(pathname: string): number { + // Check for exact endings first (most common cases) + if (pathname.endsWith(QDATA_JSON)) { + return QDATA_JSON_LEN; + } else if (pathname.endsWith(Q_LOADER_DATA_JSON)) { + return Q_LOADER_DATA_JSON_LEN; + } + + // For regex pattern, use match directly + const match = pathname.match(SINGLE_LOADER_REGEX); + if (match) { + return match[0].length; + } + + return 0; +} + export const IsQData = '@isQData'; +export const IsQLoader = '@isQLoader'; +export const QLoaderId = '@qLoaderId'; +export const IsQLoaderData = '@isQLoaderData'; export const QDATA_JSON = '/q-data.json'; export const QDATA_JSON_LEN = QDATA_JSON.length; +export const Q_LOADER_DATA_JSON = '/q-loader-data.json'; +export const Q_LOADER_DATA_JSON_LEN = Q_LOADER_DATA_JSON.length; +export const SINGLE_LOADER_REGEX = /\/q-loader-([^.]+)\.json$/; diff --git a/packages/qwik-router/src/runtime/src/link-component.tsx b/packages/qwik-router/src/runtime/src/link-component.tsx index 485c48b02ec..1087a289181 100644 --- a/packages/qwik-router/src/runtime/src/link-component.tsx +++ b/packages/qwik-router/src/runtime/src/link-component.tsx @@ -54,7 +54,7 @@ export const Link = component$((props) => { preloadRouteBundles(url.pathname); if (elm.hasAttribute('data-prefetch')) { - loadClientData(url, elm, { + loadClientData(url, { preloadRouteBundles: false, isPrefetch: true, }); diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index 18f453ecb29..e1997d420e8 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -20,14 +20,14 @@ import { _getContextContainer, _getContextElement, _getQContainerElement, - _waitUntilRendered, + _hasStoreEffects, _UNINITIALIZED, + _waitUntilRendered, + forceStoreEffects, SerializerSymbol, type _ElementVNode, type AsyncComputedReadonlySignal, type SerializationStrategy, - forceStoreEffects, - _hasStoreEffects, } from '@qwik.dev/core/internal'; import { clientNavigate } from './client-navigate'; import { CLIENT_DATA_CACHE, DEFAULT_LOADERS_SERIALIZATION_STRATEGY, Q_ROUTE } from './constants'; @@ -43,6 +43,7 @@ import { RouteStateContext, } from './contexts'; import { createDocumentHead, resolveHead } from './head'; +import transitionCss from './qwik-view-transition.css?inline'; import { loadRoute } from './routing'; import { callRestoreScrollOnDocument, @@ -51,7 +52,6 @@ import { restoreScroll, saveScrollHistory, } from './scroll-restoration'; -import spaInit from './spa-init'; import type { ClientPageData, ContentModule, @@ -74,7 +74,6 @@ import { loadClientData } from './use-endpoint'; import { useQwikRouterEnv } from './use-functions'; import { createLoaderSignal, isSameOrigin, isSamePath, toUrl } from './utils'; import { startViewTransition } from './view-transition'; -import transitionCss from './qwik-view-transition.css?inline'; /** * @deprecated Use `QWIK_ROUTER_SCROLLER` instead (will be removed in V3) @@ -354,7 +353,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { routeInternal.value = { type, dest, forceReload, replaceState, scroll }; if (isBrowser) { - loadClientData(dest, _getContextElement()); + loadClientData(dest); loadRoute( qwikRouterConfig.routes, qwikRouterConfig.menus, @@ -417,7 +416,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { trackUrl.pathname ); elm = _getContextElement(); - const pageData = (clientPageData = await loadClientData(trackUrl, elm, { + const pageData = (clientPageData = await loadClientData(trackUrl, { action, clearCache: true, })); diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index 63e7ee8cf79..f43006652d1 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -1,106 +1,125 @@ -import { getClientDataPath } from './utils'; -import { CLIENT_DATA_CACHE } from './constants'; import type { ClientPageData, RouteActionValue } from './types'; import { _deserialize } from '@qwik.dev/core/internal'; import { preloadRouteBundles } from './client-navigate'; +import type { QData } from '../../middleware/request-handler/qdata-endpoints'; -const MAX_Q_DATA_RETRY_COUNT = 3; +export const loadClientLoaderData = async (url: URL, loaderId: string) => { + const pagePathname = url.pathname.endsWith('/') ? url.pathname : url.pathname + '/'; + return fetchLoader(loaderId, pagePathname); +}; export const loadClientData = async ( url: URL, - element: unknown, opts?: { action?: RouteActionValue; loaderIds?: string[]; clearCache?: boolean; preloadRouteBundles?: boolean; isPrefetch?: boolean; - }, - retryCount: number = 0 -): Promise => { - const pagePathname = url.pathname; - const pageSearch = url.search; - const clientDataPath = getClientDataPath(pagePathname, pageSearch, { - actionId: opts?.action?.id, - loaderIds: opts?.loaderIds, - }); - let qData: Promise | undefined; - if (!opts?.action) { - qData = CLIENT_DATA_CACHE.get(clientDataPath); } - +): Promise => { + const pagePathname = url.pathname.endsWith('/') ? url.pathname : url.pathname + '/'; if (opts?.preloadRouteBundles !== false) { preloadRouteBundles(pagePathname, 0.8); } - let resolveFn: () => void | undefined; - if (!qData) { - const fetchOptions = getFetchOptions(opts?.action, opts?.clearCache); - if (opts?.action) { - opts.action.data = undefined; + if (!opts?.loaderIds) { + // we need to load all the loaders + // first we need to get the loader ids + opts = opts || {}; + opts.loaderIds = (await fetchLoaderData(pagePathname)).loaderIds; + } + + const loaderIds = opts.loaderIds; + const loaders: Record = {}; + if (loaderIds.length > 0) { + // load specific loaders + const loaderPromises = loaderIds.map((loaderId) => fetchLoader(loaderId, pagePathname)); + const loaderResults = await Promise.all(loaderPromises); + for (let i = 0; i < loaderIds.length; i++) { + loaders[loaderIds[i]] = loaderResults[i]; } - qData = fetch(clientDataPath, fetchOptions).then((rsp) => { - if (rsp.status === 404 && opts?.loaderIds && retryCount < MAX_Q_DATA_RETRY_COUNT) { - // retry if the q-data.json is not found with all options - // we want to retry with all the loaders - opts.loaderIds = undefined; - return loadClientData(url, element, opts, retryCount + 1); + } + + const fetchOptions = getFetchOptions(opts?.action, opts?.clearCache); + if (opts?.action) { + opts.action.data = undefined; + } + + let resolveFn: () => void | undefined; + const qDataUrl = `${pagePathname}q-data.json`; + const qData = fetch(qDataUrl, fetchOptions).then((rsp) => { + if (rsp.redirected) { + const redirectedURL = new URL(rsp.url); + const isQData = redirectedURL.pathname.endsWith('/q-data.json'); + if (!isQData || redirectedURL.origin !== location.origin) { + // Captive portal etc. We can't talk to the server, so redirect as asked + location.href = redirectedURL.href; + return; } - if (rsp.redirected) { - const redirectedURL = new URL(rsp.url); - const isQData = redirectedURL.pathname.endsWith('/q-data.json'); - if (!isQData || redirectedURL.origin !== location.origin) { - // Captive portal etc. We can't talk to the server, so redirect as asked - location.href = redirectedURL.href; + } + if ((rsp.headers.get('content-type') || '').includes('json')) { + // we are safe we are reading a q-data.json + return rsp.text().then((text) => { + const [clientData] = _deserialize(text) as [QData]; + if (!clientData) { + // Something went wrong, show to the user + location.href = url.href; return; } - } - if ((rsp.headers.get('content-type') || '').includes('json')) { - // we are safe we are reading a q-data.json - return rsp.text().then((text) => { - const [clientData] = _deserialize(text, element) as [ClientPageData]; - if (!clientData) { - // Something went wrong, show to the user - location.href = url.href; - return; - } - if (opts?.clearCache) { - CLIENT_DATA_CACHE.delete(clientDataPath); - } - if (clientData.redirect) { - // server function asked for redirect - location.href = clientData.redirect; - } else if (opts?.action) { - const { action } = opts; - const actionData = clientData.loaders[action.id]; - resolveFn = () => { - action!.resolve!({ status: rsp.status, result: actionData }); - }; - } - return clientData; - }); - } else { - if (opts?.isPrefetch !== true) { - location.href = url.href; + if (clientData.redirect) { + // server function asked for redirect + location.href = clientData.redirect; + } else if (opts?.action) { + const { action } = opts; + const actionData = loaders[action.id]; + resolveFn = () => { + action!.resolve!({ status: rsp.status, result: actionData }); + }; } - return undefined; + return clientData; + }); + } else { + if (opts?.isPrefetch !== true) { + location.href = url.href; } - }); - - if (!opts?.action) { - CLIENT_DATA_CACHE.set(clientDataPath, qData); + return; } - } + }); return qData.then((v) => { - if (!v) { - CLIENT_DATA_CACHE.delete(clientDataPath); - } resolveFn && resolveFn(); - return v; + return { + loaders, + href: v?.href, + status: v?.status, + action: v?.action, + redirect: v?.redirect, + isRewrite: v?.isRewrite, + } as ClientPageData; }); }; +export async function fetchLoaderData(routePath: string): Promise<{ loaderIds: string[] }> { + const url = `${routePath}q-loader-data.json`; + const response = await fetch(url); + return response.json(); +} + +export async function fetchLoader(loaderId: string, routePath: string): Promise { + const url = `${routePath}q-loader-${loaderId}.json`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load ${loaderId}: ${response.status}`); + } + + const text = await response.text(); + const [data] = _deserialize(text, document.documentElement) as [Record]; + + return data; +} + const getFetchOptions = ( action: RouteActionValue | undefined, noCache: boolean | undefined diff --git a/packages/qwik-router/src/runtime/src/utils.ts b/packages/qwik-router/src/runtime/src/utils.ts index 4d8ee6bd871..98801429c0a 100644 --- a/packages/qwik-router/src/runtime/src/utils.ts +++ b/packages/qwik-router/src/runtime/src/utils.ts @@ -7,7 +7,7 @@ import { type SerializationStrategy, } from '@qwik.dev/core/internal'; import { QACTION_KEY, QLOADER_KEY } from './constants'; -import { loadClientData } from './use-endpoint'; +import { loadClientLoaderData } from './use-endpoint'; /** Gets an absolute url path string (url.pathname + url.search + url.hash) */ export const toPath = (url: URL) => url.pathname + url.search + url.hash; @@ -98,10 +98,8 @@ export const createLoaderSignal = ( return createAsyncComputed$( async () => { if (isBrowser && loadersObject[loaderId] === _UNINITIALIZED) { - const data = await loadClientData(url, undefined, { - loaderIds: [loaderId], - }); - loadersObject[loaderId] = data?.loaders[loaderId] ?? _UNINITIALIZED; + const data = await loadClientLoaderData(url, loaderId); + loadersObject[loaderId] = data ?? _UNINITIALIZED; } return loadersObject[loaderId]; }, From efca71ae15d0a9cd0095c08ad43e47182cea80d5 Mon Sep 17 00:00:00 2001 From: Varixo Date: Thu, 18 Sep 2025 23:39:49 +0200 Subject: [PATCH 06/20] feat: add loader info to qwik router config --- .../docs/src/routes/api/qwik-router/api.json | 2 +- .../docs/src/routes/api/qwik-router/index.mdx | 17 +++ packages/qwik-router/modules.d.ts | 2 + .../generate-qwik-router-config.ts | 6 +- .../runtime-generation/generate-routes.ts | 61 +++++++++- .../request-handler/loader-endpoints.ts | 84 +++++++------ .../middleware.request-handler.api.md | 1 + .../request-handler/request-event.ts | 60 +++++++--- .../request-handler/user-response.ts | 31 ++--- .../src/runtime/src/link-component.tsx | 7 +- .../src/runtime/src/qwik-router-component.tsx | 19 ++- .../src/runtime/src/qwik-router-config.ts | 2 + .../runtime/src/qwik-router.runtime.api.md | 2 + packages/qwik-router/src/runtime/src/types.ts | 1 + .../src/runtime/src/use-endpoint.ts | 51 +++++--- .../src/runtime/src/use-functions.ts | 3 + packages/qwik-router/src/runtime/src/utils.ts | 3 +- packages/qwik/src/optimizer/core/src/parse.rs | 2 +- ...core__test__example_drop_side_effects.snap | 29 ++++- ...wik_core__test__example_noop_dev_mode.snap | 113 +++++++++++++++++- ...__test__example_reg_ctx_name_segments.snap | 29 ++++- ...core__test__example_strip_client_code.snap | 90 +++++++++++++- ...core__test__example_strip_server_code.snap | 55 ++++++++- .../qwik/src/optimizer/core/src/transform.rs | 21 +++- .../qwik/src/optimizer/src/plugins/plugin.ts | 1 - .../src/components/footer/footer.tsx | 3 + .../src/routes/layout-only/inner/index.tsx | 9 ++ .../src/routes/layout-only/layout.tsx | 20 ++++ 28 files changed, 611 insertions(+), 113 deletions(-) create mode 100644 starters/apps/qwikrouter-test/src/routes/layout-only/inner/index.tsx create mode 100644 starters/apps/qwikrouter-test/src/routes/layout-only/layout.tsx diff --git a/packages/docs/src/routes/api/qwik-router/api.json b/packages/docs/src/routes/api/qwik-router/api.json index 659c327c524..b5c13b963df 100644 --- a/packages/docs/src/routes/api/qwik-router/api.json +++ b/packages/docs/src/routes/api/qwik-router/api.json @@ -656,7 +656,7 @@ } ], "kind": "Interface", - "content": "```typescript\nexport interface QwikRouterConfig \n```\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[basePathname?](./router.qwikrouterconfig.basepathname.md)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n\n[cacheModules?](./router.qwikrouterconfig.cachemodules.md)\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\n[menus?](./router.qwikrouterconfig.menus.md)\n\n\n\n\n`readonly`\n\n\n\n\n[MenuData](#menudata)\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\n[routes](./router.qwikrouterconfig.routes.md)\n\n\n\n\n`readonly`\n\n\n\n\n[RouteData](#routedata)\\[\\]\n\n\n\n\n\n
\n\n[serverPlugins?](./router.qwikrouterconfig.serverplugins.md)\n\n\n\n\n`readonly`\n\n\n\n\nRouteModule\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\n[trailingSlash?](./router.qwikrouterconfig.trailingslash.md)\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
", + "content": "```typescript\nexport interface QwikRouterConfig \n```\n\n\n\n\n\n\n\n\n\n\n
\n\nProperty\n\n\n\n\nModifiers\n\n\n\n\nType\n\n\n\n\nDescription\n\n\n
\n\n[basePathname?](./router.qwikrouterconfig.basepathname.md)\n\n\n\n\n`readonly`\n\n\n\n\nstring\n\n\n\n\n_(Optional)_\n\n\n
\n\n[cacheModules?](./router.qwikrouterconfig.cachemodules.md)\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
\n\n[loaderIdToRoute?](./router.qwikrouterconfig.loaderidtoroute.md)\n\n\n\n\n`readonly`\n\n\n\n\nRecord<string, string>\n\n\n\n\n_(Optional)_\n\n\n
\n\n[menus?](./router.qwikrouterconfig.menus.md)\n\n\n\n\n`readonly`\n\n\n\n\n[MenuData](#menudata)\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\n[routes](./router.qwikrouterconfig.routes.md)\n\n\n\n\n`readonly`\n\n\n\n\n[RouteData](#routedata)\\[\\]\n\n\n\n\n\n
\n\n[serverPlugins?](./router.qwikrouterconfig.serverplugins.md)\n\n\n\n\n`readonly`\n\n\n\n\nRouteModule\\[\\]\n\n\n\n\n_(Optional)_\n\n\n
\n\n[trailingSlash?](./router.qwikrouterconfig.trailingslash.md)\n\n\n\n\n`readonly`\n\n\n\n\nboolean\n\n\n\n\n_(Optional)_\n\n\n
", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.qwikrouterconfig.md" }, diff --git a/packages/docs/src/routes/api/qwik-router/index.mdx b/packages/docs/src/routes/api/qwik-router/index.mdx index 8301a0c13ee..a0a875aa974 100644 --- a/packages/docs/src/routes/api/qwik-router/index.mdx +++ b/packages/docs/src/routes/api/qwik-router/index.mdx @@ -1510,6 +1510,23 @@ _(Optional)_ +[loaderIdToRoute?](./router.qwikrouterconfig.loaderidtoroute.md) + + + +`readonly` + + + +Record<string, string> + + + +_(Optional)_ + + + + [menus?](./router.qwikrouterconfig.menus.md) diff --git a/packages/qwik-router/modules.d.ts b/packages/qwik-router/modules.d.ts index 4a778f63bfc..02583436395 100644 --- a/packages/qwik-router/modules.d.ts +++ b/packages/qwik-router/modules.d.ts @@ -4,12 +4,14 @@ declare module '@qwik-router-config' { export const trailingSlash: boolean; export const basePathname: string; export const cacheModules: boolean; + export const loaderIdToRoute: Record; const defaultExport: { routes: any[]; menus: any[]; trailingSlash: boolean; basePathname: string; cacheModules: boolean; + loaderIdToRoute: Record; }; export default defaultExport; } diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts index a7eb83bf6fe..3428aa4e656 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-qwik-router-config.ts @@ -2,7 +2,7 @@ import type { QwikVitePlugin } from '@qwik.dev/core/optimizer'; import type { RoutingContext } from '../types'; import { createEntries } from './generate-entries'; import { createMenus } from './generate-menus'; -import { createRoutes } from './generate-routes'; +import { createLoaderIdToRoute, createRoutes } from './generate-routes'; import { createServerPlugins } from './generate-server-plugins'; /** Generates the Qwik Router Config runtime code */ @@ -25,6 +25,8 @@ export function generateQwikRouterConfig( createEntries(ctx, c); + createLoaderIdToRoute(ctx, qwikPlugin, c); + c.push(`export const trailingSlash = ${JSON.stringify(!globalThis.__NO_TRAILING_SLASH__)};`); c.push(`export const basePathname = ${JSON.stringify(ctx.opts.basePathname)};`); @@ -32,7 +34,7 @@ export function generateQwikRouterConfig( c.push(`export const cacheModules = !isDev;`); c.push( - `export default { routes, serverPlugins, menus, trailingSlash, basePathname, cacheModules };` + `export default { routes, serverPlugins, menus, trailingSlash, basePathname, cacheModules, loaderIdToRoute };` ); return esmImports.join('\n') + c.join('\n'); } diff --git a/packages/qwik-router/src/buildtime/runtime-generation/generate-routes.ts b/packages/qwik-router/src/buildtime/runtime-generation/generate-routes.ts index f06bae80212..9dd80835034 100644 --- a/packages/qwik-router/src/buildtime/runtime-generation/generate-routes.ts +++ b/packages/qwik-router/src/buildtime/runtime-generation/generate-routes.ts @@ -1,5 +1,5 @@ import type { QwikManifest, QwikVitePlugin } from '@qwik.dev/core/optimizer'; -import { isModuleExt, isPageExt, removeExtension } from '../../utils/fs'; +import { getPathnameFromDirPath, isModuleExt, isPageExt, removeExtension } from '../../utils/fs'; import type { RoutingContext, BuiltRoute } from '../types'; import { getImportPath } from './utils'; @@ -121,3 +121,62 @@ function getClientRouteBundleNames(qwikPlugin: QwikVitePlugin, r: BuiltRoute) { return bundlesNames; } + +export function createLoaderIdToRoute( + ctx: RoutingContext, + qwikPlugin: QwikVitePlugin, + c: string[] +) { + const manifest = qwikPlugin.api.getManifest(); + const loaderSymbols: Record = {}; + const mainDir = ctx.routes[0].routeName; + const routesDir = ctx.opts.routesDir.split('/').pop()!; + + if (manifest) { + for (const symbolData of Object.values(manifest.symbols)) { + if (symbolData.ctxName === 'routeLoader$') { + // extract file name from origin + const fileName = symbolData.origin.split('/').pop(); + if (!fileName) { + console.warn(`File name not found for loader: ${symbolData.origin}`); + continue; + } + + const filePath = + mainDir + + symbolData.origin + .replace(routesDir, '') + .replace(fileName, '') + // remove trailing slash + .substring(1); + + const routePath = getPathnameFromDirPath(ctx.opts, filePath); + const route = ctx.routes.find((r) => r.pathname === routePath); + if (route) { + // everything fine, route exists we can use that + loaderSymbols[symbolData.hash] = routePath; + } else { + /** + * Route not found, we need to get first available route this is the case for folders with + * layout files only like: + * + * ``` + * layout-only/ + * ├─ inner-route/ + * │ └─ index.tsx + * └─ layout.tsx + * ``` + */ + const firstRoute = ctx.routes.find((r) => r.pathname.startsWith(filePath)); + if (firstRoute) { + loaderSymbols[symbolData.hash] = firstRoute.pathname; + } else { + console.warn(`Route not found for loader: ${symbolData.origin}`); + } + } + } + } + } + + c.push(`export const loaderIdToRoute = ${JSON.stringify(loaderSymbols)};`); +} diff --git a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts index b7c888c96cd..dc90d38d7b5 100644 --- a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts +++ b/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts @@ -9,10 +9,13 @@ import { getRequestLoaders, getRequestLoaderSerializationStrategyMap, getRequestMode, + RequestEventInternal, } from './request-event'; import { measure, verifySerializable } from './resolve-request-handlers'; import type { RequestEvent } from './types'; import { IsQLoader, IsQLoaderData, QLoaderId } from './user-response'; +import qwikRouterConfig from '@qwik-router-config'; +import { getPathnameForDynamicRoute } from '../../utils/pathname'; export function loaderDataHandler(routeLoaders: LoaderInternal[]): RequestHandler { return async (requestEvent: RequestEvent) => { @@ -27,15 +30,30 @@ export function loaderDataHandler(routeLoaders: LoaderInternal[]): RequestHandle return; } - // Set cache headers - aggressive for loaders + // Set cache headers - cache it as never expires requestEv.cacheControl({ - maxAge: 300, // 5 minutes - staleWhileRevalidate: 3600, // 1 hour + maxAge: 365 * 24 * 60 * 60, // 1 year }); - // return loader ids - const loaderIds = routeLoaders.map((l) => l.__id); - return requestEv.json(200, { loaderIds }); + // return loader data: id and route + const loaderData = routeLoaders.map((l) => { + const loaderId = l.__id; + let loaderRoute = qwikRouterConfig.loaderIdToRoute[loaderId]; + const params = requestEv.params; + if (Object.keys(params).length > 0) { + const pathname = getPathnameForDynamicRoute( + requestEv.url.pathname, + Object.keys(params), + params + ); + loaderRoute = pathname; + } + return { + id: loaderId, + route: loaderRoute, + }; + }); + requestEv.json(200, { loaderData }); }; } @@ -53,42 +71,38 @@ export function singleLoaderHandler(routeLoaders: LoaderInternal[]): RequestHand } const loaderId = requestEv.sharedMap.get(QLoaderId); - try { - // Execute just this loader - const loaders = getRequestLoaders(requestEv); - const isDev = getRequestMode(requestEv) === 'dev'; - - let loader: LoaderInternal | undefined; - for (const routeLoader of routeLoaders) { - if (routeLoader.__id === loaderId) { - loader = routeLoader; - } else if (!loaders[routeLoader.__id]) { - loaders[routeLoader.__id] = _UNINITIALIZED; - } - } + // Execute just this loader + const loaders = getRequestLoaders(requestEv); + const isDev = getRequestMode(requestEv) === 'dev'; - if (!loader) { - return requestEv.json(404, { error: 'Loader not found' }); + let loader: LoaderInternal | undefined; + for (const routeLoader of routeLoaders) { + if (routeLoader.__id === loaderId) { + loader = routeLoader; + } else if (!loaders[routeLoader.__id]) { + loaders[routeLoader.__id] = _UNINITIALIZED; } + } - await executeLoader(loader, loaders, requestEv, isDev); + if (!loader) { + requestEv.json(404, { error: 'Loader not found' }); + return; + } - // Set cache headers - aggressive for loaders - requestEv.cacheControl({ - maxAge: 300, // 5 minutes - staleWhileRevalidate: 3600, // 1 hour - }); + await executeLoader(loader, loaders, requestEv, isDev); - const data = await _serialize([loaders[loaderId]]); + // Set cache headers - aggressive for loaders + requestEv.cacheControl({ + maxAge: 300, // 5 minutes + staleWhileRevalidate: 3600, // 1 hour + }); - requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); + const data = await _serialize([loaders[loaderId]]); - // Return just this loader's result - return requestEv.send(200, data); - } catch (error) { - console.error(`Loader ${loaderId} failed:`, error); - return requestEv.json(500, { error: 'Loader execution failed' }); - } + requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); + + // Return just this loader's result + requestEv.send(200, data); }; } diff --git a/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md b/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md index b430ba87592..b0b2bb84eed 100644 --- a/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md +++ b/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md @@ -11,6 +11,7 @@ import type { Loader as Loader_2 } from '@qwik.dev/router'; import type { QwikCityPlan } from '@qwik.dev/router'; import type { QwikIntrinsicElements } from '@qwik.dev/core'; import type { QwikRouterConfig } from '@qwik.dev/router'; +import { RedirectMessage as RedirectMessage_2 } from '@qwik.dev/router/middleware/request-handler'; import type { Render } from '@qwik.dev/core/server'; import type { RenderOptions } from '@qwik.dev/core/server'; import { RequestEvent as RequestEvent_2 } from '@qwik.dev/router/middleware/request-handler'; diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index 9c0bff567d3..00172f9374a 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -35,8 +35,7 @@ import { IsQData, IsQLoader, IsQLoaderData, - Q_LOADER_DATA_JSON, - Q_LOADER_DATA_JSON_LEN, + Q_LOADER_DATA_REGEX, QDATA_JSON, QDATA_JSON_LEN, QLoaderId, @@ -80,18 +79,14 @@ export function createRequestEvent( } }; - if (url.pathname.endsWith(QDATA_JSON)) { - trimEnd(QDATA_JSON_LEN); - sharedMap.set(IsQData, true); - } else if (url.pathname.endsWith(Q_LOADER_DATA_JSON)) { - trimEnd(Q_LOADER_DATA_JSON_LEN); - sharedMap.set(IsQLoaderData, true); - } - const loaderMatch = url.pathname.match(SINGLE_LOADER_REGEX); - if (loaderMatch) { - trimEnd(loaderMatch[0].length); - sharedMap.set(IsQLoader, true); - sharedMap.set(QLoaderId, loaderMatch[1]); // Store which loader was requested + const requestRecognized = recognizeRequest(url.pathname); + if (requestRecognized) { + sharedMap.set(requestRecognized.type, true); + if (requestRecognized.type === IsQLoader && requestRecognized.data) { + sharedMap.set(QLoaderId, requestRecognized.data.loaderId); + } + + trimEnd(requestRecognized.trimLength); } let routeModuleIndex = -1; @@ -463,3 +458,40 @@ const formToObj = (formData: FormData): Record => { // Return values object return values; }; + +export function recognizeRequest(pathname: string) { + // Quick length check for common cases + if (pathname.length < 10) { + return null; + } + + // Check exact matches first (fastest) + if (pathname.endsWith(QDATA_JSON)) { + return { + type: IsQData, + trimLength: QDATA_JSON_LEN, + data: null, + }; + } + + // Check for loader patterns + const loaderDataMatch = pathname.match(Q_LOADER_DATA_REGEX); + if (loaderDataMatch) { + return { + type: IsQLoaderData, + trimLength: loaderDataMatch[0].length, + data: null, + }; + } + + const loaderMatch = pathname.match(SINGLE_LOADER_REGEX); + if (loaderMatch) { + return { + type: IsQLoader, + trimLength: loaderMatch[0].length, + data: { loaderId: loaderMatch[1] }, + }; + } + + return null; +} diff --git a/packages/qwik-router/src/middleware/request-handler/user-response.ts b/packages/qwik-router/src/middleware/request-handler/user-response.ts index 22a30cae736..26e3e0f29f0 100644 --- a/packages/qwik-router/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-router/src/middleware/request-handler/user-response.ts @@ -6,7 +6,12 @@ import type { RequestHandler, } from '../../runtime/src/types'; import { getErrorHtml } from './error-handler'; -import { createRequestEvent, getRequestMode, type RequestEventInternal } from './request-event'; +import { + createRequestEvent, + getRequestMode, + recognizeRequest, + type RequestEventInternal, +} from './request-event'; import { encoder } from './resolve-request-handlers'; // Import separately to avoid duplicate imports in the vite dev server import { @@ -165,7 +170,7 @@ async function runNext( * be treated as a pathname without it. */ export function getRouteMatchPathname(pathname: string) { - const frameworkSpecificFileLength = getFrameworkSpecificFileLength(pathname); + const frameworkSpecificFileLength = recognizeRequest(pathname)?.trimLength; if (frameworkSpecificFileLength) { const trimEnd = pathname.length - frameworkSpecificFileLength + (globalThis.__NO_TRAILING_SLASH__ ? 0 : 1); @@ -177,29 +182,11 @@ export function getRouteMatchPathname(pathname: string) { return pathname; } -function getFrameworkSpecificFileLength(pathname: string): number { - // Check for exact endings first (most common cases) - if (pathname.endsWith(QDATA_JSON)) { - return QDATA_JSON_LEN; - } else if (pathname.endsWith(Q_LOADER_DATA_JSON)) { - return Q_LOADER_DATA_JSON_LEN; - } - - // For regex pattern, use match directly - const match = pathname.match(SINGLE_LOADER_REGEX); - if (match) { - return match[0].length; - } - - return 0; -} - export const IsQData = '@isQData'; export const IsQLoader = '@isQLoader'; export const QLoaderId = '@qLoaderId'; export const IsQLoaderData = '@isQLoaderData'; export const QDATA_JSON = '/q-data.json'; export const QDATA_JSON_LEN = QDATA_JSON.length; -export const Q_LOADER_DATA_JSON = '/q-loader-data.json'; -export const Q_LOADER_DATA_JSON_LEN = Q_LOADER_DATA_JSON.length; -export const SINGLE_LOADER_REGEX = /\/q-loader-([^.]+)\.json$/; +export const Q_LOADER_DATA_REGEX = /\/q-loader-data\.(.+)\.json$/; +export const SINGLE_LOADER_REGEX = /\/q-loader-(.+)\.(.+)\.json$/; diff --git a/packages/qwik-router/src/runtime/src/link-component.tsx b/packages/qwik-router/src/runtime/src/link-component.tsx index 1087a289181..ec90d0d6533 100644 --- a/packages/qwik-router/src/runtime/src/link-component.tsx +++ b/packages/qwik-router/src/runtime/src/link-component.tsx @@ -13,13 +13,14 @@ import { } from '@qwik.dev/core'; import { preloadRouteBundles } from './client-navigate'; import { loadClientData } from './use-endpoint'; -import { useLocation, useNavigate } from './use-functions'; +import { useInstanceHash, useLocation, useNavigate } from './use-functions'; import { getClientNavPath, shouldPreload } from './utils'; /** @public */ export const Link = component$((props) => { const nav = useNavigate(); const loc = useLocation(); + const instanceHash = useInstanceHash(); const originalHref = props.href; const anchorRef = useSignal(); const { @@ -53,8 +54,8 @@ export const Link = component$((props) => { const url = new URL(elm.href); preloadRouteBundles(url.pathname); - if (elm.hasAttribute('data-prefetch')) { - loadClientData(url, { + if (elm.hasAttribute('data-prefetch') && instanceHash) { + loadClientData(url, instanceHash, { preloadRouteBundles: false, isPrefetch: true, }); diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index e1997d420e8..e3ea8227822 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -71,7 +71,7 @@ import type { ScrollState, } from './types'; import { loadClientData } from './use-endpoint'; -import { useQwikRouterEnv } from './use-functions'; +import { useInstanceHash, useQwikRouterEnv } from './use-functions'; import { createLoaderSignal, isSameOrigin, isSamePath, toUrl } from './utils'; import { startViewTransition } from './view-transition'; @@ -134,6 +134,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { throw new Error(`Missing Qwik URL Env Data`); } const serverHead = useServerData('documentHead'); + const instanceHash = useInstanceHash(); if (isServer) { if ( @@ -180,6 +181,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { key, url, getSerializationStrategy(key), + instanceHash!, container ); } @@ -353,7 +355,9 @@ export const useQwikRouter = (props?: QwikRouterProps) => { routeInternal.value = { type, dest, forceReload, replaceState, scroll }; if (isBrowser) { - loadClientData(dest); + if (instanceHash) { + loadClientData(dest, instanceHash); + } loadRoute( qwikRouterConfig.routes, qwikRouterConfig.menus, @@ -416,10 +420,12 @@ export const useQwikRouter = (props?: QwikRouterProps) => { trackUrl.pathname ); elm = _getContextElement(); - const pageData = (clientPageData = await loadClientData(trackUrl, { - action, - clearCache: true, - })); + const pageData = + instanceHash && + (clientPageData = await loadClientData(trackUrl, instanceHash, { + action, + clearCache: true, + })); if (!pageData) { // Reset the path to the current path (routeInternal as any).untrackedValue = { type: navType, dest: trackUrl }; @@ -541,6 +547,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { key, trackUrl, DEFAULT_LOADERS_SERIALIZATION_STRATEGY, + instanceHash!, container ); } else { diff --git a/packages/qwik-router/src/runtime/src/qwik-router-config.ts b/packages/qwik-router/src/runtime/src/qwik-router-config.ts index ca5c0487cea..c75416f1d27 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-config.ts +++ b/packages/qwik-router/src/runtime/src/qwik-router-config.ts @@ -7,6 +7,7 @@ export const serverPlugins: RouteModule[] = []; export const trailingSlash = !globalThis.__NO_TRAILING_SLASH__; export const basePathname = '/'; export const cacheModules = false; +export const loaderIdToRoute: Record = {}; export default { routes, @@ -14,4 +15,5 @@ export default { trailingSlash, basePathname, cacheModules, + loaderIdToRoute, }; diff --git a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md index 645ee16d2b8..c047be3b001 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md +++ b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md @@ -330,6 +330,8 @@ export interface QwikRouterConfig { // (undocumented) readonly cacheModules?: boolean; // (undocumented) + readonly loaderIdToRoute?: Record; + // (undocumented) readonly menus?: MenuData[]; // (undocumented) readonly routes: RouteData[]; diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index 83d3c835734..0827a543be1 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -306,6 +306,7 @@ export interface QwikRouterConfig { readonly menus?: MenuData[]; readonly trailingSlash?: boolean; readonly cacheModules?: boolean; + readonly loaderIdToRoute?: Record; } /** @public */ diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index f43006652d1..1b47eec8022 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -1,15 +1,21 @@ import type { ClientPageData, RouteActionValue } from './types'; -import { _deserialize } from '@qwik.dev/core/internal'; +import { _deserialize, _getDomContainer } from '@qwik.dev/core/internal'; import { preloadRouteBundles } from './client-navigate'; import type { QData } from '../../middleware/request-handler/qdata-endpoints'; -export const loadClientLoaderData = async (url: URL, loaderId: string) => { +interface LoaderDataResponse { + id: string; + route: string; +} + +export const loadClientLoaderData = async (url: URL, loaderId: string, instanceHash: string) => { const pagePathname = url.pathname.endsWith('/') ? url.pathname : url.pathname + '/'; - return fetchLoader(loaderId, pagePathname); + return fetchLoader(loaderId, pagePathname, instanceHash); }; export const loadClientData = async ( url: URL, + instanceHash: string, opts?: { action?: RouteActionValue; loaderIds?: string[]; @@ -23,21 +29,29 @@ export const loadClientData = async ( preloadRouteBundles(pagePathname, 0.8); } + let loaderData: LoaderDataResponse[] = []; if (!opts?.loaderIds) { // we need to load all the loaders - // first we need to get the loader ids - opts = opts || {}; - opts.loaderIds = (await fetchLoaderData(pagePathname)).loaderIds; + // first we need to get the loader urls + loaderData = (await fetchLoaderData(pagePathname, instanceHash)).loaderData; + } else { + loaderData = opts.loaderIds.map((loaderId) => { + return { + id: loaderId, + route: pagePathname, + }; + }); } - const loaderIds = opts.loaderIds; const loaders: Record = {}; - if (loaderIds.length > 0) { + if (loaderData.length > 0) { // load specific loaders - const loaderPromises = loaderIds.map((loaderId) => fetchLoader(loaderId, pagePathname)); + const loaderPromises = loaderData.map((loader) => + fetchLoader(loader.id, loader.route, instanceHash) + ); const loaderResults = await Promise.all(loaderPromises); - for (let i = 0; i < loaderIds.length; i++) { - loaders[loaderIds[i]] = loaderResults[i]; + for (let i = 0; i < loaderData.length; i++) { + loaders[loaderData[i].id] = loaderResults[i]; } } @@ -100,14 +114,21 @@ export const loadClientData = async ( }); }; -export async function fetchLoaderData(routePath: string): Promise<{ loaderIds: string[] }> { - const url = `${routePath}q-loader-data.json`; +export async function fetchLoaderData( + routePath: string, + instanceHash: string +): Promise<{ loaderData: LoaderDataResponse[] }> { + const url = `${routePath}q-loader-data.${instanceHash}.json`; const response = await fetch(url); return response.json(); } -export async function fetchLoader(loaderId: string, routePath: string): Promise { - const url = `${routePath}q-loader-${loaderId}.json`; +export async function fetchLoader( + loaderId: string, + routePath: string, + instanceHash: string +): Promise { + const url = `${routePath}q-loader-${loaderId}.${instanceHash}.json`; const response = await fetch(url); if (!response.ok) { diff --git a/packages/qwik-router/src/runtime/src/use-functions.ts b/packages/qwik-router/src/runtime/src/use-functions.ts index cd8ae919041..e02bcc3c8fb 100644 --- a/packages/qwik-router/src/runtime/src/use-functions.ts +++ b/packages/qwik-router/src/runtime/src/use-functions.ts @@ -96,5 +96,8 @@ export const useAction = (): RouteAction => useContext(RouteActionContext); export const useQwikRouterEnv = () => noSerialize(useServerData('qwikrouter')); +export const useInstanceHash = () => + useServerData>('containerAttributes')?.['q:instance']; + /** @deprecated Use `useQwikRouterEnv` instead. Will be removed in v3 */ export const useQwikCityEnv = useQwikRouterEnv; diff --git a/packages/qwik-router/src/runtime/src/utils.ts b/packages/qwik-router/src/runtime/src/utils.ts index 98801429c0a..c0c43b87896 100644 --- a/packages/qwik-router/src/runtime/src/utils.ts +++ b/packages/qwik-router/src/runtime/src/utils.ts @@ -93,12 +93,13 @@ export const createLoaderSignal = ( loaderId: string, url: URL, serializationStrategy: SerializationStrategy, + instanceHash: string, container?: ClientContainer ) => { return createAsyncComputed$( async () => { if (isBrowser && loadersObject[loaderId] === _UNINITIALIZED) { - const data = await loadClientLoaderData(url, loaderId); + const data = await loadClientLoaderData(url, loaderId, instanceHash); loadersObject[loaderId] = data ?? _UNINITIALIZED; } return loadersObject[loaderId]; diff --git a/packages/qwik/src/optimizer/core/src/parse.rs b/packages/qwik/src/optimizer/core/src/parse.rs index 0392eba6dfe..601b32ed206 100644 --- a/packages/qwik/src/optimizer/core/src/parse.rs +++ b/packages/qwik/src/optimizer/core/src/parse.rs @@ -388,7 +388,7 @@ pub fn transform_code(config: TransformCodeOptions) -> Result { ) }); +============================= test.tsx_api_server_JonPp043gH0.js (ENTRY POINT)== + +export const api_server_JonPp043gH0 = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "test.tsx", + "name": "api_server_JonPp043gH0", + "entry": null, + "displayName": "test.tsx_api_server", + "hash": "JonPp043gH0", + "canonicalFilename": "test.tsx_api_server_JonPp043gH0", + "path": "", + "extension": "js", + "parent": null, + "ctxKind": "function", + "ctxName": "server$", + "captures": false, + "loc": [ + 0, + 0 + ] +} +*/ ============================= test.js == import { serverQrl } from "@qwik.dev/router"; diff --git a/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_noop_dev_mode.snap b/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_noop_dev_mode.snap index dc3704fd721..7bdf4cf0bf6 100644 --- a/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_noop_dev_mode.snap +++ b/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_noop_dev_mode.snap @@ -1,8 +1,7 @@ --- source: packages/qwik/src/optimizer/core/src/test.rs -assertion_line: 3814 +assertion_line: 3815 expression: output -snapshot_kind: text --- ==INPUT== @@ -31,6 +30,116 @@ export const App = component$(() => { ); }); +============================= test.tsx_App_component_serverStuff_ebyHaP15ytQ.js (ENTRY POINT)== + +export const App_component_serverStuff_ebyHaP15ytQ = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "test.tsx", + "name": "App_component_serverStuff_ebyHaP15ytQ", + "entry": null, + "displayName": "test.tsx_App_component_serverStuff", + "hash": "ebyHaP15ytQ", + "canonicalFilename": "test.tsx_App_component_serverStuff_ebyHaP15ytQ", + "path": "", + "extension": "js", + "parent": "App_component_ckEPmXZlub0", + "ctxKind": "function", + "ctxName": "serverStuff$", + "captures": true, + "loc": [ + 0, + 0 + ], + "captureNames": [ + "stuff" + ] +} +*/ +============================= test.tsx_App_component_serverStuff_1_PQCqO0ANabY.js (ENTRY POINT)== + +export const App_component_serverStuff_1_PQCqO0ANabY = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "test.tsx", + "name": "App_component_serverStuff_1_PQCqO0ANabY", + "entry": null, + "displayName": "test.tsx_App_component_serverStuff_1", + "hash": "PQCqO0ANabY", + "canonicalFilename": "test.tsx_App_component_serverStuff_1_PQCqO0ANabY", + "path": "", + "extension": "js", + "parent": "App_component_ckEPmXZlub0", + "ctxKind": "function", + "ctxName": "serverStuff$", + "captures": false, + "loc": [ + 0, + 0 + ] +} +*/ +============================= test.tsx_App_component_Cmp_p_shouldRemove_uU0MG0jvQD4.js (ENTRY POINT)== + +export const App_component_Cmp_p_shouldRemove_uU0MG0jvQD4 = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "test.tsx", + "name": "App_component_Cmp_p_shouldRemove_uU0MG0jvQD4", + "entry": null, + "displayName": "test.tsx_App_component_Cmp_p_shouldRemove", + "hash": "uU0MG0jvQD4", + "canonicalFilename": "test.tsx_App_component_Cmp_p_shouldRemove_uU0MG0jvQD4", + "path": "", + "extension": "js", + "parent": "App_component_ckEPmXZlub0", + "ctxKind": "eventHandler", + "ctxName": "shouldRemove$", + "captures": true, + "loc": [ + 0, + 0 + ], + "captureNames": [ + "stuff" + ] +} +*/ +============================= test.tsx_App_component_Cmp_p_onClick_vuXzfUTkpto.js (ENTRY POINT)== + +export const App_component_Cmp_p_onClick_vuXzfUTkpto = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "test.tsx", + "name": "App_component_Cmp_p_onClick_vuXzfUTkpto", + "entry": null, + "displayName": "test.tsx_App_component_Cmp_p_onClick", + "hash": "vuXzfUTkpto", + "canonicalFilename": "test.tsx_App_component_Cmp_p_onClick_vuXzfUTkpto", + "path": "", + "extension": "js", + "parent": "App_component_ckEPmXZlub0", + "ctxKind": "eventHandler", + "ctxName": "onClick$", + "captures": false, + "loc": [ + 0, + 0 + ] +} +*/ ============================= test.js == import { componentQrl } from "@qwik.dev/core"; diff --git a/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_reg_ctx_name_segments.snap b/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_reg_ctx_name_segments.snap index a12d1260b39..fd60399074f 100644 --- a/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_reg_ctx_name_segments.snap +++ b/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_reg_ctx_name_segments.snap @@ -1,8 +1,7 @@ --- source: packages/qwik/src/optimizer/core/src/test.rs -assertion_line: 865 +assertion_line: 866 expression: output -snapshot_kind: text --- ==INPUT== @@ -19,6 +18,32 @@ export const Works = component$((props) => { ); }); +============================= test.tsx_Works_component_Fragment_div_onClick_nO4DPVZWP7g.js (ENTRY POINT)== + +export const Works_component_Fragment_div_onClick_nO4DPVZWP7g = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "test.tsx", + "name": "Works_component_Fragment_div_onClick_nO4DPVZWP7g", + "entry": null, + "displayName": "test.tsx_Works_component_Fragment_div_onClick", + "hash": "nO4DPVZWP7g", + "canonicalFilename": "test.tsx_Works_component_Fragment_div_onClick_nO4DPVZWP7g", + "path": "", + "extension": "js", + "parent": "Works_component_t45qL4vNGv0", + "ctxKind": "eventHandler", + "ctxName": "onClick$", + "captures": false, + "loc": [ + 0, + 0 + ] +} +*/ ============================= test.js == import "./foo"; diff --git a/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_strip_client_code.snap b/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_strip_client_code.snap index 3c8cf855aa1..1b47688d8bb 100644 --- a/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_strip_client_code.snap +++ b/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_strip_client_code.snap @@ -1,8 +1,7 @@ --- source: packages/qwik/src/optimizer/core/src/test.rs -assertion_line: 1929 +assertion_line: 1930 expression: output -snapshot_kind: text --- ==INPUT== @@ -44,6 +43,93 @@ export const Parent = component$(() => { ); }); +============================= components/component.tsx_Parent_component_useClientMount_Yn2kIDABoYw.js (ENTRY POINT)== + +export const Parent_component_useClientMount_Yn2kIDABoYw = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "components/component.tsx", + "name": "Parent_component_useClientMount_Yn2kIDABoYw", + "entry": null, + "displayName": "component.tsx_Parent_component_useClientMount", + "hash": "Yn2kIDABoYw", + "canonicalFilename": "component.tsx_Parent_component_useClientMount_Yn2kIDABoYw", + "path": "components", + "extension": "js", + "parent": "Parent_component_t6Wy3C0Q0XM", + "ctxKind": "function", + "ctxName": "useClientMount$", + "captures": true, + "loc": [ + 0, + 0 + ], + "captureNames": [ + "state" + ] +} +*/ +============================= components/component.tsx_Parent_component_div_shouldRemove_EBj69wTX1do.js (ENTRY POINT)== + +export const Parent_component_div_shouldRemove_EBj69wTX1do = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "components/component.tsx", + "name": "Parent_component_div_shouldRemove_EBj69wTX1do", + "entry": null, + "displayName": "component.tsx_Parent_component_div_shouldRemove", + "hash": "EBj69wTX1do", + "canonicalFilename": "component.tsx_Parent_component_div_shouldRemove_EBj69wTX1do", + "path": "components", + "extension": "js", + "parent": "Parent_component_t6Wy3C0Q0XM", + "ctxKind": "eventHandler", + "ctxName": "shouldRemove$", + "captures": true, + "loc": [ + 0, + 0 + ], + "captureNames": [ + "state" + ] +} +*/ +============================= components/component.tsx_Parent_component_div_onClick_0PioS4FByUg.js (ENTRY POINT)== + +export const Parent_component_div_onClick_0PioS4FByUg = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "components/component.tsx", + "name": "Parent_component_div_onClick_0PioS4FByUg", + "entry": null, + "displayName": "component.tsx_Parent_component_div_onClick", + "hash": "0PioS4FByUg", + "canonicalFilename": "component.tsx_Parent_component_div_onClick_0PioS4FByUg", + "path": "components", + "extension": "js", + "parent": "Parent_component_t6Wy3C0Q0XM", + "ctxKind": "eventHandler", + "ctxName": "onClick$", + "captures": true, + "loc": [ + 0, + 0 + ], + "captureNames": [ + "state" + ] +} +*/ ============================= components/component.js == import "./keep"; diff --git a/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_strip_server_code.snap b/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_strip_server_code.snap index 4a07442bbf6..1dd45669f52 100644 --- a/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_strip_server_code.snap +++ b/packages/qwik/src/optimizer/core/src/snapshots/qwik_core__test__example_strip_server_code.snap @@ -1,8 +1,7 @@ --- source: packages/qwik/src/optimizer/core/src/test.rs -assertion_line: 1822 +assertion_line: 1823 expression: output -snapshot_kind: text --- ==INPUT== @@ -49,6 +48,58 @@ export const Parent = component$(() => { ); }); +============================= test.tsx_Parent_component_serverStuff_r1qAHX7Opp0.js (ENTRY POINT)== + +export const s_r1qAHX7Opp0 = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "test.tsx", + "name": "s_r1qAHX7Opp0", + "entry": null, + "displayName": "test.tsx_Parent_component_serverStuff", + "hash": "r1qAHX7Opp0", + "canonicalFilename": "test.tsx_Parent_component_serverStuff_r1qAHX7Opp0", + "path": "", + "extension": "js", + "parent": "s_0TaiDayHrlo", + "ctxKind": "function", + "ctxName": "serverStuff$", + "captures": false, + "loc": [ + 0, + 0 + ] +} +*/ +============================= test.tsx_Parent_component_serverLoader_k1L0DiPQV1I.js (ENTRY POINT)== + +export const s_k1L0DiPQV1I = null; + + +Some("{\"version\":3,\"sources\":[],\"names\":[],\"mappings\":\"\"}") +/* +{ + "origin": "test.tsx", + "name": "s_k1L0DiPQV1I", + "entry": null, + "displayName": "test.tsx_Parent_component_serverLoader", + "hash": "k1L0DiPQV1I", + "canonicalFilename": "test.tsx_Parent_component_serverLoader_k1L0DiPQV1I", + "path": "", + "extension": "js", + "parent": "s_0TaiDayHrlo", + "ctxKind": "function", + "ctxName": "serverLoader$", + "captures": false, + "loc": [ + 0, + 0 + ] +} +*/ ============================= test.tsx_Parent_component_useTask_gDH1EtUWqBU.js (ENTRY POINT)== import { useLexicalScope } from "@qwik.dev/core"; diff --git a/packages/qwik/src/optimizer/core/src/transform.rs b/packages/qwik/src/optimizer/core/src/transform.rs index 91cb292a536..ded30016304 100644 --- a/packages/qwik/src/optimizer/core/src/transform.rs +++ b/packages/qwik/src/optimizer/core/src/transform.rs @@ -1807,12 +1807,31 @@ impl<'a> QwikTransform<'a> { true } - // TODO export segment data for the noop qrl + // Noop segments are now added to the segments collection for manifest generation fn create_noop_qrl( &mut self, symbol_name: &swc_atoms::Atom, segment_data: SegmentData, ) -> ast::CallExpr { + // Add the noop segment to the segments collection for manifest generation + let canonical_filename = get_canonical_filename(&segment_data.display_name, symbol_name); + let param_names = None; // Noop segments don't have parameter names since they're not actual functions + + // Create a dummy expression for the noop segment + let dummy_expr = ast::Expr::Lit(ast::Lit::Null(ast::Null { span: DUMMY_SP })); + + // Add the segment to the collection with entry set to None to indicate it's a noop + self.segments.push(Segment { + entry: None, // Noop segments don't have an entry file + span: DUMMY_SP, + canonical_filename, + name: symbol_name.clone(), + data: segment_data.clone(), + expr: Box::new(dummy_expr), + hash: 0, // Noop segments don't need a hash since they're not actual code + param_names, + }); + let mut args = vec![ast::Expr::Lit(ast::Lit::Str(ast::Str { span: DUMMY_SP, value: symbol_name.clone(), diff --git a/packages/qwik/src/optimizer/src/plugins/plugin.ts b/packages/qwik/src/optimizer/src/plugins/plugin.ts index 6bfc9a2702a..fc443eb41f0 100644 --- a/packages/qwik/src/optimizer/src/plugins/plugin.ts +++ b/packages/qwik/src/optimizer/src/plugins/plugin.ts @@ -813,7 +813,6 @@ export function createQwikPlugin(optimizerOptions: OptimizerOptions = {}) { } const deps = new Set(); for (const mod of newOutput.modules) { - // TODO handle noop modules if (mod !== module) { const key = normalizePath(path.join(srcDir, mod.path)); debug(`transform(${count})`, `segment ${key}`, mod.segment!.displayName); diff --git a/starters/apps/qwikrouter-test/src/components/footer/footer.tsx b/starters/apps/qwikrouter-test/src/components/footer/footer.tsx index 0fc0d019e6d..f08cf4ef830 100644 --- a/starters/apps/qwikrouter-test/src/components/footer/footer.tsx +++ b/starters/apps/qwikrouter-test/src/components/footer/footer.tsx @@ -55,6 +55,9 @@ export default component$(() => { Home +
  • + Layout Only +
    • {serverData.value.serverTime.toISOString()}
    • diff --git a/starters/apps/qwikrouter-test/src/routes/layout-only/inner/index.tsx b/starters/apps/qwikrouter-test/src/routes/layout-only/inner/index.tsx new file mode 100644 index 00000000000..b61126ec510 --- /dev/null +++ b/starters/apps/qwikrouter-test/src/routes/layout-only/inner/index.tsx @@ -0,0 +1,9 @@ +import { component$ } from "@qwik.dev/core"; + +export default component$(() => { + return ( +
      +

      Inner

      +
      + ); +}); diff --git a/starters/apps/qwikrouter-test/src/routes/layout-only/layout.tsx b/starters/apps/qwikrouter-test/src/routes/layout-only/layout.tsx new file mode 100644 index 00000000000..f499966d2ab --- /dev/null +++ b/starters/apps/qwikrouter-test/src/routes/layout-only/layout.tsx @@ -0,0 +1,20 @@ +import { routeLoader$ } from "@qwik.dev/router"; +import { component$, Slot } from "@qwik.dev/core"; + +const useSomeLayoutData = routeLoader$(async () => { + return { + someData: "some data", + }; +}); + +export default component$(() => { + const someData = useSomeLayoutData(); + + return ( +
      +

      Layout

      +

      {someData.value.someData}

      + +
      + ); +}); From 12d437650735c12120293883673b27bd7ff2651b Mon Sep 17 00:00:00 2001 From: Varixo Date: Thu, 18 Sep 2025 23:41:17 +0200 Subject: [PATCH 07/20] fix: handle q-loaders-data and q-loader in middlewares --- .../request-handler/loader-endpoints.ts | 1 + .../request-handler/request-event.ts | 7 +- .../resolve-request-handlers.ts | 64 +++++++++++-------- .../request-handler/user-response.ts | 5 +- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts index dc90d38d7b5..a1dbcea0798 100644 --- a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts +++ b/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts @@ -41,6 +41,7 @@ export function loaderDataHandler(routeLoaders: LoaderInternal[]): RequestHandle let loaderRoute = qwikRouterConfig.loaderIdToRoute[loaderId]; const params = requestEv.params; if (Object.keys(params).length > 0) { + // TODO: use RequestEvRoute? const pathname = getPathnameForDynamicRoute( requestEv.url.pathname, Object.keys(params), diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index 00172f9374a..05239cef61b 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -35,6 +35,7 @@ import { IsQData, IsQLoader, IsQLoaderData, + OriginalQDataName, Q_LOADER_DATA_REGEX, QDATA_JSON, QDATA_JSON_LEN, @@ -82,6 +83,7 @@ export function createRequestEvent( const requestRecognized = recognizeRequest(url.pathname); if (requestRecognized) { sharedMap.set(requestRecognized.type, true); + sharedMap.set(OriginalQDataName, requestRecognized.originalQDataName); if (requestRecognized.type === IsQLoader && requestRecognized.data) { sharedMap.set(QLoaderId, requestRecognized.data.loaderId); } @@ -470,6 +472,7 @@ export function recognizeRequest(pathname: string) { return { type: IsQData, trimLength: QDATA_JSON_LEN, + originalQDataName: QDATA_JSON, data: null, }; } @@ -480,6 +483,7 @@ export function recognizeRequest(pathname: string) { return { type: IsQLoaderData, trimLength: loaderDataMatch[0].length, + originalQDataName: loaderDataMatch[1], data: null, }; } @@ -489,7 +493,8 @@ export function recognizeRequest(pathname: string) { return { type: IsQLoader, trimLength: loaderMatch[0].length, - data: { loaderId: loaderMatch[1] }, + originalQDataName: loaderMatch[1], + data: { loaderId: loaderMatch[2] }, }; } diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts index 903edfe389a..edfb4a4c469 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts @@ -1,5 +1,5 @@ import { type QRL } from '@qwik.dev/core'; -import { _serialize, _UNINITIALIZED, _verifySerializable } from '@qwik.dev/core/internal'; +import { _serialize, _verifySerializable } from '@qwik.dev/core/internal'; import type { Render, RenderToStringResult } from '@qwik.dev/core/server'; import { QACTION_KEY, QFN_KEY } from '../../runtime/src/constants'; import { @@ -13,6 +13,13 @@ import { type RouteModule, } from '../../runtime/src/types'; import { HttpStatus } from './http-status-codes'; +import { + executeLoader, + loaderDataHandler, + runValidators, + singleLoaderHandler, +} from './loader-endpoints'; +import { qDataHandler } from './qdata-endpoints'; import { RequestEvShareQData, RequestEvShareServerTiming, @@ -20,18 +27,12 @@ import { RequestRouteName, getRequestLoaders, getRequestMode, + recognizeRequest, type RequestEventInternal, } from './request-event'; import { getQwikRouterServerData } from './response-page'; import type { ErrorCodes, RequestEvent, RequestEventBase, RequestHandler } from './types'; -import { IsQData, QDATA_JSON } from './user-response'; -import { - executeLoader, - singleLoaderHandler, - runValidators, - loaderDataHandler, -} from './loader-endpoints'; -import { qDataHandler } from './qdata-endpoints'; +import { IsQData, IsQLoader, IsQLoaderData, OriginalQDataName } from './user-response'; // Import separately to avoid duplicate imports in the vite dev server import { RedirectMessage, ServerError } from '@qwik.dev/router/middleware/request-handler'; @@ -80,24 +81,21 @@ export const resolveRequestHandlers = ( requestHandlers.unshift(csrfCheckMiddleware); } } + requestHandlers.push(handleRedirect); if (isPageRoute) { // server$ if (method === 'POST' || method === 'GET') { requestHandlers.push(pureServerFunction); } - requestHandlers.push(fixTrailingSlash); - requestHandlers.push(loaderDataHandler(routeLoaders)); - requestHandlers.push(singleLoaderHandler(routeLoaders)); - requestHandlers.push(qDataHandler); - } - requestHandlers.push(handleRedirect); - - if (isPageRoute) { requestHandlers.push((ev) => { // Set the current route name ev.sharedMap.set(RequestRouteName, routeName); }); + requestHandlers.push(fixTrailingSlash); + requestHandlers.push(loaderDataHandler(routeLoaders)); + requestHandlers.push(singleLoaderHandler(routeLoaders)); + requestHandlers.push(qDataHandler); requestHandlers.push(actionsMiddleware(routeActions)); requestHandlers.push(loadersMiddleware(routeLoaders)); requestHandlers.push(renderHandler); @@ -313,7 +311,8 @@ async function pureServerFunction(ev: RequestEvent) { function fixTrailingSlash(ev: RequestEvent) { const { basePathname, originalUrl, sharedMap } = ev; const { pathname, search } = originalUrl; - const isQData = sharedMap.has(IsQData); + const isQData = + sharedMap.has(IsQData) || sharedMap.has(IsQLoaderData) || sharedMap.has(IsQLoader); if (!isQData && pathname !== basePathname && !pathname.endsWith('.html')) { // only check for slash redirect on pages if (!globalThis.__NO_TRAILING_SLASH__) { @@ -357,8 +356,11 @@ export function isLastModulePageRoute(routeModules: RouteModule[]) { export function getPathname(url: URL) { url = new URL(url); - if (url.pathname.endsWith(QDATA_JSON)) { - url.pathname = url.pathname.slice(0, -QDATA_JSON.length); + + const qDataInfo = recognizeRequest(url.pathname); + + if (qDataInfo) { + url.pathname = url.pathname.slice(0, -qDataInfo.trimLength); } if (!globalThis.__NO_TRAILING_SLASH__) { if (!url.pathname.endsWith('/')) { @@ -417,7 +419,10 @@ export function renderQwikMiddleware(render: Render) { if (requestEv.headersSent) { return; } - const isPageDataReq = requestEv.sharedMap.has(IsQData); + const isPageDataReq = + requestEv.sharedMap.has(IsQData) || + requestEv.sharedMap.has(IsQLoaderData) || + requestEv.sharedMap.has(IsQLoader); if (isPageDataReq) { return; } @@ -469,7 +474,10 @@ export function renderQwikMiddleware(render: Render) { } export async function handleRedirect(requestEv: RequestEvent) { - const isPageDataReq = requestEv.sharedMap.has(IsQData); + const isPageDataReq = + requestEv.sharedMap.has(IsQData) || + requestEv.sharedMap.has(IsQLoaderData) || + requestEv.sharedMap.has(IsQLoader); if (!isPageDataReq) { return; } @@ -490,7 +498,7 @@ export async function handleRedirect(requestEv: RequestEvent) { const isRedirect = status >= 301 && status <= 308 && location; if (isRedirect) { - const adaptedLocation = makeQDataPath(location); + const adaptedLocation = makeQDataPath(location, requestEv.sharedMap); if (adaptedLocation) { requestEv.headers.set('Location', adaptedLocation); requestEv.getWritableStream().close(); @@ -502,12 +510,16 @@ export async function handleRedirect(requestEv: RequestEvent) { } } -function makeQDataPath(href: string) { +function makeQDataPath(href: string, sharedMap: Map) { if (href.startsWith('/')) { - const append = QDATA_JSON; const url = new URL(href, 'http://localhost'); - const pathname = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname; + const append = sharedMap.get(OriginalQDataName) as string; + + if (!append) { + return undefined; + } + return pathname + (append.startsWith('/') ? '' : '/') + append + url.search; } else { return undefined; diff --git a/packages/qwik-router/src/middleware/request-handler/user-response.ts b/packages/qwik-router/src/middleware/request-handler/user-response.ts index 26e3e0f29f0..e2d23bdc936 100644 --- a/packages/qwik-router/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-router/src/middleware/request-handler/user-response.ts @@ -186,7 +186,8 @@ export const IsQData = '@isQData'; export const IsQLoader = '@isQLoader'; export const QLoaderId = '@qLoaderId'; export const IsQLoaderData = '@isQLoaderData'; +export const OriginalQDataName = '@originalQDataName'; export const QDATA_JSON = '/q-data.json'; export const QDATA_JSON_LEN = QDATA_JSON.length; -export const Q_LOADER_DATA_REGEX = /\/q-loader-data\.(.+)\.json$/; -export const SINGLE_LOADER_REGEX = /\/q-loader-(.+)\.(.+)\.json$/; +export const Q_LOADER_DATA_REGEX = /\/(q-loader-data\.(.+)\.json)$/; +export const SINGLE_LOADER_REGEX = /\/(q-loader-(.+)\.(.+)\.json)$/; From 7a79cd8837f1bb340e3237dfcf038a01f7f48dc2 Mon Sep 17 00:00:00 2001 From: Varixo Date: Thu, 18 Sep 2025 23:41:55 +0200 Subject: [PATCH 08/20] feat: use manifest hash instead of instance hash --- .../src/runtime/src/link-component.tsx | 8 ++++---- .../src/runtime/src/qwik-router-component.tsx | 16 ++++++++-------- .../src/runtime/src/use-endpoint.ts | 18 +++++++++--------- .../src/runtime/src/use-functions.ts | 4 ++-- packages/qwik-router/src/runtime/src/utils.ts | 4 ++-- 5 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/qwik-router/src/runtime/src/link-component.tsx b/packages/qwik-router/src/runtime/src/link-component.tsx index ec90d0d6533..25627a9df19 100644 --- a/packages/qwik-router/src/runtime/src/link-component.tsx +++ b/packages/qwik-router/src/runtime/src/link-component.tsx @@ -13,14 +13,14 @@ import { } from '@qwik.dev/core'; import { preloadRouteBundles } from './client-navigate'; import { loadClientData } from './use-endpoint'; -import { useInstanceHash, useLocation, useNavigate } from './use-functions'; +import { useManifestHash, useLocation, useNavigate } from './use-functions'; import { getClientNavPath, shouldPreload } from './utils'; /** @public */ export const Link = component$((props) => { const nav = useNavigate(); const loc = useLocation(); - const instanceHash = useInstanceHash(); + const manifestHash = useManifestHash(); const originalHref = props.href; const anchorRef = useSignal(); const { @@ -54,8 +54,8 @@ export const Link = component$((props) => { const url = new URL(elm.href); preloadRouteBundles(url.pathname); - if (elm.hasAttribute('data-prefetch') && instanceHash) { - loadClientData(url, instanceHash, { + if (elm.hasAttribute('data-prefetch') && manifestHash) { + loadClientData(url, manifestHash, { preloadRouteBundles: false, isPrefetch: true, }); diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index e3ea8227822..d6af237a472 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -71,7 +71,7 @@ import type { ScrollState, } from './types'; import { loadClientData } from './use-endpoint'; -import { useInstanceHash, useQwikRouterEnv } from './use-functions'; +import { useManifestHash, useQwikRouterEnv } from './use-functions'; import { createLoaderSignal, isSameOrigin, isSamePath, toUrl } from './utils'; import { startViewTransition } from './view-transition'; @@ -134,7 +134,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { throw new Error(`Missing Qwik URL Env Data`); } const serverHead = useServerData('documentHead'); - const instanceHash = useInstanceHash(); + const manifestHash = useManifestHash(); if (isServer) { if ( @@ -181,7 +181,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { key, url, getSerializationStrategy(key), - instanceHash!, + manifestHash!, container ); } @@ -355,8 +355,8 @@ export const useQwikRouter = (props?: QwikRouterProps) => { routeInternal.value = { type, dest, forceReload, replaceState, scroll }; if (isBrowser) { - if (instanceHash) { - loadClientData(dest, instanceHash); + if (manifestHash) { + loadClientData(dest, manifestHash); } loadRoute( qwikRouterConfig.routes, @@ -421,8 +421,8 @@ export const useQwikRouter = (props?: QwikRouterProps) => { ); elm = _getContextElement(); const pageData = - instanceHash && - (clientPageData = await loadClientData(trackUrl, instanceHash, { + manifestHash && + (clientPageData = await loadClientData(trackUrl, manifestHash, { action, clearCache: true, })); @@ -547,7 +547,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { key, trackUrl, DEFAULT_LOADERS_SERIALIZATION_STRATEGY, - instanceHash!, + manifestHash!, container ); } else { diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index 1b47eec8022..489ab506787 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -8,14 +8,14 @@ interface LoaderDataResponse { route: string; } -export const loadClientLoaderData = async (url: URL, loaderId: string, instanceHash: string) => { +export const loadClientLoaderData = async (url: URL, loaderId: string, manifestHash: string) => { const pagePathname = url.pathname.endsWith('/') ? url.pathname : url.pathname + '/'; - return fetchLoader(loaderId, pagePathname, instanceHash); + return fetchLoader(loaderId, pagePathname, manifestHash); }; export const loadClientData = async ( url: URL, - instanceHash: string, + manifestHash: string, opts?: { action?: RouteActionValue; loaderIds?: string[]; @@ -33,7 +33,7 @@ export const loadClientData = async ( if (!opts?.loaderIds) { // we need to load all the loaders // first we need to get the loader urls - loaderData = (await fetchLoaderData(pagePathname, instanceHash)).loaderData; + loaderData = (await fetchLoaderData(pagePathname, manifestHash)).loaderData; } else { loaderData = opts.loaderIds.map((loaderId) => { return { @@ -47,7 +47,7 @@ export const loadClientData = async ( if (loaderData.length > 0) { // load specific loaders const loaderPromises = loaderData.map((loader) => - fetchLoader(loader.id, loader.route, instanceHash) + fetchLoader(loader.id, loader.route, manifestHash) ); const loaderResults = await Promise.all(loaderPromises); for (let i = 0; i < loaderData.length; i++) { @@ -116,9 +116,9 @@ export const loadClientData = async ( export async function fetchLoaderData( routePath: string, - instanceHash: string + manifestHash: string ): Promise<{ loaderData: LoaderDataResponse[] }> { - const url = `${routePath}q-loader-data.${instanceHash}.json`; + const url = `${routePath}q-loader-data.${manifestHash}.json`; const response = await fetch(url); return response.json(); } @@ -126,9 +126,9 @@ export async function fetchLoaderData( export async function fetchLoader( loaderId: string, routePath: string, - instanceHash: string + manifestHash: string ): Promise { - const url = `${routePath}q-loader-${loaderId}.${instanceHash}.json`; + const url = `${routePath}q-loader-${loaderId}.${manifestHash}.json`; const response = await fetch(url); if (!response.ok) { diff --git a/packages/qwik-router/src/runtime/src/use-functions.ts b/packages/qwik-router/src/runtime/src/use-functions.ts index e02bcc3c8fb..3f4bdf59f3f 100644 --- a/packages/qwik-router/src/runtime/src/use-functions.ts +++ b/packages/qwik-router/src/runtime/src/use-functions.ts @@ -96,8 +96,8 @@ export const useAction = (): RouteAction => useContext(RouteActionContext); export const useQwikRouterEnv = () => noSerialize(useServerData('qwikrouter')); -export const useInstanceHash = () => - useServerData>('containerAttributes')?.['q:instance']; +export const useManifestHash = () => + useServerData>('containerAttributes')?.['q:manifest-hash']; /** @deprecated Use `useQwikRouterEnv` instead. Will be removed in v3 */ export const useQwikCityEnv = useQwikRouterEnv; diff --git a/packages/qwik-router/src/runtime/src/utils.ts b/packages/qwik-router/src/runtime/src/utils.ts index c0c43b87896..126ff7c7fbd 100644 --- a/packages/qwik-router/src/runtime/src/utils.ts +++ b/packages/qwik-router/src/runtime/src/utils.ts @@ -93,13 +93,13 @@ export const createLoaderSignal = ( loaderId: string, url: URL, serializationStrategy: SerializationStrategy, - instanceHash: string, + manifestHash: string, container?: ClientContainer ) => { return createAsyncComputed$( async () => { if (isBrowser && loadersObject[loaderId] === _UNINITIALIZED) { - const data = await loadClientLoaderData(url, loaderId, instanceHash); + const data = await loadClientLoaderData(url, loaderId, manifestHash); loadersObject[loaderId] = data ?? _UNINITIALIZED; } return loadersObject[loaderId]; From 0f7d2def79eb6b31bc6d153ffdb1f1d41a8c2f70 Mon Sep 17 00:00:00 2001 From: Varixo Date: Thu, 18 Sep 2025 23:59:58 +0200 Subject: [PATCH 09/20] feat: implement q-action --- packages/qwik-router/global.d.ts | 2 - .../request-handler/action-endpoints.ts | 105 ++++++++++++++ .../request-handler/loader-endpoints.ts | 2 +- .../request-handler/qdata-endpoints.ts | 4 +- .../request-handler/request-event.ts | 27 +++- .../resolve-request-handlers.ts | 54 ++++--- .../request-handler/response-page.ts | 13 +- .../request-handler/user-response.ts | 6 +- .../qwik-router/src/runtime/src/constants.ts | 1 + .../src/runtime/src/qwik-router-component.tsx | 8 +- packages/qwik-router/src/runtime/src/types.ts | 23 +-- .../src/runtime/src/use-endpoint.ts | 132 ++++++++++-------- packages/qwik-router/src/runtime/src/utils.ts | 21 --- .../qwik-router/src/runtime/src/utils.unit.ts | 19 +-- 14 files changed, 271 insertions(+), 146 deletions(-) create mode 100644 packages/qwik-router/src/middleware/request-handler/action-endpoints.ts diff --git a/packages/qwik-router/global.d.ts b/packages/qwik-router/global.d.ts index 477fb3336ea..1c46f10ecc3 100644 --- a/packages/qwik-router/global.d.ts +++ b/packages/qwik-router/global.d.ts @@ -1,8 +1,6 @@ /* eslint-disable no-var */ // Globals used by qwik-router, for internal use only -type RequestEventInternal = - import('./middleware/request-handler/request-event').RequestEventInternal; type AsyncStore = import('node:async_hooks').AsyncLocalStorage; type SerializationStrategy = import('@qwik.dev/core/internal').SerializationStrategy; diff --git a/packages/qwik-router/src/middleware/request-handler/action-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/action-endpoints.ts new file mode 100644 index 00000000000..3a08b81afc1 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/action-endpoints.ts @@ -0,0 +1,105 @@ +import type { + ActionInternal, + JSONObject, + RequestEvent, + RequestHandler, +} from '../../runtime/src/types'; +import { runValidators } from './loader-endpoints'; +import { + getRequestActions, + getRequestMode, + RequestEvQwikSerializer, + type RequestEventInternal, +} from './request-event'; +import { measure, verifySerializable } from './resolve-request-handlers'; +import type { QwikSerializer } from './types'; +import { IsQAction, QActionId } from './user-response'; +import { _UNINITIALIZED, type ValueOrPromise } from '@qwik.dev/core/internal'; + +export function actionHandler(routeActions: ActionInternal[]): RequestHandler { + return async (requestEvent: RequestEvent) => { + const requestEv = requestEvent as RequestEventInternal; + + const isQAction = requestEv.sharedMap.has(IsQAction); + if (!isQAction) { + return; + } + + if (requestEv.headersSent || requestEv.exited) { + return; + } + const actionId = requestEv.sharedMap.get(QActionId); + + // Execute just this action + const actions = getRequestActions(requestEv); + const isDev = getRequestMode(requestEv) === 'dev'; + const qwikSerializer = requestEv[RequestEvQwikSerializer]; + const method = requestEv.method; + + if (isDev && method === 'GET') { + console.warn( + 'Seems like you are submitting a Qwik Action via GET request. Qwik Actions should be submitted via POST request.\nMake sure your
      has method="POST" attribute, like this: ' + ); + } + if (method === 'POST') { + let action: ActionInternal | undefined; + for (const routeAction of routeActions) { + if (routeAction.__id === actionId) { + action = routeAction; + break; + } + // TODO: do we need to initialize the rest with _UNINITIALIZED? + } + if (!action) { + const serverActionsMap = globalThis._qwikActionsMap as + | Map + | undefined; + action = serverActionsMap?.get(actionId); + } + + if (!action) { + requestEv.json(404, { error: 'Action not found' }); + return; + } + + await executeAction(action, actions, requestEv, isDev, qwikSerializer); + + if (requestEv.request.headers.get('accept')?.includes('application/json')) { + // only return the action data if the client accepts json, otherwise return the html page + const data = await qwikSerializer._serialize([actions[actionId]]); + requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); + requestEv.send(200, data); + return; + } + } + }; +} + +async function executeAction( + action: ActionInternal, + actions: Record | undefined>, + requestEv: RequestEventInternal, + isDev: boolean, + qwikSerializer: QwikSerializer +) { + const selectedActionId = action.__id; + requestEv.sharedMap.set(QActionId, selectedActionId); + const data = await requestEv.parseBody(); + if (!data || typeof data !== 'object') { + throw new Error(`Expected request data for the action id ${selectedActionId} to be an object`); + } + const result = await runValidators(requestEv, action.__validators, data, isDev); + if (!result.success) { + actions[selectedActionId] = requestEv.fail(result.status ?? 500, result.error); + } else { + const actionResolved = isDev + ? await measure(requestEv, action.__qrl.getHash(), () => + action.__qrl.call(requestEv, result.data as JSONObject, requestEv) + ) + : await action.__qrl.call(requestEv, result.data as JSONObject, requestEv); + if (isDev) { + verifySerializable(qwikSerializer, actionResolved, action.__qrl); + } + actions[selectedActionId] = actionResolved; + } +} diff --git a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts index a1dbcea0798..41039825986 100644 --- a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts +++ b/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts @@ -58,7 +58,7 @@ export function loaderDataHandler(routeLoaders: LoaderInternal[]): RequestHandle }; } -export function singleLoaderHandler(routeLoaders: LoaderInternal[]): RequestHandler { +export function loaderHandler(routeLoaders: LoaderInternal[]): RequestHandler { return async (requestEvent: RequestEvent) => { const requestEv = requestEvent as RequestEventInternal; diff --git a/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts index 3fc4fcdba46..a753de9a923 100644 --- a/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts +++ b/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts @@ -2,14 +2,13 @@ import type { RequestEvent } from '@qwik.dev/router'; import { _serialize } from 'packages/qwik/core-internal'; -import { RequestEvIsRewrite, RequestEvSharedActionId } from './request-event'; +import { RequestEvIsRewrite } from './request-event'; import { getPathname } from './resolve-request-handlers'; import { IsQData } from './user-response'; export interface QData { status: number; href: string; - action?: string; redirect?: string; isRewrite?: boolean; } @@ -32,7 +31,6 @@ export async function qDataHandler(requestEv: RequestEvent) { const qData: QData = { status, href: getPathname(requestEv.url), - action: requestEv.sharedMap.get(RequestEvSharedActionId), redirect: redirectLocation ?? undefined, isRewrite: requestEv.sharedMap.get(RequestEvIsRewrite), }; diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index 05239cef61b..91b7068c859 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -1,6 +1,6 @@ import type { ValueOrPromise } from '@qwik.dev/core'; import { _deserialize, _UNINITIALIZED, type SerializationStrategy } from '@qwik.dev/core/internal'; -import { QDATA_KEY } from '../../runtime/src/constants'; +import { QACTION_KEY, QDATA_KEY } from '../../runtime/src/constants'; import { LoadedRouteProp, type ActionInternal, @@ -16,9 +16,10 @@ import { Cookie } from './cookie'; import { AbortMessage, RedirectMessage, - ServerError, RewriteMessage, + ServerError, } from '@qwik.dev/router/middleware/request-handler'; +import { executeLoader } from './loader-endpoints'; import { encoder } from './resolve-request-handlers'; import type { CacheControl, @@ -32,26 +33,27 @@ import type { ServerRequestMode, } from './types'; import { + IsQAction, IsQData, IsQLoader, IsQLoaderData, + LOADER_REGEX, OriginalQDataName, Q_LOADER_DATA_REGEX, + QActionId, QDATA_JSON, QDATA_JSON_LEN, QLoaderId, - SINGLE_LOADER_REGEX, } from './user-response'; -import { executeLoader } from './loader-endpoints'; const RequestEvLoaders = Symbol('RequestEvLoaders'); +const RequestEvActions = Symbol('RequestEvActions'); const RequestEvMode = Symbol('RequestEvMode'); const RequestEvRoute = Symbol('RequestEvRoute'); export const RequestEvLoaderSerializationStrategyMap = Symbol( 'RequestEvLoaderSerializationStrategyMap' ); export const RequestRouteName = '@routeName'; -export const RequestEvSharedActionId = '@actionId'; export const RequestEvSharedActionFormData = '@actionFormData'; export const RequestEvSharedNonce = '@nonce'; export const RequestEvIsRewrite = '@rewrite'; @@ -91,6 +93,12 @@ export function createRequestEvent( trimEnd(requestRecognized.trimLength); } + const actionMatch = url.searchParams.get(QACTION_KEY); + if (actionMatch) { + sharedMap.set(IsQAction, true); + sharedMap.set(QActionId, actionMatch); + } + let routeModuleIndex = -1; let writableStream: WritableStream | null = null; let requestData: Promise | undefined = undefined; @@ -174,8 +182,10 @@ export function createRequestEvent( }; const loaders: Record | undefined> = {}; + const actions: Record | undefined> = {}; const requestEv: RequestEventInternal = { [RequestEvLoaders]: loaders, + [RequestEvActions]: actions, [RequestEvLoaderSerializationStrategyMap]: new Map(), [RequestEvMode]: serverRequestEv.mode, get [RequestEvRoute]() { @@ -359,6 +369,7 @@ export function createRequestEvent( export interface RequestEventInternal extends RequestEvent, RequestEventLoader { [RequestEvLoaders]: Record | undefined>; + [RequestEvActions]: Record | undefined>; [RequestEvLoaderSerializationStrategyMap]: Map; [RequestEvMode]: ServerRequestMode; [RequestEvRoute]: LoadedRoute | null; @@ -388,6 +399,10 @@ export function getRequestLoaders(requestEv: RequestEventCommon) { return (requestEv as RequestEventInternal)[RequestEvLoaders]; } +export function getRequestActions(requestEv: RequestEventCommon) { + return (requestEv as RequestEventInternal)[RequestEvActions]; +} + export function getRequestLoaderSerializationStrategyMap(requestEv: RequestEventCommon) { return (requestEv as RequestEventInternal)[RequestEvLoaderSerializationStrategyMap]; } @@ -488,7 +503,7 @@ export function recognizeRequest(pathname: string) { }; } - const loaderMatch = pathname.match(SINGLE_LOADER_REGEX); + const loaderMatch = pathname.match(LOADER_REGEX); if (loaderMatch) { return { type: IsQLoader, diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts index edfb4a4c469..89520ec69df 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts @@ -12,19 +12,15 @@ import { type PageModule, type RouteModule, } from '../../runtime/src/types'; +import { actionHandler } from './action-endpoints'; import { HttpStatus } from './http-status-codes'; -import { - executeLoader, - loaderDataHandler, - runValidators, - singleLoaderHandler, -} from './loader-endpoints'; +import { executeLoader, loaderDataHandler, loaderHandler, runValidators } from './loader-endpoints'; import { qDataHandler } from './qdata-endpoints'; import { RequestEvShareQData, RequestEvShareServerTiming, - RequestEvSharedActionId, RequestRouteName, + getRequestActions, getRequestLoaders, getRequestMode, recognizeRequest, @@ -32,9 +28,7 @@ import { } from './request-event'; import { getQwikRouterServerData } from './response-page'; import type { ErrorCodes, RequestEvent, RequestEventBase, RequestHandler } from './types'; -import { IsQData, IsQLoader, IsQLoaderData, OriginalQDataName } from './user-response'; -// Import separately to avoid duplicate imports in the vite dev server -import { RedirectMessage, ServerError } from '@qwik.dev/router/middleware/request-handler'; +import { IsQData, IsQLoader, IsQLoaderData, OriginalQDataName, QActionId } from './user-response'; export const resolveRequestHandlers = ( serverPlugins: RouteModule[] | undefined, @@ -94,9 +88,10 @@ export const resolveRequestHandlers = ( }); requestHandlers.push(fixTrailingSlash); requestHandlers.push(loaderDataHandler(routeLoaders)); - requestHandlers.push(singleLoaderHandler(routeLoaders)); + requestHandlers.push(loaderHandler(routeLoaders)); + requestHandlers.push(actionHandler(routeActions)); requestHandlers.push(qDataHandler); - requestHandlers.push(actionsMiddleware(routeActions)); + // requestHandlers.push(actionsMiddleware(routeActions)); requestHandlers.push(loadersMiddleware(routeLoaders)); requestHandlers.push(renderHandler); } @@ -177,14 +172,14 @@ export const checkBrand = (obj: any, brand: string) => { }; export function actionsMiddleware(routeActions: ActionInternal[]): RequestHandler { - return async (requestEvent: RequestEvent) => { + return async (requestEvent: RequestEvent): Promise => { const requestEv = requestEvent as RequestEventInternal; if (requestEv.headersSent) { requestEv.exit(); return; } const { method } = requestEv; - const loaders = getRequestLoaders(requestEv); + const actions = getRequestActions(requestEv); const isDev = getRequestMode(requestEv) === 'dev'; if (isDev && method === 'GET') { if (requestEv.query.has(QACTION_KEY)) { @@ -203,7 +198,7 @@ export function actionsMiddleware(routeActions: ActionInternal[]): RequestHandle routeActions.find((action) => action.__id === selectedActionId) ?? serverActionsMap?.get(selectedActionId); if (action) { - requestEv.sharedMap.set(RequestEvSharedActionId, selectedActionId); + requestEv.sharedMap.set(QActionId, selectedActionId); const data = await requestEv.parseBody(); if (!data || typeof data !== 'object') { throw new Error( @@ -212,7 +207,7 @@ export function actionsMiddleware(routeActions: ActionInternal[]): RequestHandle } const result = await runValidators(requestEv, action.__validators, data, isDev); if (!result.success) { - loaders[selectedActionId] = requestEv.fail(result.status ?? 500, result.error); + actions[selectedActionId] = requestEv.fail(result.status ?? 500, result.error); } else { const actionResolved = isDev ? await measure(requestEv, action.__qrl.getHash(), () => @@ -222,7 +217,7 @@ export function actionsMiddleware(routeActions: ActionInternal[]): RequestHandle if (isDev) { verifySerializable(actionResolved, action.__qrl); } - loaders[selectedActionId] = actionResolved; + actions[selectedActionId] = actionResolved; } } } @@ -311,8 +306,7 @@ async function pureServerFunction(ev: RequestEvent) { function fixTrailingSlash(ev: RequestEvent) { const { basePathname, originalUrl, sharedMap } = ev; const { pathname, search } = originalUrl; - const isQData = - sharedMap.has(IsQData) || sharedMap.has(IsQLoaderData) || sharedMap.has(IsQLoader); + const isQData = isQDataRequestBasedOnSharedMap(sharedMap); if (!isQData && pathname !== basePathname && !pathname.endsWith('.html')) { // only check for slash redirect on pages if (!globalThis.__NO_TRAILING_SLASH__) { @@ -334,6 +328,10 @@ function fixTrailingSlash(ev: RequestEvent) { } } +export function isQDataRequestBasedOnSharedMap(sharedMap: Map) { + return sharedMap.has(IsQData) || sharedMap.has(IsQLoaderData) || sharedMap.has(IsQLoader); +} + export function verifySerializable(data: any, qrl: QRL) { try { _verifySerializable(data, undefined); @@ -419,10 +417,7 @@ export function renderQwikMiddleware(render: Render) { if (requestEv.headersSent) { return; } - const isPageDataReq = - requestEv.sharedMap.has(IsQData) || - requestEv.sharedMap.has(IsQLoaderData) || - requestEv.sharedMap.has(IsQLoader); + const isPageDataReq = isQDataRequestBasedOnSharedMap(requestEv.sharedMap); if (isPageDataReq) { return; } @@ -451,9 +446,15 @@ export function renderQwikMiddleware(render: Render) { ...serverData.containerAttributes, }, }); + const actionId = requestEv.sharedMap.get(QActionId) as string | undefined; const qData: ClientPageData = { loaders: getRequestLoaders(requestEv), - action: requestEv.sharedMap.get(RequestEvSharedActionId), + action: actionId + ? { + id: actionId, + data: getRequestActions(requestEv)[actionId], + } + : undefined, status: status !== 200 ? status : 200, href: getPathname(requestEv.url), }; @@ -474,10 +475,7 @@ export function renderQwikMiddleware(render: Render) { } export async function handleRedirect(requestEv: RequestEvent) { - const isPageDataReq = - requestEv.sharedMap.has(IsQData) || - requestEv.sharedMap.has(IsQLoaderData) || - requestEv.sharedMap.has(IsQLoader); + const isPageDataReq = isQDataRequestBasedOnSharedMap(requestEv.sharedMap); if (!isPageDataReq) { return; } diff --git a/packages/qwik-router/src/middleware/request-handler/response-page.ts b/packages/qwik-router/src/middleware/request-handler/response-page.ts index 657ea919f1c..9360816ea99 100644 --- a/packages/qwik-router/src/middleware/request-handler/response-page.ts +++ b/packages/qwik-router/src/middleware/request-handler/response-page.ts @@ -1,22 +1,23 @@ import { Q_ROUTE } from '../../runtime/src/constants'; import type { QwikRouterEnvData } from '../../runtime/src/types'; import { + getRequestActions, getRequestLoaders, getRequestLoaderSerializationStrategyMap, getRequestRoute, RequestEvSharedActionFormData, - RequestEvSharedActionId, RequestEvSharedNonce, RequestRouteName, } from './request-event'; import type { RequestEvent } from './types'; +import { QActionId } from './user-response'; export function getQwikRouterServerData(requestEv: RequestEvent) { const { params, request, status, locale, originalUrl } = requestEv; const requestHeaders: Record = {}; request.headers.forEach((value, key) => (requestHeaders[key] = value)); - const action = requestEv.sharedMap.get(RequestEvSharedActionId) as string; + const actionId = requestEv.sharedMap.get(QActionId) as string | undefined; const formData = requestEv.sharedMap.get(RequestEvSharedActionFormData); const routeName = requestEv.sharedMap.get(RequestRouteName) as string; const nonce = requestEv.sharedMap.get(RequestEvSharedNonce); @@ -34,6 +35,7 @@ export function getQwikRouterServerData(requestEv: RequestEvent) { const loaders = getRequestLoaders(requestEv); const loadersSerializationStrategy = getRequestLoaderSerializationStrategyMap(requestEv); + const actions = getRequestActions(requestEv); return { url: reconstructedUrl.href, @@ -52,7 +54,12 @@ export function getQwikRouterServerData(requestEv: RequestEvent) { status: status(), loaders, loadersSerializationStrategy, - action, + action: actionId + ? { + id: actionId, + data: actions[actionId], + } + : undefined, formData, }, } satisfies QwikRouterEnvData, diff --git a/packages/qwik-router/src/middleware/request-handler/user-response.ts b/packages/qwik-router/src/middleware/request-handler/user-response.ts index e2d23bdc936..7eeed771e35 100644 --- a/packages/qwik-router/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-router/src/middleware/request-handler/user-response.ts @@ -184,10 +184,12 @@ export function getRouteMatchPathname(pathname: string) { export const IsQData = '@isQData'; export const IsQLoader = '@isQLoader'; -export const QLoaderId = '@qLoaderId'; +export const IsQAction = '@isQAction'; +export const QLoaderId = '@loaderId'; +export const QActionId = '@actionId'; export const IsQLoaderData = '@isQLoaderData'; export const OriginalQDataName = '@originalQDataName'; export const QDATA_JSON = '/q-data.json'; export const QDATA_JSON_LEN = QDATA_JSON.length; export const Q_LOADER_DATA_REGEX = /\/(q-loader-data\.(.+)\.json)$/; -export const SINGLE_LOADER_REGEX = /\/(q-loader-(.+)\.(.+)\.json)$/; +export const LOADER_REGEX = /\/(q-loader-(.+)\.(.+)\.json)$/; diff --git a/packages/qwik-router/src/runtime/src/constants.ts b/packages/qwik-router/src/runtime/src/constants.ts index 3032f6e5149..c17deec1a2d 100644 --- a/packages/qwik-router/src/runtime/src/constants.ts +++ b/packages/qwik-router/src/runtime/src/constants.ts @@ -6,6 +6,7 @@ export const MODULE_CACHE = /*#__PURE__*/ new WeakMap(); export const CLIENT_DATA_CACHE = new Map>(); export const QACTION_KEY = 'qaction'; +export const QACTION_FORMAT_KEY = 'format'; export const QLOADER_KEY = 'qloaders'; diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index d6af237a472..cfe6411e130 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -211,9 +211,9 @@ export const useQwikRouter = (props?: QwikRouterProps) => { const contentInternal = useSignal(); - const currentActionId = env.response.action; - const currentAction = currentActionId ? env.response.loaders[currentActionId] : undefined; - const actionState = useSignal( + const currentActionId = env.response.action?.id; + const currentAction = currentActionId ? env.response.action?.data : undefined; + const actionState = useSignal( currentAction ? { id: currentActionId!, @@ -840,7 +840,7 @@ const useQwikMockRouter = (props: QwikRouterMockProps) => { const contentInternal = useSignal(); - const actionState = useSignal(); + const actionState = useSignal(); useContextProvider(ContentContext, content); useContextProvider(ContentInternalContext, contentInternal); diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index 0827a543be1..da60b59ff60 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -125,17 +125,15 @@ export type RouteNavigate = QRL< ) => Promise >; -export type RouteAction = Signal; +export type RouteAction = Signal; export type RouteActionResolver = { status: number; result: unknown }; -export type RouteActionValue = - | { - id: string; - data: FormData | Record | undefined; - output?: RouteActionResolver; - resolve?: NoSerialize<(data: RouteActionResolver) => void>; - } - | undefined; +export type RouteActionValue = { + id: string; + data: FormData | Record | undefined; + output?: RouteActionResolver; + resolve?: NoSerialize<(data: RouteActionResolver) => void>; +}; export type MutableRouteLocation = Mutable; @@ -336,8 +334,13 @@ export interface EndpointResponse { status: number; loaders: Record; loadersSerializationStrategy: Map; + action?: ClientActionData; formData?: FormData; - action?: string; +} + +export interface ClientActionData { + id: string; + data: unknown; } export interface ClientPageData extends Omit { diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index 489ab506787..ee423dba080 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -2,6 +2,7 @@ import type { ClientPageData, RouteActionValue } from './types'; import { _deserialize, _getDomContainer } from '@qwik.dev/core/internal'; import { preloadRouteBundles } from './client-navigate'; import type { QData } from '../../middleware/request-handler/qdata-endpoints'; +import { QACTION_KEY } from './constants'; interface LoaderDataResponse { id: string; @@ -26,43 +27,46 @@ export const loadClientData = async ( ): Promise => { const pagePathname = url.pathname.endsWith('/') ? url.pathname : url.pathname + '/'; if (opts?.preloadRouteBundles !== false) { - preloadRouteBundles(pagePathname, 0.8); - } - - let loaderData: LoaderDataResponse[] = []; - if (!opts?.loaderIds) { - // we need to load all the loaders - // first we need to get the loader urls - loaderData = (await fetchLoaderData(pagePathname, manifestHash)).loaderData; - } else { - loaderData = opts.loaderIds.map((loaderId) => { - return { - id: loaderId, - route: pagePathname, - }; - }); + preloadRouteBundles(pagePathname); } const loaders: Record = {}; - if (loaderData.length > 0) { - // load specific loaders - const loaderPromises = loaderData.map((loader) => - fetchLoader(loader.id, loader.route, manifestHash) - ); - const loaderResults = await Promise.all(loaderPromises); - for (let i = 0; i < loaderData.length; i++) { - loaders[loaderData[i].id] = loaderResults[i]; - } - } - - const fetchOptions = getFetchOptions(opts?.action, opts?.clearCache); + let resolveFn: () => void | undefined; + let actionData: unknown; if (opts?.action) { - opts.action.data = undefined; + const actionResult = await fetchActionData(opts.action, pagePathname, url.searchParams); + actionData = actionResult.data; + resolveFn = () => { + opts.action!.resolve!({ status: actionResult.status, result: actionData }); + }; + } else { + let loaderData: LoaderDataResponse[] = []; + if (!opts?.loaderIds) { + // we need to load all the loaders + // first we need to get the loader urls + loaderData = (await fetchLoaderData(pagePathname, manifestHash)).loaderData; + } else { + loaderData = opts.loaderIds.map((loaderId) => { + return { + id: loaderId, + route: pagePathname, + }; + }); + } + if (loaderData.length > 0) { + // load specific loaders + const loaderPromises = loaderData.map((loader) => + fetchLoader(loader.id, loader.route, manifestHash) + ); + const loaderResults = await Promise.all(loaderPromises); + for (let i = 0; i < loaderData.length; i++) { + loaders[loaderData[i].id] = loaderResults[i]; + } + } } - let resolveFn: () => void | undefined; const qDataUrl = `${pagePathname}q-data.json`; - const qData = fetch(qDataUrl, fetchOptions).then((rsp) => { + const qData = fetch(qDataUrl).then((rsp) => { if (rsp.redirected) { const redirectedURL = new URL(rsp.url); const isQData = redirectedURL.pathname.endsWith('/q-data.json'); @@ -84,12 +88,6 @@ export const loadClientData = async ( if (clientData.redirect) { // server function asked for redirect location.href = clientData.redirect; - } else if (opts?.action) { - const { action } = opts; - const actionData = loaders[action.id]; - resolveFn = () => { - action!.resolve!({ status: rsp.status, result: actionData }); - }; } return clientData; }); @@ -105,12 +103,17 @@ export const loadClientData = async ( resolveFn && resolveFn(); return { loaders, - href: v?.href, - status: v?.status, - action: v?.action, + action: actionData + ? { + id: opts!.action!.id, + data: actionData, + } + : undefined, + href: v?.href ?? pagePathname, + status: v?.status ?? 200, redirect: v?.redirect, isRewrite: v?.isRewrite, - } as ClientPageData; + } satisfies ClientPageData; }); }; @@ -141,33 +144,48 @@ export async function fetchLoader( return data; } -const getFetchOptions = ( - action: RouteActionValue | undefined, - noCache: boolean | undefined -): RequestInit | undefined => { - const actionData = action?.data; - if (!actionData) { - if (noCache) { - return { - cache: 'no-cache', - headers: { - 'Cache-Control': 'no-cache', - Pragma: 'no-cache', - }, - }; - } - return undefined; - } +function buildActionUrl( + routePath: string, + searchParams: URLSearchParams, + actionId: string +): string { + searchParams.set(QACTION_KEY, actionId); + return `${routePath}?${searchParams.toString()}`; +} + +export async function fetchActionData( + action: RouteActionValue, + routePath: string, + searchParams: URLSearchParams +): Promise<{ data: unknown; status: number }> { + const url = buildActionUrl(routePath, searchParams, action.id); + const fetchOptions = getActionFetchOptions(action); + // TODO: why we need it? + action.data = undefined; + const response = await fetch(url, fetchOptions); + + const text = await response.text(); + const [data] = _deserialize(text, document.documentElement) as [Record]; + + return { data, status: response.status }; +} + +const getActionFetchOptions = (action: RouteActionValue): RequestInit | undefined => { + const actionData = action.data; if (actionData instanceof FormData) { return { method: 'POST', body: actionData, + headers: { + accept: 'application/json', + }, }; } else { return { method: 'POST', body: JSON.stringify(actionData), headers: { + accept: 'application/json', 'Content-Type': 'application/json; charset=UTF-8', }, }; diff --git a/packages/qwik-router/src/runtime/src/utils.ts b/packages/qwik-router/src/runtime/src/utils.ts index 126ff7c7fbd..a7a411b6bbe 100644 --- a/packages/qwik-router/src/runtime/src/utils.ts +++ b/packages/qwik-router/src/runtime/src/utils.ts @@ -6,7 +6,6 @@ import { type ClientContainer, type SerializationStrategy, } from '@qwik.dev/core/internal'; -import { QACTION_KEY, QLOADER_KEY } from './constants'; import { loadClientLoaderData } from './use-endpoint'; /** Gets an absolute url path string (url.pathname + url.search + url.hash) */ @@ -35,26 +34,6 @@ export const isSamePath = (a: SimpleURL, b: SimpleURL) => export const isSameOriginDifferentPathname = (a: SimpleURL, b: SimpleURL) => isSameOrigin(a, b) && !isSamePath(a, b); -export const getClientDataPath = ( - pathname: string, - pageSearch?: string, - options?: { - actionId?: string; - loaderIds?: string[]; - } -) => { - let search = pageSearch ?? ''; - if (options?.actionId) { - search += (search ? '&' : '?') + QACTION_KEY + '=' + encodeURIComponent(options.actionId); - } - if (options?.loaderIds) { - for (const loaderId of options.loaderIds) { - search += (search ? '&' : '?') + QLOADER_KEY + '=' + encodeURIComponent(loaderId); - } - } - return pathname + (pathname.endsWith('/') ? '' : '/') + 'q-data.json' + search; -}; - export const getClientNavPath = (props: Record, baseUrl: { url: URL }) => { const href = props.href; if (typeof href === 'string' && typeof props.target !== 'string' && !props.reload) { diff --git a/packages/qwik-router/src/runtime/src/utils.unit.ts b/packages/qwik-router/src/runtime/src/utils.unit.ts index a915d25f995..ac437258a8e 100644 --- a/packages/qwik-router/src/runtime/src/utils.unit.ts +++ b/packages/qwik-router/src/runtime/src/utils.unit.ts @@ -1,6 +1,5 @@ import { assert, test } from 'vitest'; import { - getClientDataPath, getClientNavPath, shouldPreload, isSameOrigin, @@ -68,10 +67,11 @@ import { { pathname: '/about', expect: '/about/q-data.json' }, { pathname: '/about/', expect: '/about/q-data.json' }, ].forEach((t) => { - test(`getClientEndpointUrl("${t.pathname}")`, () => { - const endpointPath = getClientDataPath(t.pathname); - assert.equal(endpointPath, t.expect); - }); + // TODO: fix this test + // test(`getClientEndpointUrl("${t.pathname}")`, () => { + // const endpointPath = getClientDataPath(t.pathname); + // assert.equal(endpointPath, t.expect); + // }); }); [ @@ -80,10 +80,11 @@ import { { pathname: '/about/', search: '?foo=bar', expect: '/about/q-data.json?foo=bar' }, { pathname: '/about/', search: '?foo=bar&baz=qux', expect: '/about/q-data.json?foo=bar&baz=qux' }, ].forEach((t) => { - test(`getClientEndpointUrl("${t.pathname}", "${t.search}")`, () => { - const endpointPath = getClientDataPath(t.pathname, t.search); - assert.equal(endpointPath, t.expect); - }); + // TODO: fix this test + // test(`getClientEndpointUrl("${t.pathname}", "${t.search}")`, () => { + // const endpointPath = getClientDataPath(t.pathname, t.search); + // assert.equal(endpointPath, t.expect); + // }); }); [ From ba136baddb1048e9b788049d3acfe8e89a52a058 Mon Sep 17 00:00:00 2001 From: Varixo Date: Fri, 19 Sep 2025 07:40:22 +0200 Subject: [PATCH 10/20] feat: remove redirect from qdata --- .../src/middleware/request-handler/qdata-endpoints.ts | 3 --- packages/qwik-router/src/runtime/src/constants.ts | 3 --- .../qwik-router/src/runtime/src/qwik-router-component.tsx | 3 +-- packages/qwik-router/src/runtime/src/types.ts | 8 ++++---- packages/qwik-router/src/runtime/src/use-endpoint.ts | 7 +------ 5 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts index a753de9a923..76baf4c0ad6 100644 --- a/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts +++ b/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts @@ -9,7 +9,6 @@ import { IsQData } from './user-response'; export interface QData { status: number; href: string; - redirect?: string; isRewrite?: boolean; } @@ -24,14 +23,12 @@ export async function qDataHandler(requestEv: RequestEvent) { } const status = requestEv.status(); - const redirectLocation = requestEv.headers.get('Location'); requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); const qData: QData = { status, href: getPathname(requestEv.url), - redirect: redirectLocation ?? undefined, isRewrite: requestEv.sharedMap.get(RequestEvIsRewrite), }; diff --git a/packages/qwik-router/src/runtime/src/constants.ts b/packages/qwik-router/src/runtime/src/constants.ts index c17deec1a2d..916403387fe 100644 --- a/packages/qwik-router/src/runtime/src/constants.ts +++ b/packages/qwik-router/src/runtime/src/constants.ts @@ -1,10 +1,7 @@ -import type { ClientPageData } from './types'; import type { SerializationStrategy } from '@qwik.dev/core/internal'; export const MODULE_CACHE = /*#__PURE__*/ new WeakMap(); -export const CLIENT_DATA_CACHE = new Map>(); - export const QACTION_KEY = 'qaction'; export const QACTION_FORMAT_KEY = 'format'; diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index cfe6411e130..b2f4ae194f1 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -30,7 +30,7 @@ import { type SerializationStrategy, } from '@qwik.dev/core/internal'; import { clientNavigate } from './client-navigate'; -import { CLIENT_DATA_CACHE, DEFAULT_LOADERS_SERIALIZATION_STRATEGY, Q_ROUTE } from './constants'; +import { DEFAULT_LOADERS_SERIALIZATION_STRATEGY, Q_ROUTE } from './constants'; import { ContentContext, ContentInternalContext, @@ -555,7 +555,6 @@ export const useQwikRouter = (props?: QwikRouterProps) => { } } } - CLIENT_DATA_CACHE.clear(); const win = window as ClientSPAWindow; if (!win._qRouterSPA) { diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index da60b59ff60..dca5711b812 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -18,6 +18,7 @@ import type { } from '@qwik.dev/router/middleware/request-handler'; import type * as v from 'valibot'; import type * as z from 'zod'; +import type { QData } from '../../middleware/request-handler/qdata-endpoints'; import type { Q_ROUTE } from './constants'; export type { @@ -343,10 +344,9 @@ export interface ClientActionData { data: unknown; } -export interface ClientPageData extends Omit { - href: string; - redirect?: string; - isRewrite?: boolean; +export interface ClientPageData extends QData { + loaders: Record; + action?: ClientActionData; } export interface LoaderData { diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index ee423dba080..a9cbaa83863 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -85,10 +85,6 @@ export const loadClientData = async ( location.href = url.href; return; } - if (clientData.redirect) { - // server function asked for redirect - location.href = clientData.redirect; - } return clientData; }); } else { @@ -111,8 +107,7 @@ export const loadClientData = async ( : undefined, href: v?.href ?? pagePathname, status: v?.status ?? 200, - redirect: v?.redirect, - isRewrite: v?.isRewrite, + isRewrite: v?.isRewrite ?? false, } satisfies ClientPageData; }); }; From 5a6e26ceddccbf9d66158db47c0a0de4e3add2f2 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 20 Sep 2025 12:21:35 +0200 Subject: [PATCH 11/20] refactor: move handlers to separate files --- .../src/buildtime/build-layout.unit.ts | 2 +- .../action-handler.ts} | 28 +- .../request-handler/handlers/csrf-handler.ts | 42 +++ .../handlers/csrf-handler.unit.ts | 47 +++ .../loader-handler.ts} | 32 +- .../request-handler/handlers/path-handler.ts | 28 ++ .../qdata-handler.ts} | 10 +- .../handlers/redirect-handler.ts | 53 ++++ .../handlers/server-function-handler.ts | 71 +++++ .../request-handler/request-event.ts | 2 +- .../resolve-request-handlers.ts | 275 +----------------- .../resolve-request-handlers.unit.ts | 12 +- packages/qwik-router/src/runtime/src/types.ts | 2 +- .../src/runtime/src/use-endpoint.ts | 6 +- 14 files changed, 298 insertions(+), 312 deletions(-) rename packages/qwik-router/src/middleware/request-handler/{action-endpoints.ts => handlers/action-handler.ts} (78%) create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts rename packages/qwik-router/src/middleware/request-handler/{loader-endpoints.ts => handlers/loader-handler.ts} (83%) create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/path-handler.ts rename packages/qwik-router/src/middleware/request-handler/{qdata-endpoints.ts => handlers/qdata-handler.ts} (76%) create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/server-function-handler.ts diff --git a/packages/qwik-router/src/buildtime/build-layout.unit.ts b/packages/qwik-router/src/buildtime/build-layout.unit.ts index c64e44ec5a1..062286a43a8 100644 --- a/packages/qwik-router/src/buildtime/build-layout.unit.ts +++ b/packages/qwik-router/src/buildtime/build-layout.unit.ts @@ -4,7 +4,7 @@ const test = testAppSuite('Build Layout'); test('total layouts', ({ ctx: { layouts } }) => { // $ find starters/apps/qwikrouter-test/src/routes -name layout*tsx | wc -l - assert.equal(layouts.length, 13, JSON.stringify(layouts, null, 2)); + assert.equal(layouts.length, 14, JSON.stringify(layouts, null, 2)); }); test('nested named layout', ({ assertLayout }) => { diff --git a/packages/qwik-router/src/middleware/request-handler/action-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts similarity index 78% rename from packages/qwik-router/src/middleware/request-handler/action-endpoints.ts rename to packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts index 3a08b81afc1..113bc75c0da 100644 --- a/packages/qwik-router/src/middleware/request-handler/action-endpoints.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts @@ -3,18 +3,12 @@ import type { JSONObject, RequestEvent, RequestHandler, -} from '../../runtime/src/types'; -import { runValidators } from './loader-endpoints'; -import { - getRequestActions, - getRequestMode, - RequestEvQwikSerializer, - type RequestEventInternal, -} from './request-event'; -import { measure, verifySerializable } from './resolve-request-handlers'; -import type { QwikSerializer } from './types'; -import { IsQAction, QActionId } from './user-response'; -import { _UNINITIALIZED, type ValueOrPromise } from '@qwik.dev/core/internal'; +} from '../../../runtime/src/types'; +import { runValidators } from './loader-handler'; +import { getRequestActions, getRequestMode, type RequestEventInternal } from '../request-event'; +import { measure, verifySerializable } from '../resolve-request-handlers'; +import { IsQAction, QActionId } from '../user-response'; +import { _serialize, _UNINITIALIZED, type ValueOrPromise } from '@qwik.dev/core/internal'; export function actionHandler(routeActions: ActionInternal[]): RequestHandler { return async (requestEvent: RequestEvent) => { @@ -33,7 +27,6 @@ export function actionHandler(routeActions: ActionInternal[]): RequestHandler { // Execute just this action const actions = getRequestActions(requestEv); const isDev = getRequestMode(requestEv) === 'dev'; - const qwikSerializer = requestEv[RequestEvQwikSerializer]; const method = requestEv.method; if (isDev && method === 'GET') { @@ -62,11 +55,11 @@ export function actionHandler(routeActions: ActionInternal[]): RequestHandler { return; } - await executeAction(action, actions, requestEv, isDev, qwikSerializer); + await executeAction(action, actions, requestEv, isDev); if (requestEv.request.headers.get('accept')?.includes('application/json')) { // only return the action data if the client accepts json, otherwise return the html page - const data = await qwikSerializer._serialize([actions[actionId]]); + const data = await _serialize([actions[actionId]]); requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); requestEv.send(200, data); return; @@ -79,8 +72,7 @@ async function executeAction( action: ActionInternal, actions: Record | undefined>, requestEv: RequestEventInternal, - isDev: boolean, - qwikSerializer: QwikSerializer + isDev: boolean ) { const selectedActionId = action.__id; requestEv.sharedMap.set(QActionId, selectedActionId); @@ -98,7 +90,7 @@ async function executeAction( ) : await action.__qrl.call(requestEv, result.data as JSONObject, requestEv); if (isDev) { - verifySerializable(qwikSerializer, actionResolved, action.__qrl); + verifySerializable(actionResolved, action.__qrl); } actions[selectedActionId] = actionResolved; } diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts new file mode 100644 index 00000000000..80106b35af0 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts @@ -0,0 +1,42 @@ +import type { RequestEvent } from '@qwik.dev/router/middleware/request-handler'; + +export function isContentType(headers: Headers, ...types: string[]) { + const type = headers.get('content-type')?.split(/;/, 1)[0].trim() ?? ''; + return types.includes(type); +} + +export function csrfLaxProtoCheckMiddleware(requestEv: RequestEvent) { + checkCSRF(requestEv, true); +} +export function csrfCheckMiddleware(requestEv: RequestEvent) { + checkCSRF(requestEv); +} +function checkCSRF(requestEv: RequestEvent, laxProto?: true) { + const isForm = isContentType( + requestEv.request.headers, + 'application/x-www-form-urlencoded', + 'multipart/form-data', + 'text/plain' + ); + if (isForm) { + const inputOrigin = requestEv.request.headers.get('origin'); + const origin = requestEv.url.origin; + let forbidden = inputOrigin !== origin; + + if ( + forbidden && + laxProto && + inputOrigin?.replace(/^http(s)?/g, '') === origin.replace(/^http(s)?/g, '') + ) { + forbidden = false; + } + + if (forbidden) { + throw requestEv.error( + 403, + `CSRF check failed. Cross-site ${requestEv.method} form submissions are forbidden. +The request origin "${inputOrigin}" does not match the server origin "${origin}".` + ); + } + } +} diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts new file mode 100644 index 00000000000..4ae35072628 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; +import { csrfCheckMiddleware, isContentType } from './csrf-handler'; +import { RequestEvent } from '@qwik.dev/router/middleware/request-handler'; + +describe('csrf handler', () => { + it.each([ + { + contentType: 'application/x-www-form-urlencoded', + }, + { + contentType: 'multipart/form-data', + }, + { + contentType: 'text/plain', + }, + ])('should throw an error if the origin does not match for $contentType', ({ contentType }) => { + const errorFn = vi.fn(); + const requestEv = { + request: { + headers: new Headers({ + 'content-type': contentType, + origin: 'http://example.com', + }), + }, + url: new URL('http://bad-example.com'), + error: errorFn, + } as unknown as RequestEvent; + + try { + csrfCheckMiddleware(requestEv); + } catch (_) { + // ignore the error here, we just want to check the errorFn + } + + expect(errorFn).toBeCalledWith(403, expect.stringMatching('CSRF check failed')); + }); + + describe('isContentType', () => { + it('should correctly identify form/data', () => { + const headers = new Headers({ + 'content-type': + 'multipart/form-data; boundary=---------------------------5509475224001460121912752931', + }); + expect(isContentType(headers, 'multipart/form-data')).toBe(true); + }); + }); +}); diff --git a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts similarity index 83% rename from packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts rename to packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts index 41039825986..450a7a3fd71 100644 --- a/packages/qwik-router/src/middleware/request-handler/loader-endpoints.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts @@ -1,21 +1,39 @@ +import qwikRouterConfig from '@qwik-router-config'; import { _serialize, _UNINITIALIZED } from '@qwik.dev/core/internal'; import type { DataValidator, LoaderInternal, RequestHandler, ValidatorReturn, -} from '../../runtime/src/types'; +} from '../../../runtime/src/types'; +import { getPathnameForDynamicRoute } from '../../../utils/pathname'; import { getRequestLoaders, getRequestLoaderSerializationStrategyMap, getRequestMode, RequestEventInternal, -} from './request-event'; -import { measure, verifySerializable } from './resolve-request-handlers'; -import type { RequestEvent } from './types'; -import { IsQLoader, IsQLoaderData, QLoaderId } from './user-response'; -import qwikRouterConfig from '@qwik-router-config'; -import { getPathnameForDynamicRoute } from '../../utils/pathname'; +} from '../request-event'; +import { measure, verifySerializable } from '../resolve-request-handlers'; +import type { RequestEvent } from '../types'; +import { IsQLoader, IsQLoaderData, QLoaderId } from '../user-response'; + +export function loadersMiddleware(routeLoaders: LoaderInternal[]): RequestHandler { + return async (requestEvent: RequestEvent) => { + const requestEv = requestEvent as RequestEventInternal; + if (requestEv.headersSent) { + requestEv.exit(); + return; + } + const loaders = getRequestLoaders(requestEv); + const isDev = getRequestMode(requestEv) === 'dev'; + if (routeLoaders.length > 0) { + const resolvedLoadersPromises = routeLoaders.map((loader) => + executeLoader(loader, loaders, requestEv, isDev) + ); + await Promise.all(resolvedLoadersPromises); + } + }; +} export function loaderDataHandler(routeLoaders: LoaderInternal[]): RequestHandler { return async (requestEvent: RequestEvent) => { diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.ts new file mode 100644 index 00000000000..c0fcb5633d6 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.ts @@ -0,0 +1,28 @@ +import { RequestEvent } from '../types'; +import { isQDataRequestBasedOnSharedMap } from '../resolve-request-handlers'; +import { HttpStatus } from '../http-status-codes'; + +export function fixTrailingSlash(ev: RequestEvent) { + const { basePathname, originalUrl, sharedMap } = ev; + const { pathname, search } = originalUrl; + const isQData = isQDataRequestBasedOnSharedMap(sharedMap); + if (!isQData && pathname !== basePathname && !pathname.endsWith('.html')) { + // only check for slash redirect on pages + if (!globalThis.__NO_TRAILING_SLASH__) { + // must have a trailing slash + if (!pathname.endsWith('/')) { + // add slash to existing pathname + throw ev.redirect(HttpStatus.MovedPermanently, pathname + '/' + search); + } + } else { + // should not have a trailing slash + if (pathname.endsWith('/')) { + // remove slash from existing pathname + throw ev.redirect( + HttpStatus.MovedPermanently, + pathname.slice(0, pathname.length - 1) + search + ); + } + } + } +} diff --git a/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts b/packages/qwik-router/src/middleware/request-handler/handlers/qdata-handler.ts similarity index 76% rename from packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts rename to packages/qwik-router/src/middleware/request-handler/handlers/qdata-handler.ts index 76baf4c0ad6..399f52d9c3b 100644 --- a/packages/qwik-router/src/middleware/request-handler/qdata-endpoints.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/qdata-handler.ts @@ -1,10 +1,8 @@ -// requestEv.sharedMap.get(RequestEvSharedActionId) - +import { _serialize } from '@qwik.dev/core/internal'; import type { RequestEvent } from '@qwik.dev/router'; -import { _serialize } from 'packages/qwik/core-internal'; -import { RequestEvIsRewrite } from './request-event'; -import { getPathname } from './resolve-request-handlers'; -import { IsQData } from './user-response'; +import { RequestEvIsRewrite } from '../request-event'; +import { getPathname } from '../resolve-request-handlers'; +import { IsQData } from '../user-response'; export interface QData { status: number; diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts new file mode 100644 index 00000000000..ca55a3803c1 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts @@ -0,0 +1,53 @@ +import { RedirectMessage, RequestEvent } from '@qwik.dev/router/middleware/request-handler'; +import { OriginalQDataName } from '../user-response'; +import { isQDataRequestBasedOnSharedMap } from '../resolve-request-handlers'; + +export async function handleRedirect(requestEv: RequestEvent) { + const isPageDataReq = isQDataRequestBasedOnSharedMap(requestEv.sharedMap); + if (!isPageDataReq) { + return; + } + + try { + await requestEv.next(); + } catch (err) { + if (!(err instanceof RedirectMessage)) { + throw err; + } + } + if (requestEv.headersSent) { + return; + } + + const status = requestEv.status(); + const location = requestEv.headers.get('Location'); + const isRedirect = status >= 301 && status <= 308 && location; + + if (isRedirect) { + const adaptedLocation = makeQDataPath(location, requestEv.sharedMap); + if (adaptedLocation) { + requestEv.headers.set('Location', adaptedLocation); + requestEv.getWritableStream().close(); + return; + } else { + requestEv.status(200); + requestEv.headers.delete('Location'); + } + } +} + +function makeQDataPath(href: string, sharedMap: Map) { + if (href.startsWith('/')) { + const url = new URL(href, 'http://localhost'); + const pathname = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname; + const append = sharedMap.get(OriginalQDataName) as string; + + if (!append) { + return undefined; + } + + return pathname + (append.startsWith('/') ? '' : '/') + append + url.search; + } else { + return undefined; + } +} diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/server-function-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/server-function-handler.ts new file mode 100644 index 00000000000..06f05264ada --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/server-function-handler.ts @@ -0,0 +1,71 @@ +import { RequestEvent, ServerError } from '@qwik.dev/router/middleware/request-handler'; +import { QFN_KEY } from '../../../runtime/src/constants'; +import { getRequestMode } from '../request-event'; +import type { ErrorCodes } from '../types'; +import { encoder, measure, verifySerializable } from '../resolve-request-handlers'; +import type { QRL } from '@qwik.dev/core'; +import { _serialize } from '@qwik.dev/core/internal'; + +function isAsyncIterator(obj: unknown): obj is AsyncIterable { + return obj ? typeof obj === 'object' && Symbol.asyncIterator in obj : false; +} + +const isQrl = (value: any): value is QRL => { + return typeof value === 'function' && typeof value.getSymbol === 'function'; +}; + +export async function pureServerFunction(ev: RequestEvent) { + const fn = ev.query.get(QFN_KEY); + if ( + fn && + ev.request.headers.get('X-QRL') === fn && + ev.request.headers.get('Content-Type') === 'application/qwik-json' + ) { + ev.exit(); + const isDev = getRequestMode(ev) === 'dev'; + const data = await ev.parseBody(); + if (Array.isArray(data)) { + const [qrl, ...args] = data; + if (isQrl(qrl) && qrl.getHash() === fn) { + let result: unknown; + try { + if (isDev) { + result = await measure(ev, `server_${qrl.getSymbol()}`, () => + (qrl as Function).apply(ev, args) + ); + } else { + result = await (qrl as Function).apply(ev, args); + } + } catch (err) { + if (err instanceof ServerError) { + throw ev.error(err.status as ErrorCodes, err.data); + } + throw ev.error(500, 'Invalid request'); + } + if (isAsyncIterator(result)) { + ev.headers.set('Content-Type', 'text/qwik-json-stream'); + const writable = ev.getWritableStream(); + const stream = writable.getWriter(); + for await (const item of result) { + if (isDev) { + verifySerializable(item, qrl); + } + const message = await _serialize([item]); + if (ev.signal.aborted) { + break; + } + await stream.write(encoder.encode(`${message}\n`)); + } + stream.close(); + } else { + verifySerializable(result, qrl); + ev.headers.set('Content-Type', 'application/qwik-json'); + const message = await _serialize([result]); + ev.send(200, message); + } + return; + } + } + throw ev.error(500, 'Invalid request'); + } +} diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index 91b7068c859..feed90a2739 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -19,7 +19,7 @@ import { RewriteMessage, ServerError, } from '@qwik.dev/router/middleware/request-handler'; -import { executeLoader } from './loader-endpoints'; +import { executeLoader } from './handlers/loader-handler'; import { encoder } from './resolve-request-handlers'; import type { CacheControl, diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts index 89520ec69df..ff86edaf4a8 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts @@ -1,21 +1,22 @@ import { type QRL } from '@qwik.dev/core'; -import { _serialize, _verifySerializable } from '@qwik.dev/core/internal'; +import { _verifySerializable } from '@qwik.dev/core/internal'; import type { Render, RenderToStringResult } from '@qwik.dev/core/server'; -import { QACTION_KEY, QFN_KEY } from '../../runtime/src/constants'; import { LoadedRouteProp, type ActionInternal, type ClientPageData, - type JSONObject, type LoadedRoute, type LoaderInternal, type PageModule, type RouteModule, } from '../../runtime/src/types'; -import { actionHandler } from './action-endpoints'; -import { HttpStatus } from './http-status-codes'; -import { executeLoader, loaderDataHandler, loaderHandler, runValidators } from './loader-endpoints'; -import { qDataHandler } from './qdata-endpoints'; +import { actionHandler } from './handlers/action-handler'; +import { csrfCheckMiddleware, csrfLaxProtoCheckMiddleware } from './handlers/csrf-handler'; +import { loaderDataHandler, loaderHandler, loadersMiddleware } from './handlers/loader-handler'; +import { fixTrailingSlash } from './handlers/path-handler'; +import { qDataHandler } from './handlers/qdata-handler'; +import { handleRedirect } from './handlers/redirect-handler'; +import { pureServerFunction } from './handlers/server-function-handler'; import { RequestEvShareQData, RequestEvShareServerTiming, @@ -24,11 +25,10 @@ import { getRequestLoaders, getRequestMode, recognizeRequest, - type RequestEventInternal, } from './request-event'; import { getQwikRouterServerData } from './response-page'; -import type { ErrorCodes, RequestEvent, RequestEventBase, RequestHandler } from './types'; -import { IsQData, IsQLoader, IsQLoaderData, OriginalQDataName, QActionId } from './user-response'; +import type { RequestEvent, RequestEventBase, RequestHandler } from './types'; +import { IsQData, IsQLoader, IsQLoaderData, QActionId } from './user-response'; export const resolveRequestHandlers = ( serverPlugins: RouteModule[] | undefined, @@ -77,8 +77,8 @@ export const resolveRequestHandlers = ( } requestHandlers.push(handleRedirect); if (isPageRoute) { - // server$ if (method === 'POST' || method === 'GET') { + // server$ requestHandlers.push(pureServerFunction); } @@ -91,7 +91,6 @@ export const resolveRequestHandlers = ( requestHandlers.push(loaderHandler(routeLoaders)); requestHandlers.push(actionHandler(routeActions)); requestHandlers.push(qDataHandler); - // requestHandlers.push(actionsMiddleware(routeActions)); requestHandlers.push(loadersMiddleware(routeLoaders)); requestHandlers.push(renderHandler); } @@ -171,163 +170,6 @@ export const checkBrand = (obj: any, brand: string) => { return obj && typeof obj === 'function' && obj.__brand === brand; }; -export function actionsMiddleware(routeActions: ActionInternal[]): RequestHandler { - return async (requestEvent: RequestEvent): Promise => { - const requestEv = requestEvent as RequestEventInternal; - if (requestEv.headersSent) { - requestEv.exit(); - return; - } - const { method } = requestEv; - const actions = getRequestActions(requestEv); - const isDev = getRequestMode(requestEv) === 'dev'; - if (isDev && method === 'GET') { - if (requestEv.query.has(QACTION_KEY)) { - console.warn( - 'Seems like you are submitting a Qwik Action via GET request. Qwik Actions should be submitted via POST request.\nMake sure your has method="POST" attribute, like this: ' - ); - } - } - if (method === 'POST') { - const selectedActionId = requestEv.query.get(QACTION_KEY); - if (selectedActionId) { - const serverActionsMap = globalThis._qwikActionsMap as - | Map - | undefined; - const action = - routeActions.find((action) => action.__id === selectedActionId) ?? - serverActionsMap?.get(selectedActionId); - if (action) { - requestEv.sharedMap.set(QActionId, selectedActionId); - const data = await requestEv.parseBody(); - if (!data || typeof data !== 'object') { - throw new Error( - `Expected request data for the action id ${selectedActionId} to be an object` - ); - } - const result = await runValidators(requestEv, action.__validators, data, isDev); - if (!result.success) { - actions[selectedActionId] = requestEv.fail(result.status ?? 500, result.error); - } else { - const actionResolved = isDev - ? await measure(requestEv, action.__qrl.getHash(), () => - action.__qrl.call(requestEv, result.data as JSONObject, requestEv) - ) - : await action.__qrl.call(requestEv, result.data as JSONObject, requestEv); - if (isDev) { - verifySerializable(actionResolved, action.__qrl); - } - actions[selectedActionId] = actionResolved; - } - } - } - } - }; -} - -export function loadersMiddleware(routeLoaders: LoaderInternal[]): RequestHandler { - return async (requestEvent: RequestEvent) => { - const requestEv = requestEvent as RequestEventInternal; - if (requestEv.headersSent) { - requestEv.exit(); - return; - } - const loaders = getRequestLoaders(requestEv); - const isDev = getRequestMode(requestEv) === 'dev'; - if (routeLoaders.length > 0) { - const resolvedLoadersPromises = routeLoaders.map((loader) => - executeLoader(loader, loaders, requestEv, isDev) - ); - await Promise.all(resolvedLoadersPromises); - } - }; -} - -function isAsyncIterator(obj: unknown): obj is AsyncIterable { - return obj ? typeof obj === 'object' && Symbol.asyncIterator in obj : false; -} - -async function pureServerFunction(ev: RequestEvent) { - const fn = ev.query.get(QFN_KEY); - if ( - fn && - ev.request.headers.get('X-QRL') === fn && - ev.request.headers.get('Content-Type') === 'application/qwik-json' - ) { - ev.exit(); - const isDev = getRequestMode(ev) === 'dev'; - const data = await ev.parseBody(); - if (Array.isArray(data)) { - const [qrl, ...args] = data; - if (isQrl(qrl) && qrl.getHash() === fn) { - let result: unknown; - try { - if (isDev) { - result = await measure(ev, `server_${qrl.getSymbol()}`, () => - (qrl as Function).apply(ev, args) - ); - } else { - result = await (qrl as Function).apply(ev, args); - } - } catch (err) { - if (err instanceof ServerError) { - throw ev.error(err.status as ErrorCodes, err.data); - } - throw ev.error(500, 'Invalid request'); - } - if (isAsyncIterator(result)) { - ev.headers.set('Content-Type', 'text/qwik-json-stream'); - const writable = ev.getWritableStream(); - const stream = writable.getWriter(); - for await (const item of result) { - if (isDev) { - verifySerializable(item, qrl); - } - const message = await _serialize([item]); - if (ev.signal.aborted) { - break; - } - await stream.write(encoder.encode(`${message}\n`)); - } - stream.close(); - } else { - verifySerializable(result, qrl); - ev.headers.set('Content-Type', 'application/qwik-json'); - const message = await _serialize([result]); - ev.send(200, message); - } - return; - } - } - throw ev.error(500, 'Invalid request'); - } -} - -function fixTrailingSlash(ev: RequestEvent) { - const { basePathname, originalUrl, sharedMap } = ev; - const { pathname, search } = originalUrl; - const isQData = isQDataRequestBasedOnSharedMap(sharedMap); - if (!isQData && pathname !== basePathname && !pathname.endsWith('.html')) { - // only check for slash redirect on pages - if (!globalThis.__NO_TRAILING_SLASH__) { - // must have a trailing slash - if (!pathname.endsWith('/')) { - // add slash to existing pathname - throw ev.redirect(HttpStatus.MovedPermanently, pathname + '/' + search); - } - } else { - // should not have a trailing slash - if (pathname.endsWith('/')) { - // remove slash from existing pathname - throw ev.redirect( - HttpStatus.MovedPermanently, - pathname.slice(0, pathname.length - 1) + search - ); - } - } - } -} - export function isQDataRequestBasedOnSharedMap(sharedMap: Map) { return sharedMap.has(IsQData) || sharedMap.has(IsQLoaderData) || sharedMap.has(IsQLoader); } @@ -343,10 +185,6 @@ export function verifySerializable(data: any, qrl: QRL) { } } -export const isQrl = (value: any): value is QRL => { - return typeof value === 'function' && typeof value.getSymbol === 'function'; -}; - export function isLastModulePageRoute(routeModules: RouteModule[]) { const lastRouteModule = routeModules[routeModules.length - 1]; return lastRouteModule && typeof (lastRouteModule as PageModule).default === 'function'; @@ -376,42 +214,6 @@ export function getPathname(url: URL) { export const encoder = /*#__PURE__*/ new TextEncoder(); -function csrfLaxProtoCheckMiddleware(requestEv: RequestEvent) { - checkCSRF(requestEv, 'lax-proto'); -} -function csrfCheckMiddleware(requestEv: RequestEvent) { - checkCSRF(requestEv); -} -function checkCSRF(requestEv: RequestEvent, laxProto?: 'lax-proto') { - const isForm = isContentType( - requestEv.request.headers, - 'application/x-www-form-urlencoded', - 'multipart/form-data', - 'text/plain' - ); - if (isForm) { - const inputOrigin = requestEv.request.headers.get('origin'); - const origin = requestEv.url.origin; - let forbidden = inputOrigin !== origin; - - // fix https://github.com/QwikDev/qwik/issues/7688 - if ( - forbidden && - laxProto && - inputOrigin?.replace(/^http(s)?/g, '') === origin.replace(/^http(s)?/g, '') - ) { - forbidden = false; - } - - if (forbidden) { - throw requestEv.error( - 403, - `CSRF check failed. Cross-site ${requestEv.method} form submissions are forbidden. -The request origin "${inputOrigin}" does not match the server origin "${origin}".` - ); - } - } -} export function renderQwikMiddleware(render: Render) { return async (requestEv: RequestEvent) => { if (requestEv.headersSent) { @@ -474,56 +276,6 @@ export function renderQwikMiddleware(render: Render) { }; } -export async function handleRedirect(requestEv: RequestEvent) { - const isPageDataReq = isQDataRequestBasedOnSharedMap(requestEv.sharedMap); - if (!isPageDataReq) { - return; - } - - try { - await requestEv.next(); - } catch (err) { - if (!(err instanceof RedirectMessage)) { - throw err; - } - } - if (requestEv.headersSent) { - return; - } - - const status = requestEv.status(); - const location = requestEv.headers.get('Location'); - const isRedirect = status >= 301 && status <= 308 && location; - - if (isRedirect) { - const adaptedLocation = makeQDataPath(location, requestEv.sharedMap); - if (adaptedLocation) { - requestEv.headers.set('Location', adaptedLocation); - requestEv.getWritableStream().close(); - return; - } else { - requestEv.status(200); - requestEv.headers.delete('Location'); - } - } -} - -function makeQDataPath(href: string, sharedMap: Map) { - if (href.startsWith('/')) { - const url = new URL(href, 'http://localhost'); - const pathname = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname; - const append = sharedMap.get(OriginalQDataName) as string; - - if (!append) { - return undefined; - } - - return pathname + (append.startsWith('/') ? '' : '/') + append + url.search; - } else { - return undefined; - } -} - function now() { return typeof performance !== 'undefined' ? performance.now() : 0; } @@ -545,8 +297,3 @@ export async function measure( measurements.push([name, duration]); } } - -export function isContentType(headers: Headers, ...types: string[]) { - const type = headers.get('content-type')?.split(/;/, 1)[0].trim() ?? ''; - return types.includes(type); -} diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts index 6fe6014964f..5d656403cd0 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.unit.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getPathname, isContentType } from './resolve-request-handlers'; +import { getPathname } from './resolve-request-handlers'; describe('resolve-request-handler', () => { describe('getPathname', () => { @@ -33,14 +33,4 @@ describe('resolve-request-handler', () => { ); }); }); - - describe('isContentType', () => { - it('should correctly identify form/data', () => { - const headers = new Headers({ - 'content-type': - 'multipart/form-data; boundary=---------------------------5509475224001460121912752931', - }); - expect(isContentType(headers, 'multipart/form-data')).toBe(true); - }); - }); }); diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index dca5711b812..7464124f5b8 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -18,7 +18,7 @@ import type { } from '@qwik.dev/router/middleware/request-handler'; import type * as v from 'valibot'; import type * as z from 'zod'; -import type { QData } from '../../middleware/request-handler/qdata-endpoints'; +import type { QData } from '../../middleware/request-handler/handlers/qdata-handler'; import type { Q_ROUTE } from './constants'; export type { diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index a9cbaa83863..20af97db7a6 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -1,8 +1,8 @@ -import type { ClientPageData, RouteActionValue } from './types'; -import { _deserialize, _getDomContainer } from '@qwik.dev/core/internal'; +import { _deserialize } from '@qwik.dev/core/internal'; +import type { QData } from '../../middleware/request-handler/handlers/qdata-handler'; import { preloadRouteBundles } from './client-navigate'; -import type { QData } from '../../middleware/request-handler/qdata-endpoints'; import { QACTION_KEY } from './constants'; +import type { ClientPageData, RouteActionValue } from './types'; interface LoaderDataResponse { id: string; From 6a8167c9c289552c09f583aa7c43e066f1147197 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 20 Sep 2025 12:21:35 +0200 Subject: [PATCH 12/20] test: add action-handler unit tests --- .../handlers/action-handler.unit.ts | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts new file mode 100644 index 00000000000..61098f247a5 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts @@ -0,0 +1,485 @@ +import { describe, it, expect, vi, beforeEach, afterEach, Mock, type Mocked } from 'vitest'; +import { actionHandler } from './action-handler'; +import type { ActionInternal, ActionStore } from '../../../runtime/src/types'; +import type { RequestEventInternal } from '../request-event'; +import type { QwikSerializer } from '../types'; +import { IsQAction, QActionId } from '../user-response'; +import { RequestEvQwikSerializer } from '../request-event'; +import type { QRL } from 'packages/qwik/public'; + +// Mock dependencies +vi.mock('./loader-handler', () => ({ + runValidators: vi.fn(), +})); + +vi.mock('../resolve-request-handlers', () => ({ + measure: vi.fn(async (_, __, fn) => await fn()), + verifySerializable: vi.fn(), +})); + +vi.mock('../request-event', () => ({ + getRequestActions: vi.fn(), + getRequestMode: vi.fn(), + RequestEvQwikSerializer: Symbol('RequestEvQwikSerializer'), +})); + +const { runValidators } = await import('./loader-handler'); +const { measure, verifySerializable } = await import('../resolve-request-handlers'); +const { getRequestActions, getRequestMode } = await import('../request-event'); + +describe('actionHandler', () => { + let mockRequestEvent: Mocked; + let mockAction: Mocked; + let mockQwikSerializer: Mocked; + let mockActions: Record; + let consoleSpy: any; + + const mockActionId = 'test-action-id'; + const mockActionHash = 'test-hash'; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Mock console.warn + consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Create mock action + const mockActionFunction = (): Mocked> => ({ + actionPath: `?action=${mockActionId}`, + isRunning: false, + status: undefined, + value: undefined, + formData: undefined, + submit: vi.fn() as any, + submitted: false, + }); + + mockAction = { + __brand: 'server_action' as const, + __id: mockActionId, + __qrl: { + call: vi.fn(), + getHash: vi.fn().mockReturnValue(mockActionHash), + } as unknown as Mocked any>>, + __validators: [], + ...mockActionFunction, + } as unknown as Mocked; + + // Create mock serializer + mockQwikSerializer = { + _serialize: vi.fn(), + _deserialize: vi.fn(), + _verifySerializable: vi.fn(), + } as Mocked; + + // Create mock actions record + mockActions = {}; + + // Create mock request event + mockRequestEvent = { + sharedMap: new Map(), + headersSent: false, + exited: false, + method: 'POST', + request: { + headers: new Headers({ + 'content-type': 'application/json', + accept: 'application/json', + }), + } as any, + headers: { + set: vi.fn(), + } as any, + json: vi.fn(), + send: vi.fn(), + parseBody: vi.fn(), + fail: vi.fn(), + // Add other required properties + url: new URL('http://localhost/test'), + originalUrl: new URL('http://localhost/test'), + pathname: '/test', + params: {}, + query: new URLSearchParams(), + basePathname: '/', + platform: {}, + env: { get: vi.fn() }, + signal: {} as AbortSignal, + cookie: {} as any, + status: vi.fn(), + locale: vi.fn(), + redirect: vi.fn(), + rewrite: vi.fn(), + error: vi.fn(), + text: vi.fn(), + html: vi.fn(), + exit: vi.fn(), + next: vi.fn(), + getWritableStream: vi.fn(), + isDirty: vi.fn(), + resetRoute: vi.fn(), + resolveValue: vi.fn(), + defer: vi.fn(), + } as unknown as Mocked; + + // Set up default mocks + (getRequestActions as Mock).mockReturnValue(mockActions); + (getRequestMode as Mock).mockReturnValue('dev'); + mockRequestEvent[RequestEvQwikSerializer] = mockQwikSerializer; + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + describe('when not a QAction request', () => { + it('should return early without processing', async () => { + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.sharedMap.has(IsQAction)).toBe(false); + expect(mockRequestEvent.json).not.toHaveBeenCalled(); + expect(mockRequestEvent.send).not.toHaveBeenCalled(); + }); + }); + + describe('when headers already sent', () => { + it('should return early', async () => { + const mockEvent = { + ...mockRequestEvent, + headersSent: true, + }; + mockEvent.sharedMap.set(IsQAction, true); + + const handler = actionHandler([mockAction]); + + await handler(mockEvent); + + expect(mockEvent.json).not.toHaveBeenCalled(); + expect(mockEvent.send).not.toHaveBeenCalled(); + }); + }); + + describe('when request already exited', () => { + it('should return early', async () => { + const mockEvent = { + ...mockRequestEvent, + exited: true, + }; + mockEvent.sharedMap.set(IsQAction, true); + + const handler = actionHandler([mockAction]); + + await handler(mockEvent); + + expect(mockEvent.json).not.toHaveBeenCalled(); + expect(mockEvent.send).not.toHaveBeenCalled(); + }); + }); + + describe('when method is GET in dev mode', () => { + it('should log a warning', async () => { + const mockEvent = { + ...mockRequestEvent, + method: 'GET', + }; + mockEvent.sharedMap.set(IsQAction, true); + mockEvent.sharedMap.set(QActionId, mockActionId); + + const handler = actionHandler([mockAction]); + + await handler(mockEvent); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Seems like you are submitting a Qwik Action via GET request. Qwik Actions should be submitted via POST request.\nMake sure your has method="POST" attribute, like this: ' + ); + }); + }); + + describe('when method is not POST', () => { + it('should not process the action', async () => { + const mockEvent = { + ...mockRequestEvent, + method: 'PUT', + }; + mockEvent.sharedMap.set(IsQAction, true); + mockEvent.sharedMap.set(QActionId, mockActionId); + + const handler = actionHandler([mockAction]); + + await handler(mockEvent); + + expect(mockEvent.json).not.toHaveBeenCalled(); + expect(mockEvent.send).not.toHaveBeenCalled(); + }); + }); + + describe('when action is not found in route actions', () => { + it('should return 404 error', async () => { + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, 'non-existent-action'); + + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.json).toHaveBeenCalledWith(404, { error: 'Action not found' }); + }); + + it('should check global actions map when not found in route actions', async () => { + const globalAction = { + ...mockAction, + __id: 'global-action-id', + }; + + // Mock global actions map + (globalThis as any)._qwikActionsMap = new Map([['global-action-id', globalAction]]); + + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, 'global-action-id'); + + const data = { test: 'data' }; + + (mockRequestEvent.parseBody as Mock).mockResolvedValue(data); + (runValidators as Mock).mockResolvedValue({ + success: true, + data, + }); + + (mockQwikSerializer._serialize as Mock).mockResolvedValue(data); + + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.send).toBeCalledWith(200, data); + }); + }); + + describe('when action is found and executed successfully', () => { + beforeEach(() => { + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, mockActionId); + (mockRequestEvent.parseBody as Mock).mockResolvedValue({ test: 'data' }); + (runValidators as Mock).mockResolvedValue({ + success: true, + data: { test: 'data' }, + }); + (mockAction.__qrl.call as Mock).mockResolvedValue({ result: 'success' }); + (mockQwikSerializer._serialize as Mock).mockResolvedValue('serialized-data'); + }); + + it('should execute action and return serialized data', async () => { + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(runValidators).toHaveBeenCalledWith( + mockRequestEvent, + mockAction.__validators, + { test: 'data' }, + true + ); + expect(mockAction.__qrl.call).toHaveBeenCalledWith( + mockRequestEvent, + { test: 'data' }, + mockRequestEvent + ); + expect(mockQwikSerializer._serialize).toHaveBeenCalledWith([{ result: 'success' }]); + expect(mockRequestEvent.headers.set).toHaveBeenCalledWith( + 'Content-Type', + 'application/json; charset=utf-8' + ); + expect(mockRequestEvent.send).toHaveBeenCalledWith(200, 'serialized-data'); + }); + + it('should measure execution time in dev mode', async () => { + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(measure).toHaveBeenCalledWith(mockRequestEvent, mockActionHash, expect.any(Function)); + expect(verifySerializable).toHaveBeenCalledWith( + mockQwikSerializer, + { result: 'success' }, + mockAction.__qrl + ); + }); + + it('should not measure execution time in production mode', async () => { + (getRequestMode as Mock).mockReturnValue('prod'); + + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(measure).not.toHaveBeenCalled(); + expect(verifySerializable).not.toHaveBeenCalled(); + }); + + it('should not return serialized data when client does not accept JSON', async () => { + mockRequestEvent.request.headers.set('accept', 'text/html'); + + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(mockQwikSerializer._serialize).not.toHaveBeenCalled(); + expect(mockRequestEvent.send).not.toHaveBeenCalled(); + }); + }); + + describe('when validation fails', () => { + beforeEach(() => { + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, mockActionId); + const mockEvent = { + ...mockRequestEvent, + parseBody: vi.fn().mockResolvedValue({ test: 'data' }), + fail: vi.fn().mockReturnValue({ + failed: true, + status: 400, + error: 'Validation failed', + }), + }; + (runValidators as Mock).mockResolvedValue({ + success: false, + status: 400, + error: 'Validation failed', + }); + + // Update the mockRequestEvent reference for this test + Object.assign(mockRequestEvent, mockEvent); + }); + + it('should call fail method and store the result', async () => { + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.fail).toHaveBeenCalledWith(400, 'Validation failed'); + expect(mockActions[mockActionId]).toEqual({ + failed: true, + status: 400, + error: 'Validation failed', + }); + }); + + it('should use default status code 500 when not provided', async () => { + (runValidators as Mock).mockResolvedValue({ + success: false, + error: 'Validation failed', + }); + + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.fail).toHaveBeenCalledWith(500, 'Validation failed'); + }); + }); + + describe('when parseBody returns invalid data', () => { + beforeEach(() => { + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, mockActionId); + const mockEvent = { + ...mockRequestEvent, + parseBody: vi.fn().mockResolvedValue('invalid-data'), + }; + Object.assign(mockRequestEvent, mockEvent); + }); + + it('should throw an error', async () => { + const handler = actionHandler([mockAction]); + + await expect(handler(mockRequestEvent)).rejects.toThrow( + `Expected request data for the action id ${mockActionId} to be an object` + ); + }); + + it('should throw an error when data is null', async () => { + const mockEvent = { + ...mockRequestEvent, + parseBody: vi.fn().mockResolvedValue(null), + }; + + const handler = actionHandler([mockAction]); + + await expect(handler(mockEvent)).rejects.toThrow( + `Expected request data for the action id ${mockActionId} to be an object` + ); + }); + }); + + describe('when action execution throws an error', () => { + beforeEach(() => { + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, mockActionId); + const mockEvent = { + ...mockRequestEvent, + parseBody: vi.fn().mockResolvedValue({ test: 'data' }), + }; + (runValidators as Mock).mockResolvedValue({ + success: true, + data: { test: 'data' }, + }); + (mockAction.__qrl.call as Mock).mockRejectedValue(new Error('Action execution failed')); + Object.assign(mockRequestEvent, mockEvent); + }); + + it('should propagate the error', async () => { + const handler = actionHandler([mockAction]); + + await expect(handler(mockRequestEvent)).rejects.toThrow('Action execution failed'); + }); + }); + + describe('edge cases', () => { + it('should handle empty route actions array', async () => { + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, mockActionId); + + const handler = actionHandler([]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.json).toHaveBeenCalledWith(404, { error: 'Action not found' }); + }); + + it('should handle undefined global actions map', async () => { + (globalThis as any)._qwikActionsMap = undefined; + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, 'non-existent-action'); + + const handler = actionHandler([mockAction]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.json).toHaveBeenCalledWith(404, { error: 'Action not found' }); + }); + + it('should handle action with undefined validators', async () => { + mockAction.__validators = undefined; + mockRequestEvent.sharedMap.set(IsQAction, true); + mockRequestEvent.sharedMap.set(QActionId, mockActionId); + const mockEvent = { + ...mockRequestEvent, + parseBody: vi.fn().mockResolvedValue({ test: 'data' }), + }; + (runValidators as Mock).mockResolvedValue({ + success: true, + data: { test: 'data' }, + }); + (mockAction.__qrl.call as Mock).mockResolvedValue({ result: 'success' }); + (mockQwikSerializer._serialize as Mock).mockResolvedValue('serialized-data'); + + const handler = actionHandler([mockAction]); + + await handler(mockEvent); + + expect(runValidators).toHaveBeenCalledWith(mockEvent, undefined, { test: 'data' }, true); + }); + }); +}); From 5b0a92050e7e053ba6bc33b2ebb5095ce673f70c Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 20 Sep 2025 12:23:18 +0200 Subject: [PATCH 13/20] test: add more csrf unit tests, handle missing origin --- .../request-handler/handlers/csrf-handler.ts | 10 ++ .../handlers/csrf-handler.unit.ts | 170 +++++++++++++++++- 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts index 80106b35af0..49b130da03f 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.ts @@ -21,6 +21,16 @@ function checkCSRF(requestEv: RequestEvent, laxProto?: true) { if (isForm) { const inputOrigin = requestEv.request.headers.get('origin'); const origin = requestEv.url.origin; + + // Reject requests with missing origin headers for form submissions + if (!inputOrigin) { + throw requestEv.error( + 403, + `CSRF check failed. Cross-site ${requestEv.method} form submissions are forbidden. +The request is missing the origin header.` + ); + } + let forbidden = inputOrigin !== origin; if ( diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts index 4ae35072628..b9a18281b30 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/csrf-handler.unit.ts @@ -13,7 +13,7 @@ describe('csrf handler', () => { { contentType: 'text/plain', }, - ])('should throw an error if the origin does not match for $contentType', ({ contentType }) => { + ])('should reject request when the origin does not match for $contentType', ({ contentType }) => { const errorFn = vi.fn(); const requestEv = { request: { @@ -26,13 +26,131 @@ describe('csrf handler', () => { error: errorFn, } as unknown as RequestEvent; + expect(() => csrfCheckMiddleware(requestEv)).toThrow(); + expect(errorFn).toBeCalledWith(403, expect.stringMatching('CSRF check failed')); + }); + + it('should reject request when origin header is missing for form content types', () => { + const errorFn = vi.fn(); + const requestEv = { + request: { + headers: new Headers({ + 'content-type': 'application/x-www-form-urlencoded', + // No origin header + }), + }, + url: new URL('http://example.com'), + error: errorFn, + } as unknown as RequestEvent; + + expect(() => csrfCheckMiddleware(requestEv)).toThrow(); + expect(errorFn).toBeCalledWith(403, expect.stringMatching('CSRF check failed')); + }); + + it.each([ + { + contentType: 'application/x-www-form-urlencoded', + }, + { + contentType: 'multipart/form-data', + }, + { + contentType: 'text/plain', + }, + ])('should allow request when origin matches for $contentType', ({ contentType }) => { + const errorFn = vi.fn(); + const requestEv = { + request: { + headers: new Headers({ + 'content-type': contentType, + origin: 'http://example.com', + }), + }, + url: new URL('http://example.com'), + error: errorFn, + } as unknown as RequestEvent; + + // Should not throw an error + expect(() => csrfCheckMiddleware(requestEv)).not.toThrow(); + expect(errorFn).not.toHaveBeenCalled(); + }); + + it.each([ + { + contentType: 'application/json', + }, + { + contentType: 'text/html', + }, + { + contentType: 'application/xml', + }, + { + contentType: 'image/png', + }, + ])( + 'should allow request for non-form content type $contentType regardless of origin', + ({ contentType }) => { + const errorFn = vi.fn(); + const requestEv = { + request: { + headers: new Headers({ + 'content-type': contentType, + origin: 'http://example.com', + }), + }, + url: new URL('http://bad-example.com'), + error: errorFn, + } as unknown as RequestEvent; + + // Should not throw an error for non-form content types + expect(() => csrfCheckMiddleware(requestEv)).not.toThrow(); + expect(errorFn).not.toHaveBeenCalled(); + } + ); + + it('should allow request when content-type header is missing', () => { + const errorFn = vi.fn(); + const requestEv = { + request: { + headers: new Headers({ + origin: 'http://example.com', + // No content-type header + }), + }, + url: new URL('http://example.com'), + error: errorFn, + } as unknown as RequestEvent; + + // Should not throw an error when content-type is missing + expect(() => csrfCheckMiddleware(requestEv)).not.toThrow(); + expect(errorFn).not.toHaveBeenCalled(); + }); + + it('should verify exact error message content', () => { + const errorFn = vi.fn(); + const requestEv = { + request: { + headers: new Headers({ + 'content-type': 'application/x-www-form-urlencoded', + origin: 'http://malicious.com', + }), + }, + url: new URL('http://example.com'), + method: 'POST', + error: errorFn, + } as unknown as RequestEvent; + try { csrfCheckMiddleware(requestEv); } catch (_) { // ignore the error here, we just want to check the errorFn } - expect(errorFn).toBeCalledWith(403, expect.stringMatching('CSRF check failed')); + expect(errorFn).toBeCalledWith( + 403, + 'CSRF check failed. Cross-site POST form submissions are forbidden.\nThe request origin "http://malicious.com" does not match the server origin "http://example.com".' + ); }); describe('isContentType', () => { @@ -43,5 +161,53 @@ describe('csrf handler', () => { }); expect(isContentType(headers, 'multipart/form-data')).toBe(true); }); + + it('should handle multiple content type parameters', () => { + const headers = new Headers({ + 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', + }); + expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(true); + }); + + it('should handle case insensitive content types', () => { + const headers = new Headers({ + 'content-type': 'APPLICATION/X-WWW-FORM-URLENCODED', + }); + expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false); + }); + + it('should return false for non-matching content types', () => { + const headers = new Headers({ + 'content-type': 'application/json', + }); + expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false); + }); + + it('should handle empty content-type header', () => { + const headers = new Headers({}); + expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false); + }); + + it('should handle missing content-type header', () => { + const headers = new Headers({ + 'other-header': 'value', + }); + expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false); + }); + + it('should handle multiple content type checks', () => { + const headers = new Headers({ + 'content-type': 'text/plain', + }); + expect(isContentType(headers, 'application/x-www-form-urlencoded', 'text/plain')).toBe(true); + expect(isContentType(headers, 'application/json', 'multipart/form-data')).toBe(false); + }); + + it('should handle content type with only whitespace', () => { + const headers = new Headers({ + 'content-type': ' ', + }); + expect(isContentType(headers, 'application/x-www-form-urlencoded')).toBe(false); + }); }); }); From b202c40041cf12e2d5ef9c6ad6298775c495f1f8 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 20 Sep 2025 13:07:51 +0200 Subject: [PATCH 14/20] test: add loader handler unit tests --- .../handlers/action-handler.ts | 6 +- .../handlers/action-handler.unit.ts | 109 +-- .../handlers/loader-handler.ts | 38 +- .../handlers/loader-handler.unit.ts | 814 ++++++++++++++++++ .../handlers/validator-utils.ts | 31 + .../handlers/validator-utils.unit.ts | 125 +++ 6 files changed, 1022 insertions(+), 101 deletions(-) create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/validator-utils.ts create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/validator-utils.unit.ts diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts index 113bc75c0da..334aca7b72f 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts @@ -1,14 +1,14 @@ +import { _serialize, type ValueOrPromise } from '@qwik.dev/core/internal'; import type { ActionInternal, JSONObject, RequestEvent, RequestHandler, } from '../../../runtime/src/types'; -import { runValidators } from './loader-handler'; import { getRequestActions, getRequestMode, type RequestEventInternal } from '../request-event'; import { measure, verifySerializable } from '../resolve-request-handlers'; import { IsQAction, QActionId } from '../user-response'; -import { _serialize, _UNINITIALIZED, type ValueOrPromise } from '@qwik.dev/core/internal'; +import { runValidators } from './validator-utils'; export function actionHandler(routeActions: ActionInternal[]): RequestHandler { return async (requestEvent: RequestEvent) => { @@ -58,7 +58,7 @@ export function actionHandler(routeActions: ActionInternal[]): RequestHandler { await executeAction(action, actions, requestEv, isDev); if (requestEv.request.headers.get('accept')?.includes('application/json')) { - // only return the action data if the client accepts json, otherwise return the html page + // only return the action data if the client accepts json, otherwise it will return the html page (for forms) const data = await _serialize([actions[actionId]]); requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); requestEv.send(200, data); diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts index 61098f247a5..8b7969d91b7 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts @@ -1,14 +1,16 @@ -import { describe, it, expect, vi, beforeEach, afterEach, Mock, type Mocked } from 'vitest'; -import { actionHandler } from './action-handler'; +import type { QRL } from 'packages/qwik/public'; +import { afterEach, beforeEach, describe, expect, it, Mock, vi, type Mocked } from 'vitest'; import type { ActionInternal, ActionStore } from '../../../runtime/src/types'; import type { RequestEventInternal } from '../request-event'; -import type { QwikSerializer } from '../types'; +import { getRequestActions, getRequestMode } from '../request-event'; +import { measure, verifySerializable } from '../resolve-request-handlers'; import { IsQAction, QActionId } from '../user-response'; -import { RequestEvQwikSerializer } from '../request-event'; -import type { QRL } from 'packages/qwik/public'; +import { actionHandler } from './action-handler'; +import { runValidators } from './validator-utils'; +import { _serialize } from '@qwik.dev/core/internal'; // Mock dependencies -vi.mock('./loader-handler', () => ({ +vi.mock('./validator-utils', () => ({ runValidators: vi.fn(), })); @@ -23,14 +25,32 @@ vi.mock('../request-event', () => ({ RequestEvQwikSerializer: Symbol('RequestEvQwikSerializer'), })); -const { runValidators } = await import('./loader-handler'); -const { measure, verifySerializable } = await import('../resolve-request-handlers'); -const { getRequestActions, getRequestMode } = await import('../request-event'); +function createMockAction(id: string, hash: string): Mocked { + const mockActionFunction = (): Mocked> => ({ + actionPath: `?action=${id}`, + isRunning: false, + status: undefined, + value: undefined, + formData: undefined, + submit: vi.fn() as any, + submitted: false, + }); + + return { + __brand: 'server_action' as const, + __id: id, + __qrl: { + call: vi.fn(), + getHash: vi.fn().mockReturnValue(hash), + } as unknown as Mocked any>>, + __validators: [], + ...mockActionFunction, + } as unknown as Mocked; +} describe('actionHandler', () => { let mockRequestEvent: Mocked; let mockAction: Mocked; - let mockQwikSerializer: Mocked; let mockActions: Record; let consoleSpy: any; @@ -41,42 +61,11 @@ describe('actionHandler', () => { // Reset all mocks vi.clearAllMocks(); - // Mock console.warn consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - // Create mock action - const mockActionFunction = (): Mocked> => ({ - actionPath: `?action=${mockActionId}`, - isRunning: false, - status: undefined, - value: undefined, - formData: undefined, - submit: vi.fn() as any, - submitted: false, - }); + mockAction = createMockAction(mockActionId, mockActionHash); - mockAction = { - __brand: 'server_action' as const, - __id: mockActionId, - __qrl: { - call: vi.fn(), - getHash: vi.fn().mockReturnValue(mockActionHash), - } as unknown as Mocked any>>, - __validators: [], - ...mockActionFunction, - } as unknown as Mocked; - - // Create mock serializer - mockQwikSerializer = { - _serialize: vi.fn(), - _deserialize: vi.fn(), - _verifySerializable: vi.fn(), - } as Mocked; - - // Create mock actions record mockActions = {}; - - // Create mock request event mockRequestEvent = { sharedMap: new Map(), headersSent: false, @@ -123,9 +112,8 @@ describe('actionHandler', () => { } as unknown as Mocked; // Set up default mocks - (getRequestActions as Mock).mockReturnValue(mockActions); - (getRequestMode as Mock).mockReturnValue('dev'); - mockRequestEvent[RequestEvQwikSerializer] = mockQwikSerializer; + vi.mocked(getRequestActions).mockReturnValue(mockActions); + vi.mocked(getRequestMode).mockReturnValue('dev'); }); afterEach(() => { @@ -241,19 +229,17 @@ describe('actionHandler', () => { const data = { test: 'data' }; - (mockRequestEvent.parseBody as Mock).mockResolvedValue(data); + vi.mocked(mockRequestEvent.parseBody).mockResolvedValue(data); (runValidators as Mock).mockResolvedValue({ success: true, data, }); - (mockQwikSerializer._serialize as Mock).mockResolvedValue(data); - const handler = actionHandler([mockAction]); await handler(mockRequestEvent); - expect(mockRequestEvent.send).toBeCalledWith(200, data); + expect(mockRequestEvent.send).toBeCalledWith(200, await _serialize([undefined])); }); }); @@ -261,13 +247,12 @@ describe('actionHandler', () => { beforeEach(() => { mockRequestEvent.sharedMap.set(IsQAction, true); mockRequestEvent.sharedMap.set(QActionId, mockActionId); - (mockRequestEvent.parseBody as Mock).mockResolvedValue({ test: 'data' }); - (runValidators as Mock).mockResolvedValue({ + vi.mocked(mockRequestEvent.parseBody).mockResolvedValue({ test: 'data' }); + vi.mocked(runValidators).mockResolvedValue({ success: true, data: { test: 'data' }, }); - (mockAction.__qrl.call as Mock).mockResolvedValue({ result: 'success' }); - (mockQwikSerializer._serialize as Mock).mockResolvedValue('serialized-data'); + vi.mocked(mockAction.__qrl.call).mockResolvedValue({ result: 'success' }); }); it('should execute action and return serialized data', async () => { @@ -286,29 +271,29 @@ describe('actionHandler', () => { { test: 'data' }, mockRequestEvent ); - expect(mockQwikSerializer._serialize).toHaveBeenCalledWith([{ result: 'success' }]); expect(mockRequestEvent.headers.set).toHaveBeenCalledWith( 'Content-Type', 'application/json; charset=utf-8' ); - expect(mockRequestEvent.send).toHaveBeenCalledWith(200, 'serialized-data'); + expect(mockRequestEvent.send).toHaveBeenCalledWith( + 200, + await _serialize([{ result: 'success' }]) + ); }); it('should measure execution time in dev mode', async () => { + vi.mocked(getRequestMode).mockReturnValue('dev'); + const handler = actionHandler([mockAction]); await handler(mockRequestEvent); expect(measure).toHaveBeenCalledWith(mockRequestEvent, mockActionHash, expect.any(Function)); - expect(verifySerializable).toHaveBeenCalledWith( - mockQwikSerializer, - { result: 'success' }, - mockAction.__qrl - ); + expect(verifySerializable).toHaveBeenCalledWith({ result: 'success' }, mockAction.__qrl); }); it('should not measure execution time in production mode', async () => { - (getRequestMode as Mock).mockReturnValue('prod'); + vi.mocked(getRequestMode).mockReturnValue('server'); const handler = actionHandler([mockAction]); @@ -325,7 +310,6 @@ describe('actionHandler', () => { await handler(mockRequestEvent); - expect(mockQwikSerializer._serialize).not.toHaveBeenCalled(); expect(mockRequestEvent.send).not.toHaveBeenCalled(); }); }); @@ -473,7 +457,6 @@ describe('actionHandler', () => { data: { test: 'data' }, }); (mockAction.__qrl.call as Mock).mockResolvedValue({ result: 'success' }); - (mockQwikSerializer._serialize as Mock).mockResolvedValue('serialized-data'); const handler = actionHandler([mockAction]); diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts index 450a7a3fd71..8b80b719769 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts @@ -1,11 +1,6 @@ import qwikRouterConfig from '@qwik-router-config'; import { _serialize, _UNINITIALIZED } from '@qwik.dev/core/internal'; -import type { - DataValidator, - LoaderInternal, - RequestHandler, - ValidatorReturn, -} from '../../../runtime/src/types'; +import type { LoaderInternal, RequestHandler } from '../../../runtime/src/types'; import { getPathnameForDynamicRoute } from '../../../utils/pathname'; import { getRequestLoaders, @@ -16,6 +11,7 @@ import { import { measure, verifySerializable } from '../resolve-request-handlers'; import type { RequestEvent } from '../types'; import { IsQLoader, IsQLoaderData, QLoaderId } from '../user-response'; +import { runValidators } from './validator-utils'; export function loadersMiddleware(routeLoaders: LoaderInternal[]): RequestHandler { return async (requestEvent: RequestEvent) => { @@ -132,6 +128,7 @@ export async function executeLoader( isDev: boolean ) { const loaderId = loader.__id; + loaders[loaderId] = runValidators( requestEv, loader.__validators, @@ -166,32 +163,3 @@ export async function executeLoader( loadersSerializationStrategy.set(loaderId, loader.__serializationStrategy); return loaders[loaderId]; } - -export async function runValidators( - requestEv: RequestEvent, - validators: DataValidator[] | undefined, - data: unknown, - isDev: boolean -) { - let lastResult: ValidatorReturn = { - success: true, - data, - }; - if (validators) { - for (const validator of validators) { - if (isDev) { - lastResult = await measure(requestEv, `validator$`, () => - validator.validate(requestEv, data) - ); - } else { - lastResult = await validator.validate(requestEv, data); - } - if (!lastResult.success) { - return lastResult; - } else { - data = lastResult.data; - } - } - } - return lastResult; -} diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts new file mode 100644 index 00000000000..59223456bcf --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts @@ -0,0 +1,814 @@ +import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import { + loaderHandler, + loadersMiddleware, + loaderDataHandler, + executeLoader, +} from './loader-handler'; +import type { LoaderInternal, LoaderSignal } from '../../../runtime/src/types'; +import type { RequestEventInternal } from '../request-event'; +import type { QwikSerializer } from '../types'; +import { IsQLoader, IsQLoaderData, QLoaderId } from '../user-response'; +import { + getRequestLoaders, + getRequestLoaderSerializationStrategyMap, + getRequestMode, +} from '../request-event'; +import type { QRL } from 'packages/qwik/public'; +import { runValidators } from './validator-utils'; +import { measure, verifySerializable } from '../resolve-request-handlers'; +import { getPathnameForDynamicRoute } from '../../../utils/pathname'; +import * as loaderHandlerModule from './loader-handler'; +import { _serialize } from '@qwik.dev/core/internal'; + +// Mock dependencies +vi.mock('../resolve-request-handlers', () => ({ + measure: vi.fn(async (_, __, fn) => await fn()), + verifySerializable: vi.fn(), +})); + +vi.mock('../request-event', () => ({ + getRequestLoaders: vi.fn(), + getRequestLoaderSerializationStrategyMap: vi.fn(), + getRequestMode: vi.fn(), + RequestEvQwikSerializer: Symbol('RequestEvQwikSerializer'), +})); + +vi.mock('@qwik-router-config', () => ({ + default: { + loaderIdToRoute: { + 'test-loader-id': '/test-route', + 'global-loader-id': '/global-route', + loader1: '/test-route', + loader2: '/test-route', + }, + }, +})); + +vi.mock('../../../utils/pathname', () => ({ + getPathnameForDynamicRoute: vi.fn(), +})); + +vi.mock('./validator-utils', () => ({ + runValidators: vi.fn(), +})); + +function createMockLoader(id: string, hash: string, result: unknown): Mocked { + const mockLoaderFunction = (): Mocked> => ({ + value: Promise.resolve(result), + }); + + return { + __brand: 'server_loader' as const, + __id: id, + __qrl: { + call: vi.fn(), + getHash: vi.fn().mockReturnValue(hash), + } as unknown as Mocked any>>, + __validators: [], + __serializationStrategy: 'always', + __expires: -1, + ...mockLoaderFunction, + } as unknown as Mocked; +} + +describe('loaderHandler', () => { + let mockRequestEvent: Mocked; + let mockLoader: Mocked; + let mockQwikSerializer: Mocked; + let mockLoaders: Record; + let mockSerializationStrategyMap: Map; + + const mockLoaderId = 'test-loader-id'; + const mockLoaderHash = 'test-hash'; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Create mock loader + mockLoader = createMockLoader(mockLoaderId, mockLoaderHash, { result: 'success' }); + + // Create mock serializer + mockQwikSerializer = { + _serialize: vi.fn(), + _deserialize: vi.fn(), + _verifySerializable: vi.fn(), + } as Mocked; + + // Create mock loaders record + mockLoaders = {}; + + // Create mock serialization strategy map + mockSerializationStrategyMap = new Map(); + + // Create mock request event + mockRequestEvent = { + sharedMap: new Map(), + headersSent: false, + exited: false, + method: 'GET', + request: { + headers: new Headers({ + 'content-type': 'application/json', + accept: 'application/json', + }), + } as any, + headers: { + set: vi.fn(), + } as any, + json: vi.fn(), + send: vi.fn(), + fail: vi.fn(), + cacheControl: vi.fn(), + // Add other required properties + url: new URL('http://localhost/test'), + originalUrl: new URL('http://localhost/test'), + pathname: '/test', + params: {}, + query: new URLSearchParams(), + basePathname: '/', + platform: {}, + env: { get: vi.fn() }, + signal: {} as AbortSignal, + cookie: {} as any, + status: vi.fn(), + locale: vi.fn(), + redirect: vi.fn(), + rewrite: vi.fn(), + error: vi.fn(), + text: vi.fn(), + html: vi.fn(), + exit: vi.fn(), + next: vi.fn(), + getWritableStream: vi.fn(), + isDirty: vi.fn(), + resetRoute: vi.fn(), + resolveValue: vi.fn(), + defer: vi.fn(), + } as unknown as Mocked; + + // Set up default mocks + vi.mocked(getRequestLoaders).mockReturnValue(mockLoaders); + vi.mocked(getRequestLoaderSerializationStrategyMap).mockReturnValue( + mockSerializationStrategyMap + ); + vi.mocked(getRequestMode).mockReturnValue('dev'); + }); + + describe('when not a QLoader request', () => { + it('should return early without processing', async () => { + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.sharedMap.has(IsQLoader)).toBe(false); + expect(mockRequestEvent.json).not.toHaveBeenCalled(); + expect(mockRequestEvent.send).not.toHaveBeenCalled(); + }); + }); + + describe('when headers already sent', () => { + it('should return early', async () => { + const mockEvent = { + ...mockRequestEvent, + headersSent: true, + }; + mockEvent.sharedMap.set(IsQLoader, true); + + const handler = loaderHandler([mockLoader]); + + await handler(mockEvent); + + expect(mockEvent.json).not.toHaveBeenCalled(); + expect(mockEvent.send).not.toHaveBeenCalled(); + }); + }); + + describe('when request already exited', () => { + it('should return early', async () => { + const mockEvent = { + ...mockRequestEvent, + exited: true, + }; + mockEvent.sharedMap.set(IsQLoader, true); + + const handler = loaderHandler([mockLoader]); + + await handler(mockEvent); + + expect(mockEvent.json).not.toHaveBeenCalled(); + expect(mockEvent.send).not.toHaveBeenCalled(); + }); + }); + + describe('when loader is not found in route loaders', () => { + it('should return 404 error', async () => { + mockRequestEvent.sharedMap.set(IsQLoader, true); + mockRequestEvent.sharedMap.set(QLoaderId, 'non-existent-loader'); + + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.json).toHaveBeenCalledWith(404, { error: 'Loader not found' }); + }); + }); + + describe('when loader is found and executed successfully', () => { + beforeEach(() => { + mockRequestEvent.sharedMap.set(IsQLoader, true); + mockRequestEvent.sharedMap.set(QLoaderId, mockLoaderId); + vi.mocked(runValidators).mockResolvedValue({ + success: true, + data: { test: 'data' }, + }); + vi.mocked(mockLoader.__qrl.call).mockResolvedValue({ result: 'success' }); + vi.mocked(mockQwikSerializer._serialize).mockResolvedValue('serialized-data'); + }); + + it('should execute loader and return serialized data', async () => { + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(runValidators).toHaveBeenCalledWith( + mockRequestEvent, + mockLoader.__validators, + undefined, + true + ); + expect(mockLoader.__qrl.call).toHaveBeenCalledWith(mockRequestEvent, mockRequestEvent); + expect(mockRequestEvent.headers.set).toHaveBeenCalledWith( + 'Content-Type', + 'application/json; charset=utf-8' + ); + expect(mockRequestEvent.send).toHaveBeenCalledWith( + 200, + await _serialize([{ result: 'success' }]) + ); + }); + + it('should set cache headers for loaders', async () => { + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.cacheControl).toHaveBeenCalled(); + }); + + it('should measure execution time in dev mode', async () => { + vi.mocked(getRequestMode).mockReturnValue('dev'); + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(measure).toHaveBeenCalledWith(mockRequestEvent, mockLoaderHash, expect.any(Function)); + expect(verifySerializable).toHaveBeenCalledWith({ result: 'success' }, mockLoader.__qrl); + }); + + it('should not measure execution time in production mode', async () => { + vi.mocked(getRequestMode).mockReturnValue('server'); + + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(measure).not.toHaveBeenCalled(); + expect(verifySerializable).not.toHaveBeenCalled(); + }); + }); + + describe('when validation fails', () => { + beforeEach(() => { + mockRequestEvent.sharedMap.set(IsQLoader, true); + mockRequestEvent.sharedMap.set(QLoaderId, mockLoaderId); + const mockEvent = { + ...mockRequestEvent, + fail: vi.fn().mockReturnValue({ + failed: true, + status: 400, + error: 'Validation failed', + }), + }; + Object.assign(mockRequestEvent, mockEvent); + }); + + it('should call fail method when validation fails', async () => { + vi.mocked(runValidators).mockResolvedValue({ + success: false, + status: 400, + error: 'Validation failed', + }); + + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.fail).toHaveBeenCalledWith(400, 'Validation failed'); + }); + + it('should use default status code 500 when not provided', async () => { + vi.mocked(runValidators).mockResolvedValue({ + success: false, + error: 'Validation failed', + }); + + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.fail).toHaveBeenCalledWith(500, 'Validation failed'); + }); + }); + + describe('when loader execution throws an error', () => { + beforeEach(() => { + mockRequestEvent.sharedMap.set(IsQLoader, true); + mockRequestEvent.sharedMap.set(QLoaderId, mockLoaderId); + vi.mocked(runValidators).mockResolvedValue({ + success: true, + data: undefined, + }); + vi.mocked(mockLoader.__qrl.call).mockImplementation(() => { + throw new Error('Loader execution failed'); + }); + }); + + it('should propagate the error', async () => { + const handler = loaderHandler([mockLoader]); + await expect(handler(mockRequestEvent)).rejects.toThrow('Loader execution failed'); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + vi.mocked(runValidators).mockResolvedValue({ + success: true, + data: undefined, + }); + }); + + it('should handle empty route loaders array', async () => { + mockRequestEvent.sharedMap.set(IsQLoader, true); + mockRequestEvent.sharedMap.set(QLoaderId, mockLoaderId); + + const handler = loaderHandler([]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.json).toHaveBeenCalledWith(404, { error: 'Loader not found' }); + }); + + it('should handle loader with undefined validators', async () => { + mockLoader.__validators = undefined; + mockRequestEvent.sharedMap.set(IsQLoader, true); + mockRequestEvent.sharedMap.set(QLoaderId, mockLoaderId); + vi.mocked(mockLoader.__qrl.call).mockResolvedValue({ result: 'success' }); + vi.mocked(mockQwikSerializer._serialize).mockResolvedValue('serialized-data'); + + const handler = loaderHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockLoader.__qrl.call).toHaveBeenCalledWith(mockRequestEvent, mockRequestEvent); + }); + }); +}); + +describe('loadersMiddleware', () => { + let mockRequestEvent: Mocked; + let mockLoader: Mocked; + let mockQwikSerializer: Mocked; + let mockLoaders: Record; + + beforeEach(() => { + vi.clearAllMocks(); + + mockLoader = createMockLoader('test-loader-id', 'test-hash', { result: 'success' }); + + mockQwikSerializer = { + _serialize: vi.fn(), + _deserialize: vi.fn(), + _verifySerializable: vi.fn(), + } as Mocked; + + mockLoaders = {}; + + mockRequestEvent = { + sharedMap: new Map(), + headersSent: false, + exited: false, + exit: vi.fn(), + url: new URL('http://localhost/test'), + originalUrl: new URL('http://localhost/test'), + pathname: '/test', + params: {}, + query: new URLSearchParams(), + basePathname: '/', + platform: {}, + env: { get: vi.fn() }, + signal: {} as AbortSignal, + cookie: {} as any, + status: vi.fn(), + locale: vi.fn(), + redirect: vi.fn(), + rewrite: vi.fn(), + error: vi.fn(), + text: vi.fn(), + html: vi.fn(), + next: vi.fn(), + getWritableStream: vi.fn(), + isDirty: vi.fn(), + resetRoute: vi.fn(), + resolveValue: vi.fn(), + defer: vi.fn(), + } as unknown as Mocked; + + vi.mocked(getRequestLoaders).mockReturnValue(mockLoaders); + vi.mocked(getRequestMode).mockReturnValue('dev'); + }); + + describe('when headers already sent', () => { + it('should exit early', async () => { + const mockEvent = { + ...mockRequestEvent, + headersSent: true, + }; + + const middleware = loadersMiddleware([mockLoader]); + + await middleware(mockEvent); + + expect(mockEvent.exit).toHaveBeenCalled(); + }); + }); + + describe('when loaders exist', () => { + let mockSerializationStrategyMap: Map; + beforeEach(() => { + mockSerializationStrategyMap = new Map(); + + vi.mocked(getRequestLoaderSerializationStrategyMap).mockReturnValue( + mockSerializationStrategyMap + ); + vi.mocked(runValidators).mockResolvedValue({ + success: true, + data: undefined, + }); + }); + + it('should execute all loaders in parallel', async () => { + const loader1 = createMockLoader('loader1', 'loader1-hash', { result: 'success' }); + const loader2 = createMockLoader('loader2', 'loader2-hash', { result: 'success' }); + + const middleware = loadersMiddleware([loader1, loader2]); + + await middleware(mockRequestEvent); + + expect(loader1.__qrl.call).toHaveBeenCalledWith(mockRequestEvent, mockRequestEvent); + expect(loader2.__qrl.call).toHaveBeenCalledWith(mockRequestEvent, mockRequestEvent); + }); + }); + + describe('when no loaders exist', () => { + it('should not execute any loaders', async () => { + const middleware = loadersMiddleware([]); + + vi.spyOn(loaderHandlerModule, 'executeLoader'); + + await middleware(mockRequestEvent); + + expect(executeLoader).not.toHaveBeenCalled(); + }); + }); +}); + +describe('loaderDataHandler', () => { + let mockRequestEvent: Mocked; + let mockLoader: Mocked; + let mockQwikSerializer: Mocked; + + beforeEach(() => { + vi.clearAllMocks(); + + mockLoader = createMockLoader('test-loader-id', 'test-hash', { result: 'success' }); + + mockQwikSerializer = { + _serialize: vi.fn(), + _deserialize: vi.fn(), + _verifySerializable: vi.fn(), + } as Mocked; + + mockRequestEvent = { + sharedMap: new Map(), + headersSent: false, + exited: false, + url: new URL('http://localhost/test'), + originalUrl: new URL('http://localhost/test'), + pathname: '/test', + params: {}, + query: new URLSearchParams(), + basePathname: '/', + platform: {}, + env: { get: vi.fn() }, + signal: {} as AbortSignal, + cookie: {} as any, + status: vi.fn(), + locale: vi.fn(), + redirect: vi.fn(), + rewrite: vi.fn(), + error: vi.fn(), + text: vi.fn(), + html: vi.fn(), + json: vi.fn(), + next: vi.fn(), + getWritableStream: vi.fn(), + isDirty: vi.fn(), + resetRoute: vi.fn(), + resolveValue: vi.fn(), + defer: vi.fn(), + cacheControl: vi.fn(), + } as unknown as Mocked; + }); + + describe('when not a QLoaderData request', () => { + it('should return early without processing', async () => { + const handler = loaderDataHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.sharedMap.has(IsQLoaderData)).toBe(false); + expect(mockRequestEvent.json).not.toHaveBeenCalled(); + }); + }); + + describe('when headers already sent', () => { + it('should return early', async () => { + const mockEvent = { + ...mockRequestEvent, + headersSent: true, + }; + mockEvent.sharedMap.set(IsQLoaderData, true); + + const handler = loaderDataHandler([mockLoader]); + + await handler(mockEvent); + + expect(mockEvent.json).not.toHaveBeenCalled(); + }); + }); + + describe('when request already exited', () => { + it('should return early', async () => { + const mockEvent = { + ...mockRequestEvent, + exited: true, + }; + mockEvent.sharedMap.set(IsQLoaderData, true); + + const handler = loaderDataHandler([mockLoader]); + + await handler(mockEvent); + + expect(mockEvent.json).not.toHaveBeenCalled(); + }); + }); + + describe('when processing loader data', () => { + beforeEach(() => { + mockRequestEvent.sharedMap.set(IsQLoaderData, true); + }); + + it('should set cache headers for loader data', async () => { + const handler = loaderDataHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.cacheControl).toHaveBeenCalledWith({ + maxAge: 365 * 24 * 60 * 60, // 1 year + }); + }); + + it('should return loader data with id and route', async () => { + const handler = loaderDataHandler([mockLoader]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.json).toHaveBeenCalledWith(200, { + loaderData: [ + { + id: 'test-loader-id', + route: '/test-route', + }, + ], + }); + }); + + it('should handle dynamic routes with params', async () => { + const mockEvent = { + ...mockRequestEvent, + params: { id: '123' }, + }; + mockEvent.sharedMap.set(IsQLoaderData, true); + vi.mocked(getPathnameForDynamicRoute).mockReturnValue('/dynamic-route'); + + const handler = loaderDataHandler([mockLoader]); + + await handler(mockEvent); + + expect(getPathnameForDynamicRoute).toHaveBeenCalledWith(mockEvent.url.pathname, ['id'], { + id: '123', + }); + expect(mockEvent.json).toHaveBeenCalledWith(200, { + loaderData: [ + { + id: 'test-loader-id', + route: '/dynamic-route', + }, + ], + }); + }); + + it('should handle multiple loaders', async () => { + const loader1 = createMockLoader('loader1', 'loader1-hash', { result: 'success' }); + const loader2 = createMockLoader('loader2', 'loader2-hash', { result: 'success' }); + + const handler = loaderDataHandler([loader1, loader2]); + + await handler(mockRequestEvent); + + expect(mockRequestEvent.json).toHaveBeenCalledWith(200, { + loaderData: [ + { + id: 'loader1', + route: '/test-route', + }, + { + id: 'loader2', + route: '/test-route', + }, + ], + }); + }); + }); +}); + +describe('executeLoader', () => { + let mockRequestEvent: Mocked; + let mockLoader: Mocked; + let mockQwikSerializer: Mocked; + let mockLoaders: Record; + let mockSerializationStrategyMap: Map; + + beforeEach(() => { + vi.clearAllMocks(); + + mockLoader = createMockLoader('test-loader-id', 'test-hash', { result: 'success' }); + + mockQwikSerializer = { + _serialize: vi.fn(), + _deserialize: vi.fn(), + _verifySerializable: vi.fn(), + } as Mocked; + + mockLoaders = {}; + mockSerializationStrategyMap = new Map(); + + mockRequestEvent = { + sharedMap: new Map(), + headersSent: false, + exited: false, + url: new URL('http://localhost/test'), + originalUrl: new URL('http://localhost/test'), + pathname: '/test', + params: {}, + query: new URLSearchParams(), + basePathname: '/', + platform: {}, + env: { get: vi.fn() }, + signal: {} as AbortSignal, + cookie: {} as any, + status: vi.fn(), + locale: vi.fn(), + redirect: vi.fn(), + rewrite: vi.fn(), + error: vi.fn(), + text: vi.fn(), + html: vi.fn(), + json: vi.fn(), + fail: vi.fn(), + next: vi.fn(), + getWritableStream: vi.fn(), + isDirty: vi.fn(), + resetRoute: vi.fn(), + resolveValue: vi.fn(), + defer: vi.fn(), + } as unknown as Mocked; + + vi.mocked(getRequestLoaderSerializationStrategyMap).mockReturnValue( + mockSerializationStrategyMap + ); + }); + + describe('when validation succeeds', () => { + beforeEach(() => { + vi.mocked(runValidators).mockResolvedValue({ + success: true, + data: undefined, + }); + }); + + it('should execute loader and store result', async () => { + vi.mocked(mockLoader.__qrl.call).mockResolvedValue({ result: 'success' }); + + await executeLoader(mockLoader, mockLoaders, mockRequestEvent, true); + + expect(runValidators).toHaveBeenCalledWith( + mockRequestEvent, + mockLoader.__validators, + undefined, + true + ); + expect(mockLoader.__qrl.call).toHaveBeenCalledWith(mockRequestEvent, mockRequestEvent); + expect(verifySerializable).toHaveBeenCalledWith({ result: 'success' }, mockLoader.__qrl); + expect(mockLoaders['test-loader-id']).toEqual({ result: 'success' }); + expect(mockSerializationStrategyMap.get('test-loader-id')).toBe('always'); + }); + + it('should not measure in production mode', async () => { + vi.mocked(mockLoader.__qrl.call).mockResolvedValue({ result: 'success' }); + + await executeLoader(mockLoader, mockLoaders, mockRequestEvent, false); + + expect(measure).not.toHaveBeenCalled(); + expect(verifySerializable).not.toHaveBeenCalled(); + }); + + it('should handle function return from loader', async () => { + const mockFunction = vi.fn().mockReturnValue('function-result'); + vi.mocked(mockLoader.__qrl.call).mockResolvedValue(mockFunction); + + await executeLoader(mockLoader, mockLoaders, mockRequestEvent, true); + + expect(mockFunction).toHaveBeenCalled(); + expect(mockLoaders['test-loader-id']).toEqual('function-result'); + }); + }); + + describe('when validation fails', () => { + beforeEach(() => { + vi.mocked(runValidators).mockResolvedValue({ + success: false, + status: 400, + error: 'Validation failed', + }); + }); + + it('should call fail method', async () => { + const mockEvent = { + ...mockRequestEvent, + fail: vi.fn().mockReturnValue('failed-result'), + }; + + await executeLoader(mockLoader, mockLoaders, mockEvent, true); + + expect(mockEvent.fail).toHaveBeenCalledWith(400, 'Validation failed'); + expect(mockLoaders['test-loader-id']).toEqual('failed-result'); + }); + + it('should use default status code 500 when not provided', async () => { + vi.mocked(runValidators).mockResolvedValue({ + success: false, + error: 'Validation failed', + }); + + const mockEvent = { + ...mockRequestEvent, + fail: vi.fn().mockReturnValue('failed-result'), + }; + + await executeLoader(mockLoader, mockLoaders, mockEvent, true); + + expect(mockEvent.fail).toHaveBeenCalledWith(500, 'Validation failed'); + }); + }); + + describe('when loader execution throws an error', () => { + beforeEach(() => { + vi.mocked(runValidators).mockResolvedValue({ + success: true, + data: undefined, + }); + vi.mocked(mockLoader.__qrl.call).mockImplementation(() => { + throw new Error('Loader execution failed'); + }); + }); + + it('should propagate the error', async () => { + await expect(executeLoader(mockLoader, mockLoaders, mockRequestEvent, true)).rejects.toThrow( + 'Loader execution failed' + ); + }); + }); +}); diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/validator-utils.ts b/packages/qwik-router/src/middleware/request-handler/handlers/validator-utils.ts new file mode 100644 index 00000000000..787ab5361d5 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/validator-utils.ts @@ -0,0 +1,31 @@ +import type { DataValidator, RequestEvent, ValidatorReturn } from '@qwik.dev/router'; +import { measure } from '../resolve-request-handlers'; + +export async function runValidators( + requestEv: RequestEvent, + validators: DataValidator[] | undefined, + data: unknown, + isDev: boolean +) { + let lastResult: ValidatorReturn = { + success: true, + data, + }; + if (validators) { + for (const validator of validators) { + if (isDev) { + lastResult = await measure(requestEv, `validator$`, () => + validator.validate(requestEv, data) + ); + } else { + lastResult = await validator.validate(requestEv, data); + } + if (!lastResult.success) { + return lastResult; + } else { + data = lastResult.data; + } + } + } + return lastResult; +} diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/validator-utils.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/validator-utils.unit.ts new file mode 100644 index 00000000000..dd1d45f28ea --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/validator-utils.unit.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import { runValidators } from './validator-utils'; +import type { RequestEventInternal } from '../request-event'; +import { measure } from '../resolve-request-handlers'; + +vi.mock('../resolve-request-handlers', () => ({ + measure: vi.fn(async (_, __, fn) => await fn()), +})); + +describe('runValidators', () => { + let mockRequestEvent: Mocked; + let mockValidators: any[]; + + beforeEach(() => { + vi.clearAllMocks(); + + mockRequestEvent = { + sharedMap: new Map(), + headersSent: false, + exited: false, + url: new URL('http://localhost/test'), + originalUrl: new URL('http://localhost/test'), + pathname: '/test', + params: {}, + query: new URLSearchParams(), + basePathname: '/', + platform: {}, + env: { get: vi.fn() }, + signal: {} as AbortSignal, + cookie: {} as any, + status: vi.fn(), + locale: vi.fn(), + redirect: vi.fn(), + rewrite: vi.fn(), + error: vi.fn(), + text: vi.fn(), + html: vi.fn(), + json: vi.fn(), + fail: vi.fn(), + next: vi.fn(), + getWritableStream: vi.fn(), + isDirty: vi.fn(), + resetRoute: vi.fn(), + resolveValue: vi.fn(), + defer: vi.fn(), + } as unknown as Mocked; + + mockValidators = [ + { + validate: vi.fn().mockResolvedValue({ success: true, data: 'validated-data' }), + }, + ]; + }); + + describe('when no validators exist', () => { + it('should return success with original data', async () => { + const result = await runValidators(mockRequestEvent, undefined, 'test-data', true); + + expect(result).toEqual({ + success: true, + data: 'test-data', + }); + }); + }); + + describe('when validators exist', () => { + it('should run all validators in sequence', async () => { + const validator1 = { + validate: vi.fn().mockResolvedValue({ success: true, data: 'data1' }), + }; + const validator2 = { + validate: vi.fn().mockResolvedValue({ success: true, data: 'data2' }), + }; + + const result = await runValidators( + mockRequestEvent, + [validator1, validator2], + 'initial-data', + true + ); + + expect(validator1.validate).toHaveBeenCalledWith(mockRequestEvent, 'initial-data'); + expect(validator2.validate).toHaveBeenCalledWith(mockRequestEvent, 'data1'); + expect(result).toEqual({ + success: true, + data: 'data2', + }); + }); + + it('should stop on first validation failure', async () => { + const validator1 = { + validate: vi.fn().mockResolvedValue({ success: false, error: 'First validation failed' }), + }; + const validator2 = { + validate: vi.fn().mockResolvedValue({ success: true, data: 'data2' }), + }; + + const result = await runValidators( + mockRequestEvent, + [validator1, validator2], + 'initial-data', + true + ); + + expect(validator1.validate).toHaveBeenCalledWith(mockRequestEvent, 'initial-data'); + expect(validator2.validate).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: false, + error: 'First validation failed', + }); + }); + + it('should measure validators in dev mode', async () => { + await runValidators(mockRequestEvent, mockValidators, 'test-data', true); + + expect(measure).toHaveBeenCalledWith(mockRequestEvent, 'validator$', expect.any(Function)); + }); + + it('should not measure validators in production mode', async () => { + await runValidators(mockRequestEvent, mockValidators, 'test-data', false); + + expect(measure).not.toHaveBeenCalled(); + }); + }); +}); From 3e1ae70e4d660ad109583a744711f5c9093206f1 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 20 Sep 2025 13:07:51 +0200 Subject: [PATCH 15/20] test: add path handler unit tests --- .../handlers/loader-handler.unit.ts | 31 +- .../handlers/path-handler.unit.ts | 284 ++++++++++++++++++ 2 files changed, 291 insertions(+), 24 deletions(-) create mode 100644 packages/qwik-router/src/middleware/request-handler/handlers/path-handler.unit.ts diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts index 59223456bcf..7fdacbf665c 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts @@ -54,9 +54,13 @@ vi.mock('./validator-utils', () => ({ })); function createMockLoader(id: string, hash: string, result: unknown): Mocked { - const mockLoaderFunction = (): Mocked> => ({ - value: Promise.resolve(result), - }); + const mockLoaderFunction = (): Mocked> => + ({ + value: Promise.resolve(result), + force: vi.fn(), + invalidate: vi.fn(), + refetch: vi.fn(), + }) as unknown as Mocked>; return { __brand: 'server_loader' as const, @@ -379,7 +383,6 @@ describe('loaderHandler', () => { describe('loadersMiddleware', () => { let mockRequestEvent: Mocked; let mockLoader: Mocked; - let mockQwikSerializer: Mocked; let mockLoaders: Record; beforeEach(() => { @@ -387,12 +390,6 @@ describe('loadersMiddleware', () => { mockLoader = createMockLoader('test-loader-id', 'test-hash', { result: 'success' }); - mockQwikSerializer = { - _serialize: vi.fn(), - _deserialize: vi.fn(), - _verifySerializable: vi.fn(), - } as Mocked; - mockLoaders = {}; mockRequestEvent = { @@ -487,19 +484,12 @@ describe('loadersMiddleware', () => { describe('loaderDataHandler', () => { let mockRequestEvent: Mocked; let mockLoader: Mocked; - let mockQwikSerializer: Mocked; beforeEach(() => { vi.clearAllMocks(); mockLoader = createMockLoader('test-loader-id', 'test-hash', { result: 'success' }); - mockQwikSerializer = { - _serialize: vi.fn(), - _deserialize: vi.fn(), - _verifySerializable: vi.fn(), - } as Mocked; - mockRequestEvent = { sharedMap: new Map(), headersSent: false, @@ -657,7 +647,6 @@ describe('loaderDataHandler', () => { describe('executeLoader', () => { let mockRequestEvent: Mocked; let mockLoader: Mocked; - let mockQwikSerializer: Mocked; let mockLoaders: Record; let mockSerializationStrategyMap: Map; @@ -666,12 +655,6 @@ describe('executeLoader', () => { mockLoader = createMockLoader('test-loader-id', 'test-hash', { result: 'success' }); - mockQwikSerializer = { - _serialize: vi.fn(), - _deserialize: vi.fn(), - _verifySerializable: vi.fn(), - } as Mocked; - mockLoaders = {}; mockSerializationStrategyMap = new Map(); diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.unit.ts new file mode 100644 index 00000000000..2a8aa7220e2 --- /dev/null +++ b/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.unit.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; +import { fixTrailingSlash } from './path-handler'; +import type { RequestEvent } from '../types'; +import { isQDataRequestBasedOnSharedMap } from '../resolve-request-handlers'; +import { HttpStatus } from '../http-status-codes'; + +// Mock dependencies +vi.mock('../request-event', () => ({ + getRequestTrailingSlash: vi.fn(), +})); + +vi.mock('../resolve-request-handlers', () => ({ + isQDataRequestBasedOnSharedMap: vi.fn(), +})); + +describe('fixTrailingSlash', () => { + let mockRequestEvent: Mocked; + + beforeEach(() => { + // Reset all mocks + vi.clearAllMocks(); + + // Create mock request event + mockRequestEvent = { + basePathname: '/', + originalUrl: new URL('http://localhost/test'), + sharedMap: new Map(), + redirect: vi.fn(), + } as unknown as Mocked; + + // Set up default mocks + globalThis.__NO_TRAILING_SLASH__ = true; + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(false); + }); + + describe('when it is a QData request', () => { + it('should not check for trailing slash redirects', () => { + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(true); + mockRequestEvent.originalUrl.pathname = '/test'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).not.toThrow(); + expect(mockRequestEvent.redirect).not.toHaveBeenCalled(); + }); + }); + + describe('when pathname ends with .html', () => { + it('should not check for trailing slash redirects', () => { + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(false); + mockRequestEvent.originalUrl.pathname = '/test.html'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).not.toThrow(); + expect(mockRequestEvent.redirect).not.toHaveBeenCalled(); + }); + }); + + describe('when pathname equals basePathname', () => { + it('should not check for trailing slash redirects', () => { + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(false); + const mockEvent = { + ...mockRequestEvent, + basePathname: '/', + originalUrl: new URL('http://localhost/'), + }; + mockEvent.originalUrl.pathname = '/'; + mockEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockEvent)).not.toThrow(); + expect(mockEvent.redirect).not.toHaveBeenCalled(); + }); + }); + + describe('when trailing slash is required (trailingSlash = true)', () => { + beforeEach(() => { + globalThis.__NO_TRAILING_SLASH__ = false; + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(false); + }); + + it('should redirect when pathname does not end with slash', () => { + mockRequestEvent.originalUrl.pathname = '/test'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test/?param=value' + ); + }); + + it('should not redirect when pathname already ends with slash', () => { + mockRequestEvent.originalUrl.pathname = '/test/'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).not.toThrow(); + expect(mockRequestEvent.redirect).not.toHaveBeenCalled(); + }); + + it('should handle pathname with multiple segments', () => { + mockRequestEvent.originalUrl.pathname = '/test/path'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test/path/?param=value' + ); + }); + + it('should handle pathname with no search params', () => { + mockRequestEvent.originalUrl.pathname = '/test'; + mockRequestEvent.originalUrl.search = ''; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith(HttpStatus.MovedPermanently, '/test/'); + }); + + it('should handle pathname with complex search params', () => { + mockRequestEvent.originalUrl.pathname = '/test/path'; + mockRequestEvent.originalUrl.search = '?param1=value1¶m2=value2¶m3=value3'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test/path/?param1=value1¶m2=value2¶m3=value3' + ); + }); + }); + + describe('when trailing slash is not required (trailingSlash = false)', () => { + beforeEach(() => { + globalThis.__NO_TRAILING_SLASH__ = true; + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(false); + }); + + it('should redirect when pathname ends with slash', () => { + mockRequestEvent.originalUrl.pathname = '/test/'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test?param=value' + ); + }); + + it('should not redirect when pathname does not end with slash', () => { + mockRequestEvent.originalUrl.pathname = '/test'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).not.toThrow(); + expect(mockRequestEvent.redirect).not.toHaveBeenCalled(); + }); + + it('should handle pathname with multiple segments ending with slash', () => { + mockRequestEvent.originalUrl.pathname = '/test/path/'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test/path?param=value' + ); + }); + + it('should handle pathname with no search params', () => { + mockRequestEvent.originalUrl.pathname = '/test/'; + mockRequestEvent.originalUrl.search = ''; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith(HttpStatus.MovedPermanently, '/test'); + }); + + it('should handle pathname with complex search params', () => { + mockRequestEvent.originalUrl.pathname = '/test/path/'; + mockRequestEvent.originalUrl.search = '?param1=value1¶m2=value2¶m3=value3'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test/path?param1=value1¶m2=value2¶m3=value3' + ); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(false); + }); + + it('should handle pathname with only slash', () => { + globalThis.__NO_TRAILING_SLASH__ = true; + mockRequestEvent.originalUrl.pathname = '/'; + mockRequestEvent.originalUrl.search = ''; + + expect(() => fixTrailingSlash(mockRequestEvent)).not.toThrow(); + expect(mockRequestEvent.redirect).not.toHaveBeenCalled(); + }); + + it('should handle pathname with multiple trailing slashes', () => { + globalThis.__NO_TRAILING_SLASH__ = true; + mockRequestEvent.originalUrl.pathname = '/test///'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test//?param=value' + ); + }); + + it('should handle pathname with special characters', () => { + globalThis.__NO_TRAILING_SLASH__ = false; + mockRequestEvent.originalUrl.pathname = '/test-path_with_underscores'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test-path_with_underscores/?param=value' + ); + }); + + it('should handle pathname with numbers', () => { + globalThis.__NO_TRAILING_SLASH__ = false; + mockRequestEvent.originalUrl.pathname = '/test123'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test123/?param=value' + ); + }); + + it('should handle pathname with encoded characters', () => { + globalThis.__NO_TRAILING_SLASH__ = false; + mockRequestEvent.originalUrl.pathname = '/test%20path'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).toThrow(); + expect(mockRequestEvent.redirect).toHaveBeenCalledWith( + HttpStatus.MovedPermanently, + '/test%20path/?param=value' + ); + }); + }); + + describe('integration with other handlers', () => { + it('should work correctly with QData requests', () => { + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(true); + globalThis.__NO_TRAILING_SLASH__ = false; + mockRequestEvent.originalUrl.pathname = '/test'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).not.toThrow(); + expect(mockRequestEvent.redirect).not.toHaveBeenCalled(); + }); + + it('should work correctly with HTML files', () => { + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(false); + globalThis.__NO_TRAILING_SLASH__ = false; + mockRequestEvent.originalUrl.pathname = '/test.html'; + mockRequestEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockRequestEvent)).not.toThrow(); + expect(mockRequestEvent.redirect).not.toHaveBeenCalled(); + }); + + it('should work correctly with base pathname', () => { + vi.mocked(isQDataRequestBasedOnSharedMap).mockReturnValue(false); + globalThis.__NO_TRAILING_SLASH__ = false; + const mockEvent = { + ...mockRequestEvent, + basePathname: '/app', + originalUrl: new URL('http://localhost/app'), + }; + mockEvent.originalUrl.search = '?param=value'; + + expect(() => fixTrailingSlash(mockEvent)).not.toThrow(); + expect(mockEvent.redirect).not.toHaveBeenCalled(); + }); + }); +}); From 4b71f510b2793d29aed5000048ebb91e2d8f53fc Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 20 Sep 2025 13:22:18 +0200 Subject: [PATCH 16/20] fix: route loader type --- packages/docs/src/routes/api/qwik-router/api.json | 2 +- packages/docs/src/routes/api/qwik-router/index.mdx | 4 ++-- .../request-handler/middleware.request-handler.api.md | 4 ++-- .../qwik-router/src/middleware/request-handler/types.ts | 4 ++-- packages/qwik-router/src/runtime/src/contexts.ts | 3 ++- packages/qwik-router/src/runtime/src/head.ts | 2 +- .../qwik-router/src/runtime/src/qwik-router-component.tsx | 4 ++-- .../qwik-router/src/runtime/src/qwik-router.runtime.api.md | 4 ++-- packages/qwik-router/src/runtime/src/types.ts | 7 +++---- packages/qwik-router/src/runtime/src/utils.ts | 6 +++--- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/docs/src/routes/api/qwik-router/api.json b/packages/docs/src/routes/api/qwik-router/api.json index b5c13b963df..781d48e70a0 100644 --- a/packages/docs/src/routes/api/qwik-router/api.json +++ b/packages/docs/src/routes/api/qwik-router/api.json @@ -446,7 +446,7 @@ } ], "kind": "TypeAlias", - "content": "```typescript\nexport type LoaderSignal = TYPE extends () => ValueOrPromise ? ReadonlySignal> : ReadonlySignal;\n```", + "content": "```typescript\nexport type LoaderSignal = TYPE extends () => ValueOrPromise ? AsyncComputedReadonlySignal> : AsyncComputedReadonlySignal;\n```", "editUrl": "https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts", "mdFile": "router.loadersignal.md" }, diff --git a/packages/docs/src/routes/api/qwik-router/index.mdx b/packages/docs/src/routes/api/qwik-router/index.mdx index a0a875aa974..d2d3df8792f 100644 --- a/packages/docs/src/routes/api/qwik-router/index.mdx +++ b/packages/docs/src/routes/api/qwik-router/index.mdx @@ -1166,8 +1166,8 @@ export type Loader = { export type LoaderSignal = TYPE extends () => ValueOrPromise< infer VALIDATOR > - ? ReadonlySignal> - : ReadonlySignal; + ? AsyncComputedReadonlySignal> + : AsyncComputedReadonlySignal; ``` [Edit this section](https://github.com/QwikDev/qwik/tree/main/packages/qwik-router/src/runtime/src/types.ts) diff --git a/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md b/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md index b0b2bb84eed..366d90cd6b6 100644 --- a/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md +++ b/packages/qwik-router/src/middleware/request-handler/middleware.request-handler.api.md @@ -179,7 +179,7 @@ export interface ResolveSyncValue { // (undocumented) (loader: Loader_2): Awaited extends () => any ? never : Awaited; // (undocumented) - (action: Action): Awaited | undefined; + (action: Action): O | undefined; } // @public (undocumented) @@ -187,7 +187,7 @@ export interface ResolveValue { // (undocumented) (loader: Loader_2): Awaited extends () => any ? never : Promise; // (undocumented) - (action: Action): Promise; + (action: Action): Promise; } // @public (undocumented) diff --git a/packages/qwik-router/src/middleware/request-handler/types.ts b/packages/qwik-router/src/middleware/request-handler/types.ts index 5531efcd88b..a94fa1faf1b 100644 --- a/packages/qwik-router/src/middleware/request-handler/types.ts +++ b/packages/qwik-router/src/middleware/request-handler/types.ts @@ -503,13 +503,13 @@ export interface RequestEventLoader /** @public */ export interface ResolveValue { (loader: Loader): Awaited extends () => any ? never : Promise; - (action: Action): Promise; + (action: Action): Promise; } /** @public */ export interface ResolveSyncValue { (loader: Loader): Awaited extends () => any ? never : Awaited; - (action: Action): Awaited | undefined; + (action: Action): O | undefined; } /** @public */ diff --git a/packages/qwik-router/src/runtime/src/contexts.ts b/packages/qwik-router/src/runtime/src/contexts.ts index 5f16b512ea8..7385b2d35af 100644 --- a/packages/qwik-router/src/runtime/src/contexts.ts +++ b/packages/qwik-router/src/runtime/src/contexts.ts @@ -2,6 +2,7 @@ import { createContextId, type Signal } from '@qwik.dev/core'; import type { ContentState, ContentStateInternal, + LoaderSignal, ResolvedDocumentHead, RouteAction, RouteLocation, @@ -11,7 +12,7 @@ import type { } from './types'; export const RouteStateContext = - /*#__PURE__*/ createContextId>>('qc-s'); + /*#__PURE__*/ createContextId>>('qc-s'); export const ContentContext = /*#__PURE__*/ createContextId('qc-c'); export const ContentInternalContext = diff --git a/packages/qwik-router/src/runtime/src/head.ts b/packages/qwik-router/src/runtime/src/head.ts index 6e2136cd43f..251a50fe7fa 100644 --- a/packages/qwik-router/src/runtime/src/head.ts +++ b/packages/qwik-router/src/runtime/src/head.ts @@ -36,7 +36,7 @@ export const resolveHead = ( throw new Error('Loaders returning a promise can not be resolved for the head function.'); } return data; - }) as any as ResolveSyncValue; + }) as ResolveSyncValue; const headProps: DocumentHeadProps = { head, withLocale: (fn) => withLocale(locale, fn), diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index b2f4ae194f1..9f44d8e0bf3 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -26,7 +26,6 @@ import { forceStoreEffects, SerializerSymbol, type _ElementVNode, - type AsyncComputedReadonlySignal, type SerializationStrategy, } from '@qwik.dev/core/internal'; import { clientNavigate } from './client-navigate'; @@ -61,6 +60,7 @@ import type { Editable, EndpointResponse, LoadedRoute, + LoaderSignal, MutableRouteLocation, PageModule, PreventNavigateCallback, @@ -172,7 +172,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { // This object contains the signals for the loaders // It is used for the loaders context RouteStateContext - const loaderState: Record> = {}; + const loaderState: Record> = {}; for (const [key, value] of Object.entries(env.response.loaders)) { loadersObject[key] = value; diff --git a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md index c047be3b001..4a4eaa3bd30 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md +++ b/packages/qwik-router/src/runtime/src/qwik-router.runtime.api.md @@ -4,6 +4,7 @@ ```ts +import type { AsyncComputedReadonlySignal } from '@qwik.dev/core/internal'; import { Component } from '@qwik.dev/core'; import { Cookie } from '@qwik.dev/router/middleware/request-handler'; import { CookieOptions } from '@qwik.dev/router/middleware/request-handler'; @@ -15,7 +16,6 @@ import { QRL } from '@qwik.dev/core'; import { QRLEventHandlerMulti } from '@qwik.dev/core'; import { QwikIntrinsicElements } from '@qwik.dev/core'; import { QwikJSX } from '@qwik.dev/core'; -import type { ReadonlySignal } from '@qwik.dev/core'; import { Render } from '@qwik.dev/core/server'; import { RenderOptions } from '@qwik.dev/core/server'; import { RequestEvent } from '@qwik.dev/router/middleware/request-handler'; @@ -263,7 +263,7 @@ type Loader_2 = { export { Loader_2 as Loader } // @public (undocumented) -export type LoaderSignal = TYPE extends () => ValueOrPromise ? ReadonlySignal> : ReadonlySignal; +export type LoaderSignal = TYPE extends () => ValueOrPromise ? AsyncComputedReadonlySignal> : AsyncComputedReadonlySignal; // Warning: (ae-forgotten-export) The symbol "MenuModuleLoader" needs to be exported by the entry point index.d.ts // diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index 7464124f5b8..aabc13ab9f0 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -2,11 +2,10 @@ import type { NoSerialize, QRL, QwikIntrinsicElements, - ReadonlySignal, Signal, ValueOrPromise, } from '@qwik.dev/core'; -import type { SerializationStrategy } from '@qwik.dev/core/internal'; +import type { AsyncComputedReadonlySignal, SerializationStrategy } from '@qwik.dev/core/internal'; import type { EnvGetter, RequestEvent, @@ -810,8 +809,8 @@ export type FailReturn = T & Failed; /** @public */ export type LoaderSignal = TYPE extends () => ValueOrPromise - ? ReadonlySignal> - : ReadonlySignal; + ? AsyncComputedReadonlySignal> + : AsyncComputedReadonlySignal; /** @public */ export type Loader = { diff --git a/packages/qwik-router/src/runtime/src/utils.ts b/packages/qwik-router/src/runtime/src/utils.ts index a7a411b6bbe..e9bf419e638 100644 --- a/packages/qwik-router/src/runtime/src/utils.ts +++ b/packages/qwik-router/src/runtime/src/utils.ts @@ -1,4 +1,4 @@ -import type { SimpleURL } from './types'; +import type { LoaderSignal, SimpleURL } from './types'; import { createAsyncComputed$, isBrowser } from '@qwik.dev/core'; import { @@ -74,7 +74,7 @@ export const createLoaderSignal = ( serializationStrategy: SerializationStrategy, manifestHash: string, container?: ClientContainer -) => { +): LoaderSignal => { return createAsyncComputed$( async () => { if (isBrowser && loadersObject[loaderId] === _UNINITIALIZED) { @@ -87,5 +87,5 @@ export const createLoaderSignal = ( container: container as ClientContainer, serializationStrategy, } - ); + ) as LoaderSignal; }; From cfba19d4d9cf19340a82f49852e5057564aff190 Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 20 Sep 2025 13:26:24 +0200 Subject: [PATCH 17/20] fix: correctly handle redirect --- .../handlers/loader-handler.ts | 1 - .../request-handler/handlers/path-handler.ts | 2 +- .../handlers/path-handler.unit.ts | 3 + .../handlers/redirect-handler.ts | 10 +- .../request-handler/request-event.ts | 12 +- .../resolve-request-handlers.ts | 20 ++- .../request-handler/user-response.ts | 2 +- .../src/runtime/src/use-endpoint.ts | 132 +++++++++++++++--- 8 files changed, 144 insertions(+), 38 deletions(-) diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts index 8b80b719769..701e9d0cd0d 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts @@ -49,7 +49,6 @@ export function loaderDataHandler(routeLoaders: LoaderInternal[]): RequestHandle maxAge: 365 * 24 * 60 * 60, // 1 year }); - // return loader data: id and route const loaderData = routeLoaders.map((l) => { const loaderId = l.__id; let loaderRoute = qwikRouterConfig.loaderIdToRoute[loaderId]; diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.ts index c0fcb5633d6..d99af86d2ef 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.ts @@ -5,7 +5,7 @@ import { HttpStatus } from '../http-status-codes'; export function fixTrailingSlash(ev: RequestEvent) { const { basePathname, originalUrl, sharedMap } = ev; const { pathname, search } = originalUrl; - const isQData = isQDataRequestBasedOnSharedMap(sharedMap); + const isQData = isQDataRequestBasedOnSharedMap(sharedMap, ev.request.headers); if (!isQData && pathname !== basePathname && !pathname.endsWith('.html')) { // only check for slash redirect on pages if (!globalThis.__NO_TRAILING_SLASH__) { diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.unit.ts index 2a8aa7220e2..83fcb9ca123 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/path-handler.unit.ts @@ -26,6 +26,9 @@ describe('fixTrailingSlash', () => { originalUrl: new URL('http://localhost/test'), sharedMap: new Map(), redirect: vi.fn(), + request: { + headers: {}, + }, } as unknown as Mocked; // Set up default mocks diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts index ca55a3803c1..8212708e530 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts @@ -1,9 +1,12 @@ import { RedirectMessage, RequestEvent } from '@qwik.dev/router/middleware/request-handler'; -import { OriginalQDataName } from '../user-response'; import { isQDataRequestBasedOnSharedMap } from '../resolve-request-handlers'; +import { QDATA_JSON, QManifestHash } from '../user-response'; export async function handleRedirect(requestEv: RequestEvent) { - const isPageDataReq = isQDataRequestBasedOnSharedMap(requestEv.sharedMap); + const isPageDataReq = isQDataRequestBasedOnSharedMap( + requestEv.sharedMap, + requestEv.request.headers + ); if (!isPageDataReq) { return; } @@ -40,7 +43,8 @@ function makeQDataPath(href: string, sharedMap: Map) { if (href.startsWith('/')) { const url = new URL(href, 'http://localhost'); const pathname = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname; - const append = sharedMap.get(OriginalQDataName) as string; + const manifestHash = sharedMap.get(QManifestHash) as string; + const append = manifestHash ? `/q-loader-data.${manifestHash}.json` : QDATA_JSON; if (!append) { return undefined; diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index feed90a2739..c33d08236f6 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -38,12 +38,12 @@ import { IsQLoader, IsQLoaderData, LOADER_REGEX, - OriginalQDataName, Q_LOADER_DATA_REGEX, QActionId, QDATA_JSON, QDATA_JSON_LEN, QLoaderId, + QManifestHash, } from './user-response'; const RequestEvLoaders = Symbol('RequestEvLoaders'); @@ -85,7 +85,9 @@ export function createRequestEvent( const requestRecognized = recognizeRequest(url.pathname); if (requestRecognized) { sharedMap.set(requestRecognized.type, true); - sharedMap.set(OriginalQDataName, requestRecognized.originalQDataName); + if (requestRecognized.manifestHash) { + sharedMap.set(QManifestHash, requestRecognized.manifestHash); + } if (requestRecognized.type === IsQLoader && requestRecognized.data) { sharedMap.set(QLoaderId, requestRecognized.data.loaderId); } @@ -487,7 +489,7 @@ export function recognizeRequest(pathname: string) { return { type: IsQData, trimLength: QDATA_JSON_LEN, - originalQDataName: QDATA_JSON, + manifestHash: null, data: null, }; } @@ -498,7 +500,7 @@ export function recognizeRequest(pathname: string) { return { type: IsQLoaderData, trimLength: loaderDataMatch[0].length, - originalQDataName: loaderDataMatch[1], + manifestHash: loaderDataMatch[2], data: null, }; } @@ -508,7 +510,7 @@ export function recognizeRequest(pathname: string) { return { type: IsQLoader, trimLength: loaderMatch[0].length, - originalQDataName: loaderMatch[1], + manifestHash: loaderMatch[3], data: { loaderId: loaderMatch[2] }, }; } diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts index ff86edaf4a8..2a926e2cc98 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts @@ -28,7 +28,7 @@ import { } from './request-event'; import { getQwikRouterServerData } from './response-page'; import type { RequestEvent, RequestEventBase, RequestHandler } from './types'; -import { IsQData, IsQLoader, IsQLoaderData, QActionId } from './user-response'; +import { IsQAction, IsQData, IsQLoader, IsQLoaderData, QActionId } from './user-response'; export const resolveRequestHandlers = ( serverPlugins: RouteModule[] | undefined, @@ -75,7 +75,6 @@ export const resolveRequestHandlers = ( requestHandlers.unshift(csrfCheckMiddleware); } } - requestHandlers.push(handleRedirect); if (isPageRoute) { if (method === 'POST' || method === 'GET') { // server$ @@ -87,6 +86,7 @@ export const resolveRequestHandlers = ( ev.sharedMap.set(RequestRouteName, routeName); }); requestHandlers.push(fixTrailingSlash); + requestHandlers.push(handleRedirect); requestHandlers.push(loaderDataHandler(routeLoaders)); requestHandlers.push(loaderHandler(routeLoaders)); requestHandlers.push(actionHandler(routeActions)); @@ -170,8 +170,15 @@ export const checkBrand = (obj: any, brand: string) => { return obj && typeof obj === 'function' && obj.__brand === brand; }; -export function isQDataRequestBasedOnSharedMap(sharedMap: Map) { - return sharedMap.has(IsQData) || sharedMap.has(IsQLoaderData) || sharedMap.has(IsQLoader); +export function isQDataRequestBasedOnSharedMap(sharedMap: Map, headers: Headers) { + return ( + sharedMap.has(IsQData) || + sharedMap.has(IsQLoaderData) || + sharedMap.has(IsQLoader) || + (sharedMap.has(IsQAction) && + // we need to ignore actions without JS enabled and render the page + headers.get('accept')?.includes('application/json')) + ); } export function verifySerializable(data: any, qrl: QRL) { @@ -219,7 +226,10 @@ export function renderQwikMiddleware(render: Render) { if (requestEv.headersSent) { return; } - const isPageDataReq = isQDataRequestBasedOnSharedMap(requestEv.sharedMap); + const isPageDataReq = isQDataRequestBasedOnSharedMap( + requestEv.sharedMap, + requestEv.request.headers + ); if (isPageDataReq) { return; } diff --git a/packages/qwik-router/src/middleware/request-handler/user-response.ts b/packages/qwik-router/src/middleware/request-handler/user-response.ts index 7eeed771e35..e32bcb16617 100644 --- a/packages/qwik-router/src/middleware/request-handler/user-response.ts +++ b/packages/qwik-router/src/middleware/request-handler/user-response.ts @@ -188,7 +188,7 @@ export const IsQAction = '@isQAction'; export const QLoaderId = '@loaderId'; export const QActionId = '@actionId'; export const IsQLoaderData = '@isQLoaderData'; -export const OriginalQDataName = '@originalQDataName'; +export const QManifestHash = '@manifestHash'; export const QDATA_JSON = '/q-data.json'; export const QDATA_JSON_LEN = QDATA_JSON.length; export const Q_LOADER_DATA_REGEX = /\/(q-loader-data\.(.+)\.json)$/; diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index 20af97db7a6..c9c9c28d4bb 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -1,17 +1,29 @@ -import { _deserialize } from '@qwik.dev/core/internal'; +import { _deserialize, isDev } from '@qwik.dev/core/internal'; import type { QData } from '../../middleware/request-handler/handlers/qdata-handler'; import { preloadRouteBundles } from './client-navigate'; import { QACTION_KEY } from './constants'; import type { ClientPageData, RouteActionValue } from './types'; +class ShouldRedirect { + constructor( + public location: string, + public data: T + ) {} +} + interface LoaderDataResponse { id: string; route: string; } +interface RedirectContext { + promise: Promise | undefined; +} + export const loadClientLoaderData = async (url: URL, loaderId: string, manifestHash: string) => { const pagePathname = url.pathname.endsWith('/') ? url.pathname : url.pathname + '/'; - return fetchLoader(loaderId, pagePathname, manifestHash); + const abortController = new AbortController(); + return fetchLoader(loaderId, pagePathname, manifestHash, abortController, { promise: undefined }); }; export const loadClientData = async ( @@ -20,6 +32,7 @@ export const loadClientData = async ( opts?: { action?: RouteActionValue; loaderIds?: string[]; + redirectData?: ShouldRedirect; clearCache?: boolean; preloadRouteBundles?: boolean; isPrefetch?: boolean; @@ -34,33 +47,69 @@ export const loadClientData = async ( let resolveFn: () => void | undefined; let actionData: unknown; if (opts?.action) { - const actionResult = await fetchActionData(opts.action, pagePathname, url.searchParams); - actionData = actionResult.data; - resolveFn = () => { - opts.action!.resolve!({ status: actionResult.status, result: actionData }); - }; + try { + const actionResult = await fetchActionData(opts.action, pagePathname, url.searchParams); + actionData = actionResult.data; + resolveFn = () => { + opts.action!.resolve!({ status: actionResult.status, result: actionData }); + }; + } catch (e) { + if (e instanceof ShouldRedirect) { + const newUrl = new URL(e.location, url); + const newOpts = { + ...opts, + action: undefined, + loaderIds: undefined, + redirectData: e, + }; + return loadClientData(newUrl, manifestHash, newOpts); + } else { + throw e; + } + } } else { let loaderData: LoaderDataResponse[] = []; - if (!opts?.loaderIds) { - // we need to load all the loaders - // first we need to get the loader urls - loaderData = (await fetchLoaderData(pagePathname, manifestHash)).loaderData; - } else { + if (opts && opts.loaderIds) { loaderData = opts.loaderIds.map((loaderId) => { return { id: loaderId, route: pagePathname, }; }); + } else if (opts?.redirectData?.data) { + loaderData = opts.redirectData.data; + } else { + // we need to load all the loaders + // first we need to get the loader urls + loaderData = (await fetchLoaderData(pagePathname, manifestHash)).loaderData; } if (loaderData.length > 0) { // load specific loaders - const loaderPromises = loaderData.map((loader) => - fetchLoader(loader.id, loader.route, manifestHash) - ); - const loaderResults = await Promise.all(loaderPromises); - for (let i = 0; i < loaderData.length; i++) { - loaders[loaderData[i].id] = loaderResults[i]; + const abortController = new AbortController(); + const redirectContext: RedirectContext = { promise: undefined }; + try { + const loaderPromises = loaderData.map((loader) => + fetchLoader(loader.id, loader.route, manifestHash, abortController, redirectContext) + ); + const loaderResults = await Promise.all(loaderPromises); + for (let i = 0; i < loaderData.length; i++) { + loaders[loaderData[i].id] = loaderResults[i]; + } + } catch (e) { + if (e instanceof ShouldRedirect) { + const newUrl = new URL(e.location, url); + const newOpts = { + ...opts, + action: undefined, + loaderIds: undefined, + redirectData: e, + }; + return loadClientData(newUrl, manifestHash, newOpts); + } else if (e instanceof Error && e.name === 'AbortError') { + // Expected, do nothing + } else { + throw e; + } } } } @@ -118,24 +167,57 @@ export async function fetchLoaderData( ): Promise<{ loaderData: LoaderDataResponse[] }> { const url = `${routePath}q-loader-data.${manifestHash}.json`; const response = await fetch(url); + + if (!response.ok) { + if (isDev) { + throw new Error(`Failed to load loader data for ${routePath}: ${response.status}`); + } + return { loaderData: [] }; + } return response.json(); } export async function fetchLoader( loaderId: string, routePath: string, - manifestHash: string + manifestHash: string, + abortController: AbortController, + redirectContext: RedirectContext ): Promise { const url = `${routePath}q-loader-${loaderId}.${manifestHash}.json`; - const response = await fetch(url); + const response = await fetch(url, { + signal: abortController.signal, + }); + + if (response.redirected) { + if (!redirectContext.promise) { + redirectContext.promise = response.json(); + + abortController.abort(); + + const data = await redirectContext.promise; + // remove the q-loader-XY.json from the url and keep the rest of the url + // the url is like this: https://localhost:3000/q-loader-XY.json + // we need to remove the q-loader-XY.json and keep the rest of the url + // the new url is like this: https://localhost:3000/ + const newUrl = new URL(response.url); + newUrl.pathname = newUrl.pathname.replace(`q-loader-data.${manifestHash}.json`, ''); + throw new ShouldRedirect( + newUrl.pathname, + (data as { loaderData: LoaderDataResponse[] }).loaderData + ); + } + } if (!response.ok) { - throw new Error(`Failed to load ${loaderId}: ${response.status}`); + if (isDev) { + throw new Error(`Failed to load ${loaderId}: ${response.status}`); + } + return undefined; } const text = await response.text(); const [data] = _deserialize(text, document.documentElement) as [Record]; - return data; } @@ -159,6 +241,12 @@ export async function fetchActionData( action.data = undefined; const response = await fetch(url, fetchOptions); + if (response.redirected) { + const newUrl = new URL(response.url); + newUrl.pathname = newUrl.pathname.replace('q-data.json', ''); + throw new ShouldRedirect(newUrl.pathname, undefined); + } + const text = await response.text(); const [data] = _deserialize(text, document.documentElement) as [Record]; From f19e7bb2b6d7bd155256e824f0790ed293b7cf9a Mon Sep 17 00:00:00 2001 From: Varixo Date: Sat, 20 Sep 2025 13:43:20 +0200 Subject: [PATCH 18/20] fix: handle head action changes --- packages/qwik-router/src/runtime/src/head.ts | 25 ++++++---- .../src/runtime/src/qwik-router-component.tsx | 14 +++++- packages/qwik/src/core/internal.ts | 48 +++++++++---------- packages/qwik/src/core/qwik.core.api.md | 3 ++ .../qwik/src/core/shared/utils/promises.ts | 6 ++- 5 files changed, 61 insertions(+), 35 deletions(-) diff --git a/packages/qwik-router/src/runtime/src/head.ts b/packages/qwik-router/src/runtime/src/head.ts index 251a50fe7fa..737cc768269 100644 --- a/packages/qwik-router/src/runtime/src/head.ts +++ b/packages/qwik-router/src/runtime/src/head.ts @@ -1,21 +1,23 @@ -import { withLocale } from '@qwik.dev/core'; +import { untrack, withLocale } from '@qwik.dev/core'; +import { _retryOnPromise } from '@qwik.dev/core/internal'; import type { ContentModule, RouteLocation, - EndpointResponse, ResolvedDocumentHead, DocumentHeadProps, DocumentHeadValue, - ClientPageData, LoaderInternal, Editable, ResolveSyncValue, ActionInternal, + LoaderSignal, + ClientActionData, } from './types'; import { isPromise } from './utils'; -export const resolveHead = ( - endpoint: EndpointResponse | ClientPageData, +export const resolveHead = async ( + loaderState: Record>, + action: ClientActionData | undefined, routeLocation: RouteLocation, contentModules: ContentModule[], locale: string, @@ -25,13 +27,19 @@ export const resolveHead = ( const getData = ((loaderOrAction: LoaderInternal | ActionInternal) => { const id = loaderOrAction.__id; if (loaderOrAction.__brand === 'server_loader') { - if (!(id in endpoint.loaders)) { + if (!(id in loaderState)) { throw new Error( 'You can not get the returned data of a loader that has not been executed for this request.' ); } + } else if ( + action && + action.id === loaderOrAction.__id && + loaderOrAction.__brand === 'server_action' + ) { + return action.data; } - const data = endpoint.loaders[id]; + const data = untrack(() => loaderState[id]?.value); if (isPromise(data)) { throw new Error('Loaders returning a promise can not be resolved for the head function.'); } @@ -48,9 +56,10 @@ export const resolveHead = ( const contentModuleHead = contentModules[i] && contentModules[i].head; if (contentModuleHead) { if (typeof contentModuleHead === 'function') { + const contentModuleHeadResult = await _retryOnPromise(() => contentModuleHead(headProps)); resolveDocumentHead( head, - withLocale(locale, () => contentModuleHead(headProps)) + withLocale(locale, () => contentModuleHeadResult) ); } else if (typeof contentModuleHead === 'object') { resolveDocumentHead(head, contentModuleHead); diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index 9f44d8e0bf3..84435233d0d 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -493,10 +493,14 @@ export const useQwikRouter = (props?: QwikRouterProps) => { (routeInternal as any).untrackedValue = { type: navType, dest: trackUrl }; // Needs to be done after routeLocation is updated - const resolvedHead = resolveHead( - clientPageData!, + const resolvedHead = await resolveHead( + loaderState, + clientPageData?.action, + routeLocation, + contentModules, + locale, serverHead ); @@ -554,6 +558,12 @@ export const useQwikRouter = (props?: QwikRouterProps) => { signal.invalidate(); } } + // remove not existing loaders + for (const key of Object.keys(loaderState)) { + if (!(key in loadersObject)) { + delete loaderState[key]; + } + } } const win = window as ClientSPAWindow; diff --git a/packages/qwik/src/core/internal.ts b/packages/qwik/src/core/internal.ts index 084fa38c95e..d474d32a090 100644 --- a/packages/qwik/src/core/internal.ts +++ b/packages/qwik/src/core/internal.ts @@ -11,48 +11,47 @@ export type { QDocument as _QDocument, VNodeFlags as _VNodeFlags, } from './client/types'; +export { + mapApp_findIndx as _mapApp_findIndx, + mapArray_get as _mapArray_get, + mapArray_set as _mapArray_set, +} from './client/util-mapArray'; +export { + vnode_ensureElementInflated as _vnode_ensureElementInflated, + vnode_getAttrKeys as _vnode_getAttrKeys, + vnode_getFirstChild as _vnode_getFirstChild, + vnode_getProps as _vnode_getProps, + vnode_isMaterialized as _vnode_isMaterialized, + vnode_isTextVNode as _vnode_isTextVNode, + vnode_isVirtualVNode as _vnode_isVirtualVNode, + vnode_toString as _vnode_toString, +} from './client/vnode'; export type { ElementVNode as _ElementVNode, TextVNode as _TextVNode, VirtualVNode as _VirtualVNode, VNode as _VNode, } from './client/vnode-impl'; -export { - vnode_toString as _vnode_toString, - vnode_getProps as _vnode_getProps, - vnode_isTextVNode as _vnode_isTextVNode, - vnode_isVirtualVNode as _vnode_isVirtualVNode, - vnode_getFirstChild as _vnode_getFirstChild, - vnode_isMaterialized as _vnode_isMaterialized, - vnode_ensureElementInflated as _vnode_ensureElementInflated, - vnode_getAttrKeys as _vnode_getAttrKeys, -} from './client/vnode'; -export { - mapApp_findIndx as _mapApp_findIndx, - mapArray_get as _mapArray_get, - mapArray_set as _mapArray_set, -} from './client/util-mapArray'; +export { _hasStoreEffects, isStore as _isStore } from './reactive-primitives/impl/store'; export { _wrapProp, _wrapSignal } from './reactive-primitives/internal-api'; export { SubscriptionData as _SubscriptionData } from './reactive-primitives/subscription-data'; export { _EFFECT_BACK_REF } from './reactive-primitives/types'; -export { _hasStoreEffects } from './reactive-primitives/impl/store'; export { isStringifiable as _isStringifiable, type Stringifiable as _Stringifiable, } from './shared-types'; export { + _getConstProps, + _getVarProps, isJSXNode as _isJSXNode, _jsxC, _jsxQ, _jsxS, _jsxSorted, _jsxSplit, - _getVarProps, - _getConstProps, } from './shared/jsx/jsx-runtime'; export { _fnSignal } from './shared/qrl/inlined-fn'; -export { _SharedContainer } from './shared/shared-container'; export { _deserialize, dumpState as _dumpState, @@ -60,18 +59,19 @@ export { _serializationWeakRef, _serialize, } from './shared/serdes/index'; -export { _CONST_PROPS, _IMMUTABLE, _VAR_PROPS, _UNINITIALIZED } from './shared/utils/constants'; +export { _SharedContainer } from './shared/shared-container'; +export { _CONST_PROPS, _IMMUTABLE, _UNINITIALIZED, _VAR_PROPS } from './shared/utils/constants'; export { EMPTY_ARRAY as _EMPTY_ARRAY } from './shared/utils/flyweight'; +export { retryOnPromise as _retryOnPromise } from './shared/utils/promises'; export { _restProps } from './shared/utils/prop'; export { verifySerializable as _verifySerializable } from './shared/utils/serialize-utils'; export { _walkJSX } from './ssr/ssr-render-jsx'; +export { _resolveContextWithoutSequentialScope } from './use/use-context'; export { + _getContextContainer, _getContextElement, _getContextEvent, - _getContextContainer, _jsxBranch, _waitUntilRendered, } from './use/use-core'; -export { scheduleTask as _task, isTask as _isTask } from './use/use-task'; -export { isStore as _isStore } from './reactive-primitives/impl/store'; -export { _resolveContextWithoutSequentialScope } from './use/use-context'; +export { isTask as _isTask, scheduleTask as _task } from './use/use-task'; diff --git a/packages/qwik/src/core/qwik.core.api.md b/packages/qwik/src/core/qwik.core.api.md index b9544b63a84..ca137324aa2 100644 --- a/packages/qwik/src/core/qwik.core.api.md +++ b/packages/qwik/src/core/qwik.core.api.md @@ -894,6 +894,9 @@ export type ResourceReturn = ResourcePending | ResourceResolved | Resou // @internal (undocumented) export const _restProps: (props: PropsProxy, omit?: string[], target?: Props) => Props; +// @internal +export function _retryOnPromise(fn: () => ValueOrPromise, retryCount?: number): ValueOrPromise; + // @internal export const _run: (...args: unknown[]) => ValueOrPromise; diff --git a/packages/qwik/src/core/shared/utils/promises.ts b/packages/qwik/src/core/shared/utils/promises.ts index e1d632f1e0d..09213d011e3 100644 --- a/packages/qwik/src/core/shared/utils/promises.ts +++ b/packages/qwik/src/core/shared/utils/promises.ts @@ -96,7 +96,11 @@ export const delay = (timeout: number) => { }); }; -/** Retries a function that throws a promise. */ +/** + * Retries a function that throws a promise. + * + * @internal + */ export function retryOnPromise( fn: () => ValueOrPromise, retryCount: number = 0 From 7ec1b9fbaabb7b61d63240713e99e668437d4525 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Sun, 21 Sep 2025 08:45:20 +0200 Subject: [PATCH 19/20] fix: don't use stale-while-revalidate for loaders the browser doesn't have a way to notify you when it updated. --- .../src/middleware/request-handler/handlers/loader-handler.ts | 1 - .../src/middleware/request-handler/handlers/qdata-handler.ts | 3 +-- packages/qwik-router/src/middleware/request-handler/types.ts | 4 ++++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts index 701e9d0cd0d..2046db06b53 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts @@ -108,7 +108,6 @@ export function loaderHandler(routeLoaders: LoaderInternal[]): RequestHandler { // Set cache headers - aggressive for loaders requestEv.cacheControl({ maxAge: 300, // 5 minutes - staleWhileRevalidate: 3600, // 1 hour }); const data = await _serialize([loaders[loaderId]]); diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/qdata-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/qdata-handler.ts index 399f52d9c3b..34465e48984 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/qdata-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/qdata-handler.ts @@ -32,8 +32,7 @@ export async function qDataHandler(requestEv: RequestEvent) { // Set cache headers requestEv.cacheControl({ - maxAge: 300, // 5 minutes - staleWhileRevalidate: 3600, // 1 hour + maxAge: 60, // 1 minute }); // write just the page json data to the response body diff --git a/packages/qwik-router/src/middleware/request-handler/types.ts b/packages/qwik-router/src/middleware/request-handler/types.ts index a94fa1faf1b..dc762c8f831 100644 --- a/packages/qwik-router/src/middleware/request-handler/types.ts +++ b/packages/qwik-router/src/middleware/request-handler/types.ts @@ -399,6 +399,10 @@ export interface CacheControlOptions { /** * The stale-while-revalidate response directive indicates that the cache could reuse a stale * response while it revalidates it to a cache. + * + * Note: there is no mechanism that updates the application when the revalidation happens. Use + * this only when you know you will be fetching the revalidated resource again in the very near + * future. */ staleWhileRevalidate?: number; From 6fe82da38d0c1239635da201fd07cda040972073 Mon Sep 17 00:00:00 2001 From: Wout Mertens Date: Sun, 21 Sep 2025 08:49:02 +0200 Subject: [PATCH 20/20] WIP actions support --- PR-TODO-DO-NOT-MERGE.md | 11 +++ .../handlers/action-handler.ts | 29 ++++-- .../handlers/action-handler.unit.ts | 40 ++++---- .../handlers/loader-handler.ts | 15 ++- .../handlers/loader-handler.unit.ts | 53 ++++++----- .../handlers/redirect-handler.ts | 2 + .../request-handler/request-event.ts | 11 ++- .../resolve-request-handlers.ts | 4 +- packages/qwik-router/src/runtime/src/head.ts | 22 ++--- .../src/runtime/src/qwik-router-component.tsx | 2 +- .../src/runtime/src/use-endpoint.ts | 95 +++++++++++-------- starters/dev-server.ts | 1 + 12 files changed, 172 insertions(+), 113 deletions(-) create mode 100644 PR-TODO-DO-NOT-MERGE.md diff --git a/PR-TODO-DO-NOT-MERGE.md b/PR-TODO-DO-NOT-MERGE.md new file mode 100644 index 00000000000..90ce17efe1f --- /dev/null +++ b/PR-TODO-DO-NOT-MERGE.md @@ -0,0 +1,11 @@ +# TODO + +- fix failing e2e tests +- dev mode support for q-loader-data and other things that read the manifest for qwik-router-config +- remove every q-data.json load +- implement eTag option for loaders +- implement expires option for loaders + - changes the cache-control max-age + - use on client side to determine if the data is stale before fetching + - during SSR, store page create time and loader expires deltas +- cache q-loader-data responses in the browser diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts index 334aca7b72f..c990c2eb457 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.ts @@ -1,16 +1,25 @@ -import { _serialize, type ValueOrPromise } from '@qwik.dev/core/internal'; +import { _serialize, _UNINITIALIZED, type ValueOrPromise } from '@qwik.dev/core/internal'; import type { ActionInternal, JSONObject, + LoaderInternal, RequestEvent, RequestHandler, } from '../../../runtime/src/types'; -import { getRequestActions, getRequestMode, type RequestEventInternal } from '../request-event'; +import { + getRequestActions, + getRequestLoaders, + getRequestMode, + type RequestEventInternal, +} from '../request-event'; import { measure, verifySerializable } from '../resolve-request-handlers'; import { IsQAction, QActionId } from '../user-response'; import { runValidators } from './validator-utils'; -export function actionHandler(routeActions: ActionInternal[]): RequestHandler { +export function actionHandler( + routeActions: ActionInternal[], + routeLoaders: LoaderInternal[] +): RequestHandler { return async (requestEvent: RequestEvent) => { const requestEv = requestEvent as RequestEventInternal; @@ -25,7 +34,6 @@ export function actionHandler(routeActions: ActionInternal[]): RequestHandler { const actionId = requestEv.sharedMap.get(QActionId); // Execute just this action - const actions = getRequestActions(requestEv); const isDev = getRequestMode(requestEv) === 'dev'; const method = requestEv.method; @@ -35,13 +43,20 @@ export function actionHandler(routeActions: ActionInternal[]): RequestHandler { ); } if (method === 'POST') { + const actions = getRequestActions(requestEv); let action: ActionInternal | undefined; for (const routeAction of routeActions) { if (routeAction.__id === actionId) { action = routeAction; - break; + } else { + // actions can use other actions + actions[routeAction.__id] = _UNINITIALIZED; } - // TODO: do we need to initialize the rest with _UNINITIALIZED? + } + // actions can use loaders + const loaders = getRequestLoaders(requestEv); + for (const routeLoader of routeLoaders) { + loaders[routeLoader.__id] = _UNINITIALIZED; } if (!action) { const serverActionsMap = globalThis._qwikActionsMap as @@ -68,7 +83,7 @@ export function actionHandler(routeActions: ActionInternal[]): RequestHandler { }; } -async function executeAction( +export async function executeAction( action: ActionInternal, actions: Record | undefined>, requestEv: RequestEventInternal, diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts index 8b7969d91b7..4fb996e70b5 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/action-handler.unit.ts @@ -20,6 +20,8 @@ vi.mock('../resolve-request-handlers', () => ({ })); vi.mock('../request-event', () => ({ + getRequestLoaders: vi.fn(), + getRequestLoaderSerializationStrategyMap: vi.fn(), getRequestActions: vi.fn(), getRequestMode: vi.fn(), RequestEvQwikSerializer: Symbol('RequestEvQwikSerializer'), @@ -122,7 +124,7 @@ describe('actionHandler', () => { describe('when not a QAction request', () => { it('should return early without processing', async () => { - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -140,7 +142,7 @@ describe('actionHandler', () => { }; mockEvent.sharedMap.set(IsQAction, true); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockEvent); @@ -157,7 +159,7 @@ describe('actionHandler', () => { }; mockEvent.sharedMap.set(IsQAction, true); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockEvent); @@ -175,7 +177,7 @@ describe('actionHandler', () => { mockEvent.sharedMap.set(IsQAction, true); mockEvent.sharedMap.set(QActionId, mockActionId); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockEvent); @@ -194,7 +196,7 @@ describe('actionHandler', () => { mockEvent.sharedMap.set(IsQAction, true); mockEvent.sharedMap.set(QActionId, mockActionId); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockEvent); @@ -208,7 +210,7 @@ describe('actionHandler', () => { mockRequestEvent.sharedMap.set(IsQAction, true); mockRequestEvent.sharedMap.set(QActionId, 'non-existent-action'); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -235,7 +237,7 @@ describe('actionHandler', () => { data, }); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -256,7 +258,7 @@ describe('actionHandler', () => { }); it('should execute action and return serialized data', async () => { - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -284,7 +286,7 @@ describe('actionHandler', () => { it('should measure execution time in dev mode', async () => { vi.mocked(getRequestMode).mockReturnValue('dev'); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -295,7 +297,7 @@ describe('actionHandler', () => { it('should not measure execution time in production mode', async () => { vi.mocked(getRequestMode).mockReturnValue('server'); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -306,7 +308,7 @@ describe('actionHandler', () => { it('should not return serialized data when client does not accept JSON', async () => { mockRequestEvent.request.headers.set('accept', 'text/html'); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -338,7 +340,7 @@ describe('actionHandler', () => { }); it('should call fail method and store the result', async () => { - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -356,7 +358,7 @@ describe('actionHandler', () => { error: 'Validation failed', }); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -376,7 +378,7 @@ describe('actionHandler', () => { }); it('should throw an error', async () => { - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await expect(handler(mockRequestEvent)).rejects.toThrow( `Expected request data for the action id ${mockActionId} to be an object` @@ -389,7 +391,7 @@ describe('actionHandler', () => { parseBody: vi.fn().mockResolvedValue(null), }; - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await expect(handler(mockEvent)).rejects.toThrow( `Expected request data for the action id ${mockActionId} to be an object` @@ -414,7 +416,7 @@ describe('actionHandler', () => { }); it('should propagate the error', async () => { - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await expect(handler(mockRequestEvent)).rejects.toThrow('Action execution failed'); }); @@ -425,7 +427,7 @@ describe('actionHandler', () => { mockRequestEvent.sharedMap.set(IsQAction, true); mockRequestEvent.sharedMap.set(QActionId, mockActionId); - const handler = actionHandler([]); + const handler = actionHandler([], []); await handler(mockRequestEvent); @@ -437,7 +439,7 @@ describe('actionHandler', () => { mockRequestEvent.sharedMap.set(IsQAction, true); mockRequestEvent.sharedMap.set(QActionId, 'non-existent-action'); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockRequestEvent); @@ -458,7 +460,7 @@ describe('actionHandler', () => { }); (mockAction.__qrl.call as Mock).mockResolvedValue({ result: 'success' }); - const handler = actionHandler([mockAction]); + const handler = actionHandler([mockAction], []); await handler(mockEvent); diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts index 2046db06b53..ca4cb90a5d0 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.ts @@ -1,8 +1,9 @@ import qwikRouterConfig from '@qwik-router-config'; import { _serialize, _UNINITIALIZED } from '@qwik.dev/core/internal'; -import type { LoaderInternal, RequestHandler } from '../../../runtime/src/types'; +import type { ActionInternal, LoaderInternal, RequestHandler } from '../../../runtime/src/types'; import { getPathnameForDynamicRoute } from '../../../utils/pathname'; import { + getRequestActions, getRequestLoaders, getRequestLoaderSerializationStrategyMap, getRequestMode, @@ -71,7 +72,10 @@ export function loaderDataHandler(routeLoaders: LoaderInternal[]): RequestHandle }; } -export function loaderHandler(routeLoaders: LoaderInternal[]): RequestHandler { +export function loaderHandler( + routeLoaders: LoaderInternal[], + routeActions: ActionInternal[] +): RequestHandler { return async (requestEvent: RequestEvent) => { const requestEv = requestEvent as RequestEventInternal; @@ -94,10 +98,17 @@ export function loaderHandler(routeLoaders: LoaderInternal[]): RequestHandler { if (routeLoader.__id === loaderId) { loader = routeLoader; } else if (!loaders[routeLoader.__id]) { + // loaders can use other loaders loaders[routeLoader.__id] = _UNINITIALIZED; } } + // loaders can use actions + const actions = getRequestActions(requestEv); + for (const routeAction of routeActions) { + actions[routeAction.__id] = _UNINITIALIZED; + } + if (!loader) { requestEv.json(404, { error: 'Loader not found' }); return; diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts index 7fdacbf665c..64dff80062f 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/loader-handler.unit.ts @@ -1,25 +1,25 @@ -import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; -import { - loaderHandler, - loadersMiddleware, - loaderDataHandler, - executeLoader, -} from './loader-handler'; +import { _serialize } from '@qwik.dev/core/internal'; +import type { QRL } from 'packages/qwik/public'; +import { beforeEach, describe, expect, it, vi, type Mocked } from 'vitest'; import type { LoaderInternal, LoaderSignal } from '../../../runtime/src/types'; +import { getPathnameForDynamicRoute } from '../../../utils/pathname'; import type { RequestEventInternal } from '../request-event'; -import type { QwikSerializer } from '../types'; -import { IsQLoader, IsQLoaderData, QLoaderId } from '../user-response'; import { getRequestLoaders, getRequestLoaderSerializationStrategyMap, getRequestMode, } from '../request-event'; -import type { QRL } from 'packages/qwik/public'; -import { runValidators } from './validator-utils'; import { measure, verifySerializable } from '../resolve-request-handlers'; -import { getPathnameForDynamicRoute } from '../../../utils/pathname'; +import type { QwikSerializer } from '../types'; +import { IsQLoader, IsQLoaderData, QLoaderId } from '../user-response'; import * as loaderHandlerModule from './loader-handler'; -import { _serialize } from '@qwik.dev/core/internal'; +import { + executeLoader, + loaderDataHandler, + loaderHandler, + loadersMiddleware, +} from './loader-handler'; +import { runValidators } from './validator-utils'; // Mock dependencies vi.mock('../resolve-request-handlers', () => ({ @@ -30,6 +30,7 @@ vi.mock('../resolve-request-handlers', () => ({ vi.mock('../request-event', () => ({ getRequestLoaders: vi.fn(), getRequestLoaderSerializationStrategyMap: vi.fn(), + getRequestActions: vi.fn(), getRequestMode: vi.fn(), RequestEvQwikSerializer: Symbol('RequestEvQwikSerializer'), })); @@ -162,7 +163,7 @@ describe('loaderHandler', () => { describe('when not a QLoader request', () => { it('should return early without processing', async () => { - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); @@ -180,7 +181,7 @@ describe('loaderHandler', () => { }; mockEvent.sharedMap.set(IsQLoader, true); - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockEvent); @@ -197,7 +198,7 @@ describe('loaderHandler', () => { }; mockEvent.sharedMap.set(IsQLoader, true); - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockEvent); @@ -211,7 +212,7 @@ describe('loaderHandler', () => { mockRequestEvent.sharedMap.set(IsQLoader, true); mockRequestEvent.sharedMap.set(QLoaderId, 'non-existent-loader'); - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); @@ -232,7 +233,7 @@ describe('loaderHandler', () => { }); it('should execute loader and return serialized data', async () => { - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); @@ -254,7 +255,7 @@ describe('loaderHandler', () => { }); it('should set cache headers for loaders', async () => { - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); @@ -263,7 +264,7 @@ describe('loaderHandler', () => { it('should measure execution time in dev mode', async () => { vi.mocked(getRequestMode).mockReturnValue('dev'); - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); @@ -274,7 +275,7 @@ describe('loaderHandler', () => { it('should not measure execution time in production mode', async () => { vi.mocked(getRequestMode).mockReturnValue('server'); - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); @@ -305,7 +306,7 @@ describe('loaderHandler', () => { error: 'Validation failed', }); - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); @@ -318,7 +319,7 @@ describe('loaderHandler', () => { error: 'Validation failed', }); - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); @@ -340,7 +341,7 @@ describe('loaderHandler', () => { }); it('should propagate the error', async () => { - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await expect(handler(mockRequestEvent)).rejects.toThrow('Loader execution failed'); }); }); @@ -357,7 +358,7 @@ describe('loaderHandler', () => { mockRequestEvent.sharedMap.set(IsQLoader, true); mockRequestEvent.sharedMap.set(QLoaderId, mockLoaderId); - const handler = loaderHandler([]); + const handler = loaderHandler([], []); await handler(mockRequestEvent); @@ -371,7 +372,7 @@ describe('loaderHandler', () => { vi.mocked(mockLoader.__qrl.call).mockResolvedValue({ result: 'success' }); vi.mocked(mockQwikSerializer._serialize).mockResolvedValue('serialized-data'); - const handler = loaderHandler([mockLoader]); + const handler = loaderHandler([mockLoader], []); await handler(mockRequestEvent); diff --git a/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts b/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts index 8212708e530..574ef11120f 100644 --- a/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts +++ b/packages/qwik-router/src/middleware/request-handler/handlers/redirect-handler.ts @@ -30,6 +30,8 @@ export async function handleRedirect(requestEv: RequestEvent) { const adaptedLocation = makeQDataPath(location, requestEv.sharedMap); if (adaptedLocation) { requestEv.headers.set('Location', adaptedLocation); + // make sure we get an answer for actions + requestEv.headers.set('Content-Type', 'application/json'); requestEv.getWritableStream().close(); return; } else { diff --git a/packages/qwik-router/src/middleware/request-handler/request-event.ts b/packages/qwik-router/src/middleware/request-handler/request-event.ts index c33d08236f6..074c3f87994 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -45,6 +45,7 @@ import { QLoaderId, QManifestHash, } from './user-response'; +import { executeAction } from './handlers/action-handler'; const RequestEvLoaders = Symbol('RequestEvLoaders'); const RequestEvActions = Symbol('RequestEvActions'); @@ -247,9 +248,15 @@ export function createRequestEvent( const isDev = getRequestMode(requestEv) === 'dev'; await executeLoader(loaderOrAction, loaders, requestEv, isDev); } + return loaders[id]; + } else if (loaderOrAction.__brand === 'server_action' && id in actions) { + if (actions[id] === _UNINITIALIZED) { + const isDev = getRequestMode(requestEv) === 'dev'; + await executeAction(loaderOrAction, actions, requestEv, isDev); + } + return actions[id]; } - - return loaders[id]; + return undefined; }) as ResolveValue, status: (statusCode?: number) => { diff --git a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts index 2a926e2cc98..be7ff927c7b 100644 --- a/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts +++ b/packages/qwik-router/src/middleware/request-handler/resolve-request-handlers.ts @@ -88,8 +88,8 @@ export const resolveRequestHandlers = ( requestHandlers.push(fixTrailingSlash); requestHandlers.push(handleRedirect); requestHandlers.push(loaderDataHandler(routeLoaders)); - requestHandlers.push(loaderHandler(routeLoaders)); - requestHandlers.push(actionHandler(routeActions)); + requestHandlers.push(loaderHandler(routeLoaders, routeActions)); + requestHandlers.push(actionHandler(routeActions, routeLoaders)); requestHandlers.push(qDataHandler); requestHandlers.push(loadersMiddleware(routeLoaders)); requestHandlers.push(renderHandler); diff --git a/packages/qwik-router/src/runtime/src/head.ts b/packages/qwik-router/src/runtime/src/head.ts index 737cc768269..e10a1399a95 100644 --- a/packages/qwik-router/src/runtime/src/head.ts +++ b/packages/qwik-router/src/runtime/src/head.ts @@ -1,5 +1,4 @@ -import { untrack, withLocale } from '@qwik.dev/core'; -import { _retryOnPromise } from '@qwik.dev/core/internal'; +import { withLocale } from '@qwik.dev/core'; import type { ContentModule, RouteLocation, @@ -10,13 +9,12 @@ import type { Editable, ResolveSyncValue, ActionInternal, - LoaderSignal, ClientActionData, } from './types'; import { isPromise } from './utils'; export const resolveHead = async ( - loaderState: Record>, + loadersData: Record | undefined, action: ClientActionData | undefined, routeLocation: RouteLocation, contentModules: ContentModule[], @@ -27,11 +25,16 @@ export const resolveHead = async ( const getData = ((loaderOrAction: LoaderInternal | ActionInternal) => { const id = loaderOrAction.__id; if (loaderOrAction.__brand === 'server_loader') { - if (!(id in loaderState)) { + if (!loadersData || !(id in loadersData)) { throw new Error( 'You can not get the returned data of a loader that has not been executed for this request.' ); } + const data = loadersData[id]; + if (isPromise(data)) { + throw new Error('Loaders returning a promise can not be resolved for the head function.'); + } + return data; } else if ( action && action.id === loaderOrAction.__id && @@ -39,11 +42,7 @@ export const resolveHead = async ( ) { return action.data; } - const data = untrack(() => loaderState[id]?.value); - if (isPromise(data)) { - throw new Error('Loaders returning a promise can not be resolved for the head function.'); - } - return data; + return undefined; }) as ResolveSyncValue; const headProps: DocumentHeadProps = { head, @@ -56,10 +55,9 @@ export const resolveHead = async ( const contentModuleHead = contentModules[i] && contentModules[i].head; if (contentModuleHead) { if (typeof contentModuleHead === 'function') { - const contentModuleHeadResult = await _retryOnPromise(() => contentModuleHead(headProps)); resolveDocumentHead( head, - withLocale(locale, () => contentModuleHeadResult) + withLocale(locale, () => contentModuleHead(headProps)) ); } else if (typeof contentModuleHead === 'object') { resolveDocumentHead(head, contentModuleHead); diff --git a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx index 84435233d0d..44714e6eb27 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -494,7 +494,7 @@ export const useQwikRouter = (props?: QwikRouterProps) => { // Needs to be done after routeLocation is updated const resolvedHead = await resolveHead( - loaderState, + clientPageData?.loaders, clientPageData?.action, routeLocation, diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index c9c9c28d4bb..2ae969da8c1 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -23,7 +23,9 @@ interface RedirectContext { export const loadClientLoaderData = async (url: URL, loaderId: string, manifestHash: string) => { const pagePathname = url.pathname.endsWith('/') ? url.pathname : url.pathname + '/'; const abortController = new AbortController(); - return fetchLoader(loaderId, pagePathname, manifestHash, abortController, { promise: undefined }); + return fetchLoader(loaderId, pagePathname, manifestHash, true, abortController, { + promise: undefined, + }); }; export const loadClientData = async ( @@ -67,49 +69,55 @@ export const loadClientData = async ( throw e; } } + } + let loaderData: LoaderDataResponse[] = []; + if (opts && opts.loaderIds) { + loaderData = opts.loaderIds.map((loaderId) => { + return { + id: loaderId, + route: pagePathname, + }; + }); + } else if (opts?.redirectData?.data) { + loaderData = opts.redirectData.data; } else { - let loaderData: LoaderDataResponse[] = []; - if (opts && opts.loaderIds) { - loaderData = opts.loaderIds.map((loaderId) => { - return { - id: loaderId, - route: pagePathname, + // we need to load all the loaders + // first we need to get the loader urls + loaderData = (await fetchLoaderData(pagePathname, manifestHash)).loaderData; + } + if (loaderData.length > 0) { + // load specific loaders + const abortController = new AbortController(); + const redirectContext: RedirectContext = { promise: undefined }; + try { + const loaderPromises = loaderData.map((loader) => + fetchLoader( + loader.id, + loader.route, + manifestHash, + !opts?.action, + abortController, + redirectContext + ) + ); + const loaderResults = await Promise.all(loaderPromises); + for (let i = 0; i < loaderData.length; i++) { + loaders[loaderData[i].id] = loaderResults[i]; + } + } catch (e) { + if (e instanceof ShouldRedirect) { + const newUrl = new URL(e.location, url); + const newOpts = { + ...opts, + action: undefined, + loaderIds: undefined, + redirectData: e, }; - }); - } else if (opts?.redirectData?.data) { - loaderData = opts.redirectData.data; - } else { - // we need to load all the loaders - // first we need to get the loader urls - loaderData = (await fetchLoaderData(pagePathname, manifestHash)).loaderData; - } - if (loaderData.length > 0) { - // load specific loaders - const abortController = new AbortController(); - const redirectContext: RedirectContext = { promise: undefined }; - try { - const loaderPromises = loaderData.map((loader) => - fetchLoader(loader.id, loader.route, manifestHash, abortController, redirectContext) - ); - const loaderResults = await Promise.all(loaderPromises); - for (let i = 0; i < loaderData.length; i++) { - loaders[loaderData[i].id] = loaderResults[i]; - } - } catch (e) { - if (e instanceof ShouldRedirect) { - const newUrl = new URL(e.location, url); - const newOpts = { - ...opts, - action: undefined, - loaderIds: undefined, - redirectData: e, - }; - return loadClientData(newUrl, manifestHash, newOpts); - } else if (e instanceof Error && e.name === 'AbortError') { - // Expected, do nothing - } else { - throw e; - } + return loadClientData(newUrl, manifestHash, newOpts); + } else if (e instanceof Error && e.name === 'AbortError') { + // Expected, do nothing + } else { + throw e; } } } @@ -125,6 +133,7 @@ export const loadClientData = async ( return; } } + // TODO: why we need it? if ((rsp.headers.get('content-type') || '').includes('json')) { // we are safe we are reading a q-data.json return rsp.text().then((text) => { @@ -181,6 +190,7 @@ export async function fetchLoader( loaderId: string, routePath: string, manifestHash: string, + useCache: boolean, abortController: AbortController, redirectContext: RedirectContext ): Promise { @@ -188,6 +198,7 @@ export async function fetchLoader( const response = await fetch(url, { signal: abortController.signal, + cache: useCache ? 'default' : 'no-cache', }); if (response.redirected) { diff --git a/starters/dev-server.ts b/starters/dev-server.ts index e308a774a61..5575d116145 100644 --- a/starters/dev-server.ts +++ b/starters/dev-server.ts @@ -212,6 +212,7 @@ export { }), ); + console.log(` ✅ built client`, clientManifest); await build( getInlineConf({ build: {