import { Injector } from '@furystack/inject'
import { ObservableValue, 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('Shade edge cases', () => {
beforeEach(() => {
document.body.innerHTML = '
'
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('duplicate customElementName error', () => {
it('should throw an error when registering a duplicate customElementName', () => {
// First registration should succeed
Shade({
customElementName: 'shade-duplicate-test',
render: () => First
,
})
// Second registration with the same name should throw
expect(() => {
Shade({
customElementName: 'shade-duplicate-test',
render: () => Second
,
})
}).toThrow("A custom shade with name 'shade-duplicate-test' has already been registered!")
})
it('should include the duplicate name in the error message', () => {
const uniqueName = `shade-duplicate-name-in-error-${Date.now()}`
Shade({
customElementName: uniqueName,
render: () => First
,
})
try {
Shade({
customElementName: uniqueName,
render: () => Second
,
})
// Should not reach here
expect.fail('Expected an error to be thrown')
} catch (e) {
expect((e as Error).message).toContain(uniqueName)
}
})
})
describe('injector from props', () => {
it('should use props injector for child component instead of inheriting from parent', async () => {
await usingAsync(new Injector(), async (rootInjector) => {
await usingAsync(new Injector(), async (propsInjector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
let parentCapturedInjector: Injector | undefined
let childCapturedInjector: Injector | undefined
const ChildComponent = Shade<{ injector?: Injector }>({
customElementName: 'shade-injector-child-props-test',
render: ({ injector }) => {
childCapturedInjector = injector
return Child
},
})
const ParentComponent = Shade({
customElementName: 'shade-injector-parent-props-test',
render: ({ injector, children }) => {
parentCapturedInjector = injector
return {children}
},
})
initializeShadeRoot({
injector: rootInjector,
rootElement,
jsxElement: (
),
})
await flushUpdates()
// Parent should use root injector (inherited from parent)
expect(parentCapturedInjector).toBe(rootInjector)
// Child should use the props injector, not the parent's
expect(childCapturedInjector).toBe(propsInjector)
expect(childCapturedInjector).not.toBe(rootInjector)
})
})
})
})
describe('disconnected component should not re-render', () => {
it('should not re-render when updateComponent is called after removal', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const renderCounter = vi.fn()
const ExampleComponent = Shade({
customElementName: 'shade-no-render-after-disconnect',
render: () => {
renderCounter()
return content
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const element = document.querySelector('shade-no-render-after-disconnect') as JSX.Element
expect(element.getRenderCount()).toBe(1)
expect(renderCounter).toBeCalledTimes(1)
element.remove()
await flushUpdates()
element.updateComponent()
await flushUpdates()
expect(element.getRenderCount()).toBe(1)
expect(renderCounter).toBeCalledTimes(1)
})
})
it('should not re-render when updateComponentSync is called after removal', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const renderCounter = vi.fn()
const ExampleComponent = Shade({
customElementName: 'shade-no-sync-render-after-disconnect',
render: () => {
renderCounter()
return content
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const element = document.querySelector('shade-no-sync-render-after-disconnect') as JSX.Element
expect(element.getRenderCount()).toBe(1)
expect(renderCounter).toBeCalledTimes(1)
element.remove()
await flushUpdates()
;(element as unknown as { updateComponentSync: () => void }).updateComponentSync()
expect(element.getRenderCount()).toBe(1)
expect(renderCounter).toBeCalledTimes(1)
})
})
it('should not re-render when an observable fires during disposal', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const renderCounter = vi.fn()
const obs = new ObservableValue(0)
const ExampleComponent = Shade({
customElementName: 'shade-no-render-during-disposal',
render: ({ useObservable, useDisposable }) => {
useObservable('obs', obs)
useDisposable('cleanup', () => ({
[Symbol.dispose]: () => {
obs.setValue(999)
},
}))
renderCounter()
// eslint-disable-next-line furystack/no-direct-get-value-in-render -- Test: verifying no re-render during disposal; already subscribed via useObservable above
return {obs.getValue()}
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const element = document.querySelector('shade-no-render-during-disposal') as JSX.Element
expect(element.getRenderCount()).toBe(1)
expect(renderCounter).toBeCalledTimes(1)
element.remove()
await flushUpdates()
await sleepAsync(10)
expect(element.getRenderCount()).toBe(1)
expect(renderCounter).toBeCalledTimes(1)
})
})
})
describe('BroadcastChannel cross-tab communication', () => {
it('should update stored state when receiving BroadcastChannel message with matching key', async () => {
const mockedStorage = new Map()
const store: typeof localStorage = {
getItem: (key) => mockedStorage.get(key) || null,
setItem: (key, value) => mockedStorage.set(key, value),
length: 0,
clear: () => mockedStorage.clear(),
key: (index) => 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 stateKey = 'broadcast-test-key'
const ExampleComponent = Shade({
customElementName: 'shade-broadcast-channel-test',
render: ({ useStoredState }) => {
const [value] = useStoredState(stateKey, 'initial', store)
return {value}
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.getElementById('value')?.textContent).toBe('initial')
// Simulate cross-tab message via BroadcastChannel
const channel = new BroadcastChannel('useStoredState-broadcast-channel')
channel.postMessage({ key: stateKey, value: 'updated-from-other-tab' })
await sleepAsync(50)
expect(document.getElementById('value')?.textContent).toBe('updated-from-other-tab')
channel.close()
})
})
it('should ignore BroadcastChannel messages with different key', async () => {
const mockedStorage = new Map()
const store: typeof localStorage = {
getItem: (key) => mockedStorage.get(key) || null,
setItem: (key, value) => mockedStorage.set(key, value),
length: 0,
clear: () => mockedStorage.clear(),
key: (index) => 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 stateKey = 'broadcast-filter-test-key'
const ExampleComponent = Shade({
customElementName: 'shade-broadcast-channel-filter-test',
render: ({ useStoredState }) => {
const [value] = useStoredState(stateKey, 'initial', store)
return {value}
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.getElementById('value')?.textContent).toBe('initial')
// Simulate cross-tab message with different key
const channel = new BroadcastChannel('useStoredState-broadcast-channel')
channel.postMessage({ key: 'different-key', value: 'should-be-ignored' })
await sleepAsync(50)
// Value should remain unchanged
expect(document.getElementById('value')?.textContent).toBe('initial')
channel.close()
})
})
it('should cleanup BroadcastChannel on component disposal', async () => {
const mockedStorage = new Map()
const store: typeof localStorage = {
getItem: (key) => mockedStorage.get(key) || null,
setItem: (key, value) => mockedStorage.set(key, value),
length: 0,
clear: () => mockedStorage.clear(),
key: (index) => 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 stateKey = 'broadcast-cleanup-test-key'
const ExampleComponent = Shade({
customElementName: 'shade-broadcast-channel-cleanup-test',
render: ({ useStoredState }) => {
const [value] = useStoredState(stateKey, 'initial', store)
return {value}
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(document.getElementById('value')?.textContent).toBe('initial')
// Remove the component from DOM
document.body.innerHTML = ''
await sleepAsync(50)
// Create a new channel to send a message (simulating another tab)
const channel = new BroadcastChannel('useStoredState-broadcast-channel')
// This should not cause any errors since the component's channel should be closed
channel.postMessage({ key: stateKey, value: 'should-not-crash' })
await sleepAsync(50)
channel.close()
// Test passes if no errors occur
})
})
})
})