import { Injector } from '@furystack/inject'
import { serializeToQueryString } from '@furystack/rest'
import { sleepAsync, usingAsync } from '@furystack/utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeShadeRoot } from './initialize.js'
import { createComponent } from './shade-component.js'
import { flushUpdates, Shade } from './shade.js'
describe('Shades integration tests', () => {
beforeEach(() => {
document.body.innerHTML = '
'
})
afterEach(() => {
document.body.innerHTML = ''
})
it('Should mount a custom component to a Shade root', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({ render: () => Hello
, customElementName: 'shades-example' })
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.body.innerHTML).toBe('')
})
})
it('Should mount a custom component with a string render result', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({ render: () => 'Hello', customElementName: 'shades-string-render-result' })
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.body.innerHTML).toBe(
'Hello
',
)
})
})
it('Should mount a custom component with null render result', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({ render: () => null, customElementName: 'shades-null-render-result' })
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.body.innerHTML).toBe(
'
',
)
})
})
it('Should mount a custom component with a document fragment render result', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
render: () => (
<>
1
2
>
),
customElementName: 'shades-fragment-render-result',
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.body.innerHTML).toBe(
'',
)
})
})
it('Should mount a custom component with a nested document fragment render result', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
render: () => (
<>
1
2
>
),
customElementName: 'shades-fragment-render-result-nested',
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.body.innerHTML).toBe(
'',
)
})
})
it('Should mount a custom component with a document fragment that contains custom components', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const CustomComponent = Shade({
customElementName: 'shades-fragment-test-custom-component',
render: () => Hello
,
})
const ExampleComponent = Shade({
render: () => (
<>
>
),
customElementName: 'shades-fragment-render-result-2',
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.body.innerHTML).toBe(
'',
)
})
})
it('Should mount nested Shades components', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
render: ({ children }) => {children}
,
customElementName: 'shades-example-2',
})
const ExampleSubs = Shade<{ no: number }>({
render: ({ props }) => {props.no}
,
customElementName: 'shades-example-sub',
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
),
})
await flushUpdates()
expect(document.body.innerHTML).toBe(
'',
)
})
})
it('Should execute useDisposable cleanup on component disconnection', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const cleanup = vi.fn()
const setup = vi.fn()
const ExampleComponent = Shade({
customElementName: 'example-component-1',
render: ({ useDisposable }) => {
useDisposable('test', () => {
setup()
return { [Symbol.dispose]: cleanup }
})
return Hello
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(setup).toBeCalled()
expect(cleanup).not.toBeCalled()
document.body.innerHTML = ''
await sleepAsync(10) // Dispose can be async
expect(cleanup).toBeCalled()
})
})
it('Should update state', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
customElementName: 'example-component-3',
render: ({ useState }) => {
const [count, setCount] = useState('count', 0)
return (
Count is {count}
)
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const plus = async () => {
document.getElementById('plus')?.click()
await flushUpdates()
}
const minus = async () => {
document.getElementById('minus')?.click()
await flushUpdates()
}
const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`)
expectCount(0)
await plus()
expectCount(1)
await plus()
expectCount(2)
await minus()
await minus()
expectCount(0)
})
})
it('Should update the stored state', async () => {
const mockedStorage = new Map()
const store: typeof localStorage = {
getItem: (key) => {
return mockedStorage.get(key) || null
},
setItem: (key, value) => {
mockedStorage.set(key, value)
},
length: 0,
clear: () => {
mockedStorage.clear()
},
key: (index) => {
return Array.from(mockedStorage.keys())[index] || null
},
removeItem: (key) => {
mockedStorage.delete(key)
},
}
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
customElementName: 'example-component-3-stored-state',
render: ({ useStoredState }) => {
const [count, setCount] = useStoredState('count', 0, store)
return (
Count is {count}
)
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const plus = async () => {
document.getElementById('plus')?.click()
await flushUpdates()
}
const minus = async () => {
document.getElementById('minus')?.click()
await flushUpdates()
}
const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`)
await flushUpdates()
expectCount(0)
await flushUpdates()
await plus()
expectCount(1)
expect(store.getItem('count')).toBe('1')
await plus()
expectCount(2)
expect(store.getItem('count')).toBe('2')
await minus()
await minus()
expectCount(0)
expect(store.getItem('count')).toBe('0')
})
})
it('Should update the search state', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
customElementName: 'example-component-3-search-state',
render: ({ useSearchState }) => {
const [count, setCount] = useSearchState('count', 0)
return (
Count is {count}
)
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const plus = async () => {
document.getElementById('plus')?.click()
await flushUpdates()
}
const minus = async () => {
document.getElementById('minus')?.click()
await flushUpdates()
}
const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`)
expectCount(0)
await flushUpdates()
await plus()
expectCount(1)
expect(location.search).toBe(`?${serializeToQueryString({ count: 1 })}`)
await plus()
expectCount(2)
expect(location.search).toBe(`?${serializeToQueryString({ count: 2 })}`)
await minus()
await minus()
expectCount(0)
expect(location.search).toBe(`?${serializeToQueryString({ count: 0 })}`)
})
})
it('Should allow children update after unmount and remount', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const Parent = Shade({
customElementName: 'shade-remount-parent',
render: ({ children, useState }) => {
const [areChildrenVisible, setAreChildrenVisible] = useState('areChildrenVisible', true)
return (
{areChildrenVisible ? children : null}
)
},
})
const Child = Shade({
customElementName: 'example-remount-child',
render: ({ useState }) => {
const [count, setCount] = useState('count', 0)
return (
Count is {`${count}`}
)
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: (
),
})
await flushUpdates()
const plus = async () => {
document.getElementById('plus')?.click()
await flushUpdates()
}
const minus = async () => {
document.getElementById('minus')?.click()
await flushUpdates()
}
const expectCount = (count: number) => expect(document.body.innerHTML).toContain(`Count is ${count}`)
const toggleChildren = async () => {
document.getElementById('showHideChildren')?.click()
await flushUpdates()
}
expectCount(0)
await plus()
expectCount(1)
await toggleChildren()
expect(document.getElementById('plus')).toBeNull()
await sleepAsync(10) // Dispose can be async
await toggleChildren()
expect(document.getElementById('plus')).toBeDefined()
expectCount(0)
await plus()
expectCount(1)
await minus()
expectCount(0)
})
})
it('Should propagate changed props from parent to child Shade component', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ChildComponent = Shade<{ isDisabled: boolean }>({
customElementName: 'shade-prop-child',
render: ({ props }) => {
return
},
})
const ParentComponent = Shade({
customElementName: 'shade-prop-parent',
render: ({ useState }) => {
const [isDisabled, setIsDisabled] = useState('isDisabled', false)
return (
)
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const innerInput = document.querySelector('[data-testid="inner-input"]') as HTMLInputElement
expect(innerInput).toBeTruthy()
expect(innerInput.disabled).toBe(false)
// Click the toggle button
const toggleBtn = document.getElementById('toggle-disabled') as HTMLButtonElement
toggleBtn.click()
await flushUpdates()
const updatedInput = document.querySelector('[data-testid="inner-input"]') as HTMLInputElement
expect(updatedInput.disabled).toBe(true)
})
})
})