UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

225 lines 11.4 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 Resources integration tests', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); it('Should update the component based on a custom observable value change', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const renderCounter = vi.fn(); const obs1 = new ObservableValue(0); const obs2 = new ObservableValue('a'); const ExampleComponent = Shade({ render: ({ useObservable }) => { const [value1] = useObservable('obs1', obs1); const [value2] = useObservable('obs2', obs2); renderCounter(); return (createComponent("div", null, createComponent("div", { id: "val1" }, value1), createComponent("div", { id: "val2" }, value2))); }, customElementName: 'shades-example-resource', }); expect(obs1.getObservers().length).toBe(0); expect(obs2.getObservers().length).toBe(0); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); expect(document.body.innerHTML).toBe('<div id="root"><shades-example-resource><div><div id="val1">0</div><div id="val2">a</div></div></shades-example-resource></div>'); expect(obs1.getObservers().length).toBe(1); expect(obs2.getObservers().length).toBe(1); expect(renderCounter).toBeCalledTimes(1); obs1.setValue(1); await flushUpdates(); expect(document.body.innerHTML).toBe('<div id="root"><shades-example-resource><div><div id="val1">1</div><div id="val2">a</div></div></shades-example-resource></div>'); expect(renderCounter).toBeCalledTimes(2); obs2.setValue('b'); await flushUpdates(); expect(document.body.innerHTML).toBe('<div id="root"><shades-example-resource><div><div id="val1">1</div><div id="val2">b</div></div></shades-example-resource></div>'); const element = document.querySelector('shades-example-resource'); expect(element.getRenderCount()).toBe(3); document.body.innerHTML = ''; await sleepAsync(10); // Cleanup can be async expect(obs1.getObservers().length).toBe(0); expect(obs2.getObservers().length).toBe(0); expect(renderCounter).toBeCalledTimes(3); }); }); it('Should NOT re-render the component when a custom onChange callback is provided', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const renderCounter = vi.fn(); const customOnChange = vi.fn(); const obs = new ObservableValue(0); const ExampleComponent = Shade({ render: ({ useObservable }) => { const [value] = useObservable('obs', obs, { onChange: customOnChange }); renderCounter(); return createComponent("div", { id: "val" }, value); }, customElementName: 'shades-example-custom-onchange', }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const element = document.querySelector('shades-example-custom-onchange'); // Initial render expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); expect(customOnChange).toBeCalledTimes(0); // Not called until value changes expect(document.getElementById('val')?.textContent).toBe('0'); // Change the observable value obs.setValue(1); // Custom onChange should be called expect(customOnChange).toBeCalledTimes(1); expect(customOnChange).toHaveBeenLastCalledWith(1); // But component should NOT re-render expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); // DOM should still show old value since no re-render occurred expect(document.getElementById('val')?.textContent).toBe('0'); // Change again to verify consistent behavior obs.setValue(2); expect(customOnChange).toBeCalledTimes(2); expect(customOnChange).toHaveBeenLastCalledWith(2); expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); }); }); it('Should allow manual DOM updates in custom onChange callback without re-render', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const renderCounter = vi.fn(); const obs = new ObservableValue(0); const ExampleComponent = Shade({ render: ({ useObservable, useRef }) => { const valRef = useRef('manualVal'); useObservable('obs', obs, { onChange: (newValue) => { if (valRef.current) { valRef.current.textContent = String(newValue); } }, }); renderCounter(); return (createComponent("div", { ref: valRef, id: "manual-val" }, obs.getValue())); }, customElementName: 'shades-example-manual-dom-update', }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const element = document.querySelector('shades-example-manual-dom-update'); // Initial render expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); expect(document.getElementById('manual-val')?.textContent).toBe('0'); // Change the observable value obs.setValue(42); // Component should NOT re-render expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); // But DOM should be updated via the manual onChange callback expect(document.getElementById('manual-val')?.textContent).toBe('42'); // Change again obs.setValue(100); expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); expect(document.getElementById('manual-val')?.textContent).toBe('100'); }); }); it('Should batch multiple synchronous observable changes into a single render', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const renderCounter = vi.fn(); const obs1 = new ObservableValue(0); const obs2 = new ObservableValue('a'); const obs3 = new ObservableValue(false); const ExampleComponent = Shade({ render: ({ useObservable }) => { const [value1] = useObservable('obs1', obs1); const [value2] = useObservable('obs2', obs2); const [value3] = useObservable('obs3', obs3); renderCounter(); return (createComponent("div", null, createComponent("span", { id: "v1" }, value1), createComponent("span", { id: "v2" }, value2), createComponent("span", { id: "v3" }, String(value3)))); }, customElementName: 'shades-example-batching', }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const element = document.querySelector('shades-example-batching'); expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); // Change all three observables synchronously without awaiting in between obs1.setValue(42); obs2.setValue('z'); obs3.setValue(true); // Before flushing, the DOM should still reflect the old values expect(element.getRenderCount()).toBe(1); await flushUpdates(); // After flushing, all changes should be reflected in a single render expect(element.getRenderCount()).toBe(2); expect(renderCounter).toBeCalledTimes(2); expect(document.getElementById('v1')?.textContent).toBe('42'); expect(document.getElementById('v2')?.textContent).toBe('z'); expect(document.getElementById('v3')?.textContent).toBe('true'); }); }); it('Should batch multiple updateComponent() calls into a single render', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const renderCounter = vi.fn(); const ExampleComponent = Shade({ render: () => { renderCounter(); return createComponent("div", null, "content"); }, customElementName: 'shades-example-update-batching', }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const element = document.querySelector('shades-example-update-batching'); expect(element.getRenderCount()).toBe(1); expect(renderCounter).toBeCalledTimes(1); // Call updateComponent multiple times synchronously element.updateComponent(); element.updateComponent(); element.updateComponent(); // Before flushing, render count should still be 1 expect(element.getRenderCount()).toBe(1); await flushUpdates(); // After flushing, only a single additional render should have occurred expect(element.getRenderCount()).toBe(2); expect(renderCounter).toBeCalledTimes(2); }); }); }); //# sourceMappingURL=shade-resources.integration.spec.js.map