Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/ssr-list-hydration-fix.md
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
4 changes: 3 additions & 1 deletion examples/ssr/flight-checkin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>Flight Check-in SSR - Gea</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="../../flight-checkin/src/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" src="./src/main.ts"></script>
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions examples/ssr/flight-checkin/src/main.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
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
4 changes: 3 additions & 1 deletion examples/ssr/kanban/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>Kanban SSR - Gea</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="../../kanban/src/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" src="./src/main.ts"></script>
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions examples/ssr/kanban/src/main.ts
Original file line number Diff line number Diff line change
@@ -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'
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
4 changes: 3 additions & 1 deletion examples/ssr/router-simple/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>Router Simple SSR - Gea</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="../../router-simple/src/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" src="./src/main.ts"></script>
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions examples/ssr/router-simple/src/main.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
4 changes: 3 additions & 1 deletion examples/ssr/router-v2/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>Router v2 SSR - Gea</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="../../router-v2/src/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" src="./src/main.ts"></script>
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions examples/ssr/router-v2/src/main.ts
Original file line number Diff line number Diff line change
@@ -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'
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
4 changes: 3 additions & 1 deletion examples/ssr/todo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
<meta charset="utf-8" />
<title>Todo SSR - Gea</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="../../todo/styles.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" src="./src/main.ts"></script>
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions examples/ssr/todo/src/main.ts
Original file line number Diff line number Diff line change
@@ -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'
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
15 changes: 14 additions & 1 deletion packages/gea-ssr/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).silent === 'function') {
(store as Record<string, Function>).silent(() => { /* clears pending changes */ })
}
}
}

// Snapshot innerHTML before hydration for dev-mode mismatch detection
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions packages/gea-ssr/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface GeaComponentInstance<P extends Record<string, unknown> = Record
onAfterRender?(): void
onAfterRenderHooks?(): void
__geaRequestRender?(): void
__adoptListItems?(): void
}

/**
Expand Down
46 changes: 46 additions & 0 deletions packages/gea/src/lib/base/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -752,10 +752,56 @@ export default class Component<P = Record<string, any>> 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
* `_<name>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<string, unknown>

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]<T extends AnyComponent>(Ctor: new (props: any) => T, props: any, key?: any): T {
const _i = internals(this)
const child = new Ctor(props)
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]

Expand Down
64 changes: 64 additions & 0 deletions tests/e2e/ssr-todo.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading