Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/gea-ssr-router-layout-chain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@geajs/ssr": patch
---

### @geajs/ssr (patch)

- **Render router layouts in SSR HTML**: `createServerRouter` now walks route groups to collect the `layout` chain (outermost → innermost) and captures `queryModes` metadata; `createSSRRouterState.getComponentAtDepth(depth)` mirrors the client `Router` — returning layouts at `depth < layoutCount` (with `page`/`route`/`params` props, plus `activeKey`/`keys`/`navigate` for query-mode depths) and the leaf at `depth === layoutCount`. Before this, SSR mounted the leaf component directly into `RouterView`'s host div and layouts only appeared after client hydration. The `RouteGroup` SSR type gains `layout` and `mode` fields to match the route shape the router config actually supports; `RouteGroup.component` is kept for backward compatibility but treated as an alias for `layout`.
2 changes: 1 addition & 1 deletion examples/ssr/flight-checkin/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import { geaCoreAliases } from '../../shared/vite-config-base'
import { geaPlugin } from '../../../packages/vite-plugin-gea/index.ts'
import { geaPlugin } from '../../../packages/vite-plugin-gea/src/index.ts'
import { geaSSR } from '../../../packages/gea-ssr/src/vite.ts'

const __dirname = dirname(fileURLToPath(import.meta.url))
Expand Down
2 changes: 1 addition & 1 deletion examples/ssr/kanban/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import { geaCoreAliases } from '../../shared/vite-config-base'
import { geaPlugin } from '../../../packages/vite-plugin-gea/index.ts'
import { geaPlugin } from '../../../packages/vite-plugin-gea/src/index.ts'
import { geaSSR } from '../../../packages/gea-ssr/src/vite.ts'

const __dirname = dirname(fileURLToPath(import.meta.url))
Expand Down
19 changes: 19 additions & 0 deletions examples/ssr/router-v2/tests/router-v2-ssr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ test.describe('Router v2 (SSR)', () => {
expect(html).toContain('id="app"')
})

test('server renders nested layout chain in HTML (no client mount needed)', async ({ page }) => {
// Guards are skipped during SSR, so /dashboard should render
// AppShell → DashboardLayout → Overview in the raw HTML response.
const response = await page.request.get('/dashboard')
const html = await response.text()
expect(html).toContain('class="app-shell"')
expect(html).toContain('class="dashboard-layout"')
expect(html).toContain('class="dashboard-main"')
expect(html).toContain('class="overview"')
})

test('server renders query-mode layout with active tab in HTML', async ({ page }) => {
const response = await page.request.get('/settings?tab=billing')
const html = await response.text()
expect(html).toContain('class="app-shell"')
expect(html).toContain('class="settings-layout"')
expect(html).toContain('Billing')
})

test('no console errors after hydration', async ({ page }) => {
const errors: string[] = []
page.on('console', (msg) => {
Expand Down
2 changes: 1 addition & 1 deletion examples/ssr/router-v2/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
import { geaCoreAliases } from '../../shared/vite-config-base'
import { geaPlugin } from '../../../packages/vite-plugin-gea/index.ts'
import { geaPlugin } from '../../../packages/vite-plugin-gea/src/index.ts'
import { geaSSR } from '../../../packages/gea-ssr/src/vite.ts'

const __dirname = dirname(fileURLToPath(import.meta.url))
Expand Down
2 changes: 1 addition & 1 deletion examples/ssr/todo/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import { geaCoreAliases } from '../../shared/vite-config-base'
import { geaPlugin } from '../../../packages/vite-plugin-gea/index.ts'
import { geaPlugin } from '../../../packages/vite-plugin-gea/src/index.ts'
import { geaSSR } from '../../../packages/gea-ssr/src/vite.ts'

const __dirname = dirname(fileURLToPath(import.meta.url))
Expand Down
91 changes: 86 additions & 5 deletions packages/gea-ssr/src/server-router.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { GeaComponentConstructor, RouteMap } from './types'
import { isComponentConstructor, isRouteGroup } from './types'

export interface ServerRouteQueryMode {
activeKey: string
keys: string[]
param: string
}

export interface ServerRouteResult {
path: string
route: string
Expand All @@ -9,6 +15,10 @@ export interface ServerRouteResult {
hash: string
matches: string[]
component: GeaComponentConstructor | null
/** Layout chain from outermost to innermost. */
layouts: GeaComponentConstructor[]
/** Query-mode metadata keyed by layout depth (index into `layouts`). */
queryModes: Map<number, ServerRouteQueryMode>
guardRedirect: string | null
isNotFound: boolean
}
Expand Down Expand Up @@ -53,13 +63,22 @@ interface ResolvedRoute {
component: GeaComponentConstructor | null
matches: string[]
guardRedirect: string | null
layouts: GeaComponentConstructor[]
queryModes: Map<number, ServerRouteQueryMode>
}

interface ResolveContext {
query: Record<string, string | string[]>
skipGuards: boolean
}

function resolveRoutes(
routes: RouteMap,
path: string,
ctx: ResolveContext,
parentMatches: string[] = [],
skipGuards = false,
parentLayouts: GeaComponentConstructor[] = [],
parentQueryModes: Map<number, ServerRouteQueryMode> = new Map(),
): ResolvedRoute | null {
for (const [pattern, entry] of Object.entries(routes)) {
if (pattern === '*') continue
Expand All @@ -74,6 +93,8 @@ function resolveRoutes(
component: null,
matches: [...parentMatches, pattern],
guardRedirect: entry,
layouts: parentLayouts,
queryModes: parentQueryModes,
}
}
continue
Expand Down Expand Up @@ -104,7 +125,7 @@ function resolveRoutes(

if (prefixMatch) {
// Check guard (skip during SSR — guards may use browser-only APIs)
if (entry.guard && !skipGuards) {
if (entry.guard && !ctx.skipGuards) {
const guardResult = entry.guard()
if (guardResult !== true) {
return {
Expand All @@ -113,11 +134,59 @@ function resolveRoutes(
component: null,
matches: [...parentMatches, pattern],
guardRedirect: typeof guardResult === 'string' ? guardResult : null,
layouts: parentLayouts,
queryModes: parentQueryModes,
}
}
}

const layout = entry.layout ?? entry.component
const layouts = layout ? [...parentLayouts, layout] : parentLayouts
const queryModes = new Map(parentQueryModes)
const groupMatches = [...parentMatches, pattern]

// Query-mode group: pick child by `query[param]`, not by path segment.
if (entry.mode?.type === 'query') {
const childKeys = Object.keys(entry.children)
const raw = ctx.query[entry.mode.param]
const fromQuery = Array.isArray(raw) ? raw[0] : raw
const activeKey = fromQuery && childKeys.includes(fromQuery) ? fromQuery : childKeys[0]

if (layout) {
queryModes.set(layouts.length - 1, {
activeKey,
keys: childKeys,
param: entry.mode.param,
})
}

const childEntry = childKeys.length > 0 ? entry.children[activeKey] : undefined
if (isComponentConstructor(childEntry as any)) {
return {
route: pattern,
params,
component: childEntry as GeaComponentConstructor,
matches: [...groupMatches, activeKey],
guardRedirect: null,
layouts,
queryModes,
}
}
// Active key didn't resolve to a component — render the layout with
// no leaf so the chain still appears in the HTML.
return {
route: pattern,
params,
component: null,
matches: groupMatches,
guardRedirect: null,
layouts,
queryModes,
}
}

const rest = pattern === '/' ? path : '/' + pathParts.slice(patternParts.length).join('/')
const childResult = resolveRoutes(entry.children, rest, [...parentMatches, pattern], skipGuards)
const childResult = resolveRoutes(entry.children, rest, ctx, groupMatches, layouts, queryModes)
if (childResult) {
return { ...childResult, params: { ...params, ...childResult.params } }
}
Expand All @@ -134,6 +203,8 @@ function resolveRoutes(
component: entry,
matches: [...parentMatches, pattern],
guardRedirect: null,
layouts: parentLayouts,
queryModes: parentQueryModes,
}
}
}
Expand All @@ -142,7 +213,15 @@ function resolveRoutes(
if ('*' in routes) {
const wildcard = routes['*']
const component = isComponentConstructor(wildcard) ? wildcard : null
return { route: '*', params: {}, component, matches: [...parentMatches, '*'], guardRedirect: null }
return {
route: '*',
params: {},
component,
matches: [...parentMatches, '*'],
guardRedirect: null,
layouts: parentLayouts,
queryModes: parentQueryModes,
}
}

return null
Expand All @@ -154,7 +233,7 @@ export function createServerRouter(url: string, routes: RouteMap, skipGuards = f
const query = parseQuery(parsed.search)
const hash = parsed.hash

const resolved = resolveRoutes(routes, path, [], skipGuards)
const resolved = resolveRoutes(routes, path, { query, skipGuards })

return {
path,
Expand All @@ -164,6 +243,8 @@ export function createServerRouter(url: string, routes: RouteMap, skipGuards = f
hash,
matches: resolved?.matches ?? [],
component: resolved?.component ?? null,
layouts: resolved?.layouts ?? [],
queryModes: resolved?.queryModes ?? new Map(),
guardRedirect: resolved?.guardRedirect ?? null,
isNotFound: !resolved || resolved.route === '*',
}
Expand Down
58 changes: 49 additions & 9 deletions packages/gea-ssr/src/ssr-router-context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import type { ServerRouteResult } from './server-router'
import type { GeaComponentConstructor } from './types'
import type { ServerRouteResult, ServerRouteQueryMode } from './server-router'

interface RouteHostItem {
component: GeaComponentConstructor
props: Record<string, unknown>
cacheKey: string | null
}

interface SSRRouterState {
path: string
Expand All @@ -23,7 +30,7 @@ interface SSRRouterState {
dispose: () => void
setRoutes: (...args: unknown[]) => void
observe: (path: unknown, fn: unknown) => () => void
getComponentAtDepth: () => null
getComponentAtDepth: (depth: number) => RouteHostItem | null
}

const ssrRouterContext = new AsyncLocalStorage<SSRRouterState>()
Expand All @@ -38,6 +45,11 @@ export function runWithSSRRouter<T>(state: object, fn: () => T): T {

export function createSSRRouterState(routeResult: ServerRouteResult): SSRRouterState {
const noop = () => {}
const layouts = routeResult.layouts ?? []
const queryModes = routeResult.queryModes ?? new Map<number, ServerRouteQueryMode>()
const layoutCount = layouts.length
const leaf = routeResult.component

return {
path: routeResult.path,
route: routeResult.route,
Expand All @@ -47,8 +59,8 @@ export function createSSRRouterState(routeResult: ServerRouteResult): SSRRouterS
matches: routeResult.matches,
error: null,
routeConfig: {},
page: routeResult.component,
layoutCount: 0,
page: leaf,
layoutCount,

isActive(p: string): boolean {
if (p === '/') return routeResult.path === '/'
Expand All @@ -73,10 +85,38 @@ export function createSSRRouterState(routeResult: ServerRouteResult): SSRRouterS
observe(_path: unknown, _fn: unknown) {
return noop
},
getComponentAtDepth: (() => {
return routeResult.component
? { component: routeResult.component, props: { ...routeResult.params }, cacheKey: null }
: null
}) as any,
// Mirror client Router.getComponentAtDepth so RouterView/Outlet mount the
// full layout chain during SSR. Layouts at `depth < layoutCount`, leaf at
// `depth === layoutCount`.
getComponentAtDepth(depth: number): RouteHostItem | null {
if (depth < layoutCount) {
const layout = layouts[depth]
const props: Record<string, unknown> = { ...routeResult.params }
props.route = routeResult.route

const nextDepth = depth + 1
if (nextDepth < layoutCount) {
props.page = layouts[nextDepth]
} else {
props.page = leaf
}

let cacheKey: string | null = null
const modeInfo = queryModes.get(depth)
if (modeInfo) {
props.activeKey = modeInfo.activeKey
props.keys = modeInfo.keys
// Navigation is meaningless during SSR — provide a noop so layouts
// that destructure `navigate` don't throw.
props.navigate = noop
cacheKey = modeInfo.activeKey
}
return { component: layout, props, cacheKey }
}
if (depth === layoutCount && leaf) {
return { component: leaf, props: { ...routeResult.params }, cacheKey: null }
}
return null
},
}
}
12 changes: 12 additions & 0 deletions packages/gea-ssr/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,21 @@ export interface GeaComponentConstructor<P extends Record<string, unknown> = Rec

export type RouteGuard = () => boolean | string

export interface RouteQueryMode {
type: 'query'
param: string
}

export interface RouteGroup {
children: RouteMap
guard?: RouteGuard
/** Layout component wrapping the children. Receives `page`, `route`, `params`,
* plus `activeKey`/`keys`/`navigate` when `mode` is set. */
layout?: GeaComponentConstructor
/** Tab-style mode: pick the active child by a query-string param instead of
* by path segment. Children keys become the activeKey values. */
mode?: RouteQueryMode
/** @deprecated use `layout` instead */
component?: GeaComponentConstructor
}

Expand Down
Loading
Loading