Skip to content

Commit

Permalink
refactor: function component vnode (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
Razz21 authored Feb 3, 2025
1 parent c6b45f2 commit e83c89c
Show file tree
Hide file tree
Showing 17 changed files with 323 additions and 107 deletions.
15 changes: 15 additions & 0 deletions packages/runtime/__tests__/destroy-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,19 @@ describe('destroyVNode', () => {
expect(parentVNode.el.isConnected).toBeFalsy();
expect(childVNode.el.isConnected).toBeFalsy();
});

it('should destroy a function VNode', () => {
const FunctionComponent = () => createVNode('span');
const vnode = createVNode(FunctionComponent);

const root = document.createElement('div');

mountVNode(vnode, root);
expect(root.children).toHaveLength(1);

destroyVNode(vnode);

expect(root.children).toHaveLength(0);
expect(vnode.component.el.isConnected).toBeFalsy();
});
});
24 changes: 24 additions & 0 deletions packages/runtime/__tests__/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ describe('addEventListener', () => {
expect(handler).toHaveBeenCalled();
expect(wrappedHandler).toBeInstanceOf(Function);
});

it('should call the handler with the correct context when hostComponent is provided', () => {
const element = document.createElement('div');
const handler = vi.fn();
const hostComponent = { someMethod: vi.fn() };

addEventListener('click', handler, element, hostComponent as any);

element.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalled();
expect(handler.mock.instances[0]).toBe(hostComponent);
});

it('should call the handler without context when hostComponent is not provided', () => {
const element = document.createElement('div');
const handler = vi.fn();

addEventListener('click', handler, element);

element.dispatchEvent(new Event('click'));
expect(handler).toHaveBeenCalled();
expect(handler.mock.instances[0]).toBeUndefined();
});
});

describe('addEventListeners', () => {
Expand All @@ -21,6 +44,7 @@ describe('addEventListeners', () => {

element.dispatchEvent(new Event('click'));
element.dispatchEvent(new Event('mouseover'));

expect(handlers.click).toHaveBeenCalled();
expect(handlers.mouseover).toHaveBeenCalled();
expect(activeListeners.click).toBeInstanceOf(Function);
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/__tests__/h.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('h', () => {
it('should create a VNode for a function component', () => {
const TestComponent = (props: any, children: any) => createVNode('span', props, children);
const vnode = h(TestComponent, { prop: 'value' }, ['Child']);
expect(vnode).toMatchObject(TestComponent({ prop: 'value' }, { children: ['Child'] }));
expect(vnode).toMatchObject(createVNode(TestComponent, { prop: 'value' }, ['Child']));
});

it('should throw an error for an invalid type', () => {
Expand Down
22 changes: 22 additions & 0 deletions packages/runtime/__tests__/mount-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ describe('mountVNode', () => {
expect(root.children).toHaveLength(1);
});

it('should mount a function component VNode to the DOM', () => {
const FunctionComponent = () => createVNode('div');

const vnode = createVNode(FunctionComponent);
const root = document.createElement('div');

mountVNode(vnode, root);

expect(root.children).toHaveLength(1);
});

it("should not mount a function component VNode if it doesn't return a VNode", () => {
const FunctionComponent = () => 123;

const vnode = createVNode(FunctionComponent as any);
const root = document.createElement('div');

mountVNode(vnode, root);

expect(root.children).toHaveLength(0);
});

it('should throw an error for an invalid type', () => {
const root = document.createElement('div');

Expand Down
69 changes: 66 additions & 3 deletions packages/runtime/__tests__/patch-dom/patch-dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,48 @@ describe('patch-dom', () => {
});
});

describe('function component nodes', () => {
it('should update function component', () => {
const root = document.createElement('div');
const FunctionComponent = (props) => createVNode('div', null, [props.text]);

const oldVNode = createVNode(FunctionComponent, { text: 'old' });
const newVNode = createVNode(FunctionComponent, { text: 'new' });

mountVNode(oldVNode, root);
patchDOM(oldVNode, newVNode, root);

expect(root.textContent).toBe('new');
});

it('should handle function component returning null', () => {
const root = document.createElement('div');
const FunctionComponent = (props: {show: boolean}) =>
props.show ? createVNode('div', null, ['visible']) : null;

const oldVNode = createVNode(FunctionComponent, { show: true });
const newVNode = createVNode(FunctionComponent, { show: false });

mountVNode(oldVNode, root);
patchDOM(oldVNode, newVNode, root);

expect(root.textContent).toBe('');
});

it('should update function component with children', () => {
const root = document.createElement('div');
const FunctionComponent = (_props, { children }) => createVNode('div', null, children);

const oldVNode = createVNode(FunctionComponent, null, [createVNode('span', null, ['old'])]);
const newVNode = createVNode(FunctionComponent, null, [createVNode('span', null, ['new'])]);

mountVNode(oldVNode, root);
patchDOM(oldVNode, newVNode, root);

expect(root.textContent).toBe('new');
});
});

describe.todo('error handling', () => {
it.todo('should handle null elements gracefully', () => {
const root = document.createElement('div');
Expand Down Expand Up @@ -209,15 +251,36 @@ describe('patch-dom', () => {
]);
const newVNode = createVNode('div', null, [
createFragmentVNode([
createVNode('span', null, ['new1']),
createVNode('span', null, ['new2']),
createVNode('span', null, ['Hello ']),
createVNode('span', null, ['World']),
]),
]);

mountVNode(oldVNode, root);
patchDOM(oldVNode, newVNode, root);

expect(root.textContent).toBe('new1new2');
expect(root.textContent).toBe('Hello World');
});

it('should handle fragment conditional children updates', () => {
const root = document.createElement('div');

const oldVNode = createFragmentVNode([
createVNode('span', null, ['Hello ']),
null,
createVNode('span', null, ['from ']),
createVNode('div', null, ['Vitest']),
]);
const newVNode = createFragmentVNode([
createVNode('span', null, ['Hello ']),
createVNode('span', null, ['World ']),
createVNode('span', null, ['from ']),
createVNode('div', null, ['Vitest']),
]);
mountVNode(oldVNode, root);
patchDOM(oldVNode, newVNode, root);

expect(root.textContent).toBe('Hello World from Vitest');
});
});

Expand Down
8 changes: 4 additions & 4 deletions packages/runtime/__tests__/vdom/vnode.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineComponent } from '@/component';
import {
FragmentVNode,
TextVNode,
FragmentVNodeType,
TextVNodeType,
createFragmentVNode,
createTextVNode,
createVNode,
Expand Down Expand Up @@ -46,7 +46,7 @@ describe('vdom/vnode', () => {
const textVNode = createTextVNode('Hello');
expect(textVNode).toMatchObject({
_isVNode: true,
type: TextVNode,
type: TextVNodeType,
props: { nodeValue: 'Hello' },
children: [],
el: null,
Expand All @@ -60,7 +60,7 @@ describe('vdom/vnode', () => {
const fragmentVNode = createFragmentVNode([]);
expect(fragmentVNode).toMatchObject({
_isVNode: true,
type: FragmentVNode,
type: FragmentVNodeType,
props: {},
children: [],
el: null,
Expand Down
11 changes: 4 additions & 7 deletions packages/runtime/src/component/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { destroyVNode } from '@/destroy-dom';
import { mountVNode } from '@/mount-dom';
import { patchDOM } from '@/patch-dom';
import { extractChildNodes } from '@/utils';
import { type VNode, isClassComponent, isElementVNode, isFragmentVNode } from '@/vdom';
import { type VNode, isClassComponentVNode, isElementVNode, isFragmentVNode } from '@/vdom';
import equals from 'fast-deep-equal';

export abstract class Component<TProps, TState> {
Expand Down Expand Up @@ -123,11 +123,8 @@ export abstract class Component<TProps, TState> {
}

#extractFragmentElements(): Element[] {
return extractChildNodes(this.#vdom).flatMap((child) => {
if (isClassComponent(child.type)) {
return child.component.elements;
}
return child.el as Element;
});
return extractChildNodes(this.#vdom).flatMap((child) =>
isClassComponentVNode(child) ? child.component.elements : child.el
);
}
}
10 changes: 7 additions & 3 deletions packages/runtime/src/destroy-dom.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { removeEventListeners } from '@/events';
import { type VNode, isClassComponentVNode } from '@/vdom';
import { type VNode, isClassComponentVNode, isFunctionComponentVNode } from '@/vdom';

/**
* Recursively destroys a VNode and its children, unmounting components and cleaning up DOM elements.
*/
export function destroyVNode(vnode: VNode): void {
if (isClassComponentVNode(vnode)) {
destroyComponentVNode(vnode);
} else {
destroyElementVNode(vnode);
return;
}
if (isFunctionComponentVNode(vnode)) {
destroyVNode(vnode.component);
return;
}
destroyElementVNode(vnode);
}

/**
Expand Down
22 changes: 6 additions & 16 deletions packages/runtime/src/h.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
type VNodeProps,
createFragmentVNode,
createVNode,
isClassComponent,
isFunctionComponent,
} from '@/vdom';

Expand All @@ -31,21 +30,12 @@ export function h<T extends ElementTag>(
children?: VNodeChildren[] | null
): VNode;
export function h(type: any, props?: any, children?: VNodeChildren[] | null) {
if (typeof type === 'string') {
return createVNode(type, props, children);
if (!(typeof type === 'string' || typeof type === 'function')) {
throw new Error(
`Invalid component type passed to "h": expected a string, class component, or function component but received ${typeof type} (${type}).`
);
}
if (isClassComponent(type)) {
return createVNode(type, props, children);
}
if (isFunctionComponent(type)) {
return type(props, { children }) as any;
}

throw new Error(
`Invalid component type passed to "h": expected a string, class component, or function component but received ${typeof type} (${type}).`
);
return createVNode(type, props, children);
}

export function hFragment(vNodes: VNodeChildren[]): VNode {
return createFragmentVNode(vNodes);
}
export const hFragment = createFragmentVNode;
29 changes: 29 additions & 0 deletions packages/runtime/src/mount-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { applyAttributes } from '@/attributes';
import type { Component, ComponentInstance } from '@/component';
import { addEventListeners } from '@/events';
import {
type FunctionComponentVNode,
type VNode,
isClassComponentVNode,
isElementVNode,
isFragmentVNode,
isFunctionComponentVNode,
isTextVNode,
isVNode,
} from '@/vdom';

/**
Expand All @@ -20,6 +23,8 @@ export function mountVNode(
): void {
if (isClassComponentVNode(vnode)) {
mountComponentVNode(vnode, parentElement, index);
} else if (isFunctionComponentVNode(vnode)) {
mountFunctionComponentVNode(vnode, parentElement, index, hostComponent);
} else if (isTextVNode(vnode)) {
mountTextVNode(vnode, parentElement, index);
} else if (isFragmentVNode(vnode)) {
Expand Down Expand Up @@ -113,6 +118,30 @@ function mountComponentVNode(vnode: VNode, parentElement: Element, index?: numbe
vnode.el = component.firstElement;
}

/**
* Mounts a function component VNode to the DOM.
*/
function mountFunctionComponentVNode(
vnode: FunctionComponentVNode,
parentElement: Element,
index?: number,
hostComponent?: Component<unknown, unknown>
): void {
const functionComponent = vnode.type;
const { props, children } = vnode;

const result = functionComponent(props, { children });

if (!isVNode(result)) {
console.warn(`Function component extected to return a VNode, received: ${result}`);
return;
}

vnode.component = result;
vnode.el = result.el;
mountVNode(result, parentElement, index, hostComponent);
}

/**
* Inserts a node into the parent element at the specified index.
*/
Expand Down
Loading

0 comments on commit e83c89c

Please sign in to comment.