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 = '
' }) 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') as HTMLDivElement 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 (
{value1}
{value2}
) }, customElementName: 'shades-example-resource', }) expect(obs1.getObservers().length).toBe(0) expect(obs2.getObservers().length).toBe(0) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() expect(document.body.innerHTML).toBe( '
0
a
', ) 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( '
1
a
', ) expect(renderCounter).toBeCalledTimes(2) obs2.setValue('b') await flushUpdates() expect(document.body.innerHTML).toBe( '
1
b
', ) const element = document.querySelector('shades-example-resource') as JSX.Element 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') as HTMLDivElement 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
{value}
}, customElementName: 'shades-example-custom-onchange', }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() const element = document.querySelector('shades-example-custom-onchange') as JSX.Element // 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') as HTMLDivElement 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 (
{/* eslint-disable-next-line furystack/no-direct-get-value-in-render -- Test: verifying manual DOM update pattern with onChange callback */} {obs.getValue()}
) }, customElementName: 'shades-example-manual-dom-update', }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() const element = document.querySelector('shades-example-manual-dom-update') as JSX.Element // 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') as HTMLDivElement 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 (
{value1} {value2} {String(value3)}
) }, customElementName: 'shades-example-batching', }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() const element = document.querySelector('shades-example-batching') as JSX.Element 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') as HTMLDivElement const renderCounter = vi.fn() const ExampleComponent = Shade({ render: () => { renderCounter() return
content
}, customElementName: 'shades-example-update-batching', }) initializeShadeRoot({ injector, rootElement, jsxElement: , }) await flushUpdates() const element = document.querySelector('shades-example-update-batching') as JSX.Element 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) }) }) })