diff --git a/package.json b/package.json index e8816238f1d..5840d962ae0 100644 --- a/package.json +++ b/package.json @@ -188,6 +188,7 @@ "build.cli": "tsx --require ./scripts/runBefore.ts scripts/index.ts --cli --dev", "build.cli.prod": "tsx --require ./scripts/runBefore.ts scripts/index.ts --cli", "build.core": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --qwik --insights --qwikrouter --api --platform-binding", + "build.router": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --qwikrouter --api", "build.eslint": "tsx --require ./scripts/runBefore.ts scripts/index.ts --eslint", "build.full": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --tsc-docs --qwik --insights --supabaseauthhelpers --api --eslint --qwikrouter --qwikworker --qwikreact --cli --platform-binding --wasm", "build.local": "tsx --require ./scripts/runBefore.ts scripts/index.ts --tsc --tsc-docs --qwik --insights --supabaseauthhelpers --api --eslint --qwikrouter --qwikworker --qwikreact --cli --platform-binding-wasm-copy", diff --git a/packages/qwik-router/src/buildtime/vite/dev-server.ts b/packages/qwik-router/src/buildtime/vite/dev-server.ts index aa5adf2bd28..94fb95b6bf1 100644 --- a/packages/qwik-router/src/buildtime/vite/dev-server.ts +++ b/packages/qwik-router/src/buildtime/vite/dev-server.ts @@ -33,6 +33,7 @@ import { getExtension, normalizePath } from '../../utils/fs'; import { updateBuildContext } from '../build'; import type { BuildContext, BuildRoute } from '../types'; import { formatError } from './format-error'; +import { RequestEvShareServerTiming } from '../../middleware/request-handler/request-event'; export function ssrDevMiddleware(ctx: BuildContext, server: ViteDevServer) { const matchRouteRequest = (pathname: string) => { @@ -188,7 +189,7 @@ export function ssrDevMiddleware(ctx: BuildContext, server: ViteDevServer) { res.setHeader('Set-Cookie', cookieHeaders); } - const serverTiming = requestEv.sharedMap.get('@serverTiming') as + const serverTiming = requestEv.sharedMap.get(RequestEvShareServerTiming) as | [string, number][] | undefined; if (serverTiming) { 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 c345081ee63..ae92a8af7cf 100644 --- a/packages/qwik-router/src/middleware/request-handler/request-event.ts +++ b/packages/qwik-router/src/middleware/request-handler/request-event.ts @@ -1,12 +1,13 @@ import type { ValueOrPromise } from '@qwik.dev/core'; import type { QwikManifest, ResolvedManifest } from '@qwik.dev/core/optimizer'; import { QDATA_KEY } from '../../runtime/src/constants'; -import type { - ActionInternal, - FailReturn, - JSONValue, - LoadedRoute, - LoaderInternal, +import { + LoadedRouteProp, + type ActionInternal, + type FailReturn, + type JSONValue, + type LoadedRoute, + type LoaderInternal, } from '../../runtime/src/types'; import { isPromise } from '../../runtime/src/utils'; import { createCacheControl } from './cache-control'; @@ -37,6 +38,8 @@ export const RequestRouteName = '@routeName'; export const RequestEvSharedActionId = '@actionId'; export const RequestEvSharedActionFormData = '@actionFormData'; export const RequestEvSharedNonce = '@nonce'; +export const RequestEvShareServerTiming = '@serverTiming'; +export const RequestEvShareQData = 'qData'; export function createRequestEvent( serverRequestEv: ServerRequestEvent, @@ -147,7 +150,7 @@ export function createRequestEvent( env, method: request.method, signal: request.signal, - params: loadedRoute?.[1] ?? {}, + params: loadedRoute?.[LoadedRouteProp.Params] ?? {}, pathname: url.pathname, platform, query: url.searchParams, @@ -271,9 +274,14 @@ export function createRequestEvent( getWritableStream: () => { if (writableStream === null) { if (serverRequestEv.mode === 'dev') { - const serverTiming = sharedMap.get('@serverTiming') as [string, number][] | undefined; + const serverTiming = sharedMap.get(RequestEvShareServerTiming) as + | [string, number][] + | undefined; if (serverTiming) { - headers.set('Server-Timing', serverTiming.map((a) => `${a[0]};dur=${a[1]}`).join(',')); + headers.set( + 'Server-Timing', + serverTiming.map(([name, duration]) => `${name};dur=${duration}`).join(',') + ); } } writableStream = serverRequestEv.getWritableStream( 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 b3098ce7b5f..a6afa4a2329 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,16 +1,18 @@ -import type { QRL } from '@qwik.dev/core'; +import { type QRL } from '@qwik.dev/core'; +import { SerializerSymbol, _UNINITIALIZED } from '@qwik.dev/core/internal'; import type { Render, RenderToStringResult } from '@qwik.dev/core/server'; -import { QACTION_KEY, QFN_KEY } from '../../runtime/src/constants'; -import type { - ActionInternal, - ClientPageData, - DataValidator, - JSONObject, - LoadedRoute, - LoaderInternal, - PageModule, - RouteModule, - ValidatorReturn, +import { QACTION_KEY, QFN_KEY, QLOADER_KEY } from '../../runtime/src/constants'; +import { + type ActionInternal, + type ClientPageData, + type DataValidator, + type JSONObject, + type LoadedRoute, + LoadedRouteProp, + type LoaderInternal, + type PageModule, + type RouteModule, + type ValidatorReturn, } from '../../runtime/src/types'; import { HttpStatus } from './http-status-codes'; import { RedirectMessage } from './redirect-handler'; @@ -22,6 +24,8 @@ import { getRequestMode, getRequestTrailingSlash, type RequestEventInternal, + RequestEvShareServerTiming, + RequestEvShareQData, } from './request-event'; import { getQwikRouterServerData } from './response-page'; import type { QwikSerializer, RequestEvent, RequestEventBase, RequestHandler } from './types'; @@ -38,7 +42,7 @@ export const resolveRequestHandlers = ( const routeActions: ActionInternal[] = []; const requestHandlers: RequestHandler[] = []; - const isPageRoute = !!(route && isLastModulePageRoute(route[2])); + const isPageRoute = !!(route && isLastModulePageRoute(route[LoadedRouteProp.Mods])); if (serverPlugins) { _resolveRequestHandlers( routeLoaders, @@ -51,7 +55,7 @@ export const resolveRequestHandlers = ( } if (route) { - const routeName = route[0]; + const routeName = route[LoadedRouteProp.RouteName]; if ( checkOrigin && (method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE') @@ -67,7 +71,7 @@ export const resolveRequestHandlers = ( requestHandlers.push(fixTrailingSlash); requestHandlers.push(renderQData); } - const routeModules = route[2]; + const routeModules = route[LoadedRouteProp.Mods]; requestHandlers.push(handleRedirect); _resolveRequestHandlers( routeLoaders, @@ -82,7 +86,8 @@ export const resolveRequestHandlers = ( // Set the current route name ev.sharedMap.set(RequestRouteName, routeName); }); - requestHandlers.push(actionsMiddleware(routeActions, routeLoaders) as any); + requestHandlers.push(actionsMiddleware(routeActions)); + requestHandlers.push(loadersMiddleware(routeLoaders)); requestHandlers.push(renderHandler); } } @@ -160,8 +165,9 @@ export const checkBrand = (obj: any, brand: string) => { return obj && typeof obj === 'function' && obj.__brand === brand; }; -export function actionsMiddleware(routeActions: ActionInternal[], routeLoaders: LoaderInternal[]) { - return async (requestEv: RequestEventInternal) => { +export function actionsMiddleware(routeActions: ActionInternal[]): RequestHandler { + return async (requestEvent: RequestEvent) => { + const requestEv = requestEvent as RequestEventInternal; if (requestEv.headersSent) { requestEv.exit(); return; @@ -211,51 +217,91 @@ export function actionsMiddleware(routeActions: ActionInternal[], routeLoaders: } } } + }; +} +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'; + const qwikSerializer = requestEv[RequestEvQwikSerializer]; if (routeLoaders.length > 0) { - const resolvedLoadersPromises = routeLoaders.map((loader) => { - 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.getSymbol().split('_', 1)[0], - () => 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(qwikSerializer, resolvedLoader, loader.__qrl); - } - loaders[loaderId] = resolvedLoader; - } - return resolvedLoader; - }); - - return loaders[loaderId]; - }); + let currentLoaders: LoaderInternal[] = []; + if (requestEv.query.has(QLOADER_KEY)) { + const selectedLoaderIds = requestEv.query.getAll(QLOADER_KEY); + const skippedLoaders: LoaderInternal[] = []; + for (const loader of routeLoaders) { + if (selectedLoaderIds.includes(loader.__id)) { + currentLoaders.push(loader); + } else { + skippedLoaders.push(loader); + } + } + // mark skipped loaders as null + for (const skippedLoader of skippedLoaders) { + loaders[skippedLoader.__id] = null; + } + } else { + currentLoaders = routeLoaders; + } + const resolvedLoadersPromises = currentLoaders.map((loader) => + getRouteLoaderPromise(loader, loaders, requestEv, isDev, qwikSerializer) + ); await Promise.all(resolvedLoadersPromises); } }; } +async function getRouteLoaderPromise( + loader: LoaderInternal, + loaders: Record, + requestEv: RequestEventInternal, + isDev: boolean, + qwikSerializer: QwikSerializer +) { + 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.getSymbol().split('_', 1)[0], + () => 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(qwikSerializer, resolvedLoader, loader.__qrl); + } + loaders[loaderId] = resolvedLoader; + } + return resolvedLoader; + }); + + return loaders[loaderId]; +} + async function runValidators( requestEv: RequestEvent, validators: DataValidator[] | undefined, @@ -399,7 +445,7 @@ export function getPathname(url: URL, trailingSlash: boolean | undefined) { } } // strip internal search params - const search = url.search.slice(1).replaceAll(/&?q(action|data|func)=[^&]+/g, ''); + const search = url.search.slice(1).replaceAll(/&?q(action|data|func|loader)=[^&]+/g, ''); return `${url.pathname}${search ? `?${search}` : ''}${url.hash}`; } @@ -471,7 +517,7 @@ export function renderQwikMiddleware(render: Render) { // write the already completed html to the stream await stream.write((result as any as RenderToStringResult).html); } - requestEv.sharedMap.set('qData', qData); + requestEv.sharedMap.set(RequestEvShareQData, qData); } finally { await stream.ready; await stream.close(); @@ -533,8 +579,20 @@ export async function renderQData(requestEv: RequestEvent) { requestEv.request.headers.forEach((value, key) => (requestHeaders[key] = value)); requestEv.headers.set('Content-Type', 'application/json; charset=utf-8'); + const allLoaders = getRequestLoaders(requestEv); + const loaders: Record = {}; + for (const loaderId in allLoaders) { + const loader = allLoaders[loaderId]; + if (typeof loader === 'object' && loader !== null && SerializerSymbol in loader) { + delete (loader as any)[SerializerSymbol]; + } + if (loader !== _UNINITIALIZED) { + loaders[loaderId] = loader; + } + } + const qData: ClientPageData = { - loaders: getRequestLoaders(requestEv), + loaders, action: requestEv.sharedMap.get(RequestEvSharedActionId), status: status !== 200 ? status : 200, href: getPathname(requestEv.url, trailingSlash), @@ -545,7 +603,7 @@ export async function renderQData(requestEv: RequestEvent) { // write just the page json data to the response body const data = await qwikSerializer._serialize([qData]); writer.write(encoder.encode(data)); - requestEv.sharedMap.set('qData', qData); + requestEv.sharedMap.set(RequestEvShareQData, qData); writer.close(); } @@ -576,9 +634,9 @@ export async function measure( return await fn(); } finally { const duration = now() - start; - let measurements = requestEv.sharedMap.get('@serverTiming'); + let measurements = requestEv.sharedMap.get(RequestEvShareServerTiming); if (!measurements) { - requestEv.sharedMap.set('@serverTiming', (measurements = [])); + requestEv.sharedMap.set(RequestEvShareServerTiming, (measurements = [])); } measurements.push([name, duration]); } 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 fd6e49bfaf6..5306ade84ce 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 @@ -28,6 +28,9 @@ describe('resolve-request-handler', () => { expect(getPathname(new URL('http://server/path?foo=1&qfunc=f&bar=2'), false)).toBe( '/path?foo=1&bar=2' ); + expect(getPathname(new URL('http://server/path?foo=1&qloader=f&bar=2'), false)).toBe( + '/path?foo=1&bar=2' + ); }); }); 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 7bc79f51a18..dca8f042167 100644 --- a/packages/qwik-router/src/middleware/request-handler/response-page.ts +++ b/packages/qwik-router/src/middleware/request-handler/response-page.ts @@ -1,3 +1,5 @@ +import { SerializerSymbol } from '@qwik.dev/core'; +import { _UNINITIALIZED } from '@qwik.dev/core/internal'; import type { QwikRouterEnvData } from '../../runtime/src/types'; import { getRequestLoaders, @@ -8,6 +10,7 @@ import { RequestRouteName, } from './request-event'; import type { RequestEvent } from './types'; +import { Q_ROUTE } from '../../runtime/src/constants'; export function getQwikRouterServerData(requestEv: RequestEvent) { const { url, params, request, status, locale } = requestEv; @@ -30,13 +33,28 @@ export function getQwikRouterServerData(requestEv: RequestEvent) { reconstructedUrl.protocol = protocol; } + const loaders = getRequestLoaders(requestEv); + + // shallow serialize loaders data + (loaders as any)[SerializerSymbol] = (loaders: Record) => { + const result: Record = {}; + for (const key in loaders) { + const loader = loaders[key]; + if (typeof loader === 'object' && loader !== null) { + (loader as any)[SerializerSymbol] = () => _UNINITIALIZED; + } + result[key] = _UNINITIALIZED; + } + return result; + }; + return { url: reconstructedUrl.href, requestHeaders, locale: locale(), nonce, containerAttributes: { - 'q:route': routeName, + [Q_ROUTE]: routeName, }, qwikrouter: { routeName, @@ -45,7 +63,7 @@ export function getQwikRouterServerData(requestEv: RequestEvent) { loadedRoute: getRequestRoute(requestEv), response: { status: status(), - loaders: getRequestLoaders(requestEv), + loaders, action, formData, }, diff --git a/packages/qwik-router/src/runtime/src/constants.ts b/packages/qwik-router/src/runtime/src/constants.ts index c54e0a9a517..93c5ca8818d 100644 --- a/packages/qwik-router/src/runtime/src/constants.ts +++ b/packages/qwik-router/src/runtime/src/constants.ts @@ -8,6 +8,10 @@ export const PREFETCHED_NAVIGATE_PATHS = new Set(); export const QACTION_KEY = 'qaction'; +export const QLOADER_KEY = 'qloader'; + export const QFN_KEY = 'qfunc'; export const QDATA_KEY = 'qdata'; + +export const Q_ROUTE = 'q:route'; diff --git a/packages/qwik-router/src/runtime/src/link-component.tsx b/packages/qwik-router/src/runtime/src/link-component.tsx index 4ba6c565d0d..80eeb6ce726 100644 --- a/packages/qwik-router/src/runtime/src/link-component.tsx +++ b/packages/qwik-router/src/runtime/src/link-component.tsx @@ -63,7 +63,7 @@ export const Link = component$((props) => { }) : undefined; const preventDefault = clientNavPath - ? sync$((event: MouseEvent, target: HTMLAnchorElement) => { + ? sync$((event: MouseEvent) => { if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { event.preventDefault(); } 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 29f888b564c..e93b03c44fc 100644 --- a/packages/qwik-router/src/runtime/src/qwik-router-component.tsx +++ b/packages/qwik-router/src/runtime/src/qwik-router-component.tsx @@ -20,11 +20,10 @@ import { _getContextElement, _getQContainerElement, _waitUntilRendered, - _weakSerialize, type _ElementVNode, } from '@qwik.dev/core/internal'; import { clientNavigate } from './client-navigate'; -import { CLIENT_DATA_CACHE } from './constants'; +import { CLIENT_DATA_CACHE, Q_ROUTE } from './constants'; import { ContentContext, ContentInternalContext, @@ -146,7 +145,7 @@ export const QwikRouterProvider = component$((props) => { { deep: false } ); const navResolver: { r?: () => void } = {}; - const loaderState = _weakSerialize(useStore(env.response.loaders, { deep: false })); + const loaderState = useStore(env.response.loaders, { deep: false }); const routeInternal = useSignal({ type: 'initial', dest: url, @@ -641,7 +640,7 @@ export const QwikRouterProvider = component$((props) => { clientNavigate(window, navType, prevUrl, trackUrl, replaceState); _waitUntilRendered(elm as Element).then(() => { const container = _getQContainerElement(elm as _ElementVNode)!; - container.setAttribute('q:route', routeName); + container.setAttribute(Q_ROUTE, routeName); const scrollState = currentScrollState(scroller); saveScrollHistory(scrollState); win._qRouterScrollEnabled = true; diff --git a/packages/qwik-router/src/runtime/src/routing.ts b/packages/qwik-router/src/runtime/src/routing.ts index 2fbe62ba5e3..0412bb8ed95 100644 --- a/packages/qwik-router/src/runtime/src/routing.ts +++ b/packages/qwik-router/src/runtime/src/routing.ts @@ -1,17 +1,18 @@ import { MODULE_CACHE } from './constants'; import { matchRoute } from './route-matcher'; -import type { - ContentMenu, - LoadedRoute, - MenuData, - MenuModule, - ModuleLoader, - RouteData, - RouteModule, +import { + type ContentMenu, + type LoadedRoute, + type MenuData, + MenuDataProp, + type MenuModule, + type ModuleLoader, + type RouteData, + RouteDataProp, + type RouteModule, } from './types'; import { deepFreeze } from './utils'; -export const CACHE = new Map>(); /** LoadRoute() runs in both client and server. */ export const loadRoute = async ( routes: RouteData[] | undefined, @@ -23,13 +24,13 @@ export const loadRoute = async ( return null; } for (const routeData of routes) { - const routeName = routeData[0]; + const routeName = routeData[RouteDataProp.RouteName]; const params = matchRoute(routeName, pathname); if (!params) { continue; } - const loaders = routeData[1]; - const routeBundleNames = routeData[3]; + const loaders = routeData[RouteDataProp.Loaders]; + const routeBundleNames = routeData[RouteDataProp.RouteBundleNames]; const modules: RouteModule[] = new Array(loaders.length); const pendingLoads: Promise[] = []; @@ -93,10 +94,12 @@ export const getMenuLoader = (menus: MenuData[] | undefined, pathname: string) = if (menus) { pathname = pathname.endsWith('/') ? pathname : pathname + '/'; const menu = menus.find( - (m) => m[0] === pathname || pathname.startsWith(m[0] + (pathname.endsWith('/') ? '' : '/')) + (m) => + m[MenuDataProp.Pathname] === pathname || + pathname.startsWith(m[MenuDataProp.Pathname] + (pathname.endsWith('/') ? '' : '/')) ); if (menu) { - return menu[1]; + return menu[MenuDataProp.MenuLoader]; } } }; diff --git a/packages/qwik-router/src/runtime/src/server-functions.ts b/packages/qwik-router/src/runtime/src/server-functions.ts index d065522cd54..fd3ba627bf1 100644 --- a/packages/qwik-router/src/runtime/src/server-functions.ts +++ b/packages/qwik-router/src/runtime/src/server-functions.ts @@ -2,10 +2,13 @@ import { $, implicit$FirstArg, noSerialize, - useContext, useStore, type QRL, type ValueOrPromise, + untrack, + isBrowser, + isDev, + isServer, } from '@qwik.dev/core'; import { _deserialize, @@ -13,13 +16,15 @@ import { _getContextEvent, _serialize, _wrapStore, + _useInvokeContext, + _UNINITIALIZED, } from '@qwik.dev/core/internal'; import * as v from 'valibot'; import { z } from 'zod'; import type { RequestEventLoader } from '../../middleware/request-handler/types'; import { QACTION_KEY, QDATA_KEY, QFN_KEY } from './constants'; -import { RouteStateContext } from './contexts'; +import { RouteLocationContext, RouteStateContext } from './contexts'; import type { ActionConstructor, ActionConstructorQRL, @@ -52,10 +57,9 @@ import type { } from './types'; import { useAction, useLocation, useQwikRouterEnv } from './use-functions'; -import { isDev, isServer } from '@qwik.dev/core'; - import type { FormSubmitCompletedDetail } from './form-component'; import { deepFreeze } from './utils'; +import { loadClientData } from './use-endpoint'; /** @internal */ export const routeActionQrl = (( @@ -193,17 +197,27 @@ export const routeLoaderQrl = (( ): LoaderInternal => { const { id, validators } = getValidators(rest, loaderQrl); function loader() { - return useContext(RouteStateContext, (state) => { - if (!(id in state)) { - throw new Error(`routeLoader$ "${loaderQrl.getSymbol()}" was invoked in a route where it was not declared. + const iCtx = _useInvokeContext(); + const state = iCtx.$container$.resolveContext(iCtx.$hostElement$, RouteStateContext)!; + const location = iCtx.$container$.resolveContext(iCtx.$hostElement$, RouteLocationContext)!; + + if (!(id in state)) { + throw new Error(`routeLoader$ "${loaderQrl.getSymbol()}" was invoked in a route where it was not declared. This is because the routeLoader$ was not exported in a 'layout.tsx' or 'index.tsx' file of the existing route. For more information check: https://qwik.dev/docs/route-loader/ If your are managing reusable logic or a library it is essential that this function is re-exported from within 'layout.tsx' or 'index.tsx file of the existing route otherwise it will not run or throw exception. For more information check: https://qwik.dev/docs/re-exporting-loaders/`); - } - return _wrapStore(state, id); - }); + } + const data = untrack(() => state[id]); + if (data === _UNINITIALIZED && isBrowser) { + throw loadClientData(location.url, iCtx.$hostElement$, { + loaderIds: [id], + }).then((data) => { + state[id] = data?.loaders[id]; + }); + } + return _wrapStore(state, id); } loader.__brand = 'server_loader' as const; loader.__qrl = loaderQrl; diff --git a/packages/qwik-router/src/runtime/src/types.ts b/packages/qwik-router/src/runtime/src/types.ts index 522673f081e..6998b32fb07 100644 --- a/packages/qwik-router/src/runtime/src/types.ts +++ b/packages/qwik-router/src/runtime/src/types.ts @@ -258,9 +258,21 @@ export type RouteData = routeBundleNames: string[], ]; +export const enum RouteDataProp { + RouteName, + Loaders, + OriginalPathname, + RouteBundleNames, +} + /** @public */ export type MenuData = [pathname: string, menuLoader: MenuModuleLoader]; +export const enum MenuDataProp { + Pathname, + MenuLoader, +} + /** * @deprecated Use `QwikRouterConfig` instead. Will be removed in V3. * @public @@ -292,16 +304,14 @@ export type LoadedRoute = [ routeBundleNames: string[] | undefined, ]; -export interface LoadedContent extends LoadedRoute { - pageModule: PageModule; +export const enum LoadedRouteProp { + RouteName, + Params, + Mods, + Menu, + RouteBundleNames, } -export type RequestHandlerBody = BODY | string | number | boolean | undefined | null | void; - -export type RequestHandlerBodyFunction = () => - | RequestHandlerBody - | Promise>; - export interface EndpointResponse { status: number; loaders: Record; @@ -665,8 +675,6 @@ export type LoaderConstructorQRL = { ): Loader>>>; }; -export type LoaderStateHolder = Record>; - /** @public */ export type ActionReturn = { readonly status?: number; diff --git a/packages/qwik-router/src/runtime/src/use-endpoint.ts b/packages/qwik-router/src/runtime/src/use-endpoint.ts index fc2ec8d9f31..f0d45ce4f1b 100644 --- a/packages/qwik-router/src/runtime/src/use-endpoint.ts +++ b/packages/qwik-router/src/runtime/src/use-endpoint.ts @@ -9,6 +9,7 @@ export const loadClientData = async ( element: unknown, opts?: { action?: RouteActionValue; + loaderIds?: string[]; clearCache?: boolean; prefetchSymbols?: boolean; isPrefetch?: boolean; @@ -16,7 +17,10 @@ export const loadClientData = async ( ) => { const pagePathname = url.pathname; const pageSearch = url.search; - const clientDataPath = getClientDataPath(pagePathname, pageSearch, opts?.action); + 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); diff --git a/packages/qwik-router/src/runtime/src/utils.ts b/packages/qwik-router/src/runtime/src/utils.ts index 23784d4ea77..40ed365bca4 100644 --- a/packages/qwik-router/src/runtime/src/utils.ts +++ b/packages/qwik-router/src/runtime/src/utils.ts @@ -1,6 +1,6 @@ -import type { RouteActionValue, SimpleURL } from './types'; +import type { SimpleURL } from './types'; -import { QACTION_KEY } from './constants'; +import { QACTION_KEY, QLOADER_KEY } from './constants'; /** Gets an absolute url path string (url.pathname + url.search + url.hash) */ export const toPath = (url: URL) => url.pathname + url.search + url.hash; @@ -31,11 +31,19 @@ export const isSameOriginDifferentPathname = (a: SimpleURL, b: SimpleURL) => export const getClientDataPath = ( pathname: string, pageSearch?: string, - action?: RouteActionValue + options?: { + actionId?: string; + loaderIds?: string[]; + } ) => { let search = pageSearch ?? ''; - if (action) { - search += (search ? '&' : '?') + QACTION_KEY + '=' + encodeURIComponent(action.id); + 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; }; diff --git a/packages/qwik-router/src/static/not-found.ts b/packages/qwik-router/src/static/not-found.ts index 4584aeb5e56..d29a7dc2ee1 100644 --- a/packages/qwik-router/src/static/not-found.ts +++ b/packages/qwik-router/src/static/not-found.ts @@ -1,6 +1,7 @@ import type { RouteData } from '@qwik.dev/router'; import { getErrorHtml } from '@qwik.dev/router/middleware/request-handler'; import type { StaticGenerateOptions, System } from './types'; +import { RouteDataProp } from '../runtime/src/types'; export async function generateNotFoundPages( sys: System, @@ -11,7 +12,9 @@ export async function generateNotFoundPages( const basePathname = opts.basePathname || '/'; const rootNotFoundPathname = basePathname + '404.html'; - const hasRootNotFound = routes.some((r) => r[2] === rootNotFoundPathname); + const hasRootNotFound = routes.some( + (r) => r[RouteDataProp.OriginalPathname] === rootNotFoundPathname + ); if (!hasRootNotFound) { const filePath = sys.getRouteFilePath(rootNotFoundPathname, true); diff --git a/packages/qwik-router/src/static/worker-thread.ts b/packages/qwik-router/src/static/worker-thread.ts index 82da1350300..841ce5c2064 100644 --- a/packages/qwik-router/src/static/worker-thread.ts +++ b/packages/qwik-router/src/static/worker-thread.ts @@ -12,6 +12,7 @@ import type { StaticWorkerRenderResult, System, } from './types'; +import { RequestEvShareQData } from '../middleware/request-handler/request-event'; export async function workerThread(sys: System) { const ssgOpts = sys.getOptions(); @@ -178,7 +179,7 @@ async function workerRender( try { if (writeQDataEnabled) { - const qData: ClientPageData = requestEv.sharedMap.get('qData'); + const qData: ClientPageData = requestEv.sharedMap.get(RequestEvShareQData); if (qData && !is404ErrorPage) { // write q-data.json file when enabled and qData is set const qDataFilePath = sys.getDataFilePath(url.pathname); diff --git a/packages/qwik/src/core/core.api.md b/packages/qwik/src/core/core.api.md index 5f23e47dff1..799fbd335b2 100644 --- a/packages/qwik/src/core/core.api.md +++ b/packages/qwik/src/core/core.api.md @@ -1598,6 +1598,9 @@ export interface Tracker { (obj: T, prop: P): T[P]; } +// @internal (undocumented) +export const _UNINITIALIZED: unique symbol; + // @public export const untrack: (fn: () => T) => T; @@ -1629,6 +1632,11 @@ export const useErrorBoundary: () => ErrorBoundaryStore; // @public (undocumented) export const useId: () => string; +// Warning: (ae-forgotten-export) The symbol "RenderInvokeContext" needs to be exported by the entry point index.d.ts +// +// @internal (undocumented) +export const _useInvokeContext: () => RenderInvokeContext; + // Warning: (ae-internal-missing-underscore) The name "useLexicalScope" should be prefixed with an underscore because the declaration is marked as @internal // // @internal @@ -1808,9 +1816,6 @@ export function _walkJSX(ssr: SSRContainer, value: JSXOutput, options: { parentComponentFrame: ISsrComponentFrame | null; }): Promise; -// @internal (undocumented) -export const _weakSerialize: (input: T) => Partial; - // @public export function withLocale(locale: string, fn: () => T): T; diff --git a/packages/qwik/src/core/internal.ts b/packages/qwik/src/core/internal.ts index 604b1d767a5..c3fd4ed5319 100644 --- a/packages/qwik/src/core/internal.ts +++ b/packages/qwik/src/core/internal.ts @@ -5,15 +5,15 @@ export { queueQRL as _run } from './client/queue-qrl'; export { scheduleTask as _task } from './use/use-task'; export { _wrapSignal, _wrapProp, _wrapStore } from './reactive-primitives/internal-api'; export { _restProps } from './shared/utils/prop'; -export { _IMMUTABLE } from './shared/utils/constants'; +export { _IMMUTABLE, _UNINITIALIZED } from './shared/utils/constants'; export { _CONST_PROPS, _VAR_PROPS } from './shared/utils/constants'; -export { _weakSerialize } from './shared/utils/serialize-utils'; export { verifySerializable as _verifySerializable } from './shared/utils/serialize-utils'; export { _getContextElement, _getContextEvent, _jsxBranch, _waitUntilRendered, + useInvokeContext as _useInvokeContext, } from './use/use-core'; export { _jsxSorted, _jsxSplit, isJSXNode as _isJSXNode } from './shared/jsx/jsx-runtime'; export { _fnSignal } from './shared/qrl/inlined-fn'; diff --git a/packages/qwik/src/core/shared/shared-serialization.ts b/packages/qwik/src/core/shared/shared-serialization.ts index 1a4f27e39e3..04abe5071d1 100644 --- a/packages/qwik/src/core/shared/shared-serialization.ts +++ b/packages/qwik/src/core/shared/shared-serialization.ts @@ -35,7 +35,7 @@ import { isQrl, isSyncQrl } from './qrl/qrl-utils'; import type { QRL } from './qrl/qrl.public'; import { ChoreType } from './util-chore-type'; import type { DeserializeContainer, HostElement, ObjToProxyMap } from './types'; -import { _CONST_PROPS, _VAR_PROPS } from './utils/constants'; +import { _CONST_PROPS, _UNINITIALIZED, _VAR_PROPS } from './utils/constants'; import { isElement, isNode } from './utils/element'; import { EMPTY_ARRAY, EMPTY_OBJ } from './utils/flyweight'; import { ELEMENT_ID } from './utils/markers'; @@ -387,7 +387,7 @@ const inflate = ( propsProxy[_VAR_PROPS] = data === 0 ? {} : (data as any)[0]; propsProxy[_CONST_PROPS] = (data as any)[1]; break; - case TypeIds.EffectData: { + case TypeIds.SubscriptionData: { const effectData = target as SubscriptionData; effectData.data.$scopedStyleIdPrefix$ = (data as any[])[0]; effectData.data.$isConst$ = (data as any[])[1]; @@ -409,6 +409,7 @@ export const _constants = [ EMPTY_OBJ, NEEDS_COMPUTATION, STORE_ALL_PROPS, + _UNINITIALIZED, Slot, Fragment, NaN, @@ -428,6 +429,7 @@ const _constantNames = [ 'EMPTY_OBJ', 'NEEDS_COMPUTATION', 'STORE_ALL_PROPS', + '_UNINITIALIZED', 'Slot', 'Fragment', 'NaN', @@ -546,9 +548,8 @@ const allocate = (container: DeserializeContainer, typeId: number, value: unknow } else { throw qError(QError.serializeErrorExpectedVNode, [typeof vNode]); } - case TypeIds.EffectData: + case TypeIds.SubscriptionData: return new SubscriptionData({} as NodePropData); - default: throw qError(QError.serializeErrorCannotAllocate, [typeId]); } @@ -848,7 +849,7 @@ const discoverValuesForVNodeData = (vnodeData: VNodeData, callback: (value: unkn if (isSsrAttrs(value)) { for (let i = 1; i < value.length; i += 2) { const attrValue = value[i]; - if (typeof attrValue === 'string') { + if (attrValue == null || typeof attrValue === 'string') { continue; } callback(attrValue); @@ -1023,6 +1024,8 @@ async function serialize(serializationContext: SerializationContext): Promise { 6 Constant EMPTY_OBJ 7 Constant NEEDS_COMPUTATION 8 Constant STORE_ALL_PROPS - 9 Constant Slot - 10 Constant Fragment - 11 Constant NaN - 12 Constant Infinity - 13 Constant -Infinity - 14 Constant MAX_SAFE_INTEGER - 15 Constant MAX_SAFE_INTEGER-1 - 16 Constant MIN_SAFE_INTEGER - (76 chars)" + 9 Constant _UNINITIALIZED + 10 Constant Slot + 11 Constant Fragment + 12 Constant NaN + 13 Constant Infinity + 14 Constant -Infinity + 15 Constant MAX_SAFE_INTEGER + 16 Constant MAX_SAFE_INTEGER-1 + 17 Constant MIN_SAFE_INTEGER + (81 chars)" `); }); it(title(TypeIds.Number), async () => { @@ -546,11 +547,11 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.FormData)); it.todo(title(TypeIds.JSXNode)); it.todo(title(TypeIds.PropsProxy)); - it(title(TypeIds.EffectData), async () => { + it(title(TypeIds.SubscriptionData), async () => { expect(await dump(new SubscriptionData({ $isConst$: true, $scopedStyleIdPrefix$: null }))) .toMatchInlineSnapshot(` " - 0 EffectData [ + 0 SubscriptionData [ Constant null Constant true ] @@ -751,7 +752,7 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.ComputedSignal)); it.todo(title(TypeIds.SerializerSignal)); // this requires a domcontainer - it.skip(title(TypeIds.Store), async () => { + it(title(TypeIds.Store), async () => { const objs = await serialize(createStore(null, { a: { b: true } }, StoreFlags.RECURSIVE)); const store = deserialize(objs)[0] as any; expect(store).toHaveProperty('a'); @@ -761,7 +762,7 @@ describe('shared-serialization', () => { it.todo(title(TypeIds.FormData)); it.todo(title(TypeIds.JSXNode)); it.todo(title(TypeIds.PropsProxy)); - it(title(TypeIds.EffectData), async () => { + it(title(TypeIds.SubscriptionData), async () => { const objs = await serialize( new SubscriptionData({ $isConst$: true, $scopedStyleIdPrefix$: null }) ); diff --git a/packages/qwik/src/core/shared/utils/constants.ts b/packages/qwik/src/core/shared/utils/constants.ts index 8414be910bc..1dac1773baf 100644 --- a/packages/qwik/src/core/shared/utils/constants.ts +++ b/packages/qwik/src/core/shared/utils/constants.ts @@ -5,3 +5,6 @@ export const _VAR_PROPS = Symbol('VAR'); /** @internal @deprecated v1 compat */ export const _IMMUTABLE = Symbol('IMMUTABLE'); + +/** @internal */ +export const _UNINITIALIZED = Symbol('UNINITIALIZED'); diff --git a/packages/qwik/src/core/shared/utils/serialize-utils.ts b/packages/qwik/src/core/shared/utils/serialize-utils.ts index dbb309431a1..e47dcd01718 100644 --- a/packages/qwik/src/core/shared/utils/serialize-utils.ts +++ b/packages/qwik/src/core/shared/utils/serialize-utils.ts @@ -90,7 +90,6 @@ const _verifySerializable = ( return value; }; const noSerializeSet = /*#__PURE__*/ new WeakSet(); -const weakSerializeSet = /*#__PURE__*/ new WeakSet(); export const shouldSerialize = (obj: unknown): boolean => { if (isObject(obj) || isFunction(obj)) { @@ -103,10 +102,6 @@ export const fastSkipSerialize = (obj: object): boolean => { return typeof obj === 'object' && obj && (NoSerializeSymbol in obj || noSerializeSet.has(obj)); }; -export const fastWeakSerialize = (obj: object): boolean => { - return weakSerializeSet.has(obj); -}; - /** * Returned type of the `noSerialize()` function. It will be TYPE or undefined. * @@ -142,12 +137,6 @@ export const noSerialize = (input: T): NoSerialize return input as any; }; -/** @internal */ -export const _weakSerialize = (input: T): Partial => { - weakSerializeSet.add(input); - return input as any; -}; - /** * If an object has this property, it will not be serialized. Use this on prototypes to avoid having * to call `noSerialize()` on every object. diff --git a/packages/qwik/src/core/use/use-core.ts b/packages/qwik/src/core/use/use-core.ts index bcb1721ef9e..81fb4cd9a5f 100644 --- a/packages/qwik/src/core/use/use-core.ts +++ b/packages/qwik/src/core/use/use-core.ts @@ -66,7 +66,6 @@ export interface InvokeContext { let _context: InvokeContext | undefined; -/** @public */ export const tryGetInvokeContext = (): InvokeContext | undefined => { if (!_context) { const context = typeof document !== 'undefined' && document && document.__q_context__; @@ -89,6 +88,7 @@ export const getInvokeContext = (): InvokeContext => { return ctx; }; +/** @internal */ export const useInvokeContext = (): RenderInvokeContext => { const ctx = tryGetInvokeContext(); if (!ctx || ctx.$event$ !== RenderEvent) { diff --git a/scripts/qwik-router.ts b/scripts/qwik-router.ts index 58ad6c1ea5b..1b9bc208f90 100644 --- a/scripts/qwik-router.ts +++ b/scripts/qwik-router.ts @@ -82,6 +82,7 @@ async function buildVite(config: BuildConfig) { 'typescript', 'vite-imagetools', 'svgo', + '@qwik.dev/core', ]; const swRegisterPath = join(config.srcQwikRouterDir, 'runtime', 'src', 'sw-register.ts'); @@ -102,7 +103,6 @@ async function buildVite(config: BuildConfig) { format: 'esm', external, alias: { - '@qwik.dev/core': 'noop', '@qwik.dev/core/optimizer': 'noop', }, plugins: [serviceWorkerRegisterBuild(swRegisterCode)], diff --git a/starters/apps/qwikrouter-test/src/routes/loaders-serialization/index.tsx b/starters/apps/qwikrouter-test/src/routes/loaders-serialization/index.tsx new file mode 100644 index 00000000000..6c51d241e94 --- /dev/null +++ b/starters/apps/qwikrouter-test/src/routes/loaders-serialization/index.tsx @@ -0,0 +1,30 @@ +import { component$, useSignal } from "@qwik.dev/core"; +import { routeLoader$ } from "@qwik.dev/router"; + +export const useTestLoader = routeLoader$(async () => { + return { test: "some test value", abcd: "should not serialize this" }; +}); + +export default component$(() => { + const testSignal = useTestLoader(); + const toggle = useSignal(false); + return ( + <> + {testSignal.value.test} + + {toggle.value && } + + ); +}); + +export const Child = component$(() => { + const testSignal = useTestLoader(); + return ( + <> +
{testSignal.value.test}
+
{testSignal.value.abcd}
+ + ); +}); diff --git a/starters/e2e/qwikrouter/nav.e2e.ts b/starters/e2e/qwikrouter/nav.e2e.ts index 044e4f01052..f3fc15726f2 100644 --- a/starters/e2e/qwikrouter/nav.e2e.ts +++ b/starters/e2e/qwikrouter/nav.e2e.ts @@ -10,7 +10,7 @@ import { scrollTo, } from "./util.js"; -test.describe("actions", () => { +test.describe("nav", () => { test.describe("mpa", () => { test.use({ javaScriptEnabled: false }); tests();