UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

290 lines 14.6 kB
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 = '<div id="root"></div>'; }); 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: () => createComponent("div", null, "First"), }); // Second registration with the same name should throw expect(() => { Shade({ customElementName: 'shade-duplicate-test', render: () => createComponent("div", null, "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: () => createComponent("div", null, "First"), }); try { Shade({ customElementName: uniqueName, render: () => createComponent("div", null, "Second"), }); // Should not reach here expect.fail('Expected an error to be thrown'); } catch (e) { expect(e.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'); let parentCapturedInjector; let childCapturedInjector; const ChildComponent = Shade({ customElementName: 'shade-injector-child-props-test', render: ({ injector }) => { childCapturedInjector = injector; return createComponent("div", null, "Child"); }, }); const ParentComponent = Shade({ customElementName: 'shade-injector-parent-props-test', render: ({ injector, children }) => { parentCapturedInjector = injector; return createComponent("div", null, children); }, }); initializeShadeRoot({ injector: rootInjector, rootElement, jsxElement: (createComponent(ParentComponent, null, createComponent(ChildComponent, { injector: propsInjector }))), }); 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'); const renderCounter = vi.fn(); const ExampleComponent = Shade({ customElementName: 'shade-no-render-after-disconnect', render: () => { renderCounter(); return createComponent("div", null, "content"); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const element = document.querySelector('shade-no-render-after-disconnect'); 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'); const renderCounter = vi.fn(); const ExampleComponent = Shade({ customElementName: 'shade-no-sync-render-after-disconnect', render: () => { renderCounter(); return createComponent("div", null, "content"); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const element = document.querySelector('shade-no-sync-render-after-disconnect'); expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); element.remove(); await flushUpdates(); element.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'); 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 createComponent("div", null, obs.getValue()); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const element = document.querySelector('shade-no-render-during-disposal'); 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 = { 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'); const stateKey = 'broadcast-test-key'; const ExampleComponent = Shade({ customElementName: 'shade-broadcast-channel-test', render: ({ useStoredState }) => { const [value] = useStoredState(stateKey, 'initial', store); return createComponent("div", { id: "value" }, value); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); 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 = { 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'); const stateKey = 'broadcast-filter-test-key'; const ExampleComponent = Shade({ customElementName: 'shade-broadcast-channel-filter-test', render: ({ useStoredState }) => { const [value] = useStoredState(stateKey, 'initial', store); return createComponent("div", { id: "value" }, value); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); 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 = { 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'); const stateKey = 'broadcast-cleanup-test-key'; const ExampleComponent = Shade({ customElementName: 'shade-broadcast-channel-cleanup-test', render: ({ useStoredState }) => { const [value] = useStoredState(stateKey, 'initial', store); return createComponent("div", { id: "value" }, value); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); 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 }); }); }); }); //# sourceMappingURL=shade.spec.js.map