From 5d22388c223fca35ee4bd00dc4834d03c8c40305 Mon Sep 17 00:00:00 2001 From: Murat Erkin Cicek Date: Tue, 9 Jun 2026 16:22:59 +0300 Subject: [PATCH 1/2] fix(ssr): render router layouts during SSR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSR router only resolved the leaf component for a route — layouts declared via `layout:` on route groups were dropped, and the leaf was mounted directly into the RouterView host div. Layout components like AppShell and DashboardLayout only appeared after hydration, producing a tree mismatch that forced the client to re-render the RouterView's subtree on load. Walk route groups in createServerRouter to collect a layout chain (outermost → innermost) and capture queryModes metadata; resolve mode-query children by their query-string param instead of by path segment. createSSRRouterState.getComponentAtDepth now mirrors the client Router contract: layouts at depth < layoutCount (with page/route/params plus activeKey/keys/navigate for query-mode depths), leaf at depth === layoutCount. Expose `layout` and `mode` on the SSR RouteGroup type so the route shape the router actually supports type-checks; the legacy `component` field stays as a deprecated alias. Fix a broken vite-plugin-gea import in the ssr/router-v2 example so its dev server starts. --- .changeset/gea-ssr-router-layout-chain.md | 7 ++ .../ssr/router-v2/tests/router-v2-ssr.spec.ts | 19 ++++ packages/gea-ssr/src/server-router.ts | 91 ++++++++++++++++- packages/gea-ssr/src/ssr-router-context.ts | 58 +++++++++-- packages/gea-ssr/src/types.ts | 12 +++ packages/gea-ssr/tests/server-router.test.ts | 99 +++++++++++++++++++ .../gea-ssr/tests/ssr-router-context.test.ts | 66 +++++++++++++ 7 files changed, 338 insertions(+), 14 deletions(-) create mode 100644 .changeset/gea-ssr-router-layout-chain.md diff --git a/.changeset/gea-ssr-router-layout-chain.md b/.changeset/gea-ssr-router-layout-chain.md new file mode 100644 index 0000000..372b31b --- /dev/null +++ b/.changeset/gea-ssr-router-layout-chain.md @@ -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`. diff --git a/examples/ssr/router-v2/tests/router-v2-ssr.spec.ts b/examples/ssr/router-v2/tests/router-v2-ssr.spec.ts index f784a90..f0babf6 100644 --- a/examples/ssr/router-v2/tests/router-v2-ssr.spec.ts +++ b/examples/ssr/router-v2/tests/router-v2-ssr.spec.ts @@ -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) => { diff --git a/packages/gea-ssr/src/server-router.ts b/packages/gea-ssr/src/server-router.ts index f93da45..ea89fa2 100644 --- a/packages/gea-ssr/src/server-router.ts +++ b/packages/gea-ssr/src/server-router.ts @@ -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 @@ -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 guardRedirect: string | null isNotFound: boolean } @@ -53,13 +63,22 @@ interface ResolvedRoute { component: GeaComponentConstructor | null matches: string[] guardRedirect: string | null + layouts: GeaComponentConstructor[] + queryModes: Map +} + +interface ResolveContext { + query: Record + skipGuards: boolean } function resolveRoutes( routes: RouteMap, path: string, + ctx: ResolveContext, parentMatches: string[] = [], - skipGuards = false, + parentLayouts: GeaComponentConstructor[] = [], + parentQueryModes: Map = new Map(), ): ResolvedRoute | null { for (const [pattern, entry] of Object.entries(routes)) { if (pattern === '*') continue @@ -74,6 +93,8 @@ function resolveRoutes( component: null, matches: [...parentMatches, pattern], guardRedirect: entry, + layouts: parentLayouts, + queryModes: parentQueryModes, } } continue @@ -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 { @@ -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 } } } @@ -134,6 +203,8 @@ function resolveRoutes( component: entry, matches: [...parentMatches, pattern], guardRedirect: null, + layouts: parentLayouts, + queryModes: parentQueryModes, } } } @@ -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 @@ -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, @@ -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 === '*', } diff --git a/packages/gea-ssr/src/ssr-router-context.ts b/packages/gea-ssr/src/ssr-router-context.ts index 9dd4792..c051a0b 100644 --- a/packages/gea-ssr/src/ssr-router-context.ts +++ b/packages/gea-ssr/src/ssr-router-context.ts @@ -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 + cacheKey: string | null +} interface SSRRouterState { path: string @@ -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() @@ -38,6 +45,11 @@ export function runWithSSRRouter(state: object, fn: () => T): T { export function createSSRRouterState(routeResult: ServerRouteResult): SSRRouterState { const noop = () => {} + const layouts = routeResult.layouts ?? [] + const queryModes = routeResult.queryModes ?? new Map() + const layoutCount = layouts.length + const leaf = routeResult.component + return { path: routeResult.path, route: routeResult.route, @@ -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 === '/' @@ -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 = { ...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 + }, } } diff --git a/packages/gea-ssr/src/types.ts b/packages/gea-ssr/src/types.ts index d246d94..8baba39 100644 --- a/packages/gea-ssr/src/types.ts +++ b/packages/gea-ssr/src/types.ts @@ -97,9 +97,21 @@ export interface GeaComponentConstructor

= 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 } diff --git a/packages/gea-ssr/tests/server-router.test.ts b/packages/gea-ssr/tests/server-router.test.ts index 410e6cc..f834b2e 100644 --- a/packages/gea-ssr/tests/server-router.test.ts +++ b/packages/gea-ssr/tests/server-router.test.ts @@ -232,3 +232,102 @@ describe('createServerRouter — edge cases', () => { assert.deepEqual(result.query.tag, ['a', 'b', 'c']) }) }) + +describe('resolveRoutes — layout chain', () => { + class AppShell {} + class DashboardLayout {} + class SettingsLayout {} + class Overview {} + class Projects {} + class ProfileSettings {} + class BillingSettings {} + class NotFound {} + + const routes = { + '/': { + layout: AppShell, + children: { + '/': '/dashboard', + '/dashboard': { + layout: DashboardLayout, + children: { + '/': Overview, + '/projects': Projects, + }, + }, + '/settings': { + layout: SettingsLayout, + mode: { type: 'query' as const, param: 'tab' }, + children: { + profile: ProfileSettings, + billing: BillingSettings, + }, + }, + }, + }, + '*': NotFound, + } + + it('collects nested layouts outermost-to-innermost', () => { + const result = createServerRouter('http://localhost/dashboard', routes) + assert.deepEqual(result.layouts, [AppShell, DashboardLayout]) + assert.equal(result.component, Overview) + }) + + it('collects a single layout for a top-level group', () => { + const result = createServerRouter('http://localhost/dashboard/projects', routes) + assert.deepEqual(result.layouts, [AppShell, DashboardLayout]) + assert.equal(result.component, Projects) + }) + + it('returns empty layouts for non-layout routes', () => { + const flat = { '/': Overview, '/about': Projects } + const result = createServerRouter('http://localhost/about', flat) + assert.deepEqual(result.layouts, []) + assert.equal(result.queryModes.size, 0) + assert.equal(result.component, Projects) + }) + + it('resolves query-mode child via ?tab=', () => { + const result = createServerRouter('http://localhost/settings?tab=billing', routes) + assert.deepEqual(result.layouts, [AppShell, SettingsLayout]) + assert.equal(result.component, BillingSettings) + const mode = result.queryModes.get(1) + assert.ok(mode) + assert.equal(mode!.activeKey, 'billing') + assert.deepEqual(mode!.keys, ['profile', 'billing']) + assert.equal(mode!.param, 'tab') + }) + + it('falls back to first query-mode key when param missing', () => { + const result = createServerRouter('http://localhost/settings', routes) + assert.equal(result.component, ProfileSettings) + assert.equal(result.queryModes.get(1)!.activeKey, 'profile') + }) + + it('falls back to first query-mode key when param value unknown', () => { + const result = createServerRouter('http://localhost/settings?tab=bogus', routes) + assert.equal(result.component, ProfileSettings) + assert.equal(result.queryModes.get(1)!.activeKey, 'profile') + }) + + it('keeps layout chain when wildcard fires inside nested group', () => { + const routesWithInnerWildcard = { + '/': { + layout: AppShell, + children: { + '/dashboard': { + layout: DashboardLayout, + children: { + '/': Overview, + '*': NotFound, + }, + }, + }, + }, + } + const result = createServerRouter('http://localhost/dashboard/missing', routesWithInnerWildcard) + assert.equal(result.component, NotFound) + assert.deepEqual(result.layouts, [AppShell, DashboardLayout]) + }) +}) diff --git a/packages/gea-ssr/tests/ssr-router-context.test.ts b/packages/gea-ssr/tests/ssr-router-context.test.ts index 2ee21c6..c5f667e 100644 --- a/packages/gea-ssr/tests/ssr-router-context.test.ts +++ b/packages/gea-ssr/tests/ssr-router-context.test.ts @@ -150,6 +150,72 @@ describe('createSSRRouterState', () => { const state = createSSRRouterState(withComponent) assert.equal(state.page, MockComponent) }) + + it('layoutCount defaults to 0 when layouts missing', () => { + const state = createSSRRouterState(mockRouteResult) + assert.equal(state.layoutCount, 0) + }) + + it('getComponentAtDepth returns leaf at depth 0 when no layouts', () => { + class Leaf {} + const result = { ...mockRouteResult, component: Leaf as any } + const state = createSSRRouterState(result) + const item = state.getComponentAtDepth(0) + assert.ok(item) + assert.equal(item!.component, Leaf) + assert.equal(item!.cacheKey, null) + }) + + it('getComponentAtDepth returns layouts at their depth and leaf at the end', () => { + class AppShell {} + class DashboardLayout {} + class Overview {} + const result = { + ...mockRouteResult, + component: Overview as any, + layouts: [AppShell, DashboardLayout] as any, + queryModes: new Map(), + } + const state = createSSRRouterState(result) + assert.equal(state.layoutCount, 2) + + const depth0 = state.getComponentAtDepth(0) + assert.equal(depth0!.component, AppShell) + assert.equal(depth0!.props.page, DashboardLayout) + assert.equal(depth0!.props.route, mockRouteResult.route) + + const depth1 = state.getComponentAtDepth(1) + assert.equal(depth1!.component, DashboardLayout) + assert.equal(depth1!.props.page, Overview) + + const depth2 = state.getComponentAtDepth(2) + assert.equal(depth2!.component, Overview) + + assert.equal(state.getComponentAtDepth(3), null) + }) + + it('getComponentAtDepth exposes query-mode props on the layout', () => { + class SettingsLayout {} + class ProfileSettings {} + const result = { + ...mockRouteResult, + component: ProfileSettings as any, + layouts: [SettingsLayout] as any, + queryModes: new Map([[0, { activeKey: 'profile', keys: ['profile', 'billing'], param: 'tab' }]]), + } + const state = createSSRRouterState(result) + const item = state.getComponentAtDepth(0) + assert.equal(item!.props.activeKey, 'profile') + assert.deepEqual(item!.props.keys, ['profile', 'billing']) + assert.equal(typeof item!.props.navigate, 'function') + assert.equal(item!.cacheKey, 'profile') + }) + + it('getComponentAtDepth returns null past leaf when nothing matched', () => { + const result = { ...mockRouteResult, component: null } + const state = createSSRRouterState(result) + assert.equal(state.getComponentAtDepth(0), null) + }) }) describe('handleRequest with SSR router context', () => { From e13995629ed578b832972e48d1e2de39d2034bab Mon Sep 17 00:00:00 2001 From: Murat Erkin Cicek Date: Tue, 9 Jun 2026 16:31:45 +0300 Subject: [PATCH 2/2] fix(ssr): fix vite plugin import paths in ssr examples --- examples/ssr/flight-checkin/vite.config.ts | 2 +- examples/ssr/kanban/vite.config.ts | 2 +- examples/ssr/router-v2/vite.config.ts | 2 +- examples/ssr/todo/vite.config.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/ssr/flight-checkin/vite.config.ts b/examples/ssr/flight-checkin/vite.config.ts index 8fadcd1..ff43b74 100644 --- a/examples/ssr/flight-checkin/vite.config.ts +++ b/examples/ssr/flight-checkin/vite.config.ts @@ -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)) diff --git a/examples/ssr/kanban/vite.config.ts b/examples/ssr/kanban/vite.config.ts index 1fa194f..3591ef1 100644 --- a/examples/ssr/kanban/vite.config.ts +++ b/examples/ssr/kanban/vite.config.ts @@ -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)) diff --git a/examples/ssr/router-v2/vite.config.ts b/examples/ssr/router-v2/vite.config.ts index d2c5e32..b036cb0 100644 --- a/examples/ssr/router-v2/vite.config.ts +++ b/examples/ssr/router-v2/vite.config.ts @@ -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)) diff --git a/examples/ssr/todo/vite.config.ts b/examples/ssr/todo/vite.config.ts index 13d6961..eb5bf31 100644 --- a/examples/ssr/todo/vite.config.ts +++ b/examples/ssr/todo/vite.config.ts @@ -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))