).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.__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
}
/**
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)
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)
+ })
+})