From 2af75e4c9cc3ba7adbeeddf7c3b9fa9eaca8091e Mon Sep 17 00:00:00 2001 From: babaygt Date: Mon, 30 Mar 2026 11:36:08 +0200 Subject: [PATCH 1/6] fix(examples): fix SSR example import paths and CSS loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix vite-plugin-gea import path (index.ts → src/index.ts) in 4 SSR example vite configs (todo, kanban, flight-checkin, router-v2) - Move CSS from broken tags to JS imports in 5 SSR client entry files so Vite can resolve styles correctly - Add Google Fonts links to SSR index.html files to match the non-SSR examples --- examples/ssr/flight-checkin/index.html | 4 +++- examples/ssr/flight-checkin/src/main.ts | 1 + examples/ssr/flight-checkin/vite.config.ts | 2 +- examples/ssr/kanban/index.html | 4 +++- examples/ssr/kanban/src/main.ts | 1 + examples/ssr/kanban/vite.config.ts | 2 +- examples/ssr/router-simple/index.html | 4 +++- examples/ssr/router-simple/src/main.ts | 1 + examples/ssr/router-v2/index.html | 4 +++- examples/ssr/router-v2/src/main.ts | 1 + examples/ssr/router-v2/vite.config.ts | 2 +- examples/ssr/todo/index.html | 4 +++- examples/ssr/todo/src/main.ts | 1 + examples/ssr/todo/vite.config.ts | 2 +- 14 files changed, 24 insertions(+), 9 deletions(-) diff --git a/examples/ssr/flight-checkin/index.html b/examples/ssr/flight-checkin/index.html index c3cb0031..0c007b7d 100644 --- a/examples/ssr/flight-checkin/index.html +++ b/examples/ssr/flight-checkin/index.html @@ -4,7 +4,9 @@ Flight Check-in SSR - Gea - + + + diff --git a/examples/ssr/flight-checkin/src/main.ts b/examples/ssr/flight-checkin/src/main.ts index 86ac1c0a..079fc04e 100644 --- a/examples/ssr/flight-checkin/src/main.ts +++ b/examples/ssr/flight-checkin/src/main.ts @@ -1,3 +1,4 @@ +import '../../../flight-checkin/src/styles.css' import { hydrate } from '../../../../packages/gea-ssr/src/client' import App from '../../../flight-checkin/src/flight-checkin' import flightStore from '../../../flight-checkin/src/flight-store' diff --git a/examples/ssr/flight-checkin/vite.config.ts b/examples/ssr/flight-checkin/vite.config.ts index 8fadcd1d..ff43b744 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/index.html b/examples/ssr/kanban/index.html index 14f0407c..e0972022 100644 --- a/examples/ssr/kanban/index.html +++ b/examples/ssr/kanban/index.html @@ -4,7 +4,9 @@ Kanban SSR - Gea - + + + diff --git a/examples/ssr/kanban/src/main.ts b/examples/ssr/kanban/src/main.ts index 039d3c8c..592b7d1a 100644 --- a/examples/ssr/kanban/src/main.ts +++ b/examples/ssr/kanban/src/main.ts @@ -1,3 +1,4 @@ +import '../../../kanban/src/styles.css' import { hydrate } from '../../../../packages/gea-ssr/src/client' import App from '../../../kanban/src/kanban-app' import kanbanStore from '../../../kanban/src/kanban-store' diff --git a/examples/ssr/kanban/vite.config.ts b/examples/ssr/kanban/vite.config.ts index 1fa194f2..3591ef11 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-simple/index.html b/examples/ssr/router-simple/index.html index 1ebcfdd8..f112dc42 100644 --- a/examples/ssr/router-simple/index.html +++ b/examples/ssr/router-simple/index.html @@ -4,7 +4,9 @@ Router Simple SSR - Gea - + + + diff --git a/examples/ssr/router-simple/src/main.ts b/examples/ssr/router-simple/src/main.ts index 72ecc090..54d5701b 100644 --- a/examples/ssr/router-simple/src/main.ts +++ b/examples/ssr/router-simple/src/main.ts @@ -1,3 +1,4 @@ +import '../../../router-simple/src/styles.css' import { hydrate } from '../../../../packages/gea-ssr/src/client' import App from '../../../router-simple/src/App' diff --git a/examples/ssr/router-v2/index.html b/examples/ssr/router-v2/index.html index 23913305..9010b81a 100644 --- a/examples/ssr/router-v2/index.html +++ b/examples/ssr/router-v2/index.html @@ -4,7 +4,9 @@ Router v2 SSR - Gea - + + + diff --git a/examples/ssr/router-v2/src/main.ts b/examples/ssr/router-v2/src/main.ts index 664f26e0..859dca6a 100644 --- a/examples/ssr/router-v2/src/main.ts +++ b/examples/ssr/router-v2/src/main.ts @@ -1,3 +1,4 @@ +import '../../../router-v2/src/styles.css' import { hydrate } from '../../../../packages/gea-ssr/src/client' import App from '../../../router-v2/src/App' import authStore from '../../../router-v2/src/stores/auth-store' diff --git a/examples/ssr/router-v2/vite.config.ts b/examples/ssr/router-v2/vite.config.ts index d2c5e321..b036cb02 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/index.html b/examples/ssr/todo/index.html index 72a90765..9cbc9313 100644 --- a/examples/ssr/todo/index.html +++ b/examples/ssr/todo/index.html @@ -4,7 +4,9 @@ Todo SSR - Gea - + + + diff --git a/examples/ssr/todo/src/main.ts b/examples/ssr/todo/src/main.ts index 8b9ce4e1..ca6d199d 100644 --- a/examples/ssr/todo/src/main.ts +++ b/examples/ssr/todo/src/main.ts @@ -1,3 +1,4 @@ +import '../../../todo/styles.css' import { hydrate } from '../../../../packages/gea-ssr/src/client' import App from '../../../todo/todo-app' import todoStore from '../../../todo/todo-store' diff --git a/examples/ssr/todo/vite.config.ts b/examples/ssr/todo/vite.config.ts index 13d6961c..eb5bf31f 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)) From c26a1c72136b805a82453b9730692be6a42e9708 Mon Sep 17 00:00:00 2001 From: babaygt Date: Mon, 30 Mar 2026 11:36:12 +0200 Subject: [PATCH 2/6] docs: add Gea SSR section to documentation sidebar Add SSR documentation pages to the VitePress sidebar navigation, covering overview, getting started, hydration, store isolation, routing, streaming, and Vite plugin integration. --- docs/.vitepress/config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 30e83b13..0b19e82b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -113,6 +113,21 @@ export default defineConfig({ { text: 'Components', link: '/gea-mobile/components' }, ], }, + { + text: 'Gea SSR', + items: [ + { text: 'Overview', link: '/gea-ssr/overview' }, + { text: 'Getting Started', link: '/gea-ssr/getting-started' }, + { text: 'Handle Request', link: '/gea-ssr/handle-request' }, + { text: 'Streaming', link: '/gea-ssr/streaming' }, + { text: 'Hydration', link: '/gea-ssr/hydration' }, + { text: 'Store Isolation', link: '/gea-ssr/store-isolation' }, + { text: 'Router Context', link: '/gea-ssr/router-context' }, + { text: 'Server-Side Routing', link: '/gea-ssr/server-routing' }, + { text: 'Node Integration', link: '/gea-ssr/node-integration' }, + { text: 'Vite Plugin', link: '/gea-ssr/vite-plugin' }, + ], + }, { text: 'Tooling', items: [ From c12115f2ee7a9af3cefe2773703264e1534d4eb5 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 30 Mar 2026 19:28:51 +0200 Subject: [PATCH 3/6] test(ssr): add failing e2e tests for list hydration duplication bug Also fix SSR todo example import paths (vite-plugin src/index.ts, CSS import). --- tests/e2e/playwright.config.ts | 1 + tests/e2e/ssr-todo.spec.ts | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/e2e/ssr-todo.spec.ts diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 9a828272..72d4bcc9 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -47,6 +47,7 @@ const examples: ExampleDef[] = [ { name: 'runtime-only' }, { name: 'runtime-only-jsx' }, { name: 'ssr-router-simple', dir: 'ssr/router-simple' }, + { name: 'ssr-todo', dir: 'ssr/todo' }, { name: 'sheet-editor' }, ] diff --git a/tests/e2e/ssr-todo.spec.ts b/tests/e2e/ssr-todo.spec.ts new file mode 100644 index 00000000..be983224 --- /dev/null +++ b/tests/e2e/ssr-todo.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test' + +test.describe('SSR Todo List Hydration', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('domcontentloaded') + await page.waitForSelector('.todo-app', { timeout: 2000 }) + }) + + test('server renders 3 todo items', async ({ page }) => { + const response = await page.request.get('/') + const html = await response.text() + expect(html).toContain('Server rendered todo 1') + expect(html).toContain('Server rendered todo 2') + expect(html).toContain('Server rendered todo 3') + }) + + test('hydrated app shows exactly 3 todos without duplicates', async ({ page }) => { + const items = page.locator('.todo-item') + await expect(items).toHaveCount(3) + }) + + test('toggling a todo updates the checkbox without duplicating items', async ({ page }) => { + const items = page.locator('.todo-item') + await expect(items).toHaveCount(3) + + // Toggle the first todo + await page.locator('.todo-checkbox').first().click() + // Still exactly 3 items + await expect(items).toHaveCount(3) + }) + + test('clicking Active filter shows only active items without duplicates', async ({ page }) => { + const items = page.locator('.todo-item') + await expect(items).toHaveCount(3) + + // Server pre-populates: todo1 (active), todo2 (done), todo3 (active) + // Click Active filter + await page.getByRole('button', { name: 'Active' }).click() + + // Should show exactly 2 active todos (not 2 originals + 2 duplicates) + await expect(items).toHaveCount(2) + }) + + test('switching All -> Active -> All does not duplicate items', async ({ page }) => { + const items = page.locator('.todo-item') + await expect(items).toHaveCount(3) + + await page.getByRole('button', { name: 'Active' }).click() + await expect(items).toHaveCount(2) + + await page.getByRole('button', { name: 'All' }).click() + // Must be exactly 3, not 6 (duplicated) + await expect(items).toHaveCount(3) + }) + + test('removing a todo does not cause duplication', async ({ page }) => { + const items = page.locator('.todo-item') + await expect(items).toHaveCount(3) + + await page.locator('.todo-remove').first().click() + await expect(items).toHaveCount(2) + }) +}) From dc5823da170d5dbc236513c3cd49dfd1a5d62297 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 30 Mar 2026 20:46:31 +0200 Subject: [PATCH 4/6] fix(ssr): prevent list duplication during hydration Three changes fix SSR list hydration: 1. Add __adoptListItems() to Component: populates compiler-generated _*Items tracking arrays with adopted child components during hydration, so subsequent __reconcileList calls see existing items. 2. Silent restoreStoreState in hydrate(): discards pending change notifications from restoreStoreState since the DOM already reflects the server state and the tracking arrays aren't populated yet. 3. Clean up observers in renderToString: the dev-mode mismatch detection creates a temporary App instance whose constructor registers observers on shared stores. Without cleanup, these duplicate observers cause double reconciliation on every store change. --- packages/gea-ssr/src/client.ts | 15 +++++++- packages/gea/src/lib/base/component.tsx | 46 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/gea-ssr/src/client.ts b/packages/gea-ssr/src/client.ts index 19dc4854..97d53fea 100644 --- a/packages/gea-ssr/src/client.ts +++ b/packages/gea-ssr/src/client.ts @@ -52,9 +52,21 @@ export function hydrate( return } - // Restore store state from server + // Restore store state from server — silently, to prevent change notifications. + // The DOM already reflects the server state; observer-driven reconciles would + // duplicate list items because the tracking arrays aren't populated yet. if (options?.storeRegistry) { restoreStoreState(options.storeRegistry) + // Discard any pending change notifications generated by restoreStoreState. + // These are not real changes — they represent the initial SSR state that the + // DOM already reflects. Without this, microtask-flushed observers would run + // __refreshFilteredTodosItems (etc.) before __adoptListItems has populated + // the tracking arrays, causing duplicate list items. + for (const store of Object.values(options.storeRegistry)) { + if (typeof (store as Record).silent === 'function') { + (store as Record).silent(() => { /* clears pending changes */ }) + } + } } // Snapshot innerHTML before hydration for dev-mode mismatch detection @@ -73,6 +85,7 @@ export function hydrate( // Attach reactivity bindings (observers, events) if (typeof app[GEA_ATTACH_BINDINGS] === 'function') app[GEA_ATTACH_BINDINGS]() if (typeof app[GEA_MOUNT_COMPILED_CHILD_COMPONENTS] === 'function') app[GEA_MOUNT_COMPILED_CHILD_COMPONENTS]() + if (typeof (app as any).__adoptListItems === 'function') (app as any).__adoptListItems() if (typeof app[GEA_INSTANTIATE_CHILD_COMPONENTS] === 'function') app[GEA_INSTANTIATE_CHILD_COMPONENTS]() if (typeof app[GEA_SETUP_EVENT_DIRECTIVES] === 'function') app[GEA_SETUP_EVENT_DIRECTIVES]() if (typeof app.onAfterRender === 'function') app.onAfterRender() diff --git a/packages/gea/src/lib/base/component.tsx b/packages/gea/src/lib/base/component.tsx index 1f4be3b1..e014e0e3 100644 --- a/packages/gea/src/lib/base/component.tsx +++ b/packages/gea/src/lib/base/component.tsx @@ -752,10 +752,56 @@ export default class Component

> extends Store { ;(existing as any)[GEA_DOM_COMPONENT] = child _mountComp(child, true) child[GEA_SYNC_UNRENDERED_LIST_ITEMS]() + if (typeof (child as any).__adoptListItems === 'function') (child as any).__adoptListItems() requestAnimationFrame(() => child.onAfterRenderAsync()) } } + /** + * After hydration adopts compiled children, populate the compiler-generated + * `_Items` tracking arrays with the adopted list-item components. + * + * During normal client rendering, `template()` populates these arrays via + * `this[GEA_CHILD]()` calls inside `.map()`. During SSR hydration, `template()` + * is never called, so the arrays stay empty. When a store change later + * triggers `GEA_RECONCILE_LIST`, the empty `oldItems` array causes every item + * to be re-created — duplicating the server-rendered DOM. + */ + __adoptListItems(): void { + const _i = internals(this) + const adopted = _i.childComponents.filter( + (c) => c[GEA_ITEM_KEY] != null && (c as any)[GEA_RENDERED] && engineThis(c)[GEA_ELEMENT], + ) + if (adopted.length === 0) return + + adopted.sort((a, b) => { + const elA = engineThis(a)[GEA_ELEMENT] + const elB = engineThis(b)[GEA_ELEMENT] + if (!elA || !elB) return 0 + const pos = elA.compareDocumentPosition(elB) + if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1 + if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1 + return 0 + }) + + const raw = ((this as any).__raw ?? this) as Record + + for (const key of Object.keys(raw)) { + if (!key.endsWith('Items') || !key.startsWith('_')) continue + const arr = raw[key] + if (!Array.isArray(arr) || arr.length > 0) continue + Array.prototype.push.apply(arr, adopted) + } + + const configs = _i.listConfigs + if (!Array.isArray(configs)) return + for (const { config: c } of configs as Array<{ config: { items: AnyComponent[] | null; itemsKey?: string } }>) { + if (!c.items && c.itemsKey) c.items = raw[c.itemsKey] as AnyComponent[] + if (!c.items || c.items.length > 0) continue + Array.prototype.push.apply(c.items, adopted) + } + } + [GEA_CHILD](Ctor: new (props: any) => T, props: any, key?: any): T { const _i = internals(this) const child = new Ctor(props) From 4148486b5aadf70c14707e6d146739ce74d576c6 Mon Sep 17 00:00:00 2001 From: Tamay Eser Uysal Date: Mon, 30 Mar 2026 20:48:02 +0200 Subject: [PATCH 5/6] chore: add changeset for SSR list hydration fix --- .changeset/ssr-list-hydration-fix.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/ssr-list-hydration-fix.md diff --git a/.changeset/ssr-list-hydration-fix.md b/.changeset/ssr-list-hydration-fix.md new file mode 100644 index 00000000..9b3a00fe --- /dev/null +++ b/.changeset/ssr-list-hydration-fix.md @@ -0,0 +1,11 @@ +--- +"@geajs/core": patch +--- + +### @geajs/core (patch) + +- **SSR list hydration fix**: Add `__adoptListItems()` method that populates compiler-generated list tracking arrays (`_*Items`) with adopted child components during hydration, preventing list duplication when store changes trigger reconciliation + +### @geajs/ssr (patch) + +- **SSR list hydration fix**: Silence `restoreStoreState` change notifications during hydration and clean up observers from dev-mode `renderToString` mismatch detection to prevent duplicate observer registration on shared stores From 3e82bdcc2b4e1804016fd08425f0356403957a1c Mon Sep 17 00:00:00 2001 From: babaygt Date: Wed, 1 Apr 2026 22:54:06 +0200 Subject: [PATCH 6/6] fix(ssr): add __adoptListItems to GeaComponentInstance interface to fix TS build --- packages/gea-ssr/src/client.ts | 2 +- packages/gea-ssr/src/types.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/gea-ssr/src/client.ts b/packages/gea-ssr/src/client.ts index 97d53fea..61ad060b 100644 --- a/packages/gea-ssr/src/client.ts +++ b/packages/gea-ssr/src/client.ts @@ -85,7 +85,7 @@ export function hydrate( // Attach reactivity bindings (observers, events) if (typeof app[GEA_ATTACH_BINDINGS] === 'function') app[GEA_ATTACH_BINDINGS]() if (typeof app[GEA_MOUNT_COMPILED_CHILD_COMPONENTS] === 'function') app[GEA_MOUNT_COMPILED_CHILD_COMPONENTS]() - if (typeof (app as any).__adoptListItems === 'function') (app as any).__adoptListItems() + if (typeof app.__adoptListItems === 'function') app.__adoptListItems() if (typeof app[GEA_INSTANTIATE_CHILD_COMPONENTS] === 'function') app[GEA_INSTANTIATE_CHILD_COMPONENTS]() if (typeof app[GEA_SETUP_EVENT_DIRECTIVES] === 'function') app[GEA_SETUP_EVENT_DIRECTIVES]() if (typeof app.onAfterRender === 'function') app.onAfterRender() diff --git a/packages/gea-ssr/src/types.ts b/packages/gea-ssr/src/types.ts index da5a14c2..e73a6b24 100644 --- a/packages/gea-ssr/src/types.ts +++ b/packages/gea-ssr/src/types.ts @@ -82,6 +82,7 @@ export interface GeaComponentInstance

= Record onAfterRender?(): void onAfterRenderHooks?(): void __geaRequestRender?(): void + __adoptListItems?(): void } /**