@furystack/shades
Version:
A lightweight UI framework for FuryStack with JSX support
290 lines • 14.6 kB
JavaScript
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