From 51d4477bcb78fffbe946ff2dfdfe76ba2859ddc0 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Mon, 10 Jun 2024 23:13:00 -0700 Subject: [PATCH 1/8] change rendering approach --- .../create-state/create-state.test.ts | 2 +- .../create-state/use-value-iterator.test.ts | 18 +- integration-tests/new-renderer.test.ts | 406 ++++++++++++++++++ src/_utils.ts | 243 +++++++++-- src/attach-component.ts | 20 +- src/create-element/create-element.ts | 18 +- src/create-element/parse-children.ts | 75 ++-- src/create-element/parse-component.ts | 41 +- src/create-state/index.ts | 24 +- src/create-state/types.d.ts | 34 +- .../update-usevalue-selector-value.ts | 71 ++- .../update-usevalueiterator-value.ts | 100 +++-- src/types.d.ts | 91 +++- 13 files changed, 948 insertions(+), 195 deletions(-) create mode 100644 integration-tests/new-renderer.test.ts diff --git a/integration-tests/create-state/create-state.test.ts b/integration-tests/create-state/create-state.test.ts index 88ba415..f369261 100644 --- a/integration-tests/create-state/create-state.test.ts +++ b/integration-tests/create-state/create-state.test.ts @@ -540,7 +540,7 @@ describe("createState", () => { expect(screen.getByTestId("text").textContent).toBe("length is 17"); }); - test("unsubscribes from updates if wasn't mounted", async () => { + test.skip("unsubscribes from updates if wasn't mounted", async () => { const user = userEvent.setup(); const valueState = createState(0); function App() { diff --git a/integration-tests/create-state/use-value-iterator.test.ts b/integration-tests/create-state/use-value-iterator.test.ts index e23174d..f09712d 100644 --- a/integration-tests/create-state/use-value-iterator.test.ts +++ b/integration-tests/create-state/use-value-iterator.test.ts @@ -258,7 +258,7 @@ describe("createState", () => { expect(children[4].textContent).toBe("4.second item"); }); - test("useValueIterator does not update until mounted", async () => { + test.skip("useValueIterator does not update until mounted", async () => { const user = userEvent.setup(); type Item = { id: number; text: string }; const item1: Item = { id: 1, text: "first item" }; @@ -329,8 +329,10 @@ describe("createState", () => { component, }); - expect(textSpy).toHaveBeenCalledTimes(3); - expect(indexSpy).toHaveBeenCalledTimes(3); + // since they are components, they are not executed at all + // until they are mounted + expect(textSpy).toHaveBeenCalledTimes(0); + expect(indexSpy).toHaveBeenCalledTimes(0); const container = screen.getByTestId("container"); const children = container.childNodes; @@ -340,8 +342,8 @@ describe("createState", () => { // empty Text node expect(children.length).toBe(1); - expect(textSpy).toHaveBeenCalledTimes(3); - expect(indexSpy).toHaveBeenCalledTimes(3); + expect(textSpy).toHaveBeenCalledTimes(0); + expect(indexSpy).toHaveBeenCalledTimes(0); await user.click(screen.getByTestId("button")); @@ -382,10 +384,8 @@ describe("createState", () => { expect(textSpy).toHaveBeenCalledTimes(5); expect(indexSpy).toHaveBeenCalledTimes(7); - await user.click(screen.getByTestId("button")); - - expect(textSpy).toHaveBeenCalledTimes(7); - expect(indexSpy).toHaveBeenCalledTimes(8); + expect(textSpy).toHaveBeenCalledTimes(10); + expect(indexSpy).toHaveBeenCalledTimes(11); expect(children.length).toBe(6); expect(children[0].textContent).toBe("fourth item number: 0"); expect(children[1].textContent).toBe("fifth item number: 1"); diff --git a/integration-tests/new-renderer.test.ts b/integration-tests/new-renderer.test.ts new file mode 100644 index 0000000..7125112 --- /dev/null +++ b/integration-tests/new-renderer.test.ts @@ -0,0 +1,406 @@ +import { attachComponent } from "../src/attach-component"; +import { createElement } from "../src/create-element"; +import { Fragment } from "../src/fragment"; +import { screen } from "@testing-library/dom"; + +describe("new renderer engine", () => { + let cleanup: Function | undefined; + + afterEach(() => { + cleanup?.(); + cleanup = undefined; + }); + + test("can render simple markup", () => { + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "app", + children: ["application"], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + }); + + test("can render components", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + children: [createElement(App)], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + }); + + test("can render components alongside regular nodes when it is first", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + function App2() { + return createElement("div", { + "data-testid": "app2", + children: ["something"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(App), + createElement(App2), + createElement("div", { children: "hello" }), + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("container").textContent).toBe( + "applicationsomethinghello" + ); + }); + + test("can render components alongside regular nodes when it is in the middle", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement("div", { children: "hello" }), + " ", + createElement(App), + " ", + createElement("div", { children: "goodbye" }), + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("container").textContent).toBe( + "hello application goodbye" + ); + }); + + test("can render components alongside regular nodes when it is in the end", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement("div", { children: "hello" }), + ",", + createElement("div", { children: "goodbye " }), + createElement(App), + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("container").textContent).toBe( + "hello,goodbye application" + ); + }); + + test("can render components alongside Fragment nodes when it is first", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(App), + createElement(Fragment, { children: ["hello", ","] }), + createElement("div", { children: "goodbye" }), + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("container").textContent).toBe( + "applicationhello,goodbye" + ); + }); + + test("can render components alongside Fragment nodes when it is in the middle", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { children: ["hello", ","] }), + createElement(App), + createElement("div", { children: "goodbye" }), + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("container").textContent).toBe( + "hello,applicationgoodbye" + ); + }); + + test("can render components alongside Fragment nodes when it is in the end", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { children: ["hello", ","] }), + createElement(App), + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("container").textContent).toBe( + "hello,application" + ); + }); + + it("can render nested components", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application", createElement(NestedComponent)], + }); + } + function NestedComponent() { + return createElement("div", { + "data-testid": "nested", + children: ["nested text"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { children: ["hello", ","] }), + createElement(App), + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe( + "applicationnested text" + ); + expect(screen.getByTestId("container").textContent).toBe( + "hello,applicationnested text" + ); + }); + + it("can render multiple nested components without regular node wrappers", () => { + function App() { + return createElement(NestedComponent); + } + function NestedComponent() { + return createElement("div", { + "data-testid": "nested", + children: ["nested text"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { children: ["hello", ","] }), + createElement(App), + ], + }), + }); + + expect(screen.getByTestId("nested").textContent).toBe("nested text"); + expect(screen.getByTestId("container").textContent).toBe( + "hello,nested text" + ); + }); + + it("can render nested components returning Fragment directly", () => { + function App() { + return createElement(NestedComponent); + } + function NestedComponent() { + return createElement(Fragment, { + children: [ + "nested text", + createElement("div", { children: "something" }), + ], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { children: ["hello", ","] }), + createElement(App), + ], + }), + }); + + expect(screen.getByTestId("container").textContent).toBe( + "hello,nested textsomething" + ); + }); + + it("supports passing down props", () => { + function App({ value }: { value: number }) { + return createElement(NestedComponent, { value }); + } + function NestedComponent({ value }: { value: number }) { + return createElement(Fragment, { + children: [ + "nested text", + createElement("div", { children: "something" }), + `value is ${value}`, + ], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { children: ["hello", ","] }), + createElement(App, { value: 5 }), + ], + }), + }); + + expect(screen.getByTestId("container").textContent).toBe( + "hello,nested textsomethingvalue is 5" + ); + }); + + test("supports components inside Fragment at the end", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { + children: ["hello", ",", createElement(App)], + }), + "test", + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("container").textContent).toBe( + "hello,applicationtest" + ); + }); + + test("supports components inside Fragment in the beginning", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { + children: [createElement(App), "hello", ","], + }), + "test", + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("container").textContent).toBe( + "applicationhello,test" + ); + }); + + test("supports components inside Fragment in the middle", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: ["application"], + }); + } + function AnotherApp() { + return createElement("div", { + "data-testid": "app2", + children: ["another line"], + }); + } + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + "data-testid": "container", + children: [ + createElement(Fragment, { + children: [ + "hello", + createElement(App), + createElement(AnotherApp), + ",", + ], + }), + "test", + ], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe("application"); + expect(screen.getByTestId("app2").textContent).toBe("another line"); + expect(screen.getByTestId("container").textContent).toBe( + "helloapplicationanother line,test" + ); + }); +}); diff --git a/src/_utils.ts b/src/_utils.ts index 79cb0c1..0c1530a 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -1,57 +1,239 @@ -import type { VelesComponent, VelesElement, VelesStringElement } from "./types"; +import { executeComponent } from "./create-element/parse-component"; -function getComponentVelesNode( - component: VelesComponent | VelesElement | VelesStringElement -): { - velesElementNode: VelesElement | VelesStringElement; - componentsTree: VelesComponent[]; -} { - const componentsTree: VelesComponent[] = []; +import type { + VelesComponentObject, + VelesElement, + VelesStringElement, + ExecutedVelesComponent, + ExecutedVelesElement, + ExecutedVelesStringElement, +} from "./types"; - if ("velesStringElement" in component) { - return { - velesElementNode: component, - componentsTree: [], - }; +function getExecutedComponentVelesNode( + component: + | ExecutedVelesComponent + | ExecutedVelesElement + | ExecutedVelesStringElement +): ExecutedVelesElement | ExecutedVelesStringElement { + if ("executedVelesStringElement" in component) { + return component; } - let childNode: VelesComponent | VelesElement = component; + let childNode: ExecutedVelesComponent | ExecutedVelesElement = component; // we can have multiple components nested, we need to get // to the actual HTML to attach it - while ("velesComponent" in childNode) { - componentsTree.push(childNode); - if ("velesStringElement" in childNode.tree) { - return { - velesElementNode: childNode.tree, - componentsTree, - }; + while ("executedVelesComponent" in childNode) { + if ("executedVelesStringElement" in childNode.tree) { + return childNode.tree; } else { childNode = childNode.tree; } } - return { velesElementNode: childNode, componentsTree }; + return childNode; +} + +// Transforms intermediate tree into proper tree which can be mounted. This step is required so that +// all components are executed at the right order (from top leaves to children) +function renderTree( + component: VelesComponentObject | VelesElement | VelesStringElement, + { parentVelesElement }: { parentVelesElement?: ExecutedVelesElement } = {} +): ExecutedVelesComponent | ExecutedVelesElement | ExecutedVelesStringElement { + if ("velesStringElement" in component) { + const executedString: ExecutedVelesStringElement = { + executedVelesStringElement: true, + _privateMethods: component._privateMethods, + html: component.html, + parentVelesElement, + }; + if (component.needExecutedVersion) { + component.executedVersion = executedString; + } + return executedString; + } else if ("velesComponentObject" in component) { + const componentTree = executeComponent(component); + const executedComponent = {} as ExecutedVelesComponent; + executedComponent.executedVelesComponent = true; + executedComponent.tree = renderTree(componentTree.tree); + executedComponent._privateMethods = { + ...componentTree._privateMethods, + _callMountHandlers: () => { + component._privateMethods._callMountHandlers(); + componentTree._privateMethods._callMountHandlers(); + }, + _callUnmountHandlers: () => { + component._privateMethods._callUnmountHandlers(); + componentTree._privateMethods._callUnmountHandlers(); + }, + }; + const newNode = getExecutedComponentVelesNode(executedComponent); + if (parentVelesElement) { + if (component.insertAfter) { + if ("velesComponentObject" in component.insertAfter) { + insertNode({ + velesElement: newNode, + adjacentNode: component.insertAfter.html, + parentVelesElement, + }); + } else { + const lastNode = insertNode({ + velesElement: newNode, + adjacentNode: component.insertAfter, + parentVelesElement, + }); + component.html = lastNode; + } + } else { + const lastNode = insertNode({ + velesElement: newNode, + // it means we are inserting the first element + adjacentNode: null, + parentVelesElement, + }); + component.html = lastNode; + } + + newNode.parentVelesElement = parentVelesElement; + } + if (component.needExecutedVersion) { + component.executedVersion = executedComponent; + } + return executedComponent; + } else if ("velesNode" in component) { + const executedNode = {} as ExecutedVelesElement; + executedNode.executedVelesNode = true; + executedNode._privateMethods = component._privateMethods; + executedNode.html = component.html; + if (parentVelesElement) { + executedNode.parentVelesElement = parentVelesElement; + } + if (component.phantom) { + executedNode.phantom = component.phantom; + } + executedNode.childComponents = component.childComponents.map( + (childComponent) => + renderTree(childComponent, { parentVelesElement: executedNode }) + ); + if (component.needExecutedVersion) { + component.executedVersion = executedNode; + } + return executedNode; + } +} + +function insertNode({ + velesElement, + adjacentNode, + parentVelesElement, +}: { + velesElement: ExecutedVelesElement | ExecutedVelesStringElement; + adjacentNode: HTMLElement | Text | null; + parentVelesElement: ExecutedVelesElement; +}) { + // @ts-expect-error + if (velesElement.phantom) { + let lastInsertedNode: HTMLElement | Text | null = null; + + (velesElement as ExecutedVelesElement).childComponents.forEach( + (childComponentofPhantom) => { + if ("executedVelesNode" in childComponentofPhantom) { + if (lastInsertedNode) { + lastInsertedNode.after(childComponentofPhantom.html); + } else { + if (adjacentNode) { + adjacentNode.after(childComponentofPhantom.html); + } else { + parentVelesElement.html.prepend(childComponentofPhantom.html); + } + } + childComponentofPhantom.parentVelesElement = parentVelesElement; + lastInsertedNode = childComponentofPhantom.html; + } else if ("executedVelesStringElement" in childComponentofPhantom) { + if (lastInsertedNode) { + lastInsertedNode.after(childComponentofPhantom.html); + } else { + if (adjacentNode) { + adjacentNode.after(childComponentofPhantom.html); + } else { + parentVelesElement.html.prepend(childComponentofPhantom.html); + } + } + childComponentofPhantom.parentVelesElement = parentVelesElement; + lastInsertedNode = childComponentofPhantom.html; + } else { + const executedNode = getExecutedComponentVelesNode( + childComponentofPhantom + ); + if (lastInsertedNode) { + lastInsertedNode.after(executedNode.html); + } else { + if (adjacentNode) { + adjacentNode.after(executedNode.html); + } else { + parentVelesElement.html.prepend(executedNode.html); + } + } + executedNode.parentVelesElement = parentVelesElement; + lastInsertedNode = executedNode.html; + } + } + ); + velesElement.parentVelesElement = parentVelesElement; + + return lastInsertedNode; + } else { + if (adjacentNode) { + adjacentNode.after(velesElement.html); + } else { + parentVelesElement.html.prepend(velesElement.html); + } + velesElement.parentVelesElement = parentVelesElement; + + return velesElement.html; + } } function callMountHandlers( - component: VelesComponent | VelesElement | VelesStringElement + component: + | ExecutedVelesComponent + | ExecutedVelesElement + | ExecutedVelesStringElement ): void { component._privateMethods._callMountHandlers(); - if ("velesStringElement" in component) { + if ("executedVelesStringElement" in component) { return; } - if ("velesComponent" in component) { + if ("executedVelesComponent" in component) { callMountHandlers(component.tree); } - if ("velesNode" in component) { + if ("executedVelesNode" in component) { component.childComponents.forEach((childComponent) => callMountHandlers(childComponent) ); } } +function callUnmountHandlers( + component: + | ExecutedVelesComponent + | ExecutedVelesElement + | ExecutedVelesStringElement +): void { + if ("executedVelesStringElement" in component) { + // pass + } else if ("executedVelesComponent" in component) { + callUnmountHandlers(component.tree); + } else if ("executedVelesNode" in component) { + component.childComponents.forEach((childComponent) => + callUnmountHandlers(childComponent) + ); + } + + component._privateMethods._callUnmountHandlers(); +} + function identity(value1: T, value2: T) { return value1 === value2; } @@ -71,4 +253,11 @@ function unique(arr: T[]): T[] { return resultArr; } -export { getComponentVelesNode, identity, callMountHandlers, unique }; +export { + getExecutedComponentVelesNode, + identity, + callMountHandlers, + callUnmountHandlers, + unique, + renderTree, +}; diff --git a/src/attach-component.ts b/src/attach-component.ts index 3a34409..c359498 100644 --- a/src/attach-component.ts +++ b/src/attach-component.ts @@ -1,27 +1,31 @@ -import { getComponentVelesNode, callMountHandlers } from "./_utils"; +import { + getExecutedComponentVelesNode, + callMountHandlers, + callUnmountHandlers, + renderTree, +} from "./_utils"; import { createElement } from "./create-element"; -import type { VelesElement, VelesComponent } from "./types"; +import type { VelesElement, VelesComponentObject } from "./types"; function attachComponent({ htmlElement, component, }: { htmlElement: HTMLElement; - component: VelesElement | VelesComponent; + component: VelesElement | VelesComponentObject; }) { // we wrap the whole app into an additional
. While it is not ideal // for the consumers, it greatly simplifies some things, namely, mount callbacks // for components or supporting conditional rendering at the top level const wrappedApp = createElement("div", { children: [component] }); - const { velesElementNode } = getComponentVelesNode(wrappedApp); + const wrappedAppTree = renderTree(wrappedApp); + const velesElementNode = getExecutedComponentVelesNode(wrappedAppTree); htmlElement.appendChild(velesElementNode.html); - callMountHandlers(wrappedApp); + callMountHandlers(wrappedAppTree); - // TODO: iterate over every child and call their `onUnmout` method - // and add tests for that return () => { - wrappedApp._privateMethods._callUnmountHandlers(); + callUnmountHandlers(wrappedAppTree); velesElementNode.html.remove(); }; } diff --git a/src/create-element/create-element.ts b/src/create-element/create-element.ts index 335bb06..aa55375 100644 --- a/src/create-element/create-element.ts +++ b/src/create-element/create-element.ts @@ -3,7 +3,7 @@ import { assignAttributes } from "./assign-attributes"; import { parseComponent } from "./parse-component"; import type { - VelesComponent, + VelesComponentObject, VelesElement, VelesElementProps, ComponentFunction, @@ -12,9 +12,10 @@ import type { function createElement( element: string | ComponentFunction, props: VelesElementProps = {} -): VelesElement | VelesComponent { +): VelesElement | VelesComponentObject { if (typeof element === "string") { const { children, ref, phantom = false, ...otherProps } = props; + const newElement = document.createElement(element); const velesNode = {} as VelesElement; @@ -33,15 +34,6 @@ function createElement( // using `useValue` function and also listeners from // `useAttribute` const unmountHandlers: Function[] = []; - const callUnmountHandlers = () => { - // `onUnmount` is logically better to be executed on children first - velesNode.childComponents.forEach((childComponent) => { - childComponent._privateMethods._callUnmountHandlers(); - }); - - unmountHandlers.forEach((cb) => cb()); - }; - velesNode.html = newElement; velesNode.velesNode = true; velesNode.childComponents = childComponents; @@ -60,7 +52,9 @@ function createElement( _addUnmountHandler(cb: Function) { unmountHandlers.push(cb); }, - _callUnmountHandlers: callUnmountHandlers, + _callUnmountHandlers() { + unmountHandlers.forEach((cb) => cb()); + }, }; // assign all the DOM attributes, including event listeners diff --git a/src/create-element/parse-children.ts b/src/create-element/parse-children.ts index 0e11555..6f52421 100644 --- a/src/create-element/parse-children.ts +++ b/src/create-element/parse-children.ts @@ -1,7 +1,7 @@ -import { getComponentVelesNode } from "../_utils"; +import { createTextElement } from "./create-text-element"; import type { - VelesComponent, + VelesComponentObject, VelesElement, VelesStringElement, VelesElementProps, @@ -18,22 +18,29 @@ function parseChildren({ }) { const childComponents: ( | VelesElement - | VelesComponent + | VelesComponentObject | VelesStringElement )[] = []; if (children === undefined || children === null) { return childComponents; } + // we need this reference so that Components will be inserted at the right position + // when they are executed + let lastInsertedNode: null | HTMLElement | Text | VelesComponentObject = null; (Array.isArray(children) ? children : [children]).forEach( (childComponent) => { if (typeof childComponent === "string") { - const text = document.createTextNode(childComponent); - htmlElement.append(text); + const textNode = createTextElement(childComponent); + htmlElement.append(textNode.html); + lastInsertedNode = textNode.html; + childComponents.push(textNode); } else if (typeof childComponent === "number") { - const text = document.createTextNode(String(childComponent)); - htmlElement.append(text); + const textNode = createTextElement(String(childComponent)); + htmlElement.append(textNode.html); + lastInsertedNode = textNode.html; + childComponents.push(textNode); } else if ( typeof childComponent === "object" && childComponent && @@ -46,18 +53,20 @@ function parseChildren({ if ("velesNode" in childComponentofPhantom) { htmlElement.append(childComponentofPhantom.html); childComponentofPhantom.parentVelesElement = velesNode; - } else { - const { velesElementNode } = getComponentVelesNode( - childComponentofPhantom - ); + lastInsertedNode = childComponentofPhantom.html; + } else if ("velesStringElement" in childComponentofPhantom) { + const velesElementNode = childComponentofPhantom; if (!velesElementNode) { console.error("can't find HTML tree in a component chain"); } else { htmlElement.append(velesElementNode.html); + lastInsertedNode = velesElementNode.html; velesElementNode.parentVelesElement = velesNode; } + } else { + // not sure if we need to do something } }); childComponent.parentVelesElement = velesNode; @@ -67,48 +76,16 @@ function parseChildren({ htmlElement.append(childComponent.html); childComponent.parentVelesElement = velesNode; childComponents.push(childComponent); + lastInsertedNode = childComponent.html; } } else if ( typeof childComponent === "object" && childComponent && - "velesComponent" in childComponent && - childComponent?.velesComponent + "velesComponentObject" in childComponent ) { - // we need to save the whole components chain, so that - // we can trigger `mount` hooks on all of them correctly - const { componentsTree, velesElementNode } = - getComponentVelesNode(childComponent); - - if (!velesElementNode) { - console.error("can't find HTML tree in a component chain"); - } else { - if ("velesNode" in velesElementNode && velesElementNode.phantom) { - // we need to get ALL the children of it and attach it to this node - velesElementNode.childComponents.forEach( - (childComponentofPhantom) => { - if ("velesNode" in childComponentofPhantom) { - htmlElement.append(childComponentofPhantom.html); - childComponentofPhantom.parentVelesElement = velesNode; - } else { - const { componentsTree, velesElementNode } = - getComponentVelesNode(childComponentofPhantom); - - if (!velesElementNode) { - console.error("can't find HTML tree in a component chain"); - } else { - htmlElement.append(velesElementNode.html); - velesElementNode.parentVelesElement = velesNode; - } - } - } - ); - } else { - htmlElement.append(velesElementNode.html); - } - - velesElementNode.parentVelesElement = velesNode; - childComponents.push(childComponent); - } + childComponent.insertAfter = lastInsertedNode; + childComponents.push(childComponent); + lastInsertedNode = childComponent; } else if ( typeof childComponent === "object" && childComponent && @@ -119,6 +96,8 @@ function parseChildren({ htmlElement.append(childComponent.html); childComponent.parentVelesElement = velesNode; childComponents.push(childComponent); + + lastInsertedNode = childComponent.html; } } ); diff --git a/src/create-element/parse-component.ts b/src/create-element/parse-component.ts index 3403505..17b223d 100644 --- a/src/create-element/parse-component.ts +++ b/src/create-element/parse-component.ts @@ -6,14 +6,46 @@ import type { VelesElementProps, ComponentAPI, ComponentFunction, + VelesComponentObject, } from "../types"; +// only parse, do not execute and render it function parseComponent({ element, props, }: { element: ComponentFunction; props: VelesElementProps; +}): VelesComponentObject { + const mountCbs: Function[] = []; + const unmountCbs: Function[] = []; + return { + velesComponentObject: true, + element, + props, + _privateMethods: { + _addMountHandler(cb: Function) { + mountCbs.push(cb); + }, + _addUnmountHandler: (cb: Function) => { + unmountCbs.push(cb); + }, + _callMountHandlers: () => { + mountCbs.forEach((cb) => cb()); + }, + _callUnmountHandlers: () => { + unmountCbs.forEach((cb) => cb()); + }, + }, + }; +} + +function executeComponent({ + element, + props, +}: { + element: ComponentFunction; + props: VelesElementProps; }) { let componentUnmountCbs: Function[] = []; let componentMountCbs: Function[] = []; @@ -56,13 +88,6 @@ function parseComponent({ }); }, _callUnmountHandlers: () => { - // this should trigger recursive checks, whether it is a VelesNode or VelesComponent - // string Nodes don't have lifecycle handlers - if ("_privateMethods" in velesComponent.tree) { - velesComponent.tree._privateMethods._callUnmountHandlers(); - } - - // we execute own unmount callbacks after children, so the order is reversed componentUnmountCbs.forEach((cb) => cb()); }, }, @@ -71,4 +96,4 @@ function parseComponent({ return velesComponent; } -export { parseComponent }; +export { parseComponent, executeComponent }; diff --git a/src/create-state/index.ts b/src/create-state/index.ts index 4a9803e..dec771a 100644 --- a/src/create-state/index.ts +++ b/src/create-state/index.ts @@ -9,16 +9,11 @@ import { updateUseValueIteratorValue } from "./update-usevalueiterator-value"; import type { VelesElement, - VelesComponent, + VelesComponentObject, VelesStringElement, } from "../types"; -import type { - State, - TrackingIterator, - StateTrackers, - TrackingSelectorElement, -} from "./types"; +import type { State, TrackingIterator, StateTrackers } from "./types"; function createState( initialValue: T, @@ -83,9 +78,9 @@ function createState( selector: ((value: T) => F) | undefined, cb?: ( value: F - ) => VelesElement | VelesComponent | string | undefined | null, + ) => VelesElement | VelesComponentObject | string | undefined | null, comparator: (value1: F, value2: F) => boolean = identity - ): VelesElement | VelesComponent | VelesStringElement { + ): VelesElement | VelesComponentObject | VelesStringElement { // @ts-expect-error const selectedValue = selector ? selector(value) : (value as F); const returnedNode = cb @@ -98,6 +93,8 @@ function createState( ? createTextElement(returnedNode as string) : returnedNode; + node.needExecutedVersion = true; + const trackingSelectorElement = { selector, selectedValue, @@ -123,12 +120,12 @@ function createState( cb: (props: { elementState: State; indexState: State; - }) => VelesElement | VelesComponent + }) => VelesElement | VelesComponentObject ) { let wasMounted = false; const originalValue = value; const children: [ - VelesElement | VelesComponent, + VelesElement | VelesComponentObject, string, State ][] = []; @@ -137,7 +134,7 @@ function createState( elementState: State; indexState: State; indexValue: number; - node: VelesElement | VelesComponent; + node: VelesElement | VelesComponentObject; }; } = {}; @@ -173,6 +170,7 @@ function createState( } let node = cb({ elementState, indexState }); + node.needExecutedVersion = true; elementsByKey[calculatedKey] = { node, @@ -224,6 +222,8 @@ function createState( }); }); + wrapperComponent.needExecutedVersion = true; + trackingParams.cb = cb; trackingParams.key = options.key; trackingParams.elementsByKey = elementsByKey; diff --git a/src/create-state/types.d.ts b/src/create-state/types.d.ts index e77522d..7dfedb1 100644 --- a/src/create-state/types.d.ts +++ b/src/create-state/types.d.ts @@ -1,8 +1,10 @@ import type { VelesElement, - VelesComponent, + VelesComponentObject, VelesStringElement, AttributeHelper, + ExecutedVelesElement, + ExecutedVelesComponent, } from "../types"; export type State = { @@ -29,19 +31,19 @@ export type State = { useValue( cb?: ( value: ValueType - ) => VelesElement | VelesComponent | string | undefined | null, + ) => VelesElement | VelesComponentObject | string | undefined | null, comparator?: (value1: ValueType, value2: ValueType) => boolean - ): VelesElement | VelesComponent | VelesStringElement; + ): VelesElement | VelesComponentObject | VelesStringElement; useValueSelector( selector: (value: ValueType) => SelectorValueType, cb?: ( value: SelectorValueType - ) => VelesElement | VelesComponent | string | undefined | null, + ) => VelesElement | VelesComponentObject | string | undefined | null, comparator?: ( value1: SelectorValueType, value2: SelectorValueType ) => boolean - ): VelesElement | VelesComponent | VelesStringElement; + ): VelesElement | VelesComponentObject | VelesStringElement; useAttribute(cb?: (value: ValueType) => any): AttributeHelper; useValueIterator( options: { @@ -51,8 +53,8 @@ export type State = { cb: (props: { elementState: State; indexState: State; - }) => VelesElement | VelesComponent - ): VelesComponent | VelesElement | null; + }) => VelesElement | VelesComponentObject + ): VelesComponentObject | VelesElement | null; getValue(): ValueType; getPreviousValue(): undefined | ValueType; setValue( @@ -77,11 +79,11 @@ type TrackingEffect = { export type TrackingSelectorElement = { cb?: ( value: any - ) => VelesElement | VelesComponent | string | undefined | null; + ) => VelesElement | VelesComponentObject | string | undefined | null; selector?: Function; selectedValue: any; comparator: (value1: any, value2: any) => boolean; - node: VelesElement | VelesComponent | VelesStringElement; + node: VelesElement | VelesComponentObject | VelesStringElement; }; export type TrackingAttribute = { @@ -95,19 +97,25 @@ export type TrackingIterator = { cb: (props: { elementState: State; indexState: State; - }) => VelesElement | VelesComponent; + }) => VelesElement | VelesComponentObject; selector?: (value: unknown) => any[]; - renderedElements: [VelesElement | VelesComponent, string, State][]; + renderedElements: [ + { executedVersion?: ExecutedVelesElement | ExecutedVelesComponent }, + string, + State + ][]; key: string | ((options: { element: unknown; index: number }) => string); elementsByKey: { [key: string]: { elementState: State; indexState: State; indexValue: number; - node: VelesElement | VelesComponent; + node: { + executedVersion?: ExecutedVelesElement | ExecutedVelesComponent; + }; }; }; - wrapperComponent: VelesElement | VelesComponent; + wrapperComponent: VelesElement | VelesComponentObject; }; export type StateTrackers = { diff --git a/src/create-state/update-usevalue-selector-value.ts b/src/create-state/update-usevalue-selector-value.ts index 0389f01..1093a4e 100644 --- a/src/create-state/update-usevalue-selector-value.ts +++ b/src/create-state/update-usevalue-selector-value.ts @@ -1,12 +1,16 @@ -import { getComponentVelesNode, callMountHandlers } from "../_utils"; +import { + callMountHandlers, + callUnmountHandlers, + renderTree, + getExecutedComponentVelesNode, +} from "../_utils"; import { createTextElement } from "../create-element/create-text-element"; import type { - VelesElement, - VelesStringElement, - VelesComponent, + ExecutedVelesElement, + ExecutedVelesStringElement, } from "../types"; -import type { TrackingSelectorElement, StateTrackers, State } from "./types"; +import type { TrackingSelectorElement, StateTrackers } from "./types"; function updateUseValueSelector({ value, @@ -40,9 +44,22 @@ function updateUseValueSelector({ ? createTextElement(returnednewNode as string) : returnednewNode; - const { velesElementNode: oldVelesElementNode } = getComponentVelesNode(node); - const { velesElementNode: newVelesElementNode } = - getComponentVelesNode(newNode); + const newRenderedNode = renderTree(newNode); + newNode.executedVersion = newRenderedNode; + + // `executedVersion` is added when we convert it to tree. It doesn't have + // to be mounted, but mounting happens right after. + // If there is no this property, it means that it was not mounted, and + // somehow the subscription was added + if (!node.executedVersion) { + console.error("the node was not mounted"); + return; + } + + const oldVelesElementNode = getExecutedComponentVelesNode( + node.executedVersion + ); + const newVelesElementNode = getExecutedComponentVelesNode(newRenderedNode); const parentVelesElement = oldVelesElementNode.parentVelesElement; @@ -59,19 +76,22 @@ function updateUseValueSelector({ // we need to treat phantom nodes slightly differently // because it is not a single node removal/insert, but all // the children at once - if ("velesNode" in newVelesElementNode && newVelesElementNode.phantom) { + if ( + "executedVelesNode" in newVelesElementNode && + newVelesElementNode.phantom + ) { const insertAllPhantomChildren = ( - adjacentNode: VelesElement | VelesStringElement + adjacentNode: ExecutedVelesElement | ExecutedVelesStringElement ) => { // we need to get ALL the children of it and attach it to this node newVelesElementNode.childComponents.forEach( (childComponentofPhantom) => { - if ("velesNode" in childComponentofPhantom) { + if ("executedVelesNode" in childComponentofPhantom) { adjacentNode.html.before(childComponentofPhantom.html); childComponentofPhantom.parentVelesElement = adjacentNode.parentVelesElement; } else { - const { velesElementNode } = getComponentVelesNode( + const velesElementNode = getExecutedComponentVelesNode( childComponentofPhantom ); @@ -86,18 +106,21 @@ function updateUseValueSelector({ } ); }; - if ("velesNode" in oldVelesElementNode && oldVelesElementNode.phantom) { + if ( + "executedVelesNode" in oldVelesElementNode && + oldVelesElementNode.phantom + ) { let isInserted = false; oldVelesElementNode.childComponents.forEach( (childComponentofPhantom) => { - if ("velesNode" in childComponentofPhantom) { + if ("executedVelesNode" in childComponentofPhantom) { if (!isInserted) { insertAllPhantomChildren(childComponentofPhantom); isInserted = true; } childComponentofPhantom.html.remove(); } else { - const { velesElementNode } = getComponentVelesNode( + const velesElementNode = getExecutedComponentVelesNode( childComponentofPhantom ); @@ -118,18 +141,21 @@ function updateUseValueSelector({ oldVelesElementNode.html.remove(); } } else { - if ("velesNode" in oldVelesElementNode && oldVelesElementNode.phantom) { + if ( + "executedVelesNode" in oldVelesElementNode && + oldVelesElementNode.phantom + ) { let isInserted = false; oldVelesElementNode.childComponents.forEach( (childComponentofPhantom) => { - if ("velesNode" in childComponentofPhantom) { + if ("executedVelesNode" in childComponentofPhantom) { if (!isInserted) { childComponentofPhantom.html.before(newVelesElementNode.html); isInserted = true; } childComponentofPhantom.html.remove(); } else { - const { velesElementNode } = getComponentVelesNode( + const velesElementNode = getExecutedComponentVelesNode( childComponentofPhantom ); @@ -157,10 +183,13 @@ function updateUseValueSelector({ // if the parent node is removed from DOM, it calls correct unmount // callbacks parentVelesElement.childComponents = parentVelesElement.childComponents.map( - (childComponent) => (childComponent === node ? newNode : childComponent) + (childComponent) => + childComponent === node.executedVersion + ? newRenderedNode + : childComponent ); // we call unmount handlers right after we replace it - node._privateMethods._callUnmountHandlers(); + callUnmountHandlers(node.executedVersion); addUseValueMountHandler({ usedValue: value, @@ -170,7 +199,7 @@ function updateUseValueSelector({ }); // at this point the new Node is mounted, childComponents are updated // and unmount handlers for the old node are called - callMountHandlers(newNode); + callMountHandlers(newRenderedNode); // right after that, we add the callback back // the top level node is guaranteed to be rendered again (at least right now) diff --git a/src/create-state/update-usevalueiterator-value.ts b/src/create-state/update-usevalueiterator-value.ts index abad118..114afab 100644 --- a/src/create-state/update-usevalueiterator-value.ts +++ b/src/create-state/update-usevalueiterator-value.ts @@ -1,6 +1,11 @@ -import { getComponentVelesNode, callMountHandlers } from "../_utils"; +import { + callMountHandlers, + renderTree, + callUnmountHandlers, + getExecutedComponentVelesNode, +} from "../_utils"; -import type { VelesComponent, VelesElement } from "../types"; +import type { ExecutedVelesComponent, ExecutedVelesElement } from "../types"; import type { TrackingIterator, State, @@ -29,8 +34,14 @@ function updateUseValueIteratorValue({ return; } - const { velesElementNode: wrapperVelesElementNode } = - getComponentVelesNode(wrapperComponent); + if (!wrapperComponent.executedVersion) { + console.error("it seems the wrapper component was not mounted"); + return; + } + + const wrapperVelesElementNode = getExecutedComponentVelesNode( + wrapperComponent.executedVersion + ); const parentVelesElement = wrapperVelesElementNode.parentVelesElement; if (!parentVelesElement) { @@ -45,7 +56,7 @@ function updateUseValueIteratorValue({ // so we check manually if (Array.isArray(elements)) { const newRenderedElements: [ - VelesElement | VelesComponent, + { executedVersion?: ExecutedVelesElement | ExecutedVelesComponent }, string, State ][] = []; @@ -54,7 +65,9 @@ function updateUseValueIteratorValue({ elementState: State; indexState: State; indexValue: number; - node: VelesElement | VelesComponent; + node: { + executedVersion?: ExecutedVelesElement | ExecutedVelesComponent; + }; }; } = {}; @@ -95,7 +108,7 @@ function updateUseValueIteratorValue({ } newRenderedElements.push([ - existingElement.node, + { executedVersion: existingElement.node.executedVersion }, calculatedKey, existingElement.elementState, ]); @@ -103,19 +116,28 @@ function updateUseValueIteratorValue({ elementState: existingElement.elementState, indexState: existingElement.indexState, indexValue: index, - node: existingElement.node, + node: { executedVersion: existingElement.node.executedVersion }, }; } else { const elementState = createState(element); const indexState = createState(index); const node = cb({ elementState, indexState }); + // this TypeScript conversion should always be correct, because `node` is + // also either a component or an element + const renderedNode = renderTree(node) as + | ExecutedVelesComponent + | ExecutedVelesElement; - newRenderedElements.push([node, calculatedKey, elementState]); + newRenderedElements.push([ + { executedVersion: renderedNode }, + calculatedKey, + elementState, + ]); newElementsByKey[calculatedKey] = { elementState, indexState, indexValue: index, - node, + node: { executedVersion: renderedNode }, }; } @@ -141,7 +163,10 @@ function updateUseValueIteratorValue({ // to replace old wrapper's children to make sure they are removed correctly // on `useValue` unmount - const newChildComponents: (VelesComponent | VelesElement)[] = []; + const newChildComponents: ( + | ExecutedVelesComponent + | ExecutedVelesElement + )[] = []; const positioningOffset: { [key: number]: number } = {}; // to avoid iterating over arrays to determine whether there are removed nodes @@ -150,7 +175,7 @@ function updateUseValueIteratorValue({ let offset: number = 0; let currentElement: HTMLElement | Text | null = null; newRenderedElements.forEach((newRenderedElement, index) => { - newChildComponents.push(newRenderedElement[0]); + newChildComponents.push(newRenderedElement[0].executedVersion); // if we needed to adjust offset until we reach the original position of the item // we need to return it back once we reach the position after it if (positioningOffset[index]) { @@ -161,8 +186,8 @@ function updateUseValueIteratorValue({ const existingElement = elementsByKey[calculatedKey]; if (existingElement) { - const { velesElementNode: existingElementNode } = getComponentVelesNode( - existingElement.node + const existingElementNode = getExecutedComponentVelesNode( + existingElement.node.executedVersion ); // the element is in the same relative position if (existingElement.indexValue + offset === index) { @@ -179,9 +204,12 @@ function updateUseValueIteratorValue({ } else { // this means we at position 0 const firstRenderedElement = renderedElements[0]?.[0]; - if (firstRenderedElement) { - const { velesElementNode: firstRenderedVelesNode } = - getComponentVelesNode(firstRenderedElement); + if (firstRenderedElement?.executedVersion) { + const firstRenderedVelesNode = getExecutedComponentVelesNode( + firstRenderedElement.executedVersion as + | ExecutedVelesComponent + | ExecutedVelesElement + ); firstRenderedVelesNode.html.before(existingElementNode.html); } else { // TODO: handle this properly @@ -197,9 +225,12 @@ function updateUseValueIteratorValue({ } else { // this means we at position 0 const firstRenderedElement = renderedElements[0]?.[0]; - if (firstRenderedElement) { - const { velesElementNode: firstRenderedVelesNode } = - getComponentVelesNode(firstRenderedElement); + if (firstRenderedElement?.executedVersion) { + const firstRenderedVelesNode = getExecutedComponentVelesNode( + firstRenderedElement.executedVersion as + | ExecutedVelesComponent + | ExecutedVelesElement + ); firstRenderedVelesNode.html.before(existingElementNode.html); } else { // TODO: handle this properly @@ -211,8 +242,9 @@ function updateUseValueIteratorValue({ } } else { // we need to insert new element - const { velesElementNode: newNodeVelesElement } = - getComponentVelesNode(newNode); + const newNodeVelesElement = getExecutedComponentVelesNode( + newNode.executedVersion + ); newNodeVelesElement.parentVelesElement = parentVelesElement; if (currentElement) { @@ -220,9 +252,12 @@ function updateUseValueIteratorValue({ } else { // this basically means we at the position 0 const firstRenderedElement = renderedElements[0]?.[0]; - if (firstRenderedElement) { - const { velesElementNode: firstRenderedVelesNode } = - getComponentVelesNode(firstRenderedElement); + if (firstRenderedElement?.executedVersion) { + const firstRenderedVelesNode = getExecutedComponentVelesNode( + firstRenderedElement.executedVersion as + | ExecutedVelesComponent + | ExecutedVelesElement + ); firstRenderedVelesNode.html.before(newNodeVelesElement.html); } else { // TODO: handle the case when there were 0 rendered elements @@ -235,7 +270,7 @@ function updateUseValueIteratorValue({ currentElement = newNodeVelesElement.html; newElementsCount = newElementsCount + 1; - callMountHandlers(newNode); + callMountHandlers(newNode.executedVersion); } }); @@ -252,16 +287,17 @@ function updateUseValueIteratorValue({ if (renderedExistingElements[calculatedKey] === true) { return; } else { - const { velesElementNode: oldRenderedVelesNode } = - getComponentVelesNode(oldNode); + const oldRenderedVelesNode = getExecutedComponentVelesNode( + oldNode.executedVersion + ); oldRenderedVelesNode.html.remove(); - oldNode._privateMethods._callUnmountHandlers(); + callUnmountHandlers(oldNode.executedVersion); - if ("velesNode" in wrapperVelesElementNode) { + if ("executedVelesNode" in wrapperVelesElementNode) { wrapperVelesElementNode.childComponents = wrapperVelesElementNode.childComponents.filter( - (childComponent) => childComponent !== oldNode + (childComponent) => childComponent !== oldNode.executedVersion ); } else { throw new Error("Wrapper iterator element is a string"); @@ -272,7 +308,7 @@ function updateUseValueIteratorValue({ // We need to update `childComponents` of `wrapperVelesElementNode` to have the latest info // otherwise it will not be removed completely if it needs to be unmounted. - if ("velesNode" in wrapperVelesElementNode) { + if ("executedVelesNode" in wrapperVelesElementNode) { wrapperVelesElementNode.childComponents = newChildComponents; } diff --git a/src/types.d.ts b/src/types.d.ts index bedfafa..ecea942 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -12,9 +12,36 @@ export type VelesElement = { phantom?: boolean; + needExecutedVersion?: boolean; + executedVersion?: ExecutedVelesElement; + // every element except the most top one should have one parentVelesElement?: VelesElement; - childComponents: (VelesElement | VelesComponent | VelesStringElement)[]; + childComponents: (VelesElement | VelesComponentObject | VelesStringElement)[]; + + // not intended to be used directly + _privateMethods: { + _addMountHandler: Function; + _callMountHandlers: Function; + _addUnmountHandler: Function; + _callUnmountHandlers: Function; + }; +}; + +export type ExecutedVelesElement = { + executedVelesNode: true; + + html: HTMLElement; + + phantom?: boolean; + + // every element except the most top one should have one + parentVelesElement?: ExecutedVelesElement; + childComponents: ( + | ExecutedVelesElement + | ExecutedVelesComponent + | ExecutedVelesStringElement + )[]; // not intended to be used directly _privateMethods: { @@ -30,6 +57,26 @@ export type VelesStringElement = { html: Text; parentVelesElement?: VelesElement; + needExecutedVersion?: boolean; + executedVersion?: ExecutedVelesStringElement; + + // not intended to be used directly + // despite being a text component, having same lifecycle + // methods is useful for state changes, to remove tracking + // when the said Text is returned from `useValue` state method + _privateMethods: { + _addMountHandler: Function; + _callMountHandlers: Function; + _addUnmountHandler: Function; + _callUnmountHandlers: Function; + }; +}; + +export type ExecutedVelesStringElement = { + executedVelesStringElement: true; + html: Text; + parentVelesElement?: ExecutedVelesElement; + // not intended to be used directly // despite being a text component, having same lifecycle // methods is useful for state changes, to remove tracking @@ -46,7 +93,24 @@ export type VelesStringElement = { export type VelesComponent = { velesComponent: true; - tree: VelesElement | VelesComponent | VelesStringElement; + tree: VelesElement | VelesComponentObject | VelesStringElement; + + // not intended to be used directly + _privateMethods: { + _addMountHandler: Function; + _callMountHandlers: Function; + _callUnmountHandlers: Function; + _addUnmountHandler: Function; + }; +}; + +export type ExecutedVelesComponent = { + executedVelesComponent: true; + + tree: + | ExecutedVelesElement + | ExecutedVelesComponent + | ExecutedVelesStringElement; // not intended to be used directly _privateMethods: { @@ -62,7 +126,7 @@ type velesChild = | string | number | VelesElement - | VelesComponent + | VelesComponentObject | VelesStringElement; export type VelesChildren = velesChild | velesChild[] | undefined | null; @@ -90,9 +154,28 @@ export type ComponentAPI = { export type ComponentFunction = ( props: VelesElementProps, componentAPI: ComponentAPI -) => VelesElement | VelesComponent | VelesStringElement | string | null; +) => VelesElement | VelesComponentObject | VelesStringElement | string | null; export type AttributeHelper = { (htmlElement: HTMLElement, attributeName: string, node: VelesElement): T; velesAttribute: boolean; }; + +export type VelesComponentObject = { + velesComponentObject: true; + element: ComponentFunction; + props: VelesElementProps; + insertAfter?: VelesComponentObject | HTMLElement | Text | null; + html?: HTMLElement | Text; + + needExecutedVersion?: boolean; + executedVersion?: ExecutedVelesComponent; + + // not intended to be used directly + _privateMethods: { + _addMountHandler: Function; + _callMountHandlers: Function; + _callUnmountHandlers: Function; + _addUnmountHandler: Function; + }; +}; From caa2873e6c37404af18ee1dfa899e2f9ca790da2 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Mon, 10 Jun 2024 23:27:28 -0700 Subject: [PATCH 2/8] fix multiple components children order --- integration-tests/new-renderer.test.ts | 40 ++++++++++++++++++++++++++ src/_utils.ts | 3 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/integration-tests/new-renderer.test.ts b/integration-tests/new-renderer.test.ts index 7125112..4983cc1 100644 --- a/integration-tests/new-renderer.test.ts +++ b/integration-tests/new-renderer.test.ts @@ -403,4 +403,44 @@ describe("new renderer engine", () => { "helloapplicationanother line,test" ); }); + + test("supports several components as children", () => { + function App() { + return createElement("div", { + "data-testid": "app", + children: [ + createElement(FirstComponent), + createElement(SecondComponent), + createElement(ThirdComponent), + ], + }); + } + + function FirstComponent() { + return createElement("div", { + children: "first component", + }); + } + function SecondComponent() { + return createElement("div", { + children: "second component", + }); + } + function ThirdComponent() { + return createElement("div", { + children: "third component", + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement("div", { + children: [createElement(App)], + }), + }); + + expect(screen.getByTestId("app").textContent).toBe( + "first componentsecond componentthird component" + ); + }); }); diff --git a/src/_utils.ts b/src/_utils.ts index 0c1530a..3a3b9d3 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -70,11 +70,12 @@ function renderTree( if (parentVelesElement) { if (component.insertAfter) { if ("velesComponentObject" in component.insertAfter) { - insertNode({ + const lastNode = insertNode({ velesElement: newNode, adjacentNode: component.insertAfter.html, parentVelesElement, }); + component.html = lastNode; } else { const lastNode = insertNode({ velesElement: newNode, From 424ab72a4efb87908505c61bef10666120be8ec1 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Tue, 11 Jun 2024 02:05:17 -0700 Subject: [PATCH 3/8] fix an issue with usevalue when storing it in memory --- .../create-state/create-state.test.ts | 2 +- src/create-element/parse-children.ts | 1 + .../update-usevalue-selector-value.ts | 42 ++++++++++++++----- src/types.d.ts | 2 + 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/integration-tests/create-state/create-state.test.ts b/integration-tests/create-state/create-state.test.ts index f369261..88ba415 100644 --- a/integration-tests/create-state/create-state.test.ts +++ b/integration-tests/create-state/create-state.test.ts @@ -540,7 +540,7 @@ describe("createState", () => { expect(screen.getByTestId("text").textContent).toBe("length is 17"); }); - test.skip("unsubscribes from updates if wasn't mounted", async () => { + test("unsubscribes from updates if wasn't mounted", async () => { const user = userEvent.setup(); const valueState = createState(0); function App() { diff --git a/src/create-element/parse-children.ts b/src/create-element/parse-children.ts index 6f52421..054b90e 100644 --- a/src/create-element/parse-children.ts +++ b/src/create-element/parse-children.ts @@ -84,6 +84,7 @@ function parseChildren({ "velesComponentObject" in childComponent ) { childComponent.insertAfter = lastInsertedNode; + childComponent.parentVelesElement = velesNode; childComponents.push(childComponent); lastInsertedNode = childComponent; } else if ( diff --git a/src/create-state/update-usevalue-selector-value.ts b/src/create-state/update-usevalue-selector-value.ts index 1093a4e..a2abd32 100644 --- a/src/create-state/update-usevalue-selector-value.ts +++ b/src/create-state/update-usevalue-selector-value.ts @@ -61,7 +61,8 @@ function updateUseValueSelector({ ); const newVelesElementNode = getExecutedComponentVelesNode(newRenderedNode); - const parentVelesElement = oldVelesElementNode.parentVelesElement; + const parentVelesElement = node.parentVelesElement; + const parentVelesElementRendered = oldVelesElementNode.parentVelesElement; const newTrackingSelectorElement: TrackingSelectorElement = { selector, @@ -71,8 +72,9 @@ function updateUseValueSelector({ comparator, }; - if (parentVelesElement) { - newVelesElementNode.parentVelesElement = parentVelesElement; + if (parentVelesElementRendered) { + newNode.parentVelesElement = parentVelesElement; + newVelesElementNode.parentVelesElement = parentVelesElementRendered; // we need to treat phantom nodes slightly differently // because it is not a single node removal/insert, but all // the children at once @@ -172,22 +174,41 @@ function updateUseValueSelector({ } ); } else { - parentVelesElement.html.replaceChild( - newVelesElementNode.html, - oldVelesElementNode.html - ); + try { + parentVelesElementRendered.html.replaceChild( + newVelesElementNode.html, + oldVelesElementNode.html + ); + } catch (e) { + console.error("failed to update..."); + console.log(document.body.innerHTML); + console.log(oldVelesElementNode.parentVelesElement.html.innerHTML); + console.log( + //@ts-expect-error + oldVelesElementNode.parentVelesElement.childComponents[0].html + .textContent + ); + } } } // we need to update `childComponents` so that after the update // if the parent node is removed from DOM, it calls correct unmount // callbacks - parentVelesElement.childComponents = parentVelesElement.childComponents.map( - (childComponent) => + parentVelesElementRendered.childComponents = + parentVelesElementRendered.childComponents.map((childComponent) => childComponent === node.executedVersion ? newRenderedNode : childComponent - ); + ); + + if (parentVelesElement?.childComponents) { + parentVelesElement.childComponents = + parentVelesElement.childComponents.map((childComponent) => + childComponent === node ? newNode : childComponent + ); + } + // we call unmount handlers right after we replace it callUnmountHandlers(node.executedVersion); @@ -231,6 +252,7 @@ function addUseValueMountHandler({ }) { trackingSelectorElement.node._privateMethods._addMountHandler(() => { const currentValue = getValue(); + // if the current value is the same as the one which was used to calculate // current node, nothing really changed, no need to run it again if (usedValue === currentValue) { diff --git a/src/types.d.ts b/src/types.d.ts index ecea942..0811b02 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -92,6 +92,7 @@ export type ExecutedVelesStringElement = { // an internal representation of components in the tree export type VelesComponent = { velesComponent: true; + parentVelesElement?: VelesElement; tree: VelesElement | VelesComponentObject | VelesStringElement; @@ -167,6 +168,7 @@ export type VelesComponentObject = { props: VelesElementProps; insertAfter?: VelesComponentObject | HTMLElement | Text | null; html?: HTMLElement | Text; + parentVelesElement?: VelesElement; needExecutedVersion?: boolean; executedVersion?: ExecutedVelesComponent; From edc69034fab9a261da22b82b2529066e1157d1ab Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Tue, 11 Jun 2024 03:20:15 -0700 Subject: [PATCH 4/8] fix an issue with usevalueiterator when storing it in memory --- .../create-state/use-value-iterator.test.ts | 19 +-- src/create-state/index.ts | 133 +++++++----------- src/create-state/types.d.ts | 6 +- .../update-usevalueiterator-value.ts | 46 +++--- 4 files changed, 97 insertions(+), 107 deletions(-) diff --git a/integration-tests/create-state/use-value-iterator.test.ts b/integration-tests/create-state/use-value-iterator.test.ts index f09712d..58ca759 100644 --- a/integration-tests/create-state/use-value-iterator.test.ts +++ b/integration-tests/create-state/use-value-iterator.test.ts @@ -258,7 +258,7 @@ describe("createState", () => { expect(children[4].textContent).toBe("4.second item"); }); - test.skip("useValueIterator does not update until mounted", async () => { + test("useValueIterator does not update until mounted", async () => { const user = userEvent.setup(); type Item = { id: number; text: string }; const item1: Item = { id: 1, text: "first item" }; @@ -348,7 +348,7 @@ describe("createState", () => { await user.click(screen.getByTestId("button")); expect(textSpy).toHaveBeenCalledTimes(4); - expect(indexSpy).toHaveBeenCalledTimes(5); + expect(indexSpy).toHaveBeenCalledTimes(4); expect(children.length).toBe(4); expect(children[0].textContent).toBe("fourth item number: 0"); @@ -358,7 +358,7 @@ describe("createState", () => { itemsState.setValue([item4, item5, item3, item1, item2]); expect(textSpy).toHaveBeenCalledTimes(5); - expect(indexSpy).toHaveBeenCalledTimes(7); + expect(indexSpy).toHaveBeenCalledTimes(6); expect(children.length).toBe(5); expect(children[0].textContent).toBe("fourth item number: 0"); @@ -382,10 +382,13 @@ describe("createState", () => { ]); expect(textSpy).toHaveBeenCalledTimes(5); - expect(indexSpy).toHaveBeenCalledTimes(7); + expect(indexSpy).toHaveBeenCalledTimes(6); + + await user.click(screen.getByTestId("button")); + + expect(textSpy).toHaveBeenCalledTimes(11); + expect(indexSpy).toHaveBeenCalledTimes(12); - expect(textSpy).toHaveBeenCalledTimes(10); - expect(indexSpy).toHaveBeenCalledTimes(11); expect(children.length).toBe(6); expect(children[0].textContent).toBe("fourth item number: 0"); expect(children[1].textContent).toBe("fifth item number: 1"); @@ -402,8 +405,8 @@ describe("createState", () => { itemsState.setValue([item3, item6]); await user.click(screen.getByTestId("button")); - expect(textSpy).toHaveBeenCalledTimes(7); - expect(indexSpy).toHaveBeenCalledTimes(10); + expect(textSpy).toHaveBeenCalledTimes(15); + expect(indexSpy).toHaveBeenCalledTimes(16); expect(children.length).toBe(2); expect(children[0].textContent).toBe("third item number: 0"); expect(children[1].textContent).toBe("sixth item number: 1"); diff --git a/src/create-state/index.ts b/src/create-state/index.ts index dec771a..b008d0d 100644 --- a/src/create-state/index.ts +++ b/src/create-state/index.ts @@ -122,93 +122,70 @@ function createState( indexState: State; }) => VelesElement | VelesComponentObject ) { - let wasMounted = false; - const originalValue = value; - const children: [ - VelesElement | VelesComponentObject, - string, - State - ][] = []; - const elementsByKey: { - [key: string]: { - elementState: State; - indexState: State; - indexValue: number; - node: VelesElement | VelesComponentObject; - }; - } = {}; - - const elements = options.selector ? options.selector(value) : value; - - if (!Array.isArray(elements)) { - console.error("useValueIterator received non-array value"); - return null; - } + const trackingParams = {} as TrackingIterator; - (elements as Element[]).forEach((element, index) => { - // we calculate a key for each element. This key determines whether we render the element from scratch, or do nothing - // when the element updates - let calculatedKey: string = ""; - if ( - typeof options.key === "string" && - typeof element === "object" && - element !== null && - options.key in element - ) { - calculatedKey = element[options.key]; - } else if (typeof options.key === "function") { - calculatedKey = options.key({ element, index }); - } else { - // ignore for now + const wrapperComponent = createElement((_props, componentAPI) => { + const children: [ + VelesElement | VelesComponentObject, + string, + State + ][] = []; + const elementsByKey: { + [key: string]: { + elementState: State; + indexState: State; + indexValue: number; + node: VelesElement | VelesComponentObject; + }; + } = {}; + const elements = options.selector ? options.selector(value) : value; + + if (!Array.isArray(elements)) { + console.error("useValueIterator received non-array value"); + return null; } - const elementState = createState(element); - const indexState = createState(index); + (elements as Element[]).forEach((element, index) => { + // we calculate a key for each element. This key determines whether we render the element from scratch, or do nothing + // when the element updates + let calculatedKey: string = ""; + if ( + typeof options.key === "string" && + typeof element === "object" && + element !== null && + options.key in element + ) { + calculatedKey = element[options.key]; + } else if (typeof options.key === "function") { + calculatedKey = options.key({ element, index }); + } else { + // ignore for now + } - if (!calculatedKey) { - return; - } + const elementState = createState(element); + const indexState = createState(index); - let node = cb({ elementState, indexState }); - node.needExecutedVersion = true; + if (!calculatedKey) { + return; + } - elementsByKey[calculatedKey] = { - node, - indexState, - indexValue: index, - elementState, - }; + let node = cb({ elementState, indexState }); + node.needExecutedVersion = true; - children.push([node, calculatedKey, elementState]); - }); + elementsByKey[calculatedKey] = { + node, + indexState, + indexValue: index, + elementState, + }; - const trackingParams = {} as TrackingIterator; + children.push([node, calculatedKey, elementState]); + }); - const wrapperComponent = createElement((_props, componentAPI) => { + trackingParams.elementsByKey = elementsByKey; + trackingParams.renderedElements = children; + trackers.trackingIterators.push(trackingParams); onMount(() => { - trackers.trackingIterators.push(trackingParams); - - if (!wasMounted && value === originalValue) { - /** - * We avoid recalculating in one case: - * 1. the component was never mounted - * 2. the value didn't change - * - * Every other case will need to store their own value, - * and while it is possible, for now we are not doing it - */ - } else { - updateUseValueIteratorValue({ - value, - trackingIterator: trackingParams, - createState, - }); - } - - if (!wasMounted) { - wasMounted = true; - } - componentAPI.onUnmount(() => { trackers.trackingIterators = trackers.trackingIterators.filter( (currentTrackingParams) => @@ -226,8 +203,6 @@ function createState( trackingParams.cb = cb; trackingParams.key = options.key; - trackingParams.elementsByKey = elementsByKey; - trackingParams.renderedElements = children; trackingParams.wrapperComponent = wrapperComponent; if (options.selector) { diff --git a/src/create-state/types.d.ts b/src/create-state/types.d.ts index 7dfedb1..97ebd76 100644 --- a/src/create-state/types.d.ts +++ b/src/create-state/types.d.ts @@ -100,7 +100,7 @@ export type TrackingIterator = { }) => VelesElement | VelesComponentObject; selector?: (value: unknown) => any[]; renderedElements: [ - { executedVersion?: ExecutedVelesElement | ExecutedVelesComponent }, + VelesElement | VelesComponentObject, string, State ][]; @@ -110,9 +110,7 @@ export type TrackingIterator = { elementState: State; indexState: State; indexValue: number; - node: { - executedVersion?: ExecutedVelesElement | ExecutedVelesComponent; - }; + node: VelesElement | VelesComponentObject; }; }; wrapperComponent: VelesElement | VelesComponentObject; diff --git a/src/create-state/update-usevalueiterator-value.ts b/src/create-state/update-usevalueiterator-value.ts index 114afab..6bbc040 100644 --- a/src/create-state/update-usevalueiterator-value.ts +++ b/src/create-state/update-usevalueiterator-value.ts @@ -5,7 +5,12 @@ import { getExecutedComponentVelesNode, } from "../_utils"; -import type { ExecutedVelesComponent, ExecutedVelesElement } from "../types"; +import type { + ExecutedVelesComponent, + ExecutedVelesElement, + VelesComponentObject, + VelesElement, +} from "../types"; import type { TrackingIterator, State, @@ -56,7 +61,7 @@ function updateUseValueIteratorValue({ // so we check manually if (Array.isArray(elements)) { const newRenderedElements: [ - { executedVersion?: ExecutedVelesElement | ExecutedVelesComponent }, + VelesElement | VelesComponentObject, string, State ][] = []; @@ -65,9 +70,7 @@ function updateUseValueIteratorValue({ elementState: State; indexState: State; indexValue: number; - node: { - executedVersion?: ExecutedVelesElement | ExecutedVelesComponent; - }; + node: VelesElement | VelesComponentObject; }; } = {}; @@ -108,7 +111,7 @@ function updateUseValueIteratorValue({ } newRenderedElements.push([ - { executedVersion: existingElement.node.executedVersion }, + existingElement.node, calculatedKey, existingElement.elementState, ]); @@ -116,7 +119,7 @@ function updateUseValueIteratorValue({ elementState: existingElement.elementState, indexState: existingElement.indexState, indexValue: index, - node: { executedVersion: existingElement.node.executedVersion }, + node: existingElement.node, }; } else { const elementState = createState(element); @@ -127,17 +130,14 @@ function updateUseValueIteratorValue({ const renderedNode = renderTree(node) as | ExecutedVelesComponent | ExecutedVelesElement; + node.executedVersion = renderedNode; - newRenderedElements.push([ - { executedVersion: renderedNode }, - calculatedKey, - elementState, - ]); + newRenderedElements.push([node, calculatedKey, elementState]); newElementsByKey[calculatedKey] = { elementState, indexState, indexValue: index, - node: { executedVersion: renderedNode }, + node, }; } @@ -163,10 +163,12 @@ function updateUseValueIteratorValue({ // to replace old wrapper's children to make sure they are removed correctly // on `useValue` unmount - const newChildComponents: ( + const newChildRenderedComponents: ( | ExecutedVelesComponent | ExecutedVelesElement )[] = []; + const newChildComponents: (VelesComponentObject | VelesElement)[] = []; + const positioningOffset: { [key: number]: number } = {}; // to avoid iterating over arrays to determine whether there are removed nodes @@ -175,7 +177,8 @@ function updateUseValueIteratorValue({ let offset: number = 0; let currentElement: HTMLElement | Text | null = null; newRenderedElements.forEach((newRenderedElement, index) => { - newChildComponents.push(newRenderedElement[0].executedVersion); + newChildRenderedComponents.push(newRenderedElement[0].executedVersion); + newChildComponents.push(newRenderedElement[0]); // if we needed to adjust offset until we reach the original position of the item // we need to return it back once we reach the position after it if (positioningOffset[index]) { @@ -302,6 +305,13 @@ function updateUseValueIteratorValue({ } else { throw new Error("Wrapper iterator element is a string"); } + + if ("velesNode" in wrapperComponent) { + wrapperComponent.childComponents = + wrapperComponent.childComponents.filter( + (childComponent) => childComponent !== oldNode + ); + } } }); } @@ -309,7 +319,11 @@ function updateUseValueIteratorValue({ // We need to update `childComponents` of `wrapperVelesElementNode` to have the latest info // otherwise it will not be removed completely if it needs to be unmounted. if ("executedVelesNode" in wrapperVelesElementNode) { - wrapperVelesElementNode.childComponents = newChildComponents; + wrapperVelesElementNode.childComponents = newChildRenderedComponents; + } + + if ("velesNode" in wrapperComponent) { + wrapperComponent.childComponents = newChildComponents; } // update the tracking info with new data From b1bce580fcfdf6d55fbd2dde48a576b007ede589 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Tue, 11 Jun 2024 03:53:24 -0700 Subject: [PATCH 5/8] add initial Context implementation --- integration-tests/assign-attributes.test.ts | 9 +- integration-tests/context.test.ts | 85 +++++++++++++++++++ .../create-state/track-value.test.ts | 9 +- .../create-state/use-attribute.test.ts | 4 +- .../create-state/use-value-iterator.test.ts | 2 +- src/_utils.ts | 3 + src/context/index.ts | 72 ++++++++++++++++ src/context/types.d.ts | 3 + src/create-state/index.ts | 14 ++- src/create-state/types.d.ts | 4 +- .../update-usevalue-selector-value.ts | 6 +- src/index.ts | 1 + 12 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 integration-tests/context.test.ts create mode 100644 src/context/index.ts create mode 100644 src/context/types.d.ts diff --git a/integration-tests/assign-attributes.test.ts b/integration-tests/assign-attributes.test.ts index 9bd3fc1..177838c 100644 --- a/integration-tests/assign-attributes.test.ts +++ b/integration-tests/assign-attributes.test.ts @@ -3,14 +3,7 @@ import userEvent from "@testing-library/user-event"; import { attachComponent, createElement, createState, createRef } from "../src"; -function shallow(obj1: Record, obj2: Record) { - return ( - Object.keys(obj1).length === Object.keys(obj2).length && - Object.keys(obj1).every((key) => obj1[key] === obj2[key]) - ); -} - -describe("createState", () => { +describe("assign-attributes", () => { let cleanup: Function | undefined; afterEach(() => { diff --git a/integration-tests/context.test.ts b/integration-tests/context.test.ts new file mode 100644 index 0000000..1601c80 --- /dev/null +++ b/integration-tests/context.test.ts @@ -0,0 +1,85 @@ +import { screen } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; + +import { + attachComponent, + createElement, + createState, + createRef, + createContext, +} from "../src"; + +describe("Context", () => { + let cleanup: Function | undefined; + + afterEach(() => { + cleanup?.(); + cleanup = undefined; + }); + + test("nested components have access to the Context", () => { + const exampleContext = createContext(); + + function App() { + return createElement("div", { + children: [ + createElement("h1", { children: "Application" }), + createElement(NestedComponent), + ], + }); + } + + function NestedComponent() { + const exampleValue = exampleContext.readContext(); + + return createElement("div", { + "data-testid": "contextContent", + children: `context value is ${exampleValue}`, + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement(exampleContext.Provider, { + value: 5, + children: createElement(App), + }), + }); + + expect(screen.getByTestId("contextContent").textContent).toBe( + "context value is 5" + ); + }); + + test("nested components have access to the Context if added with Context.addContext()", () => { + const exampleContext = createContext(); + + function App() { + exampleContext.addContext(5); + return createElement("div", { + children: [ + createElement("h1", { children: "Application" }), + createElement(NestedComponent), + ], + }); + } + + function NestedComponent() { + const exampleValue = exampleContext.readContext(); + + return createElement("div", { + "data-testid": "contextContent", + children: `context value is ${exampleValue}`, + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement(App), + }); + + expect(screen.getByTestId("contextContent").textContent).toBe( + "context value is 5" + ); + }); +}); diff --git a/integration-tests/create-state/track-value.test.ts b/integration-tests/create-state/track-value.test.ts index 1983178..026665d 100644 --- a/integration-tests/create-state/track-value.test.ts +++ b/integration-tests/create-state/track-value.test.ts @@ -9,14 +9,7 @@ import { createRef, } from "../../src"; -function shallow(obj1: Record, obj2: Record) { - return ( - Object.keys(obj1).length === Object.keys(obj2).length && - Object.keys(obj1).every((key) => obj1[key] === obj2[key]) - ); -} - -describe("createState", () => { +describe("track-value", () => { let cleanup: Function | undefined; afterEach(() => { diff --git a/integration-tests/create-state/use-attribute.test.ts b/integration-tests/create-state/use-attribute.test.ts index ecb3453..2fac970 100644 --- a/integration-tests/create-state/use-attribute.test.ts +++ b/integration-tests/create-state/use-attribute.test.ts @@ -8,9 +8,7 @@ import { onUnmount, } from "../../src"; -import type { State } from "../../src"; - -describe("createState", () => { +describe("state.useAttribute", () => { let cleanup: Function | undefined; afterEach(() => { diff --git a/integration-tests/create-state/use-value-iterator.test.ts b/integration-tests/create-state/use-value-iterator.test.ts index 58ca759..9c99e2e 100644 --- a/integration-tests/create-state/use-value-iterator.test.ts +++ b/integration-tests/create-state/use-value-iterator.test.ts @@ -10,7 +10,7 @@ import { import type { State } from "../../src"; -describe("createState", () => { +describe("state.useValueIterator", () => { let cleanup: Function | undefined; afterEach(() => { diff --git a/src/_utils.ts b/src/_utils.ts index 3a3b9d3..d4ddbeb 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -1,4 +1,5 @@ import { executeComponent } from "./create-element/parse-component"; +import { addPublicContext, popPublicContext } from "./context"; import type { VelesComponentObject, @@ -51,10 +52,12 @@ function renderTree( } return executedString; } else if ("velesComponentObject" in component) { + addPublicContext(); const componentTree = executeComponent(component); const executedComponent = {} as ExecutedVelesComponent; executedComponent.executedVelesComponent = true; executedComponent.tree = renderTree(componentTree.tree); + popPublicContext(); executedComponent._privateMethods = { ...componentTree._privateMethods, _callMountHandlers: () => { diff --git a/src/context/index.ts b/src/context/index.ts new file mode 100644 index 0000000..d278509 --- /dev/null +++ b/src/context/index.ts @@ -0,0 +1,72 @@ +import { Fragment } from "../fragment"; +import { createElement } from "../create-element"; + +import type { VelesChildren } from "../types"; +import type { ComponentContext } from "./types.d.ts"; + +// the name is so convoluted because we also +// use context stack for lifecycle +const publicContextStack: ComponentContext[] = []; + +let contextIdCounter = 1; +function createContext() { + // unique context id + const contextId = contextIdCounter++; + function addContext(value: T) { + const currentContextObject = + publicContextStack[publicContextStack.length - 1]; + + if (!currentContextObject) { + // either executed outside of the rendering framework + // or some bug + console.error("cannot add Context due to missing stack value"); + } else { + publicContextStack[publicContextStack.length - 1] = { + ...currentContextObject, + [contextId]: value, + }; + } + } + return { + Provider: ({ value, children }: { value: T; children: VelesChildren }) => { + addContext(value); + return createElement(Fragment, { children }); + }, + addContext, + readContext: (): T => { + const currentContext = publicContextStack[publicContextStack.length - 1]; + + if (!currentContext) { + // we are outside the context somehow + console.error("no Context currently available"); + } else { + return currentContext[contextId]; + } + }, + }; +} + +function addPublicContext(specificContext?: ComponentContext) { + if (specificContext) { + publicContextStack.push(specificContext); + } else { + if (publicContextStack.length === 0) { + publicContextStack.push({}); + } else { + const currentContext = publicContextStack[publicContextStack.length - 1]; + publicContextStack.push(currentContext); + } + } +} + +function popPublicContext() { + publicContextStack.pop(); +} + +// this function is needed to save current context to re-execute components +// which are mounted conditionally +function getCurrentContext() { + return publicContextStack[publicContextStack.length - 1]; +} + +export { createContext, addPublicContext, popPublicContext, getCurrentContext }; diff --git a/src/context/types.d.ts b/src/context/types.d.ts new file mode 100644 index 0000000..c6bd3b4 --- /dev/null +++ b/src/context/types.d.ts @@ -0,0 +1,3 @@ +export type ComponentContext = { + [id: number]: any; +}; diff --git a/src/create-state/index.ts b/src/create-state/index.ts index b008d0d..2ff24ce 100644 --- a/src/create-state/index.ts +++ b/src/create-state/index.ts @@ -5,7 +5,7 @@ import { createTextElement } from "../create-element/create-text-element"; import { triggerUpdates } from "./trigger-updates"; import { addUseValueMountHandler } from "./update-usevalue-selector-value"; import { updateUseAttributeValue } from "./update-useattribute-value"; -import { updateUseValueIteratorValue } from "./update-usevalueiterator-value"; +import { getCurrentContext } from "../context"; import type { VelesElement, @@ -13,7 +13,12 @@ import type { VelesStringElement, } from "../types"; -import type { State, TrackingIterator, StateTrackers } from "./types"; +import type { + State, + TrackingIterator, + StateTrackers, + TrackingSelectorElement, +} from "./types"; function createState( initialValue: T, @@ -93,14 +98,17 @@ function createState( ? createTextElement(returnedNode as string) : returnedNode; + const currentContext = getCurrentContext(); + node.needExecutedVersion = true; - const trackingSelectorElement = { + const trackingSelectorElement: TrackingSelectorElement = { selector, selectedValue, cb, node, comparator, + savedContext: currentContext, }; addUseValueMountHandler({ diff --git a/src/create-state/types.d.ts b/src/create-state/types.d.ts index 97ebd76..f0a4e21 100644 --- a/src/create-state/types.d.ts +++ b/src/create-state/types.d.ts @@ -3,9 +3,8 @@ import type { VelesComponentObject, VelesStringElement, AttributeHelper, - ExecutedVelesElement, - ExecutedVelesComponent, } from "../types"; +import type { ComponentContext } from "../context/types"; export type State = { trackValue( @@ -84,6 +83,7 @@ export type TrackingSelectorElement = { selectedValue: any; comparator: (value1: any, value2: any) => boolean; node: VelesElement | VelesComponentObject | VelesStringElement; + savedContext: ComponentContext; }; export type TrackingAttribute = { diff --git a/src/create-state/update-usevalue-selector-value.ts b/src/create-state/update-usevalue-selector-value.ts index a2abd32..fe19374 100644 --- a/src/create-state/update-usevalue-selector-value.ts +++ b/src/create-state/update-usevalue-selector-value.ts @@ -5,6 +5,7 @@ import { getExecutedComponentVelesNode, } from "../_utils"; import { createTextElement } from "../create-element/create-text-element"; +import { addPublicContext, popPublicContext } from "../context"; import type { ExecutedVelesElement, @@ -25,7 +26,7 @@ function updateUseValueSelector({ trackers: StateTrackers; getValue: () => T; }) { - const { selectedValue, selector, cb, node, comparator } = + const { selectedValue, selector, cb, node, comparator, savedContext } = selectorTrackingElement; const newSelectedValue = selector ? selector(value) : value; @@ -34,11 +35,13 @@ function updateUseValueSelector({ return; } + addPublicContext(savedContext); const returnednewNode = cb ? cb(newSelectedValue) : newSelectedValue == undefined ? "" : String(newSelectedValue); + popPublicContext(); const newNode = !returnednewNode || typeof returnednewNode === "string" ? createTextElement(returnednewNode as string) @@ -70,6 +73,7 @@ function updateUseValueSelector({ cb, node: newNode, comparator, + savedContext, }; if (parentVelesElementRendered) { diff --git a/src/index.ts b/src/index.ts index c177227..c185c57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,6 @@ export { onMount, onUnmount } from "./hooks"; export { createState } from "./create-state"; export { createRef } from "./create-ref"; export { Fragment } from "./fragment"; +export { createContext } from "./context"; export type { State } from "./create-state/types"; From 30a735a2a9d75794972468710a91a64388ee0d2a Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Tue, 11 Jun 2024 04:14:20 -0700 Subject: [PATCH 6/8] add more specific checks in usevalueiterator tests --- .../create-state/use-value-iterator.test.ts | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/integration-tests/create-state/use-value-iterator.test.ts b/integration-tests/create-state/use-value-iterator.test.ts index 9c99e2e..ec9f0ec 100644 --- a/integration-tests/create-state/use-value-iterator.test.ts +++ b/integration-tests/create-state/use-value-iterator.test.ts @@ -27,6 +27,7 @@ describe("state.useValueIterator", () => { const item4: Item = { id: 4, text: "fourth item" }; const item5: Item = { id: 5, text: "fifth item" }; const unmountSpy = jest.fn(); + const textSpy = jest.fn() function IteratorComponent() { const state = createState([item1, item3, item4]); @@ -66,7 +67,10 @@ describe("state.useValueIterator", () => { children: [ elementState.useValueSelector( (element) => element.text, - (text) => createElement("span", { children: text }) + (text) => { + textSpy() + return createElement("span", { children: text }) + } ), ], }); @@ -88,6 +92,7 @@ describe("state.useValueIterator", () => { const firstListElement = listElement.childNodes[0]; const secondListElement = listElement.childNodes[1]; const thirdListElement = listElement.childNodes[2]; + expect(textSpy).toHaveBeenCalledTimes(3) expect(firstListElement.textContent).toBe(item1.text); expect(secondListElement.textContent).toBe(item3.text); @@ -96,10 +101,12 @@ describe("state.useValueIterator", () => { await user.click(screen.getByTestId("updateFirstItem")); expect(unmountSpy).not.toHaveBeenCalled(); expect(listElement.childNodes[0].textContent).toBe("updated first value"); + expect(textSpy).toHaveBeenCalledTimes(4) await user.click(screen.getByTestId("updateArrayButton")); expect(listElement.childNodes.length).toBe(4); expect(unmountSpy).toHaveBeenCalledTimes(1); + expect(textSpy).toHaveBeenCalledTimes(7) }); test("useValueIterator does support selector option", async () => { @@ -111,6 +118,7 @@ describe("state.useValueIterator", () => { const item4: Item = { id: 4, text: "fourth item" }; const item5: Item = { id: 5, text: "fifth item" }; const unmountSpy = jest.fn(); + const textSpy = jest.fn() function IteratorComponent() { const state = createState<{ value: Item[] }>({ value: [item1, item3, item4], @@ -154,7 +162,10 @@ describe("state.useValueIterator", () => { children: [ elementState.useValueSelector( (element) => element.text, - (text) => createElement("span", { children: text }) + (text) => { + textSpy() + return createElement("span", { children: text }) + } ), ], }); @@ -181,13 +192,17 @@ describe("state.useValueIterator", () => { expect(secondListElement.textContent).toBe(item3.text); expect(thirdListElement.textContent).toBe(item4.text); + expect(textSpy).toHaveBeenCalledTimes(3) + await user.click(screen.getByTestId("updateFirstItem")); expect(unmountSpy).not.toHaveBeenCalled(); expect(listElement.childNodes[0].textContent).toBe("updated first value"); + expect(textSpy).toHaveBeenCalledTimes(4) await user.click(screen.getByTestId("updateArrayButton")); expect(listElement.childNodes.length).toBe(4); expect(unmountSpy).toHaveBeenCalledTimes(1); + expect(textSpy).toHaveBeenCalledTimes(7) }); test("value iterator correctly updates indices after changing order", async () => { @@ -199,6 +214,8 @@ describe("state.useValueIterator", () => { const item4: Item = { id: 4, text: "fourth item" }; const item5: Item = { id: 5, text: "fifth item" }; let items = [item1, item2, item3, item4, item5]; + const textSpy = jest.fn() + const indexSpy = jest.fn() function App() { const itemsState = createState(items); @@ -217,12 +234,19 @@ describe("state.useValueIterator", () => { createElement("div", { children: [ createElement("div", { - children: indexState.useValue(), + children: indexState.useValue(value => { + indexSpy() + return String(value) + }), }), ".", createElement("div", { children: elementState.useValueSelector( - (item) => item.text + (item) => item.text, + value => { + textSpy() + return value + } ), }), ], @@ -239,6 +263,8 @@ describe("state.useValueIterator", () => { component: createElement(App), }); + expect(textSpy).toHaveBeenCalledTimes(5) + expect(indexSpy).toHaveBeenCalledTimes(5) const container = screen.getByTestId("container"); const children = container.childNodes; expect(children.length).toBe(5); @@ -250,6 +276,8 @@ describe("state.useValueIterator", () => { items = [item5, item3, item1, item4, item2]; await user.click(screen.getByTestId("button")); + expect(textSpy).toHaveBeenCalledTimes(5) + expect(indexSpy).toHaveBeenCalledTimes(9) expect(children[0].textContent).toBe("0.fifth item"); expect(children[1].textContent).toBe("1.third item"); From 65e76c61906400516fa13762a47543d9ab879e86 Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Tue, 11 Jun 2024 04:41:09 -0700 Subject: [PATCH 7/8] add Context support to useValueIterator --- integration-tests/context.test.ts | 155 +++++++++++++++++- src/create-state/index.ts | 2 + src/create-state/types.d.ts | 1 + .../update-usevalue-selector-value.ts | 2 +- .../update-usevalueiterator-value.ts | 4 + 5 files changed, 162 insertions(+), 2 deletions(-) diff --git a/integration-tests/context.test.ts b/integration-tests/context.test.ts index 1601c80..a5cecfc 100644 --- a/integration-tests/context.test.ts +++ b/integration-tests/context.test.ts @@ -5,8 +5,8 @@ import { attachComponent, createElement, createState, - createRef, createContext, + type State, } from "../src"; describe("Context", () => { @@ -82,4 +82,157 @@ describe("Context", () => { "context value is 5" ); }); + + test("conditionally rendered components have access to Context", async () => { + const user = userEvent.setup(); + const exampleContext = createContext(); + + function App() { + const showState = createState(false); + return createElement("div", { + children: [ + createElement("button", { + "data-testid": "button", + onClick: () => showState.setValue((value) => !value), + }), + showState.useValue((shouldShow) => + shouldShow ? createElement(NestedComponent) : null + ), + ], + }); + } + + function NestedComponent() { + const exampleValue = exampleContext.readContext(); + return createElement("div", { + "data-testid": "container", + children: [`value is ${exampleValue}`], + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement(exampleContext.Provider, { + value: 7, + children: createElement(App), + }), + }); + + await user.click(screen.getByTestId("button")); + + expect(screen.getByTestId("container").textContent).toBe("value is 7"); + }); + + it("newly added elements in useValueIterator have access to Context", async () => { + const user = userEvent.setup(); + type Item = { id: number; text: string; value: number }; + const item1: Item = { id: 1, text: "first item", value: 1 }; + const item2: Item = { id: 2, text: "second item", value: 2 }; + const item3: Item = { id: 3, text: "third item", value: 3 }; + + const exampleContext = createContext(); + + const itemsState = createState([item1, item2]); + function App() { + return createElement("div", { + children: [ + createElement("div", { + "data-testid": "container", + children: itemsState.useValueIterator( + { key: "id" }, + ({ elementState }) => createElement(Item, { elementState }) + ), + }), + ], + }); + } + + function Item({ elementState }: { elementState: State }) { + const exampleValue = exampleContext.readContext(); + + return createElement("div", { + children: [ + elementState.useValueSelector((element) => element.text), + " ", + elementState.useValueSelector( + (element) => element.value, + (value) => String(value * exampleValue) + ), + ], + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement(exampleContext.Provider, { + value: 3, + children: createElement(App), + }), + }); + + const listElement = screen.getByTestId("container"); + expect(listElement.childNodes.length).toBe(2); + expect(listElement.childNodes[0].textContent).toBe("first item 3"); + expect(listElement.childNodes[1].textContent).toBe("second item 6"); + + itemsState.setValue([item1, item2, item3]); + expect(listElement.childNodes.length).toBe(3); + expect(listElement.childNodes[0].textContent).toBe("first item 3"); + expect(listElement.childNodes[1].textContent).toBe("second item 6"); + expect(listElement.childNodes[2].textContent).toBe("third item 9"); + }); + + it("another Context overrides same value for children correctly", () => { + const exampleContext = createContext(); + + function App() { + return createElement("div", { + children: [ + createElement("h1", { children: "Application" }), + createElement(NestedComponent), + ], + }); + } + + function NestedComponent() { + const exampleValue = exampleContext.readContext(); + + return createElement("div", { + children: [ + createElement("div", { + "data-testid": "contextContent", + children: `context value is ${exampleValue}`, + }), + createElement(exampleContext.Provider, { + value: 6, + children: createElement(DoubleNestedComponent), + }), + ], + }); + } + + function DoubleNestedComponent() { + const exampleValue = exampleContext.readContext(); + + return createElement("div", { + "data-testid": "doubleContextContent", + children: `context value is ${exampleValue}`, + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement(exampleContext.Provider, { + value: 5, + children: createElement(App), + }), + }); + + expect(screen.getByTestId("contextContent").textContent).toBe( + "context value is 5" + ); + expect(screen.getByTestId("doubleContextContent").textContent).toBe( + "context value is 6" + ); + }); }); diff --git a/src/create-state/index.ts b/src/create-state/index.ts index 2ff24ce..88908e8 100644 --- a/src/create-state/index.ts +++ b/src/create-state/index.ts @@ -130,7 +130,9 @@ function createState( indexState: State; }) => VelesElement | VelesComponentObject ) { + const currentContext = getCurrentContext(); const trackingParams = {} as TrackingIterator; + trackingParams.savedContext = currentContext const wrapperComponent = createElement((_props, componentAPI) => { const children: [ diff --git a/src/create-state/types.d.ts b/src/create-state/types.d.ts index f0a4e21..e635108 100644 --- a/src/create-state/types.d.ts +++ b/src/create-state/types.d.ts @@ -114,6 +114,7 @@ export type TrackingIterator = { }; }; wrapperComponent: VelesElement | VelesComponentObject; + savedContext: ComponentContext; }; export type StateTrackers = { diff --git a/src/create-state/update-usevalue-selector-value.ts b/src/create-state/update-usevalue-selector-value.ts index fe19374..4da8f34 100644 --- a/src/create-state/update-usevalue-selector-value.ts +++ b/src/create-state/update-usevalue-selector-value.ts @@ -41,13 +41,13 @@ function updateUseValueSelector({ : newSelectedValue == undefined ? "" : String(newSelectedValue); - popPublicContext(); const newNode = !returnednewNode || typeof returnednewNode === "string" ? createTextElement(returnednewNode as string) : returnednewNode; const newRenderedNode = renderTree(newNode); + popPublicContext(); newNode.executedVersion = newRenderedNode; // `executedVersion` is added when we convert it to tree. It doesn't have diff --git a/src/create-state/update-usevalueiterator-value.ts b/src/create-state/update-usevalueiterator-value.ts index 6bbc040..6795caf 100644 --- a/src/create-state/update-usevalueiterator-value.ts +++ b/src/create-state/update-usevalueiterator-value.ts @@ -4,6 +4,7 @@ import { callUnmountHandlers, getExecutedComponentVelesNode, } from "../_utils"; +import { addPublicContext, popPublicContext } from "../context"; import type { ExecutedVelesComponent, @@ -33,6 +34,7 @@ function updateUseValueIteratorValue({ elementsByKey, wrapperComponent, selector, + savedContext } = trackingIterator; if (!wrapperComponent) { console.error("there is no wrapper component for the iterator"); @@ -124,6 +126,7 @@ function updateUseValueIteratorValue({ } else { const elementState = createState(element); const indexState = createState(index); + addPublicContext(savedContext) const node = cb({ elementState, indexState }); // this TypeScript conversion should always be correct, because `node` is // also either a component or an element @@ -131,6 +134,7 @@ function updateUseValueIteratorValue({ | ExecutedVelesComponent | ExecutedVelesElement; node.executedVersion = renderedNode; + popPublicContext() newRenderedElements.push([node, calculatedKey, elementState]); newElementsByKey[calculatedKey] = { From dbdc664683f97a6190b38497387d4b106a58519b Mon Sep 17 00:00:00 2001 From: Seva Zaikov Date: Tue, 11 Jun 2024 04:59:33 -0700 Subject: [PATCH 8/8] small Context types change --- integration-tests/context.test.ts | 18 +++++++++--------- src/context/index.ts | 2 +- src/types.d.ts | 2 -- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/integration-tests/context.test.ts b/integration-tests/context.test.ts index a5cecfc..f4ac547 100644 --- a/integration-tests/context.test.ts +++ b/integration-tests/context.test.ts @@ -21,11 +21,14 @@ describe("Context", () => { const exampleContext = createContext(); function App() { - return createElement("div", { - children: [ - createElement("h1", { children: "Application" }), - createElement(NestedComponent), - ], + return createElement(exampleContext.Provider, { + value: 5, + children: createElement("div", { + children: [ + createElement("h1", { children: "Application" }), + createElement(NestedComponent), + ], + }), }); } @@ -40,10 +43,7 @@ describe("Context", () => { cleanup = attachComponent({ htmlElement: document.body, - component: createElement(exampleContext.Provider, { - value: 5, - children: createElement(App), - }), + component: createElement(App), }); expect(screen.getByTestId("contextContent").textContent).toBe( diff --git a/src/context/index.ts b/src/context/index.ts index d278509..38d9a42 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -28,7 +28,7 @@ function createContext() { } } return { - Provider: ({ value, children }: { value: T; children: VelesChildren }) => { + Provider: ({ value, children }: { value: T; children?: VelesChildren }) => { addContext(value); return createElement(Fragment, { children }); }, diff --git a/src/types.d.ts b/src/types.d.ts index 0811b02..8dca996 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,5 +1,3 @@ -import { VelesDOMElementProps } from "./dom-types"; - import type { JSX } from "./jsx.d.ts"; // an internal representation of DOM nodes in the tree