import { describe, it, expect, beforeEach } from 'vitest' import type { Handle } from '@remix-run/component' import { dom } from '@remix-run/events' import { hydrated } from '../hydration/hydrated.ts' import { renderToStream } from '../ssr/render-stream.ts' import type { DomRuntime } from '../runtime.ts' import { INTERNAL_RUNTIME } from '../runtime.ts' import { run } from '../run.ts' import { flushRenderQueue as flush } from '../reconcile.ts' import { clearStyleCache } from '../style/styles.ts' import type { Assert, Equal } from './utils.ts' async function readAll(stream: ReadableStream): Promise { let reader = stream.getReader() let decoder = new TextDecoder() let out = '' while (true) { let { done, value } = await reader.read() if (done) break if (value) out += decoder.decode(value, { stream: true }) } out += decoder.decode() return out } describe('run (selective hydration)', () => { let el: HTMLElement beforeEach(() => { el = document.createElement('div') clearStyleCache() document.querySelectorAll('style[data-remix-style]').forEach((el) => el.remove()) }) it('hydrates basic tree', async () => { // Create a simple hydrated counter component function Counter(this: Handle, props: { initialCount: number; label: string }) { let count = props.initialCount return (renderProps: { initialCount: number; label: string }) => ( ) } let HydratedCounter = hydrated('/js/counter.js#Counter', Counter) // Server render let stream = renderToStream(

Welcome

, ) let html = await readAll(stream) el.innerHTML = html // Verify initial server-rendered content expect(el.innerHTML).toContain('

Welcome

') expect(el.innerHTML).toContain('') expect(el.innerHTML).toContain('rmx-hydrated') // Set up DOM observers to ensure DOM doesn't get thrashed let domMutations: MutationRecord[] = [] let observer = new MutationObserver((mutations) => { domMutations.push(...mutations) }) observer.observe(el, { childList: true, subtree: true }) // Get references to existing DOM nodes let h1 = el.querySelector('h1') let button = el.querySelector('button') let script = el.querySelector('script[rmx-hydrated]') // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/counter.js' && exportName === 'Counter') { return HydratedCounter } throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) }, }) observer.disconnect() // Assert minimal DOM mutations - should only be script removal and text node patches let scriptRemovals = domMutations.filter( (m) => m.type === 'childList' && Array.from(m.removedNodes).some((n) => n.nodeName === 'SCRIPT'), ) expect(scriptRemovals).toHaveLength(1) // Should have exactly one script removal // Should not have any element nodes being added/removed (only text node fixes and script removal) let elementMutations = domMutations.filter( (m) => m.type === 'childList' && (Array.from(m.addedNodes).some((n) => n.nodeType === Node.ELEMENT_NODE) || Array.from(m.removedNodes).some( (n) => n.nodeType === Node.ELEMENT_NODE && n.nodeName !== 'SCRIPT', )), ) expect(elementMutations).toHaveLength(0) // No element thrashing // Assert DOM wasn't thrashed - same node references expect(el.querySelector('h1')).toBe(h1) expect(el.querySelector('button')).toBe(button) // Script should be removed after hydration expect(el.querySelector('script[rmx-hydrated]')).toBeNull() // Assert button is now interactive let clickCount = 0 let currentButton = el.querySelector('button') as HTMLButtonElement expect(currentButton.textContent).toBe('Count: 5') // Click the button to test interactivity currentButton.click() flush() // Process any pending renders // Should have updated the count expect(currentButton.textContent).toBe('Count: 6') }) it('preserves child ordering with mixed text and element content', async () => { // Component that creates mixed text and element content function MixedContent(this: Handle, { label }: { label: string }) { let count = 0 return ({ label }: { label: string }) => ( ) } let HydratedMixedContent = hydrated('/js/mixed.js#MixedContent', MixedContent) // Server render the component let s1 = renderToStream() let html = await readAll(s1) el.innerHTML = html // Check server HTML structure let button = el.querySelector('button')! let serverHtml = button.innerHTML expect(serverHtml).toBe('Count it up: 0') // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/mixed.js' && exportName === 'MixedContent') { return HydratedMixedContent } throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) }, }) // After hydration, the child ordering should be preserved let hydratedButton = el.querySelector('button')! let hydratedHtml = hydratedButton.innerHTML // This should NOT reorder - the ": " should stay between "Count it up" and 0 expect(hydratedHtml).toBe('Count it up: 0') }) it('adopts SSR CSS into client without duplicate injection', async () => { function Styled(this: Handle, _props: { toggle?: boolean }) { let on = true return () => ( ) } let HydratedStyled = hydrated('/js/s.js#S', Styled) // Server render with red let s2 = renderToStream() let html = await readAll(s2) el.innerHTML = html // SSR style present (style tag is inside the rendered HTML; consult el) let styleTag = el.querySelector('style[data-remix-style]') as HTMLStyleElement expect(styleTag).not.toBeNull() let initialCss = styleTag.textContent || '' expect(initialCss).toMatch(/\.rmx-[a-z0-9]+/) // has class // Hydrate: should seed cache and not re-inject SSR class into constructed sheet await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/s.js' && exportName === 'S') return HydratedStyled throw new Error('unknown component') }, }) // Toggle to force a new class (blue) let btn = el.querySelector('#styled') as HTMLButtonElement btn.click() flush() // A constructed stylesheet should exist and contain the new class let sheets = document.adoptedStyleSheets expect(sheets.length).toBeGreaterThan(0) let last = sheets[sheets.length - 1] let rulesText = Array.from(last.cssRules) .map((r) => (r as CSSStyleRule).cssText) .join('\n') expect(rulesText).toMatch(/\.rmx-[a-z0-9]+/) // The SSR style tag content should remain unchanged let afterCss = styleTag.textContent || '' expect(afterCss).toBe(initialCss) }) it('handles sibling text nodes during hydration without warnings', async () => { // Component that creates sibling text nodes function Example(this: Handle, { label }: { label: string }) { return ({ label }: { label: string }) =>

{label}: text

} let HydratedExample = hydrated('/js/example.js#Example', Example) // Server render the component with sibling text nodes let s3 = renderToStream() let html = await readAll(s3) el.innerHTML = html // Capture console warnings let warnings: string[] = [] let originalWarn = console.warn console.warn = (...args: any[]) => { warnings.push(args.join(' ')) } try { // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/example.js' && exportName === 'Example') { return HydratedExample } throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) }, }) // Should have no hydration warnings about text nodes let hydrationWarnings = warnings.filter( (w) => w.includes('hydration') && w.includes('mismatch'), ) expect(hydrationWarnings).toHaveLength(0) // The text content should be correct let paragraph = el.querySelector('p') expect(paragraph?.textContent).toBe('The label: text') } finally { console.warn = originalWarn } }) it('hydrates multiple counters including indirect ones', async () => { // Create the counter component (stateful) function Counter(this: Handle, props: { label: string }) { let count = 0 return (renderProps: { label: string }) => ( ) } let HydratedCounter = hydrated('/js/counter.js#Counter', Counter) // Regular component that renders a hydrated counter indirectly function IndirectCounter() { return (

Indirect Counter

) } // Server render with both direct and indirect hydrated counters let s4 = renderToStream(

Hello

, ) let html = await readAll(s4) el.innerHTML = html // Verify initial server-rendered content expect(el.innerHTML).toContain('

Hello

') expect(el.innerHTML).toContain('Likes: 0') expect(el.innerHTML).toContain('Indirect Counter: 0') expect(el.innerHTML).toContain('

Indirect Counter

') // Should have two script tags (one for each hydrated counter) let scripts = el.querySelectorAll('script[rmx-hydrated]') expect(scripts).toHaveLength(2) // Set up DOM mutation observer let domMutations: MutationRecord[] = [] let observer = new MutationObserver((mutations) => { domMutations.push(...mutations) }) observer.observe(el, { childList: true, subtree: true }) // Get references to existing DOM nodes let h1 = el.querySelector('h1') let buttons = el.querySelectorAll('button') let directButton = buttons[0] as HTMLButtonElement // "Likes: 0" let indirectButton = buttons[1] as HTMLButtonElement // "Indirect Counter: 0" let paragraph = el.querySelector('p') expect(directButton.textContent).toBe('Likes: 0') expect(indirectButton.textContent).toBe('Indirect Counter: 0') // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/counter.js' && exportName === 'Counter') { return HydratedCounter } throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) }, }) observer.disconnect() // Assert minimal DOM mutations - should only be script removals and text node patches let scriptRemovals = domMutations.filter( (m) => m.type === 'childList' && Array.from(m.removedNodes).some((n) => n.nodeName === 'SCRIPT'), ) expect(scriptRemovals).toHaveLength(2) // Should have exactly two script removals // Should not have any element nodes being added/removed (only text node fixes and script removal) let elementMutations = domMutations.filter( (m) => m.type === 'childList' && (Array.from(m.addedNodes).some((n) => n.nodeType === Node.ELEMENT_NODE) || Array.from(m.removedNodes).some( (n) => n.nodeType === Node.ELEMENT_NODE && n.nodeName !== 'SCRIPT', )), ) expect(elementMutations).toHaveLength(0) // No element thrashing // Assert DOM wasn't thrashed - same node references expect(el.querySelector('h1')).toBe(h1) expect(el.querySelector('p')).toBe(paragraph) // Script tags should be removed after hydration expect(el.querySelectorAll('script[rmx-hydrated]')).toHaveLength(0) // Both buttons should still be there and interactive let currentButtons = el.querySelectorAll('button') expect(currentButtons).toHaveLength(2) let currentDirectButton = currentButtons[0] as HTMLButtonElement let currentIndirectButton = currentButtons[1] as HTMLButtonElement // Test direct counter interactivity expect(currentDirectButton.textContent).toBe('Likes: 0') currentDirectButton.click() flush() expect(currentDirectButton.textContent).toBe('Likes: 1') // Test indirect counter interactivity expect(currentIndirectButton.textContent).toBe('Indirect Counter: 0') currentIndirectButton.click() flush() expect(currentIndirectButton.textContent).toBe('Indirect Counter: 1') // Click both a few more times to make sure they work independently currentDirectButton.click() currentIndirectButton.click() currentIndirectButton.click() flush() expect(currentDirectButton.textContent).toBe('Likes: 2') expect(currentIndirectButton.textContent).toBe('Indirect Counter: 3') }) it('hydrates composed server content', async () => { // Create a HydratedCard component that accepts children and can be expanded/collapsed function Card( this: Handle, props: { title: string; initiallyExpanded: boolean; children: any }, ) { let expanded = props.initiallyExpanded return (renderProps: { title: string; children: any }) => (
{renderProps.title}
{expanded &&
{renderProps.children}
}
) } let HydratedCard = hydrated('/js/card.js#Card', Card) // Server render with composed content let s5 = renderToStream(

Welcome

You should buy it

, ) let html = await readAll(s5) el.innerHTML = html // Assert initial server-rendered content expect(el.innerHTML).toContain('

Welcome

') expect(el.innerHTML).toContain('
') expect(el.innerHTML).toContain('
Cool Snowboard
') expect(el.innerHTML).toContain('') expect(el.innerHTML).toContain('
') expect(el.innerHTML).toContain('') expect(el.innerHTML).toContain('

You should buy it

') expect(el.innerHTML).toContain('rmx-hydrated') // Set up DOM observers to ensure DOM doesn't get thrashed let domMutations: MutationRecord[] = [] let observer = new MutationObserver((mutations) => { domMutations.push(...mutations) }) observer.observe(el, { childList: true, subtree: true }) // Get references to existing DOM nodes let h1 = el.querySelector('h1') let card = el.querySelector('.card') let button = el.querySelector('button') let cardContent = el.querySelector('.card-content') let img = el.querySelector('img') let p = el.querySelector('p') let script = el.querySelector('script[rmx-hydrated]') // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/card.js' && exportName === 'Card') { return HydratedCard } throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) }, }) observer.disconnect() // Assert DOM wasn't thrashed - same node references should be preserved expect(el.querySelector('h1')).toBe(h1) expect(el.querySelector('.card')).toBe(card) expect(el.querySelector('button')).toBe(button) expect(el.querySelector('.card-content')).toBe(cardContent) expect(el.querySelector('img')).toBe(img) expect(el.querySelector('p')).toBe(p) // Script should be removed after hydration expect(el.querySelector('script[rmx-hydrated]')).toBeNull() // Card should still be expanded with content visible let currentButton = el.querySelector('button') as HTMLButtonElement expect(currentButton.textContent).toBe('Collapse') expect(el.querySelector('.card-content')).toBeTruthy() // Test interactivity - collapse the card currentButton.click() flush() // Button text should change and content should be hidden expect(currentButton.textContent).toBe('Expand') expect(el.querySelector('.card-content')).toBeNull() // Expand again currentButton.click() flush() // Button text should change and content should be visible again expect(currentButton.textContent).toBe('Collapse') expect(el.querySelector('.card-content')).toBeTruthy() expect(el.querySelector('img')).toBeTruthy() expect(el.querySelector('p')).toBeTruthy() }) it('supports Context on server for regular components and after hydration', async () => { // Define a simple provider/consumer pair function Provider( this: Handle<{ value: string }>, { value, children }: { value: string; children: any }, ) { this.context.set({ value }) return children } function Consumer(this: Handle) { let ctx = this.context.get(Provider) return
{ctx?.value}
} // Server render tree with Provider -> Consumer let s6 = renderToStream(
, ) let html = await readAll(s6) el.innerHTML = html // SSR should include the consumer content expect((el.querySelector('#ctx') as HTMLElement)?.textContent).toBe('server-ok') // Hydration does not need to load any modules (no hydrated components), but run should still work await run(el, { loadModule() { throw new Error('no modules expected') }, }) // Content remains correct after hydration pass expect((el.querySelector('#ctx') as HTMLElement)?.textContent).toBe('server-ok') }) it('supports Context inside a hydrated component output on server and after hydration', async () => { function Inner(this: Handle) { let ctx = this.context.get(Outer) return {ctx?.value} } function Outer(this: Handle<{ value: string }>, { children }: { children: any }) { this.context.set({ value: 'outer-ctx' }) return children } function Widget(this: Handle) { return () => ( ) } let HydratedWidget = hydrated('/js/widget.js#Widget', Widget) // SSR let s7 = renderToStream() let html = await readAll(s7) el.innerHTML = html expect((el.querySelector('#inner') as HTMLElement)?.textContent).toBe('outer-ctx') // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/widget.js' && exportName === 'Widget') return HydratedWidget throw new Error('unknown module') }, }) expect((el.querySelector('#inner') as HTMLElement)?.textContent).toBe('outer-ctx') }) it('hydrates composed, conditional server content', async () => { // Create a HydratedCard component that accepts children and can be expanded/collapsed function Card( this: Handle, props: { title: string; initiallyExpanded: boolean; children: any }, ) { let expanded = props.initiallyExpanded return (renderProps: { title: string; children: any }) => (
{renderProps.title}
{expanded &&
{renderProps.children}
}
) } let HydratedCard = hydrated('/js/card.js#Card', Card) // Server render with initiallyExpanded=false (children should NOT be visible) let s8 = renderToStream(

Welcome

You should buy it

, ) let html = await readAll(s8) el.innerHTML = html // Assert initial elements (children NOT visible) expect(el.innerHTML).toContain('

Welcome

') expect(el.innerHTML).toContain('
') expect(el.innerHTML).toContain('
Cool Snowboard
') expect(el.innerHTML).toContain('') expect(el.innerHTML).toContain('rmx-hydrated') // Children should NOT be visible initially expect(el.querySelector('.card-content')).toBeNull() expect(el.querySelector('img')).toBeNull() expect(el.querySelector('p')).toBeNull() // Set up observers to ensure DOM doesn't get thrashed let domMutations: MutationRecord[] = [] let observer = new MutationObserver((mutations) => { domMutations.push(...mutations) }) observer.observe(el, { childList: true, subtree: true }) // Get references to existing DOM nodes let h1 = el.querySelector('h1') let card = el.querySelector('.card') let button = el.querySelector('button') let script = el.querySelector('script[rmx-hydrated]') // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/card.js' && exportName === 'Card') { return HydratedCard } throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) }, }) observer.disconnect() // Assert DOM wasn't thrashed - same node references should be preserved expect(el.querySelector('h1')).toBe(h1) expect(el.querySelector('.card')).toBe(card) expect(el.querySelector('button')).toBe(button) // Script should be removed after hydration expect(el.querySelector('script[rmx-hydrated]')).toBeNull() // Assert children still not visible after hydration expect(el.querySelector('.card-content')).toBeNull() expect(el.querySelector('img')).toBeNull() expect(el.querySelector('p')).toBeNull() // Button should show "Expand" since card is collapsed let currentButton = el.querySelector('button') as HTMLButtonElement expect(currentButton.textContent).toBe('Expand') // Click the button to expand currentButton.click() flush() // Assert children are now visible expect(currentButton.textContent).toBe('Collapse') expect(el.querySelector('.card-content')).not.toBeNull() expect(el.querySelector('img')).not.toBeNull() expect(el.querySelector('p')).not.toBeNull() // Verify the children have correct attributes/content let img = el.querySelector('img') as HTMLImageElement let p = el.querySelector('p') as HTMLParagraphElement expect(img.src).toContain('/img/snowboard.jpg') expect(p.textContent).toBe('You should buy it') }) it('hydrates composed server content with indirection', async () => { // Non-hydrated component that will be expanded during serialization function Indirection() { return

You should buy it

} // Create a HydratedCard component that accepts children and can be expanded/collapsed function Card( this: Handle, props: { title: string; initiallyExpanded: boolean; children: any }, ) { let expanded = props.initiallyExpanded return (renderProps: { title: string; children: any }) => (
{renderProps.title}
{expanded &&
{renderProps.children}
}
) } let HydratedCard = hydrated('/js/card.js#Card', Card) // Server render with initiallyExpanded=false and indirect content let s9 = renderToStream(

Welcome

, ) let html = await readAll(s9) el.innerHTML = html // Assert initial server-rendered state (card is closed, no content visible) expect(el.innerHTML).toContain('

Welcome

') expect(el.innerHTML).toContain('
') expect(el.innerHTML).toContain('
Cool Snowboard
') expect(el.innerHTML).toContain('') expect(el.innerHTML).toContain('rmx-hydrated') // Children should NOT be visible initially (card is closed) expect(el.querySelector('.card-content')).toBeNull() expect(el.querySelector('img')).toBeNull() expect(el.querySelector('p')).toBeNull() // But the indirect content should be serialized in the JSON script expect(el.innerHTML).toContain('"You should buy it"') // Set up DOM observers to ensure DOM doesn't get thrashed let domMutations: MutationRecord[] = [] let observer = new MutationObserver((mutations) => { domMutations.push(...mutations) }) observer.observe(el, { childList: true, subtree: true }) // Get references to existing DOM nodes let h1 = el.querySelector('h1') let card = el.querySelector('.card') let button = el.querySelector('button') // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/card.js' && exportName === 'Card') { return HydratedCard } throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) }, }) observer.disconnect() // Assert DOM wasn't thrashed - same node references should be preserved expect(el.querySelector('h1')).toBe(h1) expect(el.querySelector('.card')).toBe(card) expect(el.querySelector('button')).toBe(button) // Script should be removed after hydration expect(el.querySelector('script[rmx-hydrated]')).toBeNull() // Assert children still not visible after hydration (card is closed) expect(el.querySelector('.card-content')).toBeNull() expect(el.querySelector('img')).toBeNull() expect(el.querySelector('p')).toBeNull() // Button should show "Expand" since card is collapsed let currentButton = el.querySelector('button') as HTMLButtonElement expect(currentButton.textContent).toBe('Expand') // Click the button to expand - this tests that the indirect content is properly restored currentButton.click() flush() // Assert children are now visible, including the indirect content expect(currentButton.textContent).toBe('Collapse') expect(el.querySelector('.card-content')).not.toBeNull() expect(el.querySelector('img')).not.toBeNull() expect(el.querySelector('p')).not.toBeNull() // Verify the indirect content is correctly rendered let img = el.querySelector('img') as HTMLImageElement let p = el.querySelector('p') as HTMLParagraphElement expect(img.src).toContain('/img/snowboard.jpg') expect(p.textContent).toBe('You should buy it') // This is the key test - indirect content works // Test collapsing again currentButton.click() flush() // Content should be hidden again expect(currentButton.textContent).toBe('Expand') expect(el.querySelector('.card-content')).toBeNull() expect(el.querySelector('img')).toBeNull() expect(el.querySelector('p')).toBeNull() // Expand one more time to ensure it works consistently currentButton.click() flush() // Content should be visible again expect(currentButton.textContent).toBe('Collapse') expect(el.querySelector('.card-content')).not.toBeNull() expect(el.querySelector('img')).not.toBeNull() expect(el.querySelector('p')).not.toBeNull() expect(el.querySelector('p')?.textContent).toBe('You should buy it') }) it('hydrates initially visible nested hydrated components', async () => { // Create a counter component function Counter(this: Handle, { label }: { label: string }) { let count = 0 return ({ label }: { label: string }) => ( ) } // Create a card component that can contain hydrated children function Card( this: Handle, props: { title: string; initiallyExpanded: boolean; children: any }, ) { let expanded = props.initiallyExpanded return ({ title, children, initiallyExpanded, }: { title: string children: any initiallyExpanded: boolean }) => (

{title}

{expanded &&
{children}
}
) } let HydratedCounter = hydrated('/js/counter.js#Counter', Counter) let HydratedCard = hydrated('/js/card.js#Card', Card) let s10 = renderToStream(

Welcome

You should buy it

, ) let html = await readAll(s10) el.innerHTML = html // Assert initial server-rendered content expect(el.innerHTML).toContain('

Welcome

') expect(el.innerHTML).toContain('
') expect(el.innerHTML).toContain('

Cool Snowboard

') expect(el.innerHTML).toContain('Collapse') expect(el.innerHTML).toContain('') expect(el.innerHTML).toContain('

You should buy it

') expect(el.innerHTML).toContain('
Likes
') expect(el.innerHTML).toContain('
0
') // Should have two script tags (one for each hydrated component) let scripts = el.querySelectorAll('script[rmx-hydrated]') expect(scripts).toHaveLength(2) // Get references to DOM nodes let h1 = el.querySelector('h1') let card = el.querySelector('.card') let toggleButton = el.querySelector('.toggle') as HTMLButtonElement let counterButton = el.querySelector('.content button') as HTMLButtonElement let img = el.querySelector('img') let p = el.querySelector('p') await run(el, { loadModule(_, exportName) { if (exportName === 'Card') { return HydratedCard } if (exportName === 'Counter') { return HydratedCounter } throw new Error('unknown component') }, }) // Assert DOM wasn't thrashed expect(el.querySelector('h1')).toBe(h1) expect(el.querySelector('.card')).toBe(card) expect(el.querySelector('.toggle')).toBe(toggleButton) expect(el.querySelector('img')).toBe(img) expect(el.querySelector('p')).toBe(p) // Scripts should be removed expect(el.querySelectorAll('script[rmx-hydrated]')).toHaveLength(0) // Test that both components are interactive let currentCounterButton = el.querySelector('.content button') as HTMLButtonElement expect(currentCounterButton.textContent).toContain('Likes') expect(currentCounterButton.textContent).toContain('0') // Click counter to increment currentCounterButton.click() flush() expect(currentCounterButton.textContent).toContain('1') // Click toggle to collapse toggleButton.click() flush() expect(toggleButton.textContent).toBe('Expand') expect(el.querySelector('.content')).toBeNull() // Click toggle to expand toggleButton.click() flush() expect(toggleButton.textContent).toBe('Collapse') expect(el.querySelector('.content')).not.toBeNull() // Counter should still be interactive (but state is reset - it's a new instance) currentCounterButton = el.querySelector('.content button') as HTMLButtonElement expect(currentCounterButton).not.toBeNull() expect(currentCounterButton.textContent).toContain('Likes') expect(currentCounterButton.textContent).toContain('0') // Click counter again to verify it's still interactive currentCounterButton.click() flush() expect(currentCounterButton.textContent).toContain('1') }) it('hydrates initially not visible nested hydrated components', async () => { // Create a counter component function Counter(this: Handle, { label }: { label: string }) { let count = 0 return ({ label }: { label: string }) => ( ) } // Create a card component that can contain hydrated children function Card( this: Handle, props: { title: string; initiallyExpanded: boolean; children: any }, ) { let expanded = props.initiallyExpanded return ({ title, children, initiallyExpanded, }: { title: string children: any initiallyExpanded: boolean }) => (

{title}

{expanded &&
{children}
}
) } let HydratedCounter = hydrated('/js/counter.js#Counter', Counter) let HydratedCard = hydrated('/js/card.js#Card', Card) let s12 = renderToStream(

Welcome

You should buy it

, ) let html = await readAll(s12) el.innerHTML = html // Assert initial server-rendered content (card is collapsed) expect(el.innerHTML).toContain('

Welcome

') expect(el.innerHTML).toContain('
') expect(el.innerHTML).toContain('

Cool Snowboard

') expect(el.innerHTML).toContain('Expand') // Content should not be visible expect(el.querySelector('.content')).toBeNull() expect(el.querySelector('img')).toBeNull() expect(el.querySelector('p')).toBeNull() expect(el.innerHTML).not.toContain('
Likes
') // Should have one script tag for the card (counter is not rendered yet) let scripts = el.querySelectorAll('script[rmx-hydrated]') expect(scripts).toHaveLength(1) // But the counter should be in the serialized props let scriptContent = scripts[0].textContent || '' expect(scriptContent).toContain('/js/counter.js') expect(scriptContent).toContain('Counter') expect(scriptContent).toContain('Likes') // Get references to DOM nodes let h1 = el.querySelector('h1') let card = el.querySelector('.card') let toggleButton = el.querySelector('.toggle') as HTMLButtonElement await run(el, { loadModule(_, exportName) { if (exportName === 'Card') { return HydratedCard } if (exportName === 'Counter') { return HydratedCounter } throw new Error('unknown component') }, }) // Assert DOM wasn't thrashed expect(el.querySelector('h1')).toBe(h1) expect(el.querySelector('.card')).toBe(card) expect(el.querySelector('.toggle')).toBe(toggleButton) // Scripts should be removed expect(el.querySelectorAll('script[rmx-hydrated]')).toHaveLength(0) // Card should still be collapsed expect(toggleButton.textContent).toBe('Expand') expect(el.querySelector('.content')).toBeNull() // Click toggle to expand toggleButton.click() flush() expect(toggleButton.textContent).toBe('Collapse') expect(el.querySelector('.content')).not.toBeNull() // Now the nested counter should be visible and interactive let counterButton = el.querySelector('.content button') as HTMLButtonElement expect(counterButton).not.toBeNull() expect(counterButton.textContent).toContain('Likes') expect(counterButton.textContent).toContain('0') // Click counter to verify it's interactive counterButton.click() flush() expect(counterButton.textContent).toContain('1') // Collapse and expand again toggleButton.click() flush() expect(el.querySelector('.content')).toBeNull() toggleButton.click() flush() // Counter should be a new instance (state reset) counterButton = el.querySelector('.content button') as HTMLButtonElement expect(counterButton.textContent).toContain('0') }) it('hydrates initially not visible nested hydrated components with indirection', async () => { // Create a counter component function Counter(this: Handle, { label }: { label: string }) { let count = 0 return ({ label }: { label: string }) => ( ) } // Create a card component that can contain hydrated children function Card( this: Handle, props: { title: string; initiallyExpanded: boolean; children: any }, ) { let expanded = props.initiallyExpanded return ({ title, children, initiallyExpanded, }: { title: string children: any initiallyExpanded: boolean }) => (

{title}

{expanded &&
{children}
}
) } let HydratedCounter = hydrated('/js/counter.js#Counter', Counter) let HydratedCard = hydrated('/js/card.js#Card', Card) // Non-hydrated component with hydrated child function Indirection() { return (

You should buy it

) } let s11 = renderToStream(

Welcome

, ) let html = await readAll(s11) el.innerHTML = html // Assert initial server-rendered content (card is collapsed) expect(el.innerHTML).toContain('

Welcome

') expect(el.innerHTML).toContain('
') expect(el.innerHTML).toContain('

Cool Snowboard

') expect(el.innerHTML).toContain('Expand') // Content should not be visible expect(el.querySelector('.content')).toBeNull() expect(el.querySelector('img')).toBeNull() expect(el.querySelector('p')).toBeNull() // Should have one script tag for the card let scripts = el.querySelectorAll('script[rmx-hydrated]') expect(scripts).toHaveLength(1) // The counter should be in the serialized props (through Indirection) let scriptContent = scripts[0].textContent || '' expect(scriptContent).toContain('/js/counter.js') expect(scriptContent).toContain('Counter') expect(scriptContent).toContain('Likes') expect(scriptContent).toContain('You should buy it') // From Indirection // Get references to DOM nodes let h1 = el.querySelector('h1') let card = el.querySelector('.card') let toggleButton = el.querySelector('.toggle') as HTMLButtonElement await run(el, { loadModule(_, exportName) { if (exportName === 'Card') { return HydratedCard } if (exportName === 'Counter') { return HydratedCounter } throw new Error('unknown component') }, }) // Assert DOM wasn't thrashed expect(el.querySelector('h1')).toBe(h1) expect(el.querySelector('.card')).toBe(card) expect(el.querySelector('.toggle')).toBe(toggleButton) // Scripts should be removed expect(el.querySelectorAll('script[rmx-hydrated]')).toHaveLength(0) // Card should still be collapsed expect(toggleButton.textContent).toBe('Expand') expect(el.querySelector('.content')).toBeNull() // Click toggle to expand toggleButton.click() flush() expect(toggleButton.textContent).toBe('Collapse') expect(el.querySelector('.content')).not.toBeNull() // Both the paragraph from Indirection and the counter should be visible let p = el.querySelector('.content p') expect(p).not.toBeNull() expect(p?.textContent).toBe('You should buy it') // The nested counter should be visible and interactive let counterButton = el.querySelector('.content button') as HTMLButtonElement expect(counterButton).not.toBeNull() expect(counterButton.textContent).toContain('Likes') expect(counterButton.textContent).toContain('0') // Click counter to verify it's interactive counterButton.click() flush() expect(counterButton.textContent).toContain('1') // Collapse and expand again toggleButton.click() flush() expect(el.querySelector('.content')).toBeNull() toggleButton.click() flush() // Everything should be recreated p = el.querySelector('.content p') expect(p?.textContent).toBe('You should buy it') counterButton = el.querySelector('.content button') as HTMLButtonElement expect(counterButton.textContent).toContain('0') // State reset }) it('internal components can access INTERNAL_RUNTIME context', async () => { let capturedRuntime: DomRuntime | undefined function InternalComponent(this: Handle) { let setupRuntime = this.context.get(INTERNAL_RUNTIME) if (setupRuntime) { capturedRuntime = setupRuntime } return () =>
InternalComponent
} let HydratedInternal = hydrated('/js/internal.js#InternalComponent', InternalComponent) // Server render with both hydrated and regular components let stream = renderToStream() let html = await readAll(stream) el.innerHTML = html await run(el, { loadModule: () => HydratedInternal }) expect(capturedRuntime).not.toBeNull() expect(capturedRuntime).toHaveProperty('loadModule') }) it('keeps this.id stable from SSR through hydration', async () => { function IdComponent(this: Handle, props: { initialCount: number }) { let count = props.initialCount let componentId = this.id return () => (

ID: {componentId}

) } let HydratedIdComponent = hydrated('/js/id.js#IdComponent', IdComponent) // Server render let stream = renderToStream() let html = await readAll(stream) el.innerHTML = html // Capture the SSR id from markup let root = el.querySelector('#id-root') as HTMLDivElement expect(root).not.toBeNull() let ssrId = root.getAttribute('data-cid') || '' expect(ssrId).not.toBe('') // Hydrate await run(el, { loadModule(moduleUrl, exportName) { if (moduleUrl === '/js/id.js' && exportName === 'IdComponent') { return HydratedIdComponent } throw new Error(`Unknown component: ${moduleUrl}#${exportName}`) }, }) // Trigger a re-render to ensure client instance value is used let incButton = el.querySelector('#inc') as HTMLButtonElement expect(incButton.textContent).toBe('Count: 0') incButton.click() flush() // After re-render, the data-cid should still match the SSR id let hydratedRoot = el.querySelector('#id-root') as HTMLDivElement let hydratedId = hydratedRoot.getAttribute('data-cid') || '' expect(hydratedId).toBe(ssrId) // Sanity: count updated let currentIncButton = el.querySelector('#inc') as HTMLButtonElement expect(currentIncButton.textContent).toBe('Count: 1') }) })