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