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 }) }) }) })