,
),
)
}
document.body.innerHTML = await renderInitialBody()
let app = run({
loadModule: vi.fn().mockResolvedValue(FragmentEntry),
async resolveFrame(src: string) {
if (src === '/b') return await renderReloadDocument()
throw new Error(`Unexpected frame src: ${src}`)
},
})
await app.ready()
let topFrame = getTopFrame()
topFrame.src = '/b'
await topFrame.reload()
await new Promise((resolve) => setTimeout(resolve, 0))
let bodyHtml = document.body.innerHTML
let hydrationStarts = bodyHtml.match(//g)?.length ?? 0
expect(hydrationStarts).toBe(hydrationEnds)
app.dispose()
})
it('hydrates ready modules before slower modules while ready() stays pending', async () => {
let Fast = clientEntry('/js/fast.js#Fast', function Fast(handle: Handle) {
let clicked = false
return () => (
)
})
let Slow = clientEntry('/js/slow.js#Slow', function Slow(handle: Handle) {
let clicked = false
return () => (
)
})
let html = await drain(
renderToStream(
,
),
)
document.body.innerHTML = html
let [slowModulePromise, resolveSlowModule] = withResolvers()
let loadModule = vi.fn().mockImplementation((moduleUrl: string, exportName: string) => {
if (moduleUrl === '/js/fast.js' && exportName === 'Fast') return Fast
if (moduleUrl === '/js/slow.js' && exportName === 'Slow') return slowModulePromise
throw new Error(`Unexpected module request: ${moduleUrl}#${exportName}`)
})
let app = run({ loadModule })
let readySettled = false
let readyPromise = app.ready().then(() => {
readySettled = true
})
await new Promise((resolve) => setTimeout(resolve, 0))
let fastButton = document.getElementById('fast')
let slowButton = document.getElementById('slow')
invariant(fastButton instanceof HTMLButtonElement)
invariant(slowButton instanceof HTMLButtonElement)
fastButton.click()
app.flush()
expect(fastButton.textContent).toBe('Fast!')
slowButton.click()
app.flush()
expect(slowButton.textContent).toBe('Slow')
expect(readySettled).toBe(false)
resolveSlowModule(Slow)
await readyPromise
slowButton.click()
app.flush()
expect(slowButton.textContent).toBe('Slow!')
app.dispose()
})
it('handles complex props', async () => {
let Card = clientEntry('/js/card.js#Card', function Card() {
return (props: { title: string; count: number; enabled: boolean; items: string[] }) => (
{props.title}
Count: {props.count}
Enabled: {String(props.enabled)}
{props.items.map((item, i) => (
{item}
))}
)
})
let stream = renderToStream(
,
)
let html = await drain(stream)
document.body.innerHTML = html
let loadModule = vi.fn().mockResolvedValue(Card)
let frame = run({ loadModule })
await frame.ready()
expect(loadModule).toHaveBeenCalledWith('/js/card.js', 'Card')
expect(document.querySelector('h2')?.textContent).toBe('Test')
expect(document.querySelector('p')?.textContent).toBe('Count: 42')
expect(document.querySelectorAll('li')).toHaveLength(3)
frame.dispose()
})
it('ready() does not wait for hydration markers from later frame templates', async () => {
let Initial = clientEntry('/js/initial.js#Initial', function Initial(handle: Handle) {
let clicked = false
return () => (
)
})
let Late = clientEntry('/js/late.js#Late', function Late(handle: Handle) {
let clicked = false
return () => (
)
})
let pageStream = renderToStream(
Loading…} />
,
{ resolveFrame: () => new Promise(() => {}) },
)
let pageChunks = readChunks(pageStream)
let first = await pageChunks.next()
invariant(!first.done)
document.body.innerHTML = first.value
let frameId = getCommentMarkerId(first.value, 'rmx:f:')
let [lateModulePromise, resolveLateModule] = withResolvers()
let loadModule = vi.fn().mockImplementation((moduleUrl: string, exportName: string) => {
if (moduleUrl === '/js/initial.js' && exportName === 'Initial') return Initial
if (moduleUrl === '/js/late.js' && exportName === 'Late') return lateModulePromise
throw new Error(`Unexpected module request: ${moduleUrl}#${exportName}`)
})
let app = run({ loadModule })
await app.ready()
// Only initial adopted-document markers block ready().
expect(loadModule).toHaveBeenCalledTimes(1)
let template = document.createElement('template')
template.id = frameId
template.innerHTML = await drain(renderToStream())
document.body.appendChild(template)
await new Promise((resolve) => setTimeout(resolve, 0))
// Late template markers hydrate after ready() and are not part of initial barrier.
expect(loadModule).toHaveBeenCalledTimes(2)
let lateButton = document.getElementById('late')
invariant(lateButton instanceof HTMLButtonElement)
lateButton.click()
app.flush()
expect(lateButton.textContent).toBe('Late')
resolveLateModule(Late)
await new Promise((resolve) => setTimeout(resolve, 0))
lateButton.click()
app.flush()
expect(lateButton.textContent).toBe('Late!')
app.dispose()
})
it('does nothing when no rmx-data script exists', async () => {
document.body.innerHTML = '
No hydration here
'
let loadModule = vi.fn()
let frame = run({ loadModule })
await frame.ready()
expect(loadModule).not.toHaveBeenCalled()
frame.dispose()
})
it('does nothing when rmx-data has no hydration data', async () => {
document.body.innerHTML = `
Static content
`
let loadModule = vi.fn()
let frame = run({ loadModule })
await frame.ready()
expect(loadModule).not.toHaveBeenCalled()
frame.dispose()
})
it('adopts existing DOM nodes during hydration', async () => {
let Counter = clientEntry('/js/counter.js#Counter', function Counter() {
return () => (
Static text
)
})
let stream = renderToStream()
let html = await drain(stream)
document.body.innerHTML = html
let existingSpan = document.querySelector('span')
expect(existingSpan).toBeTruthy()
let loadModule = vi.fn().mockResolvedValue(Counter)
let frame = run({ loadModule })
await frame.ready()
let spanAfterHydration = document.querySelector('span')
expect(spanAfterHydration).toBe(existingSpan)
frame.dispose()
})
it('replaces pending frame regions when streamed templates arrive', async () => {
let stream = renderToStream(
Title
Loading...} />
Main
,
{ resolveFrame: () => '' },
)
let chunks = readChunks(stream)
let first = await chunks.next()
invariant(!first.done)
document.body.innerHTML = first.value
let h1 = document.querySelector('h1')
let p = document.querySelector('p')
let nav = document.querySelector('nav')
invariant(h1 && p && nav)
expect(nav.textContent).toBe('Loading...')
let frame = run({ loadModule: vi.fn() })
await frame.ready()
let second = await chunks.next()
invariant(!second.done)
document.body.insertAdjacentHTML('beforeend', second.value)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.querySelector('h1')).toBe(h1)
expect(document.querySelector('p')).toBe(p)
expect(document.querySelector('nav')).toBe(nav)
expect(nav.textContent).toBe('Loaded')
frame.dispose()
})
it('merges hydration data across multiple rmx-data scripts', async () => {
function A(handle: Handle) {
let clicked = false
return () => (
)
}
function B(handle: Handle) {
let clicked = false
return () => (
)
}
document.body.innerHTML = `
`
let loadModule = vi.fn().mockImplementation((moduleUrl: string, exportName: string) => {
if (moduleUrl === '/a.js' && exportName === 'A') return A
if (moduleUrl === '/b.js' && exportName === 'B') return B
throw new Error(`Unexpected module request: ${moduleUrl}#${exportName}`)
})
let frame = run({ loadModule })
await frame.ready()
expect(loadModule).toHaveBeenCalledTimes(2)
let a = document.getElementById('a')
let b = document.getElementById('b')
invariant(a instanceof HTMLButtonElement)
invariant(b instanceof HTMLButtonElement)
expect(a.textContent).toBe('A')
expect(b.textContent).toBe('B')
a.click()
b.click()
frame.flush()
expect(a.textContent).toBe('A!')
expect(b.textContent).toBe('B!')
frame.dispose()
})
it('ignores prototype-polluting keys when merging rmx-data scripts', async () => {
function A() {
return () =>
}
document.body.innerHTML = `
`
let loadModule = vi.fn().mockImplementation((moduleUrl: string, exportName: string) => {
if (moduleUrl === '/a.js' && exportName === 'A') return A
throw new Error(`Unexpected module request: ${moduleUrl}#${exportName}`)
})
let frame = run({ loadModule })
await frame.ready()
expect(loadModule).toHaveBeenCalledTimes(1)
expect(loadModule).toHaveBeenCalledWith('/a.js', 'A')
frame.dispose()
})
it('reloads a frame region and preserves static DOM nodes', async () => {
let renderCount = 0
let reload: undefined | (() => Promise)
let ReloadButton = clientEntry('/assets/reload.js#Reload', function Reload(handle: Handle) {
reload = () => handle.frame.reload()
return () =>
})
async function renderTimeFragment() {
renderCount++
let stream = renderToStream(
Activity
Server: {renderCount}
First
Second
,
{
onError(error) {
console.error(error)
},
},
)
return await drain(stream)
}
let stream = renderToStream(
Loading…} />
,
{ resolveFrame: renderTimeFragment },
)
let html = await drain(stream)
document.body.innerHTML = html
// Ensure template exists so the pending frame can render immediately.
let frameId = getCommentMarkerId(html, 'rmx:f:')
expect(document.querySelector(`template#${frameId}`)).toBeTruthy()
let clientFrame = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload.js' && exportName === 'Reload') return ReloadButton
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame: renderTimeFragment,
})
await clientFrame.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.querySelector('p')?.textContent).toBe('Server: 1')
invariant(reload)
// Capture references to every element before reload.
let section = document.querySelector('section')
let heading = document.querySelector('h2')
let paragraph = document.querySelector('p')
let list = document.querySelector('ul')
let items = document.querySelectorAll('li')
let button = document.querySelector('button')
invariant(section && heading && paragraph && list && button)
invariant(items.length === 2)
await reload()
await new Promise((resolve) => setTimeout(resolve, 0))
// Dynamic text updated.
expect(document.querySelector('p')?.textContent).toBe('Server: 2')
// Static elements are the exact same DOM nodes — not replaced.
expect(document.querySelector('section')).toBe(section)
expect(document.querySelector('h2')).toBe(heading)
expect(document.querySelector('p')).toBe(paragraph)
expect(document.querySelector('ul')).toBe(list)
expect(document.querySelectorAll('li')[0]).toBe(items[0])
expect(document.querySelectorAll('li')[1]).toBe(items[1])
expect(document.querySelector('button')).toBe(button)
// Static text preserved.
expect(heading.textContent).toBe('Activity')
expect(items[0].textContent).toBe('First')
expect(items[1].textContent).toBe('Second')
clientFrame.dispose()
})
it('clears frame content when reload resolves to an empty stream', async () => {
let reload: undefined | (() => Promise)
let ReloadButton = clientEntry(
'/assets/reload-empty.js#ReloadEmpty',
function ReloadEmpty(handle: Handle) {
reload = () => handle.frame.reload()
return () =>
},
)
async function renderInitial(): Promise {
return await drain(
renderToStream(
Initial content
,
),
)
}
let html = await drain(
renderToStream(
Loading…} />
,
{ resolveFrame: renderInitial },
),
)
document.body.innerHTML = html
let clientFrame = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload-empty.js' && exportName === 'ReloadEmpty') {
return ReloadButton
}
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame(src: string) {
if (src !== '/reload-empty') throw new Error(`Unexpected frame src: ${src}`)
return new ReadableStream({
start(controller) {
controller.close()
},
})
},
})
await clientFrame.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.getElementById('frame-value')?.textContent).toBe('Initial content')
invariant(reload)
await reload()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.getElementById('frame-content')).toBeNull()
expect(document.getElementById('frame-value')).toBeNull()
clientFrame.dispose()
})
it('looks up named adjacent frames from handle.frames.get(name)', async () => {
let summaryRenderCount = 0
let reloadSummary: undefined | (() => Promise)
let RowAction = clientEntry(
'/assets/row-action.js#RowAction',
function RowAction(handle: Handle) {
reloadSummary = async () => {
expect(handle.frames.get('missing-frame')).toBeUndefined()
await handle.frames.get('cart-summary')?.reload()
}
return () =>
},
)
async function resolveFrame(src: string) {
if (src === '/summary') {
summaryRenderCount++
let stream = renderToStream(
Summary: {summaryRenderCount}
)
return await drain(stream)
}
if (src === '/row') {
let stream = renderToStream()
return await drain(stream)
}
return '
Unexpected frame
'
}
let stream = renderToStream(
Loading summary…} />
Loading row…} />
,
{ resolveFrame },
)
let html = await drain(stream)
document.body.innerHTML = html
let clientFrame = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/row-action.js' && exportName === 'RowAction') return RowAction
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame,
})
await clientFrame.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.querySelector('#summary')?.textContent).toBe('Summary: 1')
invariant(reloadSummary)
await reloadSummary()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.querySelector('#summary')?.textContent).toBe('Summary: 2')
clientFrame.dispose()
})
it('exposes the root frame as handle.frames.top', async () => {
let assertTopFrame: undefined | (() => void)
let ReloadTop = clientEntry(
'/assets/reload-top.js#ReloadTop',
function ReloadTop(handle: Handle) {
assertTopFrame = () => {
expect(handle.frames.top).not.toBe(handle.frame)
}
return () =>
},
)
async function renderInner() {
let stream = renderToStream()
return await drain(stream)
}
document.body.innerHTML = await drain(
renderToStream(
,
{
resolveFrame(src: string) {
if (src === '/inner') return renderInner()
throw new Error(`Unexpected page frame src: ${src}`)
},
},
),
)
let app = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload-top.js' && exportName === 'ReloadTop') {
return ReloadTop
}
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
async resolveFrame(src: string) {
if (src === '/inner') {
return await renderInner()
}
throw new Error(`Unexpected frame src: ${src}`)
},
})
await app.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
invariant(assertTopFrame)
assertTopFrame()
app.dispose()
})
it('dispatches reloadStart and reloadComplete events for handle.frame and handle.frames.get(name)', async () => {
let summaryReloadStartEvents = 0
let rowReloadStartEvents = 0
let summaryReloadCompleteEvents = 0
let rowReloadCompleteEvents = 0
let triggerReloads: undefined | (() => Promise)
let summaryRenderCount = 0
let RowAction = clientEntry(
'/assets/reload-events.js#ReloadEvents',
function ReloadEvents(handle: Handle) {
triggerReloads = async () => {
let summaryFrame = handle.frames.get('cart-summary')
invariant(summaryFrame)
summaryFrame.addEventListener(
'reloadStart',
() => {
summaryReloadStartEvents++
},
{ once: true },
)
summaryFrame.addEventListener(
'reloadComplete',
() => {
summaryReloadCompleteEvents++
},
{ once: true },
)
handle.frame.addEventListener(
'reloadStart',
() => {
rowReloadStartEvents++
},
{ once: true },
)
handle.frame.addEventListener(
'reloadComplete',
() => {
rowReloadCompleteEvents++
},
{ once: true },
)
await Promise.all([summaryFrame.reload(), handle.frame.reload()])
}
return () =>
},
)
async function resolveFrame(src: string) {
if (src === '/summary') {
summaryRenderCount++
return await drain(renderToStream(
Summary: {summaryRenderCount}
))
}
if (src === '/row') {
return await drain(renderToStream())
}
throw new Error(`Unexpected frame src: ${src}`)
}
let html = await drain(
renderToStream(
Loading summary…} />
Loading row…} />
,
{ resolveFrame },
),
)
document.body.innerHTML = html
let app = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload-events.js' && exportName === 'ReloadEvents') {
return RowAction
}
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame,
})
await app.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.getElementById('summary-events')?.textContent).toBe('Summary: 1')
invariant(triggerReloads)
await triggerReloads()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(summaryReloadStartEvents).toBe(1)
expect(rowReloadStartEvents).toBe(1)
expect(summaryReloadCompleteEvents).toBe(1)
expect(rowReloadCompleteEvents).toBe(1)
expect(document.getElementById('summary-events')?.textContent).toBe('Summary: 2')
app.dispose()
})
it('reloads a frame region when the response uses css mixins', async () => {
let renderCount = 0
let reload: undefined | (() => Promise)
let ReloadButton = clientEntry(
'/assets/reload-css.js#ReloadCss',
function ReloadCss(handle: Handle) {
reload = () => handle.frame.reload()
return () =>
},
)
async function renderTimeFragmentWithCss() {
renderCount++
let stream = renderToStream(
Server: {renderCount}
,
{
onError(error) {
console.error(error)
},
},
)
return await drain(stream)
}
let stream = renderToStream(
Loading…} />
,
{ resolveFrame: renderTimeFragmentWithCss },
)
let html = await drain(stream)
document.body.innerHTML = html
let frameId = getCommentMarkerId(html, 'rmx:f:')
expect(document.querySelector(`template#${frameId}`)).toBeTruthy()
let clientFrame = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload-css.js' && exportName === 'ReloadCss') return ReloadButton
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame: renderTimeFragmentWithCss,
})
await clientFrame.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.querySelector('p')?.textContent).toBe('Server: 1')
invariant(reload)
let section = document.querySelector('section')
let paragraph = document.querySelector('p')
let button = document.querySelector('button')
invariant(section && paragraph && button)
await reload()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.querySelector('p')?.textContent).toBe('Server: 2')
// Regression guard: reload should preserve node identity even with css-prop styles.
expect(document.querySelector('section')).toBe(section)
expect(document.querySelector('p')).toBe(paragraph)
expect(document.querySelector('button')).toBe(button)
clientFrame.dispose()
})
it('dispatches reload rejections to app error listeners', async () => {
let reload: undefined | (() => Promise)
let reloadError = new TypeError('Failed to fetch')
let renderCount = 0
let ReloadButton = clientEntry(
'/assets/reload-error.js#ReloadError',
function ReloadError(handle: Handle) {
reload = () => handle.frame.reload()
return () =>
},
)
async function resolveFrame(src: string) {
if (src !== '/reload-error') throw new Error(`Unexpected frame src: ${src}`)
renderCount++
if (renderCount === 1) {
return await drain(
renderToStream(
Initial
,
),
)
}
throw reloadError
}
let html = await drain(
renderToStream(
Loading…} />
,
{ resolveFrame },
),
)
document.body.innerHTML = html
let app = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload-error.js' && exportName === 'ReloadError') {
return ReloadButton
}
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame,
})
let forwarded: unknown
app.addEventListener('error', (event) => {
forwarded = event.error
})
await app.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
invariant(reload)
let caught = await reload().catch((error) => error)
expect(caught).toBe(reloadError)
expect(forwarded).toBe(reloadError)
expect(document.getElementById('reload-error-value')?.textContent).toBe('Initial')
app.dispose()
})
it('aborts stale frame reloads when reload is re-entered', async () => {
let reload: undefined | (() => Promise)
let callCount = 0
let firstSignal: AbortSignal | undefined
let secondSignal: AbortSignal | undefined
let [firstReloadContent, resolveFirstReloadContent] = withResolvers()
let [secondReloadContent, resolveSecondReloadContent] = withResolvers()
let ReloadButton = clientEntry(
'/assets/reload-abort.js#ReloadAbort',
function ReloadAbort(handle: Handle) {
reload = () => handle.frame.reload()
return () =>
},
)
async function renderInitial() {
return await drain(
renderToStream(
Initial
,
),
)
}
let html = await drain(
renderToStream(
Loading…} />
,
{ resolveFrame: renderInitial },
),
)
document.body.innerHTML = html
let clientFrame = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload-abort.js' && exportName === 'ReloadAbort') {
return ReloadButton
}
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame(src: string, signal?: AbortSignal) {
if (src !== '/reload-abort') throw new Error(`Unexpected frame src: ${src}`)
callCount++
if (callCount === 1) {
firstSignal = signal
return firstReloadContent
}
if (callCount === 2) {
secondSignal = signal
return secondReloadContent
}
throw new Error(`Unexpected reload call count: ${callCount}`)
},
})
await clientFrame.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
invariant(reload)
let firstReloadPromise = reload()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(firstSignal?.aborted).toBe(false)
let secondReloadPromise = reload()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(firstSignal?.aborted).toBe(true)
expect(secondSignal?.aborted).toBe(false)
resolveFirstReloadContent('
Stale
')
let firstReturnedSignal = await firstReloadPromise
await new Promise((resolve) => setTimeout(resolve, 0))
expect(firstReturnedSignal).toBe(firstSignal)
expect(firstReturnedSignal.aborted).toBe(true)
// First reload should be ignored because it was superseded.
expect(document.getElementById('reload-value')?.textContent).toBe('Initial')
resolveSecondReloadContent('
Fresh
')
let secondReturnedSignal = await secondReloadPromise
await new Promise((resolve) => setTimeout(resolve, 0))
expect(secondReturnedSignal).toBe(secondSignal)
expect(secondReturnedSignal.aborted).toBe(false)
expect(document.getElementById('reload-value')?.textContent).toBe('Fresh')
clientFrame.dispose()
})
it('keeps head-like elements inside the frame when a frame reloads', async () => {
let renderCount = 0
let reload: undefined | (() => Promise)
let ReloadButton = clientEntry(
'/assets/reload-head.js#ReloadHead',
function ReloadHead(handle: Handle) {
reload = () => handle.frame.reload()
return () =>
},
)
async function renderHeadFragment() {
renderCount++
let stream = renderToStream(
<>
Frame title {renderCount}
Frame body {renderCount}
>,
)
return await drain(stream)
}
let stream = renderToStream(
Loading…} />
,
{ resolveFrame: renderHeadFragment },
)
let html = await drain(stream)
document.body.innerHTML = html
let frameId = getCommentMarkerId(html, 'rmx:f:')
expect(document.querySelector(`template#${frameId}`)).toBeTruthy()
let clientFrame = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload-head.js' && exportName === 'ReloadHead')
return ReloadButton
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame: renderHeadFragment,
})
await clientFrame.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
let main = document.querySelector('main')
invariant(main)
expect(main.querySelector('title')?.textContent).toBe('Frame title 1')
expect(main.querySelector('meta[name="frame-description"]')?.getAttribute('content')).toBe(
'frame-1',
)
expect(main.querySelector('script[type="application/ld+json"]')?.textContent).toBe(
'{"count":1}',
)
expect(main.querySelector('script[type="text/javascript"]')?.textContent).toBe(
'window.__frameRegular = 1',
)
expect(document.head.querySelector('title')).toBeNull()
expect(document.head.querySelector('meta[name="frame-description"]')).toBeNull()
expect(document.head.querySelector('script[type="application/ld+json"]')).toBeNull()
invariant(reload)
await reload()
await new Promise((resolve) => setTimeout(resolve, 0))
let titles = main.querySelectorAll('title')
expect(titles).toHaveLength(1)
expect(titles[0]?.textContent).toBe('Frame title 2')
let metas = main.querySelectorAll('meta[name="frame-description"]')
expect(metas).toHaveLength(1)
expect(metas[0]?.getAttribute('content')).toBe('frame-2')
let ldJsonScripts = main.querySelectorAll('script[type="application/ld+json"]')
expect(ldJsonScripts).toHaveLength(1)
expect(ldJsonScripts[0]?.textContent).toBe('{"count":2}')
expect(main.querySelector('script[type="text/javascript"]')?.textContent).toBe(
'window.__frameRegular = 2',
)
expect(document.querySelector('p')?.textContent).toBe('Frame body 2')
clientFrame.dispose()
})
it('hydrates client entries in blocking frame content without redefining virtual roots', async () => {
let Counter = clientEntry('/js/counter.js#Counter', function Counter() {
return () => (
)
})
async function renderInner(): Promise {
return await drain(renderToStream())
}
let stream = renderToStream(
,
{ resolveFrame: renderInner },
)
let html = await drain(stream)
document.body.innerHTML = html
let [modulePromise, resolveModule] = withResolvers()
let loadModule = vi.fn().mockImplementation(async () => modulePromise)
let clientFrame = run({ loadModule })
await new Promise((resolve) => setTimeout(resolve, 0))
resolveModule(Counter)
await expect(clientFrame.ready()).resolves.toBeUndefined()
let button = document.getElementById('counter') as HTMLButtonElement | null
invariant(button)
expect(button.textContent).toContain('Count')
expect(loadModule).toHaveBeenCalled()
clientFrame.dispose()
})
it('hydrates components without waiting for pending frames', async () => {
let Counter = clientEntry(
'/js/counter.js#Counter',
function Counter(handle: Handle, setup: number) {
let count = setup
return () => (
)
},
)
let [framePromise, resolveFramePromise] = withResolvers()
let stream = renderToStream(
Loading…} />
,
{ resolveFrame: () => framePromise },
)
// Get first chunk only (fallback + counter HTML).
let chunks = readChunks(stream)
let first = await chunks.next()
invariant(!first.done)
document.body.innerHTML = first.value
// Frame shows fallback.
expect(document.getElementById('frame')!.textContent).toBe('Loading…')
let clientFrame = run({
loadModule: vi.fn().mockResolvedValue(Counter),
})
await clientFrame.ready()
// Counter is hydrated and interactive BEFORE frame resolves.
let button = document.getElementById('counter') as HTMLButtonElement
expect(button.textContent).toBe('Count: 0')
button.click()
clientFrame.flush()
expect(button.textContent).toBe('Count: 1')
// Frame still shows fallback.
expect(document.getElementById('frame')!.textContent).toBe('Loading…')
// Now resolve the frame and inject the template.
resolveFramePromise('Loaded!')
let second = await chunks.next()
invariant(!second.done)
document.body.insertAdjacentHTML('beforeend', second.value)
await new Promise((resolve) => setTimeout(resolve, 0))
// Frame is now rendered.
expect(document.getElementById('frame')!.textContent).toBe('Loaded!')
// Counter still works.
button.click()
clientFrame.flush()
expect(button.textContent).toBe('Count: 2')
clientFrame.dispose()
})
it('pending frames resolve independently as their templates arrive', async () => {
let [fastPromise, resolveFast] = withResolvers()
let [slowPromise, resolveSlow] = withResolvers()
let stream = renderToStream(
Loading fast…} />
Loading slow…} />
,
{
resolveFrame(src: string) {
if (src === '/fast') return fastPromise
if (src === '/slow') return slowPromise
throw new Error(`Unexpected frame src: ${src}`)
},
},
)
// Get the first chunk (both fallbacks).
let chunks = readChunks(stream)
let first = await chunks.next()
invariant(!first.done)
document.body.innerHTML = first.value
expect(document.getElementById('fast')!.textContent).toBe('Loading fast…')
expect(document.getElementById('slow')!.textContent).toBe('Loading slow…')
let clientFrame = run({ loadModule: vi.fn() })
await clientFrame.ready()
// Resolve the fast frame first.
resolveFast('Fast loaded')
let second = await chunks.next()
invariant(!second.done)
document.body.insertAdjacentHTML('beforeend', second.value)
await new Promise((resolve) => setTimeout(resolve, 0))
// Fast frame is rendered; slow frame still shows fallback.
expect(document.getElementById('fast')!.textContent).toBe('Fast loaded')
expect(document.getElementById('slow')!.textContent).toBe('Loading slow…')
// Now resolve the slow frame.
resolveSlow('Slow loaded')
let third = await chunks.next()
invariant(!third.done)
document.body.insertAdjacentHTML('beforeend', third.value)
await new Promise((resolve) => setTimeout(resolve, 0))
// Both frames are now rendered.
expect(document.getElementById('fast')!.textContent).toBe('Fast loaded')
expect(document.getElementById('slow')!.textContent).toBe('Slow loaded')
clientFrame.dispose()
})
it('pending frames resolve while modules are still loading', async () => {
// Uses manual HTML because renderToStream + readChunks with a deferred
// loadModule has a timing issue in the Chromium test environment where
// the hydration markers aren't found by the tree walker.
let [modulePromise, resolveModule] = withResolvers()
let moduleLoaded = false
function Counter() {
return () =>
}
document.body.innerHTML =
'
' +
'' +
'Loading…' +
'
' +
''
let loadModuleFn = vi.fn().mockImplementation(async () => {
let mod = await modulePromise
moduleLoaded = true
return mod
})
let clientFrame = run({ loadModule: loadModuleFn })
await new Promise((resolve) => setTimeout(resolve, 0))
// loadModule must have been called (hydration marker was found).
expect(loadModuleFn).toHaveBeenCalled()
expect(moduleLoaded).toBe(false)
// Frame still shows fallback (template hasn't arrived).
expect(document.getElementById('frame')!.textContent).toBe('Loading…')
// Simulate frame template arriving via MutationObserver.
let template = document.createElement('template')
template.id = 'f1'
template.innerHTML = 'Loaded!'
document.body.appendChild(template)
await new Promise((resolve) => setTimeout(resolve, 0))
// Frame rendered even though module hasn't loaded yet.
expect(document.getElementById('frame')!.textContent).toBe('Loaded!')
expect(moduleLoaded).toBe(false)
// Now resolve the module and let hydration complete.
resolveModule(Counter)
await clientFrame.ready()
expect(moduleLoaded).toBe(true)
clientFrame.dispose()
})
it('hydrates a component inside a nested frame', async () => {
let Counter = clientEntry(
'/js/counter.js#Counter',
function Counter(handle: Handle, setup: number) {
let count = setup
return () => (
)
},
)
// Use renderToStream to produce proper HTML for each level.
let neverResolve = () => new Promise(() => {})
// Render inner frame content (hydrated Counter).
let innerContent = await drain(renderToStream())
// Render outer frame content (pending inner frame with fallback).
let outerStream = renderToStream(
Loading inner…} />
,
{ resolveFrame: neverResolve },
)
let outerChunks = readChunks(outerStream)
let outerFirst = await outerChunks.next()
invariant(!outerFirst.done)
let outerContent = outerFirst.value
let innerFrameId = getCommentMarkerId(outerContent, 'rmx:f:')
// Render initial page (pending outer frame with fallback).
let pageStream = renderToStream(
Loading outer…} />
,
{ resolveFrame: neverResolve },
)
let pageChunks = readChunks(pageStream)
let pageFirst = await pageChunks.next()
invariant(!pageFirst.done)
document.body.innerHTML = pageFirst.value
let outerFrameId = getCommentMarkerId(pageFirst.value, 'rmx:f:')
let clientFrame = run({
loadModule: vi.fn().mockResolvedValue(Counter),
})
await new Promise((resolve) => setTimeout(resolve, 0))
// Outer frame still shows fallback.
expect(document.getElementById('outer')!.textContent).toBe('Loading outer…')
// Outer frame template arrives.
let outerTemplate = document.createElement('template')
outerTemplate.id = outerFrameId
outerTemplate.innerHTML = outerContent
document.body.appendChild(outerTemplate)
await new Promise((resolve) => setTimeout(resolve, 0))
// Outer frame rendered, inner frame shows fallback.
expect(document.getElementById('inner')!.textContent).toBe('Loading inner…')
// Inner frame template arrives.
let innerTemplate = document.createElement('template')
innerTemplate.id = innerFrameId
innerTemplate.innerHTML = innerContent
document.body.appendChild(innerTemplate)
await new Promise((resolve) => setTimeout(resolve, 0))
// Counter inside the nested frame is hydrated and interactive.
let button = document.getElementById('nested-counter') as HTMLButtonElement
expect(button.textContent).toBe('Count: 10')
button.click()
clientFrame.flush()
expect(button.textContent).toBe('Count: 11')
clientFrame.dispose()
})
it('deeply nested frames resolve independently at each level', async () => {
// Page has outer frame → outer has middle frame → middle has inner frame.
// Each level resolves independently via MutationObserver.
let neverResolve = () => new Promise(() => {})
// Render inner content (leaf — no sub-frames).
let innerContent = await drain(renderToStream(
Inner loaded
))
// Render middle content (pending inner frame with fallback).
let middleStream = renderToStream(
Middle loaded
Loading inner…} />
,
{ resolveFrame: neverResolve },
)
let middleChunks = readChunks(middleStream)
let middleFirst = await middleChunks.next()
invariant(!middleFirst.done)
let middleContent = middleFirst.value
let innerFrameId = getCommentMarkerId(middleContent, 'rmx:f:')
// Render outer content (pending middle frame with fallback).
let outerStream = renderToStream(
Outer loaded
Loading middle…} />
,
{ resolveFrame: neverResolve },
)
let outerChunks = readChunks(outerStream)
let outerFirst = await outerChunks.next()
invariant(!outerFirst.done)
let outerContent = outerFirst.value
let middleFrameId = getCommentMarkerId(outerContent, 'rmx:f:')
// Render initial page (pending outer frame with fallback).
let pageStream = renderToStream(
Page
Loading outer…} />
,
{ resolveFrame: neverResolve },
)
let pageChunks = readChunks(pageStream)
let pageFirst = await pageChunks.next()
invariant(!pageFirst.done)
document.body.innerHTML = pageFirst.value
let outerFrameId = getCommentMarkerId(pageFirst.value, 'rmx:f:')
let clientFrame = run({ loadModule: vi.fn() })
await clientFrame.ready()
// Page content is visible, outer frame shows fallback.
expect(document.getElementById('title')!.textContent).toBe('Page')
expect(document.getElementById('outer')!.textContent).toBe('Loading outer…')
// Outer frame template arrives — contains a middle frame.
let outerTemplate = document.createElement('template')
outerTemplate.id = outerFrameId
outerTemplate.innerHTML = outerContent
document.body.appendChild(outerTemplate)
await new Promise((resolve) => setTimeout(resolve, 0))
// Outer content rendered, middle shows fallback.
expect(document.getElementById('outer-content')!.textContent).toBe('Outer loaded')
expect(document.getElementById('middle')!.textContent).toBe('Loading middle…')
// Middle frame template arrives — contains an inner frame.
let middleTemplate = document.createElement('template')
middleTemplate.id = middleFrameId
middleTemplate.innerHTML = middleContent
document.body.appendChild(middleTemplate)
await new Promise((resolve) => setTimeout(resolve, 0))
// Middle content rendered, inner shows fallback.
expect(document.getElementById('middle-content')!.textContent).toBe('Middle loaded')
expect(document.getElementById('inner')!.textContent).toBe('Loading inner…')
// Inner frame template arrives.
let innerTemplate = document.createElement('template')
innerTemplate.id = innerFrameId
innerTemplate.innerHTML = innerContent
document.body.appendChild(innerTemplate)
await new Promise((resolve) => setTimeout(resolve, 0))
// All three levels rendered.
expect(document.getElementById('outer-content')!.textContent).toBe('Outer loaded')
expect(document.getElementById('middle-content')!.textContent).toBe('Middle loaded')
expect(document.getElementById('inner-content')!.textContent).toBe('Inner loaded')
// Page content preserved throughout.
expect(document.getElementById('title')!.textContent).toBe('Page')
clientFrame.dispose()
})
it('reloads a frame that is nested inside another frame', async () => {
let reloadInner: undefined | (() => Promise)
let renderCount = 0
let ReloadButton = clientEntry(
'/js/reload.js#ReloadButton',
function ReloadButton(handle: Handle) {
reloadInner = () => handle.frame.reload()
return () =>
},
)
async function renderInner() {
renderCount++
return await drain(
renderToStream(
Render {renderCount}
,
),
)
}
// Render outer content with a pending inner frame.
let outerStream = renderToStream(
Outer
Loading…} />
,
{ resolveFrame: () => new Promise(() => {}) },
)
let outerChunks = readChunks(outerStream)
let outerFirst = await outerChunks.next()
invariant(!outerFirst.done)
let outerContent = outerFirst.value
let innerFrameId = getCommentMarkerId(outerContent, 'rmx:f:')
// Render page with a blocking outer frame that resolves to the outer content.
let pageStream = renderToStream(
,
{ resolveFrame: () => outerContent },
)
let pageHtml = await drain(pageStream)
document.body.innerHTML = pageHtml
let clientFrame = run({
loadModule: vi.fn().mockResolvedValue(ReloadButton),
resolveFrame: renderInner,
})
await new Promise((resolve) => setTimeout(resolve, 0))
// Outer is resolved, inner shows fallback.
expect(document.getElementById('outer-text')!.textContent).toBe('Outer')
expect(document.getElementById('inner-fallback')!.textContent).toBe('Loading…')
// Inner frame template arrives.
let innerTemplate = document.createElement('template')
innerTemplate.id = innerFrameId
innerTemplate.innerHTML = await renderInner()
document.body.appendChild(innerTemplate)
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
// Inner frame rendered with hydrated ReloadButton.
expect(document.getElementById('inner-text')!.textContent).toBe('Render 1')
invariant(reloadInner)
// Reload the inner frame — only the inner frame should update.
await reloadInner()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.getElementById('inner-text')!.textContent).toBe('Render 2')
expect(document.getElementById('outer-text')!.textContent).toBe('Outer')
clientFrame.dispose()
})
it('renders a client-created Frame with createRoot frameInit', async () => {
let rootContainer = document.createElement('div')
document.body.appendChild(rootContainer)
let root = createRoot(rootContainer, {
frameInit: {
resolveFrame: async () => '
Resolved frame
',
},
})
root.render(Loading…
} />)
root.flush()
expect(rootContainer.querySelector('#fallback-frame')?.textContent).toBe('Loading…')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(rootContainer.querySelector('#resolved-frame')?.textContent).toBe('Resolved frame')
root.dispose()
})
it('renders a client-created Frame with createRangeRoot frameInit', async () => {
let host = document.createElement('div')
document.body.appendChild(host)
let start = document.createComment('start')
let end = document.createComment('end')
host.append(start, end)
let root = createRangeRoot([start, end], {
frameInit: {
src: '/range-root',
resolveFrame: async () => '
Resolved range frame
',
loadModule: async () =>
function Module() {
return () => null
},
},
})
root.render(
Loading…} />,
)
root.flush()
expect(host.querySelector('#fallback-range-frame')?.textContent).toBe('Loading…')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(host.querySelector('#resolved-range-frame')?.textContent).toBe('Resolved range frame')
root.dispose()
})
it('dispatches a clear error for createRoot Frame without frameInit', () => {
let rootContainer = document.createElement('div')
document.body.appendChild(rootContainer)
let root = createRoot(rootContainer)
let error: unknown
root.addEventListener('error', (event) => {
error = (event as ErrorEvent).error
})
root.render(Loading…} />)
root.flush()
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain('Cannot render without frame runtime')
})
it('dispatches a clear error for createRangeRoot Frame without frameInit', () => {
let host = document.createElement('div')
let start = document.createComment('start')
let end = document.createComment('end')
host.append(start, end)
let root = createRangeRoot([start, end])
let error: unknown
root.addEventListener('error', (event) => {
error = (event as ErrorEvent).error
})
root.render(Loading…} />)
root.flush()
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain('Cannot render without frame runtime')
})
it('throws from the root runtime resolveFrame fallback without frameInit', () => {
let rootContainer = document.createElement('div')
document.body.appendChild(rootContainer)
let runtime: { resolveFrame(src: string): unknown } | undefined
function CaptureRuntime(handle: Handle) {
runtime = handle.frame.$runtime as { resolveFrame(src: string): unknown }
return () => null
}
let root = createRoot(rootContainer)
root.render()
root.flush()
expect(runtime).toBeDefined()
expect(() => runtime!.resolveFrame('/missing-runtime')).toThrow(
'Cannot render without frame runtime',
)
})
it('logs a clear error when hydrating client entries without loadModule', async () => {
let Counter = clientEntry(
'/js/counter.js#Counter',
function Counter(handle: Handle, setup: number) {
let count = setup
return () =>
},
)
let html = await drain(renderToStream())
let container = document.createElement('div')
let consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
let root = createRoot(container, {
frameInit: {
resolveFrame: async () => html,
},
})
root.render(Loading…} />)
root.flush()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(consoleError).toHaveBeenCalled()
expect(
consoleError.mock.calls.some((call) => String(call[0]).includes('Failed to load module')),
).toBe(true)
expect(
consoleError.mock.calls.some((call) =>
call.some((value) =>
String(value).includes(
'loadModule is required to hydrate client entries inside ',
),
),
),
).toBe(true)
} finally {
consoleError.mockRestore()
}
})
it('logs a clear error when loadModule resolves to a non-function export', async () => {
let Counter = clientEntry(
'/js/counter.js#Counter',
function Counter(handle: Handle, setup: number) {
let count = setup
return () =>
},
)
let html = await drain(renderToStream())
let container = document.createElement('div')
let consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
let root = createRoot(container, {
frameInit: {
resolveFrame: async () => html,
loadModule: async () => ({ not: 'a function' }) as any,
},
})
root.render(Loading…} />)
root.flush()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(consoleError).toHaveBeenCalled()
expect(
consoleError.mock.calls.some((call) =>
call.some((value) => String(value).includes('is not a function')),
),
).toBe(true)
} finally {
consoleError.mockRestore()
}
})
it('reloads client-created Frame in place when src changes', async () => {
let rootContainer = document.createElement('div')
document.body.appendChild(rootContainer)
let [nextFramePromise, resolveNextFrame] = withResolvers()
let root = createRoot(rootContainer, {
frameInit: {
resolveFrame: async (src) => {
if (src === '/a') return '
A
'
return await nextFramePromise
},
},
})
root.render(Loading A…} />)
root.flush()
expect(rootContainer.querySelector('#fallback-a')?.textContent).toBe('Loading A…')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(rootContainer.querySelector('#frame-a')?.textContent).toBe('A')
let frameA = rootContainer.querySelector('#frame-a')
invariant(frameA instanceof HTMLParagraphElement)
root.render(Loading B…} />)
root.flush()
// src updates should behave like reloads: existing content remains mounted
// while the new source resolves.
expect(rootContainer.querySelector('#fallback-b')).toBeNull()
expect(rootContainer.querySelector('#frame-a')).toBe(frameA)
resolveNextFrame('
B
')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(rootContainer.querySelector('#frame-b')?.textContent).toBe('B')
root.dispose()
})
it('renders a client-created Frame after run() from a hydrated entry component', async () => {
let mounted = false
let showFrame: undefined | (() => void)
let PostRunFrame = clientEntry(
'/js/post-run.js#PostRunFrame',
function PostRunFrame(handle: Handle) {
showFrame = () => {
mounted = true
handle.update()
}
return () => (
{mounted ? (
Loading post-run…}
/>
) : (
Before frame
)}
)
},
)
let pageHtml = await drain(renderToStream())
document.body.innerHTML = pageHtml
let app = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/js/post-run.js' && exportName === 'PostRunFrame') return PostRunFrame
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame: async () => '
Post-run loaded
',
})
await app.ready()
invariant(showFrame)
showFrame()
app.flush()
expect(document.getElementById('post-run-fallback')?.textContent).toBe('Loading post-run…')
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.getElementById('post-run-loaded')?.textContent).toBe('Post-run loaded')
app.dispose()
})
it('does not duplicate initially-mounted Frame hydration in a client entry', async () => {
let MountedFrame = clientEntry('/js/mounted-frame.js#MountedFrame', function MountedFrame() {
let showFrame = true
return () =>
showFrame ? (
Loading outer…} />
) : null
})
let outerStream = renderToStream(
Loading nested…} />
,
{ resolveFrame: () => new Promise(() => {}) },
)
let outerChunks = readChunks(outerStream)
let outerFirst = await outerChunks.next()
invariant(!outerFirst.done)
let outerInitialHtml = outerFirst.value
let nestedFrameId = getCommentMarkerId(outerInitialHtml, 'rmx:f:')
let [outerPromise, resolveOuter] = withResolvers()
let pageStream = renderToStream(, {
resolveFrame(src: string) {
if (src === '/outer') return outerPromise
throw new Error(`Unexpected src during page render: ${src}`)
},
})
let pageChunks = readChunks(pageStream)
let pageFirst = await pageChunks.next()
invariant(!pageFirst.done)
document.body.innerHTML = pageFirst.value
expect(document.querySelectorAll('#outer-fallback')).toHaveLength(1)
let clientResolveFrame = vi
.fn()
.mockImplementation(
async (src: string) => `
client resolve ${src}
`,
)
let app = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/js/mounted-frame.js' && exportName === 'MountedFrame') {
return MountedFrame
}
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame: clientResolveFrame,
})
await app.ready()
expect(clientResolveFrame).not.toHaveBeenCalled()
resolveOuter(outerInitialHtml)
let pageSecond = await pageChunks.next()
invariant(!pageSecond.done)
document.body.insertAdjacentHTML('beforeend', pageSecond.value)
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(clientResolveFrame).not.toHaveBeenCalled()
expect(document.querySelectorAll('#outer-root')).toHaveLength(1)
expect(document.querySelectorAll('#nested-fallback')).toHaveLength(1)
let nestedTemplate = document.createElement('template')
nestedTemplate.id = nestedFrameId
nestedTemplate.innerHTML = 'Nested loaded'
document.body.appendChild(nestedTemplate)
await new Promise((resolve) => setTimeout(resolve, 0))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(clientResolveFrame).not.toHaveBeenCalled()
expect(document.querySelectorAll('#nested-loaded')).toHaveLength(1)
expect(document.querySelectorAll('#nested-fallback')).toHaveLength(0)
app.dispose()
})
it('renders Frame semantics from entry children during initial hydration', async () => {
let Card = clientEntry('/js/card.js#Card', function Card() {
return (props: { children: any }) => {props.children}
})
let [framePromise, resolveFramePromise] = withResolvers()
let pageStream = renderToStream(
Loading child frame…} />
,
{ resolveFrame: () => framePromise },
)
let chunks = readChunks(pageStream)
let first = await chunks.next()
invariant(!first.done)
document.body.innerHTML = first.value
expect(document.getElementById('child-frame')?.textContent).toBe('Loading child frame…')
let app = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/js/card.js' && exportName === 'Card') return Card
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
})
await app.ready()
expect(document.getElementById('child-frame')?.textContent).toBe('Loading child frame…')
resolveFramePromise('Loaded child frame')
let second = await chunks.next()
invariant(!second.done)
document.body.insertAdjacentHTML('beforeend', second.value)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.getElementById('child-frame')?.textContent).toBe('Loaded child frame')
app.dispose()
})
it('does not dispose managed stylesheets when removing a client-created Frame', async () => {
let rootContainer = document.createElement('div')
document.body.appendChild(rootContainer)
function Shell(handle: Handle) {
let mounted = true
return () => (
{mounted ? (
Loading style frame…}
/>
) : null}
)
}
let root = createRoot(rootContainer, {
frameInit: {
resolveFrame: async () => 'Frame loaded',
},
})
root.render()
root.flush()
let before = document.adoptedStyleSheets.length
let button = rootContainer.querySelector('#toggle-frame')
invariant(button instanceof HTMLButtonElement)
button.click()
root.flush()
let after = document.adoptedStyleSheets.length
expect(after).toBeGreaterThan(0)
expect(after).toBeGreaterThanOrEqual(before)
root.dispose()
})
it('streams client resolveFrame templates and updates nested placeholders incrementally', async () => {
let reload: undefined | (() => Promise)
let ReloadButton = clientEntry(
'/assets/reload-stream.js#ReloadStream',
function ReloadStream(handle: Handle) {
reload = () => handle.frame.reload()
return () => (
)
},
)
async function renderInitial(): Promise {
return await drain(
renderToStream(
Initial outer
,
),
)
}
let [nestedResolvePromise, resolveNested] = withResolvers()
let streamedReload = renderToStream(
Reloaded outer
Loading nested…} />
,
{
resolveFrame(src: string) {
if (src === '/nested') return nestedResolvePromise
throw new Error(`Unexpected nested src: ${src}`)
},
},
)
let streamedChunks = readChunks(streamedReload)
let firstChunk = await streamedChunks.next()
invariant(!firstChunk.done)
resolveNested('Nested loaded')
let secondChunk = await streamedChunks.next()
invariant(!secondChunk.done)
let [secondChunkPromise, releaseSecondChunk] = withResolvers()
let serverHtml = await drain(
renderToStream(
Loading…} />
,
{
resolveFrame: renderInitial,
},
),
)
document.body.innerHTML = serverHtml
let app = run({
loadModule(moduleUrl, exportName) {
if (moduleUrl === '/assets/reload-stream.js' && exportName === 'ReloadStream') {
return ReloadButton
}
throw new Error(`Unexpected module: ${moduleUrl}#${exportName}`)
},
resolveFrame(src: string) {
if (src === '/reload-streamed') {
return streamFromChunks([firstChunk.value, secondChunkPromise])
}
throw new Error(`Unexpected frame src: ${src}`)
},
})
await app.ready()
await new Promise((resolve) => setTimeout(resolve, 0))
invariant(reload)
let reloadPromise = reload()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.getElementById('outer')?.textContent).toBe('Reloaded outer')
expect(document.getElementById('nested')?.textContent).toBe('Loading nested…')
releaseSecondChunk(secondChunk.value)
await reloadPromise
await new Promise((resolve) => setTimeout(resolve, 0))
expect(document.getElementById('nested')?.textContent).toBe('Nested loaded')
})
it('cancels stale client frame streams when src changes', async () => {
let rootContainer = document.createElement('div')
document.body.appendChild(rootContainer)
let [slowChunkPromise, resolveSlowChunk] = withResolvers()
function Shell(handle: Handle) {
let src = '/slow'
return () => (
Loading…} />
)
}
let root = createRoot(rootContainer, {
frameInit: {
resolveFrame(src: string) {
if (src === '/slow') {
return streamFromChunks([slowChunkPromise])
}
if (src === '/fast') {
return '