diff --git a/packages/one/src/createRouteConfig.ts b/packages/one/src/createRouteConfig.ts new file mode 100644 index 000000000..3a3006b1a --- /dev/null +++ b/packages/one/src/createRouteConfig.ts @@ -0,0 +1,37 @@ +import type { One } from './interfaces/router' + +/** + * Helper to create a typed route configuration object. + * + * Use this to configure route-specific behavior like loading mode and sitemap settings. + * + * @example + * ```tsx + * // blocking mode - wait for loader before navigation + * export const config = createRouteConfig({ + * loading: 'blocking', + * }) + * + * // instant mode - navigate immediately, show Loading component + * export const config = createRouteConfig({ + * loading: 'instant', + * }) + * + * // timed mode - wait 200ms, then show Loading if still loading + * export const config = createRouteConfig({ + * loading: 200, + * }) + * + * // with sitemap config + * export const config = createRouteConfig({ + * loading: 'blocking', + * sitemap: { + * priority: 0.8, + * changefreq: 'weekly', + * }, + * }) + * ``` + */ +export function createRouteConfig(config: One.RouteConfig): One.RouteConfig { + return config +} diff --git a/packages/one/src/index.ts b/packages/one/src/index.ts index ab67c183a..dbe3abe8f 100644 --- a/packages/one/src/index.ts +++ b/packages/one/src/index.ts @@ -135,3 +135,5 @@ export { SourceInspector, type SourceInspectorProps } from './views/SourceInspec export { useScrollGroup } from './useScrollGroup' // server export { getServerData, setResponseHeaders, setServerData } from './vite/one-server-only' +// route config +export { createRouteConfig } from './createRouteConfig' diff --git a/packages/one/src/interfaces/router.ts b/packages/one/src/interfaces/router.ts index 3392e0ea2..5b7bb1ced 100644 --- a/packages/one/src/interfaces/router.ts +++ b/packages/one/src/interfaces/router.ts @@ -525,4 +525,29 @@ export namespace One { */ exclude?: boolean } + + /** + * Loading mode for route navigation. + * - `'blocking'` - Wait for loader to complete before navigation (default for SSG/SSR) + * - `'instant'` - Navigate immediately, show Loading component while loader runs + * - `number` - Wait up to N milliseconds, then navigate (showing Loading if still loading) + */ + export type RouteLoadingMode = 'blocking' | 'instant' | number + + /** + * Route configuration object for customizing route behavior. + */ + export type RouteConfig = { + /** + * Loading mode for this route. + * - `'blocking'` - Wait for loader before navigation (default for SSG/SSR) + * - `'instant'` - Navigate immediately, show Loading component (default for SPA) + * - `number` - Wait up to N ms, then show Loading if still loading + */ + loading?: RouteLoadingMode + /** + * Sitemap configuration for this route. + */ + sitemap?: RouteSitemap + } } diff --git a/packages/one/src/router/Route.tsx b/packages/one/src/router/Route.tsx index f26d4810b..534242df2 100644 --- a/packages/one/src/router/Route.tsx +++ b/packages/one/src/router/Route.tsx @@ -1,5 +1,6 @@ import React, { createContext, type ReactNode, useContext } from 'react' import type { ErrorBoundaryProps } from '../views/Try' +import type { One as OneInterfaces } from '../interfaces/router' import type { LoaderProps } from '../types' import type { One } from '../vite/types' import type { ParamValidator, RouteValidationFn } from '../validateParams' @@ -21,6 +22,10 @@ export type LoadedRoute = { params?: Record }) => Record[] loader?: (props: LoaderProps) => Record[] + /** Route configuration for loading behavior, sitemap, etc. */ + config?: OneInterfaces.RouteConfig + /** Loading component shown while loader is running (for instant/timed modes). */ + Loading?: React.ComponentType /** * Validate route params before navigation. * Use with Zod, Valibot, or a custom function. @@ -77,6 +82,8 @@ export type RouteNode = { layouts?: RouteNode[] /** Parent middlewares */ middlewares?: RouteNode[] + /** Cached loading mode from route config. */ + loadingMode?: OneInterfaces.RouteLoadingMode } export const RouteParamsContext = createContext< diff --git a/packages/one/src/router/router.ts b/packages/one/src/router/router.ts index 9025dbabc..a4f21bd1a 100644 --- a/packages/one/src/router/router.ts +++ b/packages/one/src/router/router.ts @@ -16,15 +16,28 @@ import { useSyncExternalStore, } from 'react' import { Platform } from 'react-native' -import type { OneRouter } from '../interfaces/router' +import { devtoolsRegistry } from '../devtools/registry' +import type { OneRouter, One as OneInterfaces } from '../interfaces/router' import { resolveHref } from '../link/href' import { openExternalURL } from '../link/openExternalURL' import { resolve } from '../link/path' +import { checkBlocker } from '../useBlocker' import { assertIsReady } from '../utils/assertIsReady' import { getLoaderPath, getPreloadCSSPath, getPreloadPath } from '../utils/cleanUrl' import { dynamicImport } from '../utils/dynamicImport' import { shouldLinkExternally } from '../utils/url' +import { + ParamValidationError, + RouteValidationError, + validateParams as runValidateParams, +} from '../validateParams' import type { One } from '../vite/types' +import { + extractParamsFromState, + extractPathnameFromHref, + extractSearchFromHref, + findRouteNodeFromState, +} from './findRouteNode' import type { UrlObject } from './getNormalizedStatePath' import { getRouteInfo } from './getRouteInfo' import { getRoutes } from './getRoutes' @@ -35,19 +48,6 @@ import { sortRoutes } from './sortRoutes' import { getQualifiedRouteComponent } from './useScreens' import { preloadRouteModules } from './useViteRoutes' import { getNavigateAction } from './utils/getNavigateAction' -import { - findRouteNodeFromState, - extractParamsFromState, - extractSearchFromHref, - extractPathnameFromHref, -} from './findRouteNode' -import { - validateParams as runValidateParams, - RouteValidationError, - ParamValidationError, -} from '../validateParams' -import { checkBlocker } from '../useBlocker' -import { devtoolsRegistry } from '../devtools/registry' // Module-scoped variables export let routeNode: RouteNode | null = null @@ -530,9 +530,36 @@ export function cleanup() { } } -// TODO export const preloadingLoader: Record | undefined> = {} +// inlined to ensure tree shakes away in prod +// dev mode preload - fetches just the loader directly without production preload bundles +async function doPreloadDev(href: string): Promise { + if (process.env.NODE_ENV === 'development') { + try { + const loaderJSUrl = getLoaderPath(href, true) + const modulePromise = dynamicImport(loaderJSUrl) + if (!modulePromise) { + return null + } + const module = await modulePromise.catch(() => null) + + if (!module?.loader) { + return null + } + + const result = await module.loader() + return result ?? null + } catch (err) { + // graceful fail - loader will be fetched when component mounts + if (process.env.ONE_DEBUG_ROUTER) { + console.warn(`[one] dev preload failed for ${href}:`, err) + } + return null + } + } +} + async function doPreload(href: string) { const preloadPath = getPreloadPath(href) const loaderPath = getLoaderPath(href) @@ -654,8 +681,20 @@ export function preloadRoute(href: string, injectCSS = false): Promise | un if (process.env.TAMAGUI_TARGET === 'native') { return } + + // in dev mode, use a simpler preload that just fetches the loader directly + // this avoids issues with production-only preload paths while still ensuring + // loader data is available before navigation completes if (process.env.NODE_ENV === 'development') { - return + // normalize the path to match what useLoader uses for cache keys + const normalizedHref = normalizeLoaderPath(href) + if (!preloadingLoader[normalizedHref]) { + preloadingLoader[normalizedHref] = doPreloadDev(href).then((data) => { + preloadedLoaderData[normalizedHref] = data + return data + }) + } + return preloadingLoader[normalizedHref] } if (!preloadingLoader[href]) { @@ -680,6 +719,47 @@ export function preloadRoute(href: string, injectCSS = false): Promise | un return preloadingLoader[href] } +// normalize path to match what useLoader uses for currentPath +function normalizeLoaderPath(href: string): string { + // remove search params and hash, normalize trailing slashes and /index + const url = new URL(href, 'http://example.com') + return url.pathname.replace(/\/index$/, '').replace(/\/$/, '') || '/' +} + +// get the loading mode for a route +function getRouteLoadingMode( + routeNode: RouteNode | null | undefined, + loadedRoute?: { config?: OneInterfaces.RouteConfig } +): OneInterfaces.RouteLoadingMode { + if (loadedRoute?.config?.loading !== undefined) { + return loadedRoute.config.loading + } + if (routeNode?.loadingMode !== undefined) { + return routeNode.loadingMode + } + if (routeNode?.type === 'spa') { + return 'instant' + } + return 'blocking' +} + +// handle preload based on loading mode +async function handlePreloadWithMode( + href: string, + loadingMode: OneInterfaces.RouteLoadingMode +): Promise { + const preloadPromise = preloadRoute(href, true) + + if (loadingMode === 'blocking') { + await preloadPromise + } else if (loadingMode === 'instant') { + // don't wait - navigate immediately + } else if (typeof loadingMode === 'number') { + const timeoutPromise = new Promise((resolve) => setTimeout(resolve, loadingMode)) + await Promise.race([preloadPromise, timeoutPromise]) + } +} + export async function linkTo( href: string, event?: string, @@ -772,16 +852,27 @@ export async function linkTo( setLoadingState('loading') - // Preload route modules first so loadRoute() won't throw Suspense promises - await preloadRoute(href, true) + // Find the matching route node to determine loading mode + const matchingRouteNode = findRouteNodeFromState(state, routeNode) + + // Get the loading mode - uses cached config or defaults based on route type + const loadingMode = getRouteLoadingMode(matchingRouteNode) + + // Preload route modules based on loading mode + await handlePreloadWithMode(href, loadingMode) // Run async route validation before navigation - const matchingRouteNode = findRouteNodeFromState(state, routeNode) if (matchingRouteNode?.loadRoute) { setValidationState({ status: 'validating', lastValidatedHref: href }) try { const loadedRoute = matchingRouteNode.loadRoute() + + // Cache the loading mode from config for subsequent navigations + if (loadedRoute.config?.loading !== undefined && !matchingRouteNode.loadingMode) { + matchingRouteNode.loadingMode = loadedRoute.config.loading + } + const params = extractParamsFromState(state) const search = extractSearchFromHref(href) const pathname = extractPathnameFromHref(href) diff --git a/packages/one/types/createRouteConfig.d.ts b/packages/one/types/createRouteConfig.d.ts new file mode 100644 index 000000000..c10e849c5 --- /dev/null +++ b/packages/one/types/createRouteConfig.d.ts @@ -0,0 +1,35 @@ +import type { One } from './interfaces/router'; +/** + * Helper to create a typed route configuration object. + * + * Use this to configure route-specific behavior like loading mode and sitemap settings. + * + * @example + * ```tsx + * // blocking mode - wait for loader before navigation + * export const config = createRouteConfig({ + * loading: 'blocking', + * }) + * + * // instant mode - navigate immediately, show Loading component + * export const config = createRouteConfig({ + * loading: 'instant', + * }) + * + * // timed mode - wait 200ms, then show Loading if still loading + * export const config = createRouteConfig({ + * loading: 200, + * }) + * + * // with sitemap config + * export const config = createRouteConfig({ + * loading: 'blocking', + * sitemap: { + * priority: 0.8, + * changefreq: 'weekly', + * }, + * }) + * ``` + */ +export declare function createRouteConfig(config: One.RouteConfig): One.RouteConfig; +//# sourceMappingURL=createRouteConfig.d.ts.map \ No newline at end of file diff --git a/packages/one/types/index.d.ts b/packages/one/types/index.d.ts index 05e0ffef7..b36cb19eb 100644 --- a/packages/one/types/index.d.ts +++ b/packages/one/types/index.d.ts @@ -73,4 +73,5 @@ export { ScrollBehavior } from './views/ScrollBehavior'; export { SourceInspector, type SourceInspectorProps } from './views/SourceInspector'; export { useScrollGroup } from './useScrollGroup'; export { getServerData, setResponseHeaders, setServerData } from './vite/one-server-only'; +export { createRouteConfig } from './createRouteConfig'; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/one/types/interfaces/router.d.ts b/packages/one/types/interfaces/router.d.ts index df531790d..87fdb2fd0 100644 --- a/packages/one/types/interfaces/router.d.ts +++ b/packages/one/types/interfaces/router.d.ts @@ -372,5 +372,28 @@ export declare namespace One { */ exclude?: boolean; }; + /** + * Loading mode for route navigation. + * - `'blocking'` - Wait for loader to complete before navigation (default for SSG/SSR) + * - `'instant'` - Navigate immediately, show Loading component while loader runs + * - `number` - Wait up to N milliseconds, then navigate (showing Loading if still loading) + */ + type RouteLoadingMode = 'blocking' | 'instant' | number; + /** + * Route configuration object for customizing route behavior. + */ + type RouteConfig = { + /** + * Loading mode for this route. + * - `'blocking'` - Wait for loader before navigation (default for SSG/SSR) + * - `'instant'` - Navigate immediately, show Loading component (default for SPA) + * - `number` - Wait up to N ms, then show Loading if still loading + */ + loading?: RouteLoadingMode; + /** + * Sitemap configuration for this route. + */ + sitemap?: RouteSitemap; + }; } //# sourceMappingURL=router.d.ts.map \ No newline at end of file diff --git a/packages/one/types/router/Route.d.ts b/packages/one/types/router/Route.d.ts index 2e15ff47d..4cf3153be 100644 --- a/packages/one/types/router/Route.d.ts +++ b/packages/one/types/router/Route.d.ts @@ -1,5 +1,6 @@ import React, { type ReactNode } from 'react'; import type { ErrorBoundaryProps } from '../views/Try'; +import type { One as OneInterfaces } from '../interfaces/router'; import type { LoaderProps } from '../types'; import type { One } from '../vite/types'; import type { ParamValidator, RouteValidationFn } from '../validateParams'; @@ -17,6 +18,10 @@ export type LoadedRoute = { params?: Record; }) => Record[]; loader?: (props: LoaderProps) => Record[]; + /** Route configuration for loading behavior, sitemap, etc. */ + config?: OneInterfaces.RouteConfig; + /** Loading component shown while loader is running (for instant/timed modes). */ + Loading?: React.ComponentType; /** * Validate route params before navigation. * Use with Zod, Valibot, or a custom function. @@ -72,6 +77,8 @@ export type RouteNode = { layouts?: RouteNode[]; /** Parent middlewares */ middlewares?: RouteNode[]; + /** Cached loading mode from route config. */ + loadingMode?: OneInterfaces.RouteLoadingMode; }; export declare const RouteParamsContext: React.Context | undefined>; /** Return the RouteNode at the current contextual boundary. */ diff --git a/tests/test-route-loading-config/app/_layout.tsx b/tests/test-route-loading-config/app/_layout.tsx new file mode 100644 index 000000000..6cef51378 --- /dev/null +++ b/tests/test-route-loading-config/app/_layout.tsx @@ -0,0 +1,35 @@ +import { SchemeProvider, useUserScheme } from '@vxrn/color-scheme' +import { Slot } from 'one' +import { TamaguiProvider } from 'tamagui' +import config from '../config/tamagui.config' + +export default function Layout() { + return ( + + + + + Route Loading Config Test + + +
+ + + + + +
+ + + ) +} + +const TamaguiRootProvider = ({ children }: { children: React.ReactNode }) => { + const userScheme = useUserScheme() + + return ( + + {children} + + ) +} diff --git a/tests/test-route-loading-config/app/blocking/fast+ssg.tsx b/tests/test-route-loading-config/app/blocking/fast+ssg.tsx new file mode 100644 index 000000000..eb5b591dc --- /dev/null +++ b/tests/test-route-loading-config/app/blocking/fast+ssg.tsx @@ -0,0 +1,39 @@ +import { Link, useLoader, createRouteConfig } from 'one' +import { Text, YStack, H1, Paragraph } from 'tamagui' + +export const config = createRouteConfig({ + loading: 'blocking', +}) + +export function loader() { + return { + title: 'Blocking Fast Page', + loadTime: 0, + timestamp: Date.now(), + } +} + +export default function BlockingFastPage() { + const data = useLoader(loader) + + return ( + +

{data.title}

+ Load time: {data.loadTime}ms + Loaded at: {data.timestamp} + + + This page uses loading: 'blocking' with a fast loader. + + + + + Back to Home + + + Blocking Slow + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/blocking/slow+ssg.tsx b/tests/test-route-loading-config/app/blocking/slow+ssg.tsx new file mode 100644 index 000000000..010633ee7 --- /dev/null +++ b/tests/test-route-loading-config/app/blocking/slow+ssg.tsx @@ -0,0 +1,46 @@ +import { Link, useLoader, createRouteConfig } from 'one' +import { Text, YStack, H1, Paragraph } from 'tamagui' + +// blocking mode: wait for loader to complete before navigation +export const config = createRouteConfig({ + loading: 'blocking', +}) + +export async function loader() { + // simulate slow data fetch + await new Promise((resolve) => setTimeout(resolve, 500)) + return { + title: 'Blocking Slow Page', + loadTime: 500, + timestamp: Date.now(), + } +} + +export default function BlockingSlowPage() { + const data = useLoader(loader) + + return ( + +

{data.title}

+ Load time: {data.loadTime}ms + Loaded at: {data.timestamp} + + + This page uses loading: 'blocking'. Navigation should wait + for the loader to complete before showing this page. + + + + + Back to Home + + + Blocking Fast + + + Instant Slow + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/default-spa+spa.tsx b/tests/test-route-loading-config/app/default-spa+spa.tsx new file mode 100644 index 000000000..91089565c --- /dev/null +++ b/tests/test-route-loading-config/app/default-spa+spa.tsx @@ -0,0 +1,58 @@ +import { Link, useLoader, useLoaderState } from 'one' +import { Text, YStack, H1, Paragraph, Spinner } from 'tamagui' + +// no config export - uses default behavior based on render mode +// SPA pages default to instant + +export function Loading() { + return ( + + + Loading default SPA page... + + ) +} + +export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 300)) + return { + title: 'Default SPA Page', + mode: 'spa', + defaultBehavior: 'instant', + loadTime: 300, + timestamp: Date.now(), + } +} + +export default function DefaultSPAPage() { + const { data, state } = useLoaderState(loader) + + if (!data) { + return + } + + return ( + +

{data.title}

+ Render mode: {data.mode} + Default behavior: {data.defaultBehavior} + Load time: {data.loadTime}ms + Loaded at: {data.timestamp} + Loader state: {state} + + + This SPA page has no config export, so it uses the default behavior. SPA pages default to{' '} + instant navigation with a Loading component. + + + + + Back to Home + + + Default SSG + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/default-ssg+ssg.tsx b/tests/test-route-loading-config/app/default-ssg+ssg.tsx new file mode 100644 index 000000000..976c1e2ad --- /dev/null +++ b/tests/test-route-loading-config/app/default-ssg+ssg.tsx @@ -0,0 +1,44 @@ +import { Link, useLoader } from 'one' +import { Text, YStack, H1, Paragraph } from 'tamagui' + +// no config export - uses default behavior based on render mode +// SSG pages default to blocking + +export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 300)) + return { + title: 'Default SSG Page', + mode: 'ssg', + defaultBehavior: 'blocking', + loadTime: 300, + timestamp: Date.now(), + } +} + +export default function DefaultSSGPage() { + const data = useLoader(loader) + + return ( + +

{data.title}

+ Render mode: {data.mode} + Default behavior: {data.defaultBehavior} + Load time: {data.loadTime}ms + Loaded at: {data.timestamp} + + + This SSG page has no config export, so it uses the default behavior. SSG/SSR pages default + to blocking navigation. + + + + + Back to Home + + + Default SPA + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/index+ssg.tsx b/tests/test-route-loading-config/app/index+ssg.tsx new file mode 100644 index 000000000..5570d50e2 --- /dev/null +++ b/tests/test-route-loading-config/app/index+ssg.tsx @@ -0,0 +1,71 @@ +import { Link, useLoader } from 'one' +import { Text, YStack, H1, Paragraph } from 'tamagui' + +export function loader() { + return { + title: 'Route Loading Config Test', + timestamp: Date.now(), + } +} + +export default function HomePage() { + const data = useLoader(loader) + + return ( + +

{data.title}

+ Loaded at: {data.timestamp} + + + Test Pages: + + + Blocking Mode (wait for loader): + + + Blocking - Slow Loader (500ms) + + + Blocking - Fast Loader + + + + Instant Mode (show Loading component): + + + Instant - Slow Loader (500ms) + + + Instant - Fast Loader + + + + Timed Mode (wait N ms, then show Loading): + + + Timed 200ms - Slow Loader (500ms) + + + Timed 600ms - Slow Loader (500ms) + + + + Default Behavior (mode-based): + + + SSG Page (should block) + + + SPA Page (should be instant) + + + + No Loader: + + + Page without loader + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/instant/fast+ssg.tsx b/tests/test-route-loading-config/app/instant/fast+ssg.tsx new file mode 100644 index 000000000..56807b6ec --- /dev/null +++ b/tests/test-route-loading-config/app/instant/fast+ssg.tsx @@ -0,0 +1,49 @@ +import { Link, useLoader, createRouteConfig } from 'one' +import { Text, YStack, H1, Paragraph, Spinner } from 'tamagui' + +export const config = createRouteConfig({ + loading: 'instant', +}) + +export function Loading() { + return ( + + + Loading instant fast page... + + ) +} + +export function loader() { + return { + title: 'Instant Fast Page', + loadTime: 0, + timestamp: Date.now(), + } +} + +export default function InstantFastPage() { + const data = useLoader(loader) + + return ( + +

{data.title}

+ Load time: {data.loadTime}ms + Loaded at: {data.timestamp} + + + This page uses loading: 'instant' with a fast loader. The + loading state should barely be visible. + + + + + Back to Home + + + Instant Slow + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/instant/slow+ssg.tsx b/tests/test-route-loading-config/app/instant/slow+ssg.tsx new file mode 100644 index 000000000..c45ef8832 --- /dev/null +++ b/tests/test-route-loading-config/app/instant/slow+ssg.tsx @@ -0,0 +1,61 @@ +import { Link, useLoader, useLoaderState, createRouteConfig } from 'one' +import { Text, YStack, H1, Paragraph, Spinner } from 'tamagui' + +// instant mode: navigate immediately, show Loading while loader runs +export const config = createRouteConfig({ + loading: 'instant', +}) + +// this is shown while loader is running +export function Loading() { + return ( + + + Loading instant slow page... + + ) +} + +export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 500)) + return { + title: 'Instant Slow Page', + loadTime: 500, + timestamp: Date.now(), + } +} + +export default function InstantSlowPage() { + const { data, state } = useLoaderState(loader) + + // with instant mode, data may be undefined while loading + if (!data) { + return + } + + return ( + +

{data.title}

+ Load time: {data.loadTime}ms + Loaded at: {data.timestamp} + Loader state: {state} + + + This page uses loading: 'instant'. Navigation happens + immediately and a loading UI is shown while data loads. + + + + + Back to Home + + + Instant Fast + + + Blocking Slow + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/no-loader+ssg.tsx b/tests/test-route-loading-config/app/no-loader+ssg.tsx new file mode 100644 index 000000000..737e52695 --- /dev/null +++ b/tests/test-route-loading-config/app/no-loader+ssg.tsx @@ -0,0 +1,26 @@ +import { Link } from 'one' +import { Text, YStack, H1, Paragraph } from 'tamagui' + +// page without a loader - should always navigate instantly + +export default function NoLoaderPage() { + return ( + +

No Loader Page

+ This page has no loader function. + + + Pages without loaders should always navigate instantly since there's nothing to wait for. + + + + + Back to Home + + + Blocking Slow + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/routes.d.ts b/tests/test-route-loading-config/app/routes.d.ts new file mode 100644 index 000000000..2de618dbe --- /dev/null +++ b/tests/test-route-loading-config/app/routes.d.ts @@ -0,0 +1,16 @@ +// deno-lint-ignore-file +/* eslint-disable */ +// biome-ignore: needed import +import type { OneRouter } from 'one' + +declare module 'one' { + export namespace OneRouter { + export interface __routes extends Record { + StaticRoutes: `/` | `/_sitemap` | `/blocking/fast` | `/blocking/slow` | `/default-spa` | `/default-ssg` | `/instant/fast` | `/instant/slow` | `/no-loader` | `/timed/200ms` | `/timed/600ms` + DynamicRoutes: never + DynamicRouteTemplate: never + IsTyped: true + + } + } +} \ No newline at end of file diff --git a/tests/test-route-loading-config/app/timed/200ms+ssg.tsx b/tests/test-route-loading-config/app/timed/200ms+ssg.tsx new file mode 100644 index 000000000..2fdeda171 --- /dev/null +++ b/tests/test-route-loading-config/app/timed/200ms+ssg.tsx @@ -0,0 +1,59 @@ +import { Link, useLoader, useLoaderState, createRouteConfig } from 'one' +import { Text, YStack, H1, Paragraph, Spinner } from 'tamagui' + +// timed mode: wait 200ms, then show Loading if still loading +// with a 500ms loader, user will see: old page (200ms) -> loading (300ms) -> new page +export const config = createRouteConfig({ + loading: 200, // wait 200ms before showing Loading +}) + +export function Loading() { + return ( + + + Loading timed 200ms page... + + ) +} + +export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 500)) + return { + title: 'Timed 200ms Page', + loadTime: 500, + waitTime: 200, + timestamp: Date.now(), + } +} + +export default function Timed200Page() { + const { data, state } = useLoaderState(loader) + + if (!data) { + return + } + + return ( + +

{data.title}

+ Load time: {data.loadTime}ms + Wait time before loading UI: {data.waitTime}ms + Loaded at: {data.timestamp} + Loader state: {state} + + + This page uses loading: 200. Navigation waits 200ms, then + shows the Loading component if the loader hasn't finished yet. + + + + + Back to Home + + + Timed 600ms + + +
+ ) +} diff --git a/tests/test-route-loading-config/app/timed/600ms+ssg.tsx b/tests/test-route-loading-config/app/timed/600ms+ssg.tsx new file mode 100644 index 000000000..14c2c6308 --- /dev/null +++ b/tests/test-route-loading-config/app/timed/600ms+ssg.tsx @@ -0,0 +1,55 @@ +import { Link, useLoader, createRouteConfig } from 'one' +import { Text, YStack, H1, Paragraph, Spinner } from 'tamagui' + +// timed mode: wait 600ms, then show Loading if still loading +// with a 500ms loader, the loader finishes before 600ms so Loading is never shown +export const config = createRouteConfig({ + loading: 600, // wait 600ms before showing Loading +}) + +export function Loading() { + return ( + + + Loading timed 600ms page... + + ) +} + +export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 500)) + return { + title: 'Timed 600ms Page', + loadTime: 500, + waitTime: 600, + timestamp: Date.now(), + } +} + +export default function Timed600Page() { + const data = useLoader(loader) + + return ( + +

{data.title}

+ Load time: {data.loadTime}ms + Wait time before loading UI: {data.waitTime}ms + Loaded at: {data.timestamp} + + + This page uses loading: 600. Navigation waits 600ms before + showing Loading. Since the loader takes only 500ms, the Loading component is never shown - + it behaves like blocking mode. + + + + + Back to Home + + + Timed 200ms + + +
+ ) +} diff --git a/tests/test-route-loading-config/config/animations.ts b/tests/test-route-loading-config/config/animations.ts new file mode 100644 index 000000000..f0b3d1900 --- /dev/null +++ b/tests/test-route-loading-config/config/animations.ts @@ -0,0 +1,6 @@ +import { createAnimations } from '@tamagui/animations-css' + +export const animations = createAnimations({ + quick: 'ease-in 150ms', + medium: 'ease-in-out 300ms', +}) diff --git a/tests/test-route-loading-config/config/tamagui.config.ts b/tests/test-route-loading-config/config/tamagui.config.ts new file mode 100644 index 000000000..4a6005c53 --- /dev/null +++ b/tests/test-route-loading-config/config/tamagui.config.ts @@ -0,0 +1,34 @@ +import { config as configOptions } from '@tamagui/config/v3' +import { createTamagui } from '@tamagui/core' +import { animations } from './animations' + +export const config = createTamagui({ + ...configOptions, + animations, + themes: { + ...configOptions.themes, + light: { + ...configOptions.themes.light, + background: 'white', + color: 'black', + }, + dark: { + ...configOptions.themes.dark, + background: 'black', + color: 'white', + }, + }, + settings: { + ...configOptions.settings, + fastSchemeChange: true, + maxDarkLightNesting: 2, + }, +}) + +export type Conf = typeof config + +declare module '@tamagui/core' { + interface TamaguiCustomConfig extends Conf {} +} + +export default config diff --git a/tests/test-route-loading-config/package.json b/tests/test-route-loading-config/package.json new file mode 100644 index 000000000..df8c45674 --- /dev/null +++ b/tests/test-route-loading-config/package.json @@ -0,0 +1,36 @@ +{ + "name": "test-route-loading-config", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "one dev", + "build:web": "one build", + "serve": "one serve", + "test": "bun run test:dev && bun run test:prod", + "test:dev": "TEST_ONLY=dev bun run vitest --run --reporter=verbose --color=false", + "test:prod": "TEST_ONLY=prod bun run vitest --run --reporter=verbose --color=false", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@react-navigation/native": "~7.1.19", + "@tamagui/animations-css": "^1.144.1", + "@tamagui/config": "^1.144.1", + "@vxrn/color-scheme": "workspace:*", + "expo": "54.0.22", + "one": "workspace:*", + "react": "^19.1.0", + "react-native": "0.80.0", + "react-native-web": "^0.21.2", + "tamagui": "^1.144.1" + }, + "devDependencies": { + "@react-native-community/cli": "^20.0.2", + "get-port-please": "^3.1.2", + "playwright": "^1.57.0", + "tsx": "^4.20.6", + "typescript": "^5.7.3", + "vite": "^7.1.12", + "vitest": "^4.0.6" + } +} diff --git a/tests/test-route-loading-config/tests/route-loading-config.test.ts b/tests/test-route-loading-config/tests/route-loading-config.test.ts new file mode 100644 index 000000000..a8a9730b2 --- /dev/null +++ b/tests/test-route-loading-config/tests/route-loading-config.test.ts @@ -0,0 +1,489 @@ +import { type Browser, type BrowserContext, type Page, chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' + +/** + * Route Loading Config Tests + * + * Tests the configurable loading behavior for routes: + * - loading: 'blocking' - wait for loader before navigation + * - loading: 'instant' - navigate immediately, show Loading component + * - loading: - wait N ms, then show Loading if still loading + * - default behavior based on route mode (ssg/ssr = blocking, spa = instant) + */ + +const serverUrl = process.env.ONE_SERVER_URL +const isDebug = !!process.env.DEBUG +const isDev = process.env.TEST_ONLY === 'dev' + +let browser: Browser +let context: BrowserContext + +beforeAll(async () => { + browser = await chromium.launch({ headless: !isDebug }) + context = await browser.newContext() +}) + +afterAll(async () => { + await browser.close() +}) + +/** + * Wait for an element to appear with specific content + */ +async function waitForContent( + page: Page, + selector: string, + expectedText: string, + timeout = 15000 +): Promise { + const startTime = Date.now() + while (Date.now() - startTime < timeout) { + try { + const element = await page.$(selector) + if (element) { + const text = await element.textContent() + if (text && text.includes(expectedText)) { + return true + } + } + } catch { + // element not found yet + } + await new Promise((res) => setTimeout(res, 50)) + } + return false +} + +/** + * Check if an element exists on the page + */ +async function elementExists(page: Page, selector: string): Promise { + try { + const element = await page.$(selector) + return !!element + } catch { + return false + } +} + +/** + * Navigate via link click and measure timing + */ +async function navigateAndMeasure( + page: Page, + linkId: string, + targetSelector: string, + expectedText: string +): Promise<{ + success: boolean + startTime: number + endTime: number + duration: number + sawLoadingState: boolean +}> { + const startTime = Date.now() + let sawLoadingState = false + + // set up listener for loading state + const checkForLoading = async () => { + const loading = await elementExists(page, '#loading-state') + if (loading) sawLoadingState = true + } + + // start checking for loading state + const checkInterval = setInterval(checkForLoading, 20) + + await page.click(`#${linkId}`) + + // wait for target content + const success = await waitForContent(page, targetSelector, expectedText) + const endTime = Date.now() + + clearInterval(checkInterval) + + return { + success, + startTime, + endTime, + duration: endTime - startTime, + sawLoadingState, + } +} + +/** + * Track blank content during navigation + */ +async function setupBlankContentTracker(page: Page, contentSelector: string) { + await page.evaluate((selector) => { + ;(window as any).__blankContentDetected = false + ;(window as any).__contentHistory = [] + + const checkContent = () => { + const element = document.querySelector(selector) + const hasContent = element && element.textContent && element.textContent.trim().length > 10 + + ;(window as any).__contentHistory.push({ + timestamp: Date.now(), + hasContent, + }) + + if (!hasContent) { + ;(window as any).__blankContentDetected = true + } + } + + const observer = new MutationObserver(() => checkContent()) + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }) + + ;(window as any).__contentObserver = observer + ;(window as any).__contentInterval = setInterval(checkContent, 10) + }, contentSelector) +} + +async function getBlankContentResults(page: Page) { + return page.evaluate(() => { + clearInterval((window as any).__contentInterval) + ;(window as any).__contentObserver?.disconnect() + return { + blankContentDetected: (window as any).__blankContentDetected, + history: (window as any).__contentHistory, + } + }) +} + +describe('Blocking Mode', () => { + test('blocking mode waits for loader before navigation (no blank content)', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + await setupBlankContentTracker(page, '#app-container') + + // navigate to blocking slow page (500ms loader) + const result = await navigateAndMeasure( + page, + 'nav-to-blocking-slow', + '#page-title', + 'Blocking Slow Page' + ) + + const blankResults = await getBlankContentResults(page) + + expect(result.success).toBe(true) + // blocking mode should NOT show loading state component + expect(result.sawLoadingState).toBe(false) + // should not show blank content (either old page or new page, never empty) + expect(blankResults.blankContentDetected).toBe(false) + // in dev mode, navigation should take at least as long as the loader + // in prod mode, data is pre-built so it's fast + if (isDev) { + expect(result.duration).toBeGreaterThanOrEqual(400) + } + + await page.close() + }) + + test('blocking mode with fast loader navigates quickly', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + const result = await navigateAndMeasure( + page, + 'nav-to-blocking-fast', + '#page-title', + 'Blocking Fast Page' + ) + + expect(result.success).toBe(true) + expect(result.sawLoadingState).toBe(false) + // fast loader should complete quickly + expect(result.duration).toBeLessThan(300) + + await page.close() + }) +}) + +describe('Instant Mode', () => { + // NOTE: instant mode on web currently relies on Suspense which is disabled + // due to flickering issues. The preload still runs, but the Loading component + // doesn't show via Suspense. Navigation still works. + test('instant mode navigates without blocking', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + // navigate to instant slow page (500ms loader) + const result = await navigateAndMeasure( + page, + 'nav-to-instant-slow', + '#page-title', + 'Instant Slow Page' + ) + + expect(result.success).toBe(true) + // instant mode doesn't block, navigation should succeed + // NOTE: Loading component via Suspense is disabled on web + + await page.close() + }) + + test('instant mode with fast loader navigates quickly', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + const result = await navigateAndMeasure( + page, + 'nav-to-instant-fast', + '#page-title', + 'Instant Fast Page' + ) + + expect(result.success).toBe(true) + + await page.close() + }) +}) + +describe('Timed Mode', () => { + // NOTE: timed mode waits up to N ms, then navigates. + // Loading component via Suspense is disabled on web. + test('timed 200ms navigates after waiting or loader completes', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + // navigate to timed 200ms page (500ms loader, 200ms wait) + // waits 200ms, then navigation proceeds while loader continues + const result = await navigateAndMeasure( + page, + 'nav-to-timed-200', + '#page-title', + 'Timed 200ms Page' + ) + + expect(result.success).toBe(true) + + await page.close() + }) + + test('timed 600ms waits for loader when it finishes first (no blank content)', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + await setupBlankContentTracker(page, '#app-container') + + // navigate to timed 600ms page (500ms loader, 600ms wait) + // loader finishes before wait time, so no Loading shown + const result = await navigateAndMeasure( + page, + 'nav-to-timed-600', + '#page-title', + 'Timed 600ms Page' + ) + + const blankResults = await getBlankContentResults(page) + + expect(result.success).toBe(true) + // 600ms wait > 500ms loader, so loader completes before timeout + expect(result.sawLoadingState).toBe(false) + // should not show blank content + expect(blankResults.blankContentDetected).toBe(false) + // in dev mode, should take at least 500ms (loader time) + // in prod mode, data is pre-built so it's fast + if (isDev) { + expect(result.duration).toBeGreaterThanOrEqual(400) + } + + await page.close() + }) +}) + +describe('Default Mode Behavior', () => { + test('SSG page without config defaults to blocking (no blank content)', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + await setupBlankContentTracker(page, '#app-container') + + const result = await navigateAndMeasure( + page, + 'nav-to-default-ssg', + '#page-title', + 'Default SSG Page' + ) + + const blankResults = await getBlankContentResults(page) + + expect(result.success).toBe(true) + // SSG defaults to blocking - no loading state shown + expect(result.sawLoadingState).toBe(false) + // no blank content + expect(blankResults.blankContentDetected).toBe(false) + + await page.close() + }) + + test('SPA page without config defaults to instant', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + const result = await navigateAndMeasure( + page, + 'nav-to-default-spa', + '#page-title', + 'Default SPA Page' + ) + + expect(result.success).toBe(true) + // SPA defaults to instant - navigation succeeds + // NOTE: Loading component via Suspense is disabled on web + + await page.close() + }) +}) + +describe('No Loader Pages', () => { + test('page without loader navigates instantly', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + await setupBlankContentTracker(page, '#app-container') + + const result = await navigateAndMeasure( + page, + 'nav-to-no-loader', + '#page-title', + 'No Loader Page' + ) + + const blankResults = await getBlankContentResults(page) + + expect(result.success).toBe(true) + // no loader means no loading state + expect(result.sawLoadingState).toBe(false) + // should be instant with no blank content + expect(blankResults.blankContentDetected).toBe(false) + // should be fast + expect(result.duration).toBeLessThan(500) + + await page.close() + }) +}) + +describe('Cross-Mode Navigation', () => { + test('navigating from blocking to instant mode works', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + // go to blocking page first + await page.click('#nav-to-blocking-slow') + await waitForContent(page, '#page-title', 'Blocking Slow Page') + + // then to instant page + const result = await navigateAndMeasure( + page, + 'nav-to-instant-slow', + '#page-title', + 'Instant Slow Page' + ) + + expect(result.success).toBe(true) + + await page.close() + }) + + test('navigating from instant to blocking works correctly (no blank content)', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/instant/slow', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Instant Slow Page') + + await setupBlankContentTracker(page, '#app-container') + + const result = await navigateAndMeasure( + page, + 'nav-to-blocking-slow', + '#page-title', + 'Blocking Slow Page' + ) + + const blankResults = await getBlankContentResults(page) + + expect(result.success).toBe(true) + // going to blocking page should not show loading state + expect(result.sawLoadingState).toBe(false) + // should not show blank content + expect(blankResults.blankContentDetected).toBe(false) + + await page.close() + }) +}) + +describe('Config Override', () => { + test('explicit blocking config works (no blank content)', async () => { + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + await setupBlankContentTracker(page, '#app-container') + + // blocking slow has explicit blocking config + const result = await navigateAndMeasure( + page, + 'nav-to-blocking-slow', + '#page-title', + 'Blocking Slow Page' + ) + + const blankResults = await getBlankContentResults(page) + + expect(result.success).toBe(true) + expect(result.sawLoadingState).toBe(false) + expect(blankResults.blankContentDetected).toBe(false) + + await page.close() + }) + + test('explicit instant config overrides SSG default', async () => { + // instant slow is SSG with explicit instant config + const page = await context.newPage() + + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Route Loading Config Test') + + const result = await navigateAndMeasure( + page, + 'nav-to-instant-slow', + '#page-title', + 'Instant Slow Page' + ) + + expect(result.success).toBe(true) + // instant config on SSG page - navigation succeeds + // NOTE: Loading component via Suspense is disabled on web + + await page.close() + }) +}) diff --git a/tests/test-route-loading-config/tsconfig.json b/tests/test-route-loading-config/tsconfig.json new file mode 100644 index 000000000..2946f9403 --- /dev/null +++ b/tests/test-route-loading-config/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "declarationMap": false, + "noEmit": true + }, + "include": ["app/**/*", "tests/**/*", "config/**/*"], + "exclude": ["node_modules"] +} diff --git a/tests/test-route-loading-config/vite.config.ts b/tests/test-route-loading-config/vite.config.ts new file mode 100644 index 000000000..24cc1cad5 --- /dev/null +++ b/tests/test-route-loading-config/vite.config.ts @@ -0,0 +1,22 @@ +import { one } from 'one/vite' +import type { UserConfig } from 'vite' + +const defaultRenderMode = + (process.env.DEFAULT_RENDER_MODE as 'spa' | 'ssg' | 'ssr') || 'ssg' + +console.info(`[test-route-loading-config] Using defaultRenderMode: ${defaultRenderMode}`) + +export default { + plugins: [ + one({ + config: { + tsConfigPaths: { + ignoreConfigErrors: true, + }, + }, + web: { + defaultRenderMode, + }, + }), + ], +} satisfies UserConfig diff --git a/tests/test-route-loading-config/vitest.config.ts b/tests/test-route-loading-config/vitest.config.ts new file mode 100644 index 000000000..4641c5326 --- /dev/null +++ b/tests/test-route-loading-config/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + testTimeout: 120_000, + hookTimeout: 120_000, + globalSetup: '@vxrn/test/setup', + retry: 1, + fileParallelism: false, + }, +}) diff --git a/tests/test-routing-flicker/package.json b/tests/test-routing-flicker/package.json index cdd4a77d0..b341976ad 100644 --- a/tests/test-routing-flicker/package.json +++ b/tests/test-routing-flicker/package.json @@ -15,6 +15,8 @@ "test:ssg": "DEFAULT_RENDER_MODE=ssg TEST_ONLY=prod bun run vitest --run --reporter=verbose --color=false", "test:ssr": "DEFAULT_RENDER_MODE=ssr TEST_ONLY=prod bun run vitest --run --reporter=verbose --color=false", "test:all": "bun run test:spa && bun run test:ssg", + "test:dev": "TEST_ONLY=dev bun run vitest --run --reporter=verbose --color=false tests/dev-navigation-flicker.test.ts", + "test:dev:ssg": "DEFAULT_RENDER_MODE=ssg TEST_ONLY=dev bun run vitest --run --reporter=verbose --color=false tests/dev-navigation-flicker.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/tests/test-routing-flicker/tests/dev-navigation-flicker.test.ts b/tests/test-routing-flicker/tests/dev-navigation-flicker.test.ts new file mode 100644 index 000000000..131971040 --- /dev/null +++ b/tests/test-routing-flicker/tests/dev-navigation-flicker.test.ts @@ -0,0 +1,231 @@ +import { type Browser, type BrowserContext, type Page, chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' + +/** + * Dev Mode Navigation Flicker Tests + * + * These tests verify that pages don't show empty/blank content during + * client-side navigation in DEVELOPMENT mode. + * + * The issue: In dev mode, preloadRoute() returns early without preloading + * the loader data. This means when navigation happens, the component mounts + * with no data, useLoader throws a promise (Suspense), and the user briefly + * sees blank content before the data loads. + * + * Expected behavior: Content should transition smoothly without showing + * blank/empty states during navigation - same as production. + */ + +const serverUrl = process.env.ONE_SERVER_URL +const isDebug = !!process.env.DEBUG + +let browser: Browser +let context: BrowserContext + +beforeAll(async () => { + browser = await chromium.launch({ headless: !isDebug }) + context = await browser.newContext() +}) + +afterAll(async () => { + await browser.close() +}) + +/** + * Track content visibility during navigation. + * Returns true if content ever disappeared during navigation. + */ +async function setupContentTracker(page: Page, contentSelector: string) { + await page.evaluate((selector) => { + ;(window as any).__contentVisibilityHistory = [] + ;(window as any).__blankContentDetected = false + + const checkContent = () => { + const element = document.querySelector(selector) + const hasContent = element && element.textContent && element.textContent.trim().length > 10 + + ;(window as any).__contentVisibilityHistory.push({ + timestamp: Date.now(), + hasContent, + content: element?.textContent?.substring(0, 100) || '', + }) + + if (!hasContent) { + ;(window as any).__blankContentDetected = true + } + } + + // check frequently during navigation + const observer = new MutationObserver(() => checkContent()) + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }) + + ;(window as any).__contentObserver = observer + + // also poll to catch any missed states + ;(window as any).__contentInterval = setInterval(checkContent, 10) + }, contentSelector) +} + +async function getContentTrackerResults(page: Page) { + return page.evaluate(() => { + clearInterval((window as any).__contentInterval) + ;(window as any).__contentObserver?.disconnect() + return { + blankContentDetected: (window as any).__blankContentDetected, + history: (window as any).__contentVisibilityHistory, + } + }) +} + +/** + * Wait for an element to appear with content + */ +async function waitForContent( + page: Page, + selector: string, + expectedText: string, + timeout = 15000 +): Promise { + const startTime = Date.now() + while (Date.now() - startTime < timeout) { + try { + const element = await page.$(selector) + if (element) { + const text = await element.textContent() + if (text && text.includes(expectedText)) { + return true + } + } + } catch { + // element not found yet + } + await new Promise((res) => setTimeout(res, 50)) + } + return false +} + +/** + * Navigate via link and wait for target content + */ +async function navigateViaLink( + page: Page, + linkId: string, + targetSelector: string, + expectedText: string +): Promise { + // wait for link to be visible + const linkFound = await page.waitForSelector(`#${linkId}`, { + state: 'visible', + timeout: 10000, + }).catch(() => null) + + if (!linkFound) { + throw new Error(`Link #${linkId} not found`) + } + + await page.click(`#${linkId}`) + return waitForContent(page, targetSelector, expectedText) +} + +describe('Dev Mode Navigation - No Blank Content', () => { + test('navigating from page with loader to page with loader should not show blank content', async () => { + const page = await context.newPage() + + // capture console errors for debugging + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + // start at docs getting-started (has loader) + await page.goto(serverUrl + '/docs/getting-started', { waitUntil: 'networkidle' }) + await waitForContent(page, '#doc-title', 'Getting Started') + + // set up content tracker watching the app container + await setupContentTracker(page, '#app-container') + + // navigate to another doc page with loader + await navigateViaLink(page, 'nav-to-docs-api-reference', '#doc-title', 'API Reference') + + // small wait to ensure any blank flashes would have occurred + await new Promise((res) => setTimeout(res, 200)) + + const results = await getContentTrackerResults(page) + + // the issue: in dev mode, blank content is detected during navigation + // because preloadRoute returns early and useLoader has to fetch data + expect( + results.blankContentDetected, + `Blank content detected during navigation! This is the dev mode flicker bug. History: ${JSON.stringify(results.history.slice(-10), null, 2)}` + ).toBe(false) + + expect(errors).toHaveLength(0) + await page.close() + }) + + test('rapid navigation between pages with loaders should not show blank content', async () => { + const page = await context.newPage() + + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + // start at home page which has link to docs + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Home Page') + + await setupContentTracker(page, '#app-container') + + // navigate to docs index + await navigateViaLink(page, 'nav-to-docs-index', '#docs-title', 'Documentation') + // navigate to getting-started + await navigateViaLink(page, 'nav-to-docs-getting-started', '#doc-title', 'Getting Started') + // navigate to api-reference + await navigateViaLink(page, 'nav-to-docs-api-reference', '#doc-title', 'API Reference') + // navigate back home + await navigateViaLink(page, 'nav-to-home', '#page-title', 'Home Page') + + const results = await getContentTrackerResults(page) + + expect( + results.blankContentDetected, + `Blank content detected during rapid navigation! History: ${JSON.stringify(results.history.slice(-20), null, 2)}` + ).toBe(false) + + expect(errors).toHaveLength(0) + await page.close() + }) + + test('navigation between SSG and default mode pages should not show blank content', async () => { + const page = await context.newPage() + + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + // start at home (SSG) + await page.goto(serverUrl + '/', { waitUntil: 'networkidle' }) + await waitForContent(page, '#page-title', 'Home Page') + + await setupContentTracker(page, '#app-container') + + // navigate to default mode page (uses defaultRenderMode) + await navigateViaLink(page, 'nav-to-default-index', '#default-title', 'Default Mode Index') + + // navigate to default mode dynamic page + await navigateViaLink(page, 'nav-to-default-page-one', '#default-slug-title', 'Page One') + + // navigate back to SSG page + await navigateViaLink(page, 'nav-to-docs-getting-started', '#doc-title', 'Getting Started') + + const results = await getContentTrackerResults(page) + + expect( + results.blankContentDetected, + `Blank content detected during cross-mode navigation! History: ${JSON.stringify(results.history.slice(-20), null, 2)}` + ).toBe(false) + + expect(errors).toHaveLength(0) + await page.close() + }) +})