UNPKG

@furystack/shades

Version:

A lightweight UI framework for FuryStack with JSX support

494 lines 26 kB
import { Injector } from '@furystack/inject'; import { ObservableValue, 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('VNode reconciliation integration tests', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); describe('focus preservation', () => { it('should preserve focus on an input element across re-renders', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-focus-test', render: ({ useState }) => { const [label, setLabel] = useState('label', 'initial'); return (createComponent("div", null, createComponent("label", null, label), createComponent("input", { id: "my-input", type: "text" }), createComponent("button", { id: "update-label", onclick: () => setLabel('updated') }, "Update"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const input = document.getElementById('my-input'); input.focus(); expect(document.activeElement).toBe(input); // Trigger a re-render by clicking the button document.getElementById('update-label')?.click(); await flushUpdates(); // The label should have updated expect(document.querySelector('label')?.textContent).toBe('updated'); // The same input element should still be in the DOM and focused const inputAfter = document.getElementById('my-input'); expect(inputAfter).toBe(input); expect(document.activeElement).toBe(input); }); }); it('should preserve focus on a textarea across re-renders', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-focus-textarea-test', render: ({ useState }) => { const [count, setCount] = useState('count', 0); return (createComponent("div", null, createComponent("span", null, "Count: ", count), createComponent("textarea", { id: "my-textarea" }), createComponent("button", { id: "increment", onclick: () => setCount(count + 1) }, "+"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const textarea = document.getElementById('my-textarea'); textarea.focus(); expect(document.activeElement).toBe(textarea); document.getElementById('increment')?.click(); await flushUpdates(); expect(document.querySelector('span')?.textContent).toBe('Count: 1'); expect(document.getElementById('my-textarea')).toBe(textarea); expect(document.activeElement).toBe(textarea); }); }); }); describe('form value preservation', () => { it('should preserve user-typed input value across re-renders when value is not controlled', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-form-value-test', render: ({ useState }) => { const [title, setTitle] = useState('title', 'Title'); return (createComponent("div", null, createComponent("h1", null, title), createComponent("input", { id: "user-input", type: "text" }), createComponent("button", { id: "change-title", onclick: () => setTitle('New Title') }, "Change"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); // Simulate user typing const input = document.getElementById('user-input'); input.value = 'user typed this'; // Trigger re-render document.getElementById('change-title')?.click(); await flushUpdates(); // Title should have changed expect(document.querySelector('h1')?.textContent).toBe('New Title'); // Input value should be preserved (same element, value not in render props) const inputAfter = document.getElementById('user-input'); expect(inputAfter).toBe(input); expect(inputAfter.value).toBe('user typed this'); }); }); it('should preserve checkbox checked state across re-renders', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-checkbox-test', render: ({ useState }) => { const [count, setCount] = useState('count', 0); return (createComponent("div", null, createComponent("span", null, "Count: ", count), createComponent("input", { id: "my-checkbox", type: "checkbox" }), createComponent("button", { id: "increment", onclick: () => setCount(count + 1) }, "+"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); // User checks the checkbox const checkbox = document.getElementById('my-checkbox'); checkbox.checked = true; // Trigger re-render document.getElementById('increment')?.click(); await flushUpdates(); expect(document.querySelector('span')?.textContent).toBe('Count: 1'); // Checkbox should still be checked const checkboxAfter = document.getElementById('my-checkbox'); expect(checkboxAfter).toBe(checkbox); expect(checkboxAfter.checked).toBe(true); }); }); it('should preserve select value across re-renders', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-select-test', render: ({ useState }) => { const [label, setLabel] = useState('label', 'Pick one'); return (createComponent("div", null, createComponent("label", null, label), createComponent("select", { id: "my-select" }, createComponent("option", { value: "a" }, "A"), createComponent("option", { value: "b" }, "B"), createComponent("option", { value: "c" }, "C")), createComponent("button", { id: "update-label", onclick: () => setLabel('Updated label') }, "Update"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); // User selects option B const select = document.getElementById('my-select'); select.value = 'b'; // Trigger re-render document.getElementById('update-label')?.click(); await flushUpdates(); expect(document.querySelector('label')?.textContent).toBe('Updated label'); // Select should still have value 'b' const selectAfter = document.getElementById('my-select'); expect(selectAfter).toBe(select); expect(selectAfter.value).toBe('b'); }); }); }); describe('element identity preservation', () => { it('should preserve DOM element references across re-renders', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-identity-test', render: ({ useState }) => { const [count, setCount] = useState('count', 0); return (createComponent("div", { id: "container" }, createComponent("span", { id: "counter" }, "Count: ", count), createComponent("button", { id: "increment", onclick: () => setCount(count + 1) }, "+"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const container = document.getElementById('container'); const counter = document.getElementById('counter'); const button = document.getElementById('increment'); // Trigger re-render button?.click(); await flushUpdates(); // Same elements should be reused expect(document.getElementById('container')).toBe(container); expect(document.getElementById('counter')).toBe(counter); expect(document.getElementById('increment')).toBe(button); expect(counter?.textContent).toBe('Count: 1'); }); }); it('should replace element when tag changes between renders', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-tag-change-test', render: ({ useState }) => { const [useDiv, setUseDiv] = useState('useDiv', true); return useDiv ? (createComponent("div", { id: "content" }, createComponent("button", { id: "toggle", onclick: () => setUseDiv(false) }, "Toggle"))) : (createComponent("section", { id: "content" }, createComponent("button", { id: "toggle", onclick: () => setUseDiv(true) }, "Toggle"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const oldContent = document.getElementById('content'); expect(oldContent?.tagName).toBe('DIV'); document.getElementById('toggle')?.click(); await flushUpdates(); const newContent = document.getElementById('content'); expect(newContent?.tagName).toBe('SECTION'); // Different tag means different element expect(newContent).not.toBe(oldContent); }); }); }); describe('animation continuity', () => { it('should preserve CSS class-based transitions by keeping element identity', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-animation-test', render: ({ useState }) => { const [isActive, setIsActive] = // eslint-disable-next-line furystack/no-css-state-hooks -- test for re-render behavior, not CSS state useState('isActive', false); return (createComponent("div", null, createComponent("div", { id: "animated-box", className: isActive ? 'active' : 'inactive' }), createComponent("button", { id: "activate", onclick: () => setIsActive(true) }, "Activate"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const box = document.getElementById('animated-box'); expect(box?.className).toBe('inactive'); document.getElementById('activate')?.click(); await flushUpdates(); // Same element, class updated in place (animation would continue) const boxAfter = document.getElementById('animated-box'); expect(boxAfter).toBe(box); expect(boxAfter?.className).toBe('active'); }); }); it('should preserve inline style transitions by keeping element identity', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-style-transition-test', render: ({ useState }) => { const [isExpanded, setIsExpanded] = useState('isExpanded', false); return (createComponent("div", null, createComponent("div", { id: "expandable", style: { height: isExpanded ? '200px' : '50px', transition: 'height 0.3s ease', } }), createComponent("button", { id: "expand", onclick: () => setIsExpanded(!isExpanded) }, "Toggle"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const expandable = document.getElementById('expandable'); expect(expandable?.style.height).toBe('50px'); document.getElementById('expand')?.click(); await flushUpdates(); // Same element, style updated in place (transition would animate) const expandableAfter = document.getElementById('expandable'); expect(expandableAfter).toBe(expandable); expect(expandableAfter?.style.height).toBe('200px'); expect(expandableAfter?.style.transition).toBe('height 0.3s ease'); }); }); }); describe('event handler updates', () => { it('should update event handlers after re-render (closures capture new state)', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const clicks = []; const ExampleComponent = Shade({ customElementName: 'morph-handler-test', render: ({ useState }) => { const [count, setCount] = useState('count', 0); return (createComponent("div", null, createComponent("span", { id: "count" }, count), createComponent("button", { id: "increment", onclick: () => { clicks.push(count); setCount(count + 1); } }, "+"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const button = document.getElementById('increment'); // First click: count is 0 button.click(); await flushUpdates(); expect(clicks).toEqual([0]); expect(document.getElementById('count')?.textContent).toBe('1'); // Second click: handler should capture count=1 after morph button.click(); await flushUpdates(); expect(clicks).toEqual([0, 1]); expect(document.getElementById('count')?.textContent).toBe('2'); // Third click: handler should capture count=2 button.click(); await flushUpdates(); expect(clicks).toEqual([0, 1, 2]); expect(document.getElementById('count')?.textContent).toBe('3'); }); }); }); describe('observable-driven re-renders with morphing', () => { it('should morph correctly when observable drives updates', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const obs = new ObservableValue('hello'); const ExampleComponent = Shade({ customElementName: 'morph-observable-test', render: ({ useObservable }) => { const [value] = useObservable('obs', obs); return (createComponent("div", null, createComponent("span", { id: "value" }, value), createComponent("input", { id: "my-input", type: "text" }))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const input = document.getElementById('my-input'); const span = document.getElementById('value'); input.value = 'user text'; input.focus(); // External observable change obs.setValue('world'); await flushUpdates(); // Text should update expect(document.getElementById('value')).toBe(span); expect(span.textContent).toBe('world'); // Input should be preserved expect(document.getElementById('my-input')).toBe(input); expect(input.value).toBe('user text'); expect(document.activeElement).toBe(input); }); }); }); describe('fragment render result morphing', () => { it('should morph fragment children across re-renders', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-fragment-test', render: ({ useState }) => { const [count, setCount] = useState('count', 0); return (createComponent(createComponent, null, createComponent("p", { id: "counter" }, "Count: ", count), createComponent("button", { id: "increment", onclick: () => setCount(count + 1) }, "+"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const counter = document.getElementById('counter'); const button = document.getElementById('increment'); button?.click(); await flushUpdates(); // Elements should be reused expect(document.getElementById('counter')).toBe(counter); expect(document.getElementById('increment')).toBe(button); expect(counter?.textContent).toBe('Count: 1'); }); }); }); describe('text render result optimization', () => { it('should efficiently update text-only render results', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const ExampleComponent = Shade({ customElementName: 'morph-text-result-test', render: ({ useState }) => { const [text] = useState('text', 'initial'); return text; }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ExampleComponent, null), }); await flushUpdates(); const shadeEl = document.querySelector('morph-text-result-test'); expect(shadeEl.textContent).toBe('initial'); const textNode = shadeEl.firstChild; // Trigger update via the shade element const el = shadeEl; el.resourceManager.stateObservers .get('text') ?.setValue('updated'); await flushUpdates(); expect(shadeEl.textContent).toBe('updated'); // Text node should be reused (not recreated) expect(shadeEl.firstChild).toBe(textNode); }); }); }); describe('Shade component boundary morphing', () => { it('should update child Shade component props without recreating it', async () => { await usingAsync(new Injector(), async (injector) => { const rootElement = document.getElementById('root'); const childRenderSpy = vi.fn(); const ChildComponent = Shade({ customElementName: 'morph-child-component', render: ({ props }) => { childRenderSpy(); return createComponent("span", { id: "child-value" }, props.value); }, }); const ParentComponent = Shade({ customElementName: 'morph-parent-component', render: ({ useState }) => { const [count, setCount] = useState('count', 0); return (createComponent("div", null, createComponent(ChildComponent, { value: count }), createComponent("button", { id: "parent-increment", onclick: () => setCount(count + 1) }, "+"))); }, }); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ParentComponent, null), }); await flushUpdates(); expect(document.getElementById('child-value')?.textContent).toBe('0'); const childElement = document.querySelector('morph-child-component'); // Trigger parent re-render document.getElementById('parent-increment')?.click(); await flushUpdates(); // Child should be the same DOM element (not recreated) expect(document.querySelector('morph-child-component')).toBe(childElement); // Child should have been updated with new props expect(document.getElementById('child-value')?.textContent).toBe('1'); }); }); }); }); //# sourceMappingURL=vnode.integration.spec.js.map