import { describe, it, expect, vi } from 'vitest' import type { Handle, RemixNode } from '../lib/component.ts' import { createMixin, css, on } from '../index.ts' import { createElement } from '../lib/create-element.ts' import { renderToStream, renderToString } from '../lib/stream.ts' import { clientEntry } from '../lib/client-entries.ts' import { drain, readChunks, withResolvers } from './utils.ts' import { Frame } from '../lib/component.ts' import { invariant } from '../lib/invariant.ts' const rmxDataScriptSelector = 'script[type="application/json"]#rmx-data' describe('stream', () => { function getLatestRmxDataScript(root: ParentNode): HTMLScriptElement { let scripts = root.querySelectorAll(rmxDataScriptSelector) let script = scripts.item(scripts.length - 1) as HTMLScriptElement | null invariant(script) return script } function parseRmxDataFromHtml(html: string): any { let shelf = document.createElement('template') shelf.innerHTML = html let script = getLatestRmxDataScript(shelf.content) return JSON.parse(script.textContent || '{}') } function getSingleEntry(obj: Record): [string, any] { let entries = Object.entries(obj) expect(entries.length).toBe(1) return entries[0]! } function getCommentMarkerId(html: string, prefix: 'rmx:f:' | 'rmx:h:'): string { let re = prefix === 'rmx:f:' ? // : // let match = html.match(re) expect(match).not.toBeNull() return match![1]! } describe('basic nodes', () => { it('should render to a stream', () => { let stream = renderToStream(
Hello, world!
) expect(stream).toBeDefined() }) it('streams basic HTML', async () => { let stream = renderToStream(
Hello, world!
) let html = await drain(stream) expect(html).toBe('
Hello, world!
') }) it('renders string nodes', async () => { let stream = renderToStream('Hello, world!') let html = await drain(stream) expect(html).toBe('Hello, world!') }) it('escapes text node content', async () => { let stream = renderToStream('&') let html = await drain(stream) expect(html).toBe('<img src=x onerror="alert(1)">&</img>') }) it('escapes text children in elements', async () => { let stream = renderToStream(
{''}
) let html = await drain(stream) expect(html).toBe('
<script>alert(1)</script>
') }) it('renders number nodes', async () => { let stream = renderToStream(42) let html = await drain(stream) expect(html).toBe('42') }) it('renders 0', async () => { let stream = renderToStream(0) let html = await drain(stream) expect(html).toBe('0') }) it('renders bigint nodes', async () => { let stream = renderToStream(BigInt(9007199254740991)) let html = await drain(stream) expect(html).toBe('9007199254740991') }) it('renders boolean nodes', async () => { let stream = renderToStream(true) let html = await drain(stream) expect(html).toBe('') }) it('renders null nodes', async () => { let stream = renderToStream(null) let html = await drain(stream) expect(html).toBe('') }) it('renders undefined nodes', async () => { let stream = renderToStream(undefined) let html = await drain(stream) expect(html).toBe('') }) it('renders array of nodes', async () => { let stream = renderToStream([
One
, Two]) let html = await drain(stream) expect(html).toBe('
One
Two') }) it('renders mixed array of nodes', async () => { let stream = renderToStream([
One
, 'text', 42, null, undefined]) let html = await drain(stream) expect(html).toBe('
One
text42') }) it('renders fragments', async () => { let stream = renderToStream( <>

Title

Paragraph

Content
, ) let html = await drain(stream) expect(html).toBe('

Title

Paragraph

Content
') }) }) describe('component nodes', () => { it('renders component nodes', async () => { function Greeting() { return ({ name }: { name: string }) =>
Hello, {name}!
} let stream = renderToStream() let html = await drain(stream) expect(html).toBe('
Hello, World!
') }) it('renders 0', async () => { function Test() { let n = 0 return () => {n} } let stream = renderToStream() let html = await drain(stream) expect(html).toBe('0') }) it('renders stateful component nodes', async () => { function Stateful() { return () =>
Stateful
} let stream = renderToStream() let html = await drain(stream) expect(html).toBe('
Stateful
') }) it('provides and reads context', async () => { type ThemeContext = { color: string; size: number } function ThemeProvider(handle: Handle) { handle.context.set({ color: 'blue', size: 16 }) return ({ children }: { children: any }) => children } function ThemedText(handle: Handle) { let theme = handle.context.get(ThemeProvider) return () =>

Themed!

} function App() { return () => (
) } let stream = renderToStream() let html = await drain(stream) expect(html).toBe('

Themed!

') }) it('provides and reads nested context', async () => { type ThemeContext = { color: string } type UserContext = { name: string } function ThemeProvider(handle: Handle) { handle.context.set({ color: 'red' }) return ({ children }: { children: any }) => children } function UserProvider(handle: Handle) { handle.context.set({ name: 'John' }) return ({ children }: { children: any }) => children } function Display(handle: Handle) { let theme = handle.context.get(ThemeProvider) let user = handle.context.get(UserProvider) return () =>

Hello, {user.name}!

} function App() { return () => (
) } let stream = renderToStream() let html = await drain(stream) expect(html).toBe('

Hello, John!

') }) it('provides context to multiple consumers', async () => { type CountContext = { count: number } function CountProvider(handle: Handle) { handle.context.set({ count: 42 }) return ({ children }: { children: any }) => children } function CountDisplay(handle: Handle) { let { count } = handle.context.get(CountProvider) return () => Count: {count} } function DoubleDisplay(handle: Handle) { let { count } = handle.context.get(CountProvider) return () => Double: {count * 2} } function App() { return () => (

) } let stream = renderToStream() let html = await drain(stream) expect(html).toBe('
Count: 42
Double: 84
') }) it('exposes the current and top frame src during SSR', async () => { let seen: | { frameSrc: string topFrameSrc: string sameFrame: boolean } | undefined function Inspect(handle: Handle) { seen = { frameSrc: handle.frame.src, topFrameSrc: handle.frames.top.src, sameFrame: handle.frame === handle.frames.top, } return () =>
{handle.frames.top.src}
} let html = await drain( renderToStream(, { frameSrc: 'https://example.com/dashboard' }), ) expect(seen).toEqual({ frameSrc: 'https://example.com/dashboard', topFrameSrc: 'https://example.com/dashboard', sameFrame: true, }) expect(html).toBe('
https://example.com/dashboard
') }) }) describe('special props', () => { it('renders innerHTML on elements', async () => { let htmlContent = 'Bold text and italic text' let stream = renderToStream(

Title

After innerHTML

, ) let html = await drain(stream) expect(html).toBe( '

Title

Bold text and italic text

After innerHTML

', ) }) it('changes className to class', async () => { let stream = renderToStream(
Content
) let html = await drain(stream) expect(html).toBe('
Content
') }) it('changes htmlFor to for', async () => { let stream = renderToStream( <> , ) let html = await drain(stream) expect(html).toBe('') }) it('changes acceptCharset to accept-charset', async () => { let stream = renderToStream(
, ) let html = await drain(stream) expect(html).toBe('
') }) it('changes httpEquiv to http-equiv', async () => { let stream = renderToStream( , ) let html = await drain(stream) expect(html).toBe( '', ) }) it('handles namespaced xlinkHref to xlink:href', async () => { let stream = renderToStream( , ) let html = await drain(stream) expect(html).toBe('') }) it("lowercases camelCase attributes that don't need special handling", async () => { let stream = renderToStream( , ) let html = await drain(stream) expect(html).toBe( '', ) }) it('handles table attributes colSpan and rowSpan', async () => { let stream = renderToStream(
Cell
, ) let html = await drain(stream) expect(html).toBe('
Cell
') }) it('filters framework-specific props', async () => { let stream = renderToStream(
  • First
  • Second
, ) let html = await drain(stream) // Framework props should not appear in HTML expect(html).not.toContain('key=') expect(html).not.toContain('mix=') // But regular HTML attributes should be preserved expect(html).toContain('type="button"') expect(html).toContain('
  • First
  • ') expect(html).toContain('
  • Second
  • ') }) it('resolves mixins for host prop composition', async () => { let withTitle = createMixin((handle) => (title: string, props: { title?: string }) => ( )) let appendTitle = createMixin((handle) => (suffix: string, props: { title?: string }) => ( )) let stream = renderToStream(
    ) let html = await drain(stream) expect(html).toBe('
    ') expect(html).not.toContain('mix=') }) it('supports nested mix descriptors via handle.element', async () => { let withData = createMixin( (handle) => (value: string, props: { ['data-mixed']?: string }) => ( ), ) let withNested = createMixin( (handle) => (value: string, props: { ['data-mixed']?: string }) => ( ), ) let stream = renderToStream(
    ) let html = await drain(stream) expect(html).toBe('
    ') expect(html).not.toContain('mix=') }) it('supports createElement(handle.element, props) during SSR', async () => { let withData = createMixin( (handle) => (value: string, props: { ['data-mixed']?: string }) => createElement(handle.element, { ...props, 'data-mixed': value }), ) let stream = renderToStream(
    child
    ) let html = await drain(stream) expect(html).toBe('
    child
    ') }) it('strips children and innerHTML before passing props to SSR mixins', async () => { let seenProps: Array> = [] let inspect = createMixin((_handle) => (props: Record) => { seenProps.push(props) }) await drain(renderToStream(
    child
    )) await drain(renderToStream(
    )) expect('children' in seenProps[0]!).toBe(false) expect('innerHTML' in seenProps[0]!).toBe(false) expect('children' in seenProps[1]!).toBe(false) expect('innerHTML' in seenProps[1]!).toBe(false) }) it('ignores children returned from mixins during SSR', async () => { let withChildren = createMixin( (handle) => () => createElement(handle.element as any, { 'data-mode': 'children' }, 'blocked'), ) let errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { let html = await drain(renderToStream(
    safe
    )) expect(html).toBe('
    safe
    ') expect(errorSpy).toHaveBeenCalledTimes(1) expect((errorSpy.mock.calls[0]?.[0] as Error).message).toBe( 'mixin elements must not receive children', ) } finally { errorSpy.mockRestore() } }) it('ignores innerHTML returned from mixins during SSR', async () => { let withInnerHtml = createMixin((_handle) => () => (
    )) let errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) try { let html = await drain(renderToStream(
    safe
    )) expect(html).toBe('
    safe
    ') expect(errorSpy).toHaveBeenCalledTimes(1) expect((errorSpy.mock.calls[0]?.[0] as Error).message).toBe( 'mixins must not return children or innerHTML', ) } finally { errorSpy.mockRestore() } }) it('supports mixins returning nested descriptors directly during SSR', async () => { let withData = createMixin( (handle) => (value: string, props: { ['data-mixed']?: string }) => ( ), ) let withReturnedMix = createMixin((_handle) => (value) => [ false, [withData(value)], undefined, ]) let stream = renderToStream(
    ) let html = await drain(stream) expect(html).toBe('
    ') expect(html).not.toContain('mix=') }) it('reads ancestor component context from mixins during SSR', async () => { function Provider(handle: Handle<{ value: string }>) { return ({ children }: { children?: RemixNode }) => { handle.context.set({ value: 'from-context' }) return
    {children}
    } } let withContextValue = createMixin((handle) => (props: { ['data-value']?: string }) => { let provider = handle.context.get(Provider) return }) let stream = renderToStream(
    , ) let html = await drain(stream) expect(html).toBe('
    ') }) it('provides a shared no-op signal during SSR', async () => { let updateError: unknown let componentSignal: AbortSignal | undefined let mixinSignal: AbortSignal | undefined function App(handle: Handle) { componentSignal = handle.signal componentSignal.addEventListener('abort', () => { throw new Error('should not run in SSR') }) componentSignal.onabort = () => { throw new Error('should not run in SSR') } return () =>
    } let lifecycleOnly = createMixin((handle) => { mixinSignal = handle.signal mixinSignal.addEventListener('abort', () => { throw new Error('should not run in SSR') }) mixinSignal.onabort = () => { throw new Error('should not run in SSR') } handle.addEventListener('insert', () => { throw new Error('should not run in SSR') }) handle.queueTask(() => { throw new Error('should not run in SSR') }) try { void handle.update() } catch (error) { updateError = error } return (props: { title?: string }) => }) let stream = renderToStream() let html = await drain(stream) expect(html).toBe('
    ') expect(componentSignal).toBe(mixinSignal) expect(componentSignal?.aborted).toBe(false) expect(componentSignal?.reason).toBeUndefined() expect(componentSignal?.onabort).toBe(null) expect(updateError).toBeInstanceOf(Error) expect((updateError as Error).message).toBe('handle.update() is not available during SSR.') }) it('serializes css mixin styles into style tags and class names', async () => { let stream = renderToStream(
    , ) let html = await drain(stream) expect(html).toContain('') }) it('places styles in head when no html root', async () => { let stream = renderToStream(
    No HTML root
    ) let html = await drain(stream) // Style should be in a head element expect(html).toMatch(/^