import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import type { Handle } from '../lib/component.ts'
import { createRoot } from '../lib/vdom.ts'
import { renderToString } from '../lib/stream.ts'
import { clientEntry } from '../lib/client-entries.ts'
import { invariant } from '../lib/invariant.ts'
import { on, ref } from '../index.ts'
describe('hydration', () => {
let container: HTMLDivElement
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
document.body.innerHTML = ''
for (let node of Array.from(document.head.childNodes)) {
document.head.removeChild(node)
}
})
describe('component edge cases', () => {
it('hydrates component that returns null', async () => {
function NullComponent() {
return () => null
}
let html = await renderToString(
After
,
)
container.innerHTML = html
let existingSpan = container.querySelector('span')
invariant(existingSpan)
let root = createRoot(container)
root.render(
After
,
)
root.flush()
expect(container.querySelector('span')).toBe(existingSpan)
expect(existingSpan.textContent).toBe('After')
})
it('hydrates component that returns fragment', async () => {
function FragmentComponent() {
return () => (
<>
FirstSecond
>
)
}
let html = await renderToString(
,
)
container.innerHTML = html
let spans = container.querySelectorAll('span')
expect(spans).toHaveLength(2)
let root = createRoot(container)
root.render(
,
)
root.flush()
let hydratedSpans = container.querySelectorAll('span')
expect(hydratedSpans[0]).toBe(spans[0])
expect(hydratedSpans[1]).toBe(spans[1])
})
it('hydrates nested hydration boundaries', async () => {
let Outer = clientEntry('/outer.js#Outer', function Outer() {
return (props: { children: any }) =>
{props.children}
})
let Inner = clientEntry('/inner.js#Inner', function Inner() {
return () => Inner content
})
let html = await renderToString(
,
)
container.innerHTML = html
// Should have hydration comment markers
expect(html).toContain('')
let existingOuter = container.querySelector('.outer')
let existingInner = container.querySelector('.inner')
invariant(existingOuter && existingInner)
// For this test, we use createRoot which should handle the comment markers
let root = createRoot(container)
root.render(
,
)
root.flush()
// Both should be adopted
expect(container.querySelector('.outer')).toBe(existingOuter)
expect(container.querySelector('.inner')).toBe(existingInner)
})
it('hydrates component with state preservation', async () => {
function Counter(handle: Handle, setup: number) {
let count = setup
return () => (
)
}
let html = await renderToString()
container.innerHTML = html
let existingButton = container.querySelector('button')
invariant(existingButton)
expect(existingButton.textContent).toBe('Count: 5')
let root = createRoot(container)
root.render()
root.flush()
// Button should be adopted
expect(container.querySelector('button')).toBe(existingButton)
// Clicking should work
existingButton.click()
root.flush()
expect(existingButton.textContent).toBe('Count: 6')
})
})
describe('additional scenarios', () => {
it('hydrates context across component boundaries', async () => {
function Provider(handle: Handle<{ value: string }>) {
handle.context.set({ value: 'from context' })
return (props: { children: any }) =>
{props.children}
}
function Consumer(handle: Handle) {
let ctx = handle.context.get(Provider)
return () => {ctx?.value ?? 'no context'}
}
let html = await renderToString(
,
)
container.innerHTML = html
let existingProvider = container.querySelector('.provider')
let existingConsumer = container.querySelector('.consumer')
invariant(existingProvider && existingConsumer)
expect(existingConsumer.textContent).toBe('from context')
let root = createRoot(container)
root.render(
,
)
root.flush()
expect(container.querySelector('.provider')).toBe(existingProvider)
expect(container.querySelector('.consumer')).toBe(existingConsumer)
expect(existingConsumer.textContent).toBe('from context')
})
it('hydrates SVG elements with case-sensitive tags', async () => {
let html = await renderToString(
,
)
container.innerHTML = html
let existingSvg = container.querySelector('svg')
let existingGradient = container.querySelector('linearGradient')
let existingRect = container.querySelector('rect')
invariant(existingSvg && existingGradient && existingRect)
let root = createRoot(container)
root.render(
,
)
root.flush()
expect(container.querySelector('svg')).toBe(existingSvg)
expect(container.querySelector('linearGradient')).toBe(existingGradient)
expect(container.querySelector('rect')).toBe(existingRect)
})
it('hydrates innerHTML prop', async () => {
let html = await renderToString()
container.innerHTML = html
let existingDiv = container.querySelector('div')
invariant(existingDiv)
expect(existingDiv.innerHTML).toBe('Raw HTML')
let root = createRoot(container)
root.render()
root.flush()
expect(container.querySelector('div')).toBe(existingDiv)
expect(existingDiv.innerHTML).toBe('Raw HTML')
})
it('hydrates style prop as object', async () => {
let html = await renderToString(
Styled
,
)
container.innerHTML = html
let existingDiv = container.querySelector('div')
invariant(existingDiv)
let root = createRoot(container)
root.render(
Styled
,
)
root.flush()
expect(container.querySelector('div')).toBe(existingDiv)
// Style should be applied
expect(existingDiv.style.color).toBe('red')
expect(existingDiv.style.backgroundColor).toBe('blue')
})
it('calls ref callback after hydration', async () => {
let connectedNode: HTMLDivElement | null = null
function WithConnect() {
return () => (
{
connectedNode = node as HTMLDivElement
}),
]}
>
Connected
)
}
let html = await renderToString()
container.innerHTML = html
let existingDiv = container.querySelector('div')
invariant(existingDiv)
let root = createRoot(container)
root.render()
root.flush()
// Ref should be called with the adopted node
expect(connectedNode).toBe(existingDiv)
})
it('attaches event handlers during hydration', async () => {
let clicked = false
function Clickable() {
return () => (
)
}
let html = await renderToString()
container.innerHTML = html
let existingButton = container.querySelector('button')
invariant(existingButton)
let root = createRoot(container)
root.render()
root.flush()
// Button should be adopted
expect(container.querySelector('button')).toBe(existingButton)
// Event should work
existingButton.click()
expect(clicked).toBe(true)
})
it('hydrates keyed elements', async () => {
let items = [
{ id: 'a', text: 'Item A' },
{ id: 'b', text: 'Item B' },
{ id: 'c', text: 'Item C' },
]
let html = await renderToString(
{items.map((item) => (
{item.text}
))}
,
)
container.innerHTML = html
let existingItems = container.querySelectorAll('li')
expect(existingItems).toHaveLength(3)
let root = createRoot(container)
root.render(
{items.map((item) => (
{item.text}
))}
,
)
root.flush()
let hydratedItems = container.querySelectorAll('li')
expect(hydratedItems[0]).toBe(existingItems[0])
expect(hydratedItems[1]).toBe(existingItems[1])
expect(hydratedItems[2]).toBe(existingItems[2])
})
it('hydrates deeply nested elements', async () => {
let html = await renderToString(
Deep content
,
)
container.innerHTML = html
let level1 = container.querySelector('.level-1')
let level2 = container.querySelector('.level-2')
let level3 = container.querySelector('.level-3')
let level4 = container.querySelector('.level-4')
let span = container.querySelector('span')
invariant(level1 && level2 && level3 && level4 && span)
let root = createRoot(container)
root.render(
Deep content
,
)
root.flush()
// All levels should be adopted
expect(container.querySelector('.level-1')).toBe(level1)
expect(container.querySelector('.level-2')).toBe(level2)
expect(container.querySelector('.level-3')).toBe(level3)
expect(container.querySelector('.level-4')).toBe(level4)
expect(container.querySelector('span')).toBe(span)
})
it('hydrates head-like elements in place', () => {
container.innerHTML =
'Hydrated title' +
'' +
'' +
'
Body content
'
let existingTitle = container.querySelector('title')
let existingMeta = container.querySelector('meta[name="description"]')
let existingLdJson = container.querySelector('script[type="application/ld+json"]')
let existingContent = container.querySelector('#content')
invariant(existingTitle && existingMeta && existingLdJson && existingContent)
let root = createRoot(container)
root.render(
<>
Hydrated title