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..f4ac547 --- /dev/null +++ b/integration-tests/context.test.ts @@ -0,0 +1,238 @@ +import { screen } from "@testing-library/dom"; +import userEvent from "@testing-library/user-event"; + +import { + attachComponent, + createElement, + createState, + createContext, + type State, +} 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(exampleContext.Provider, { + value: 5, + children: 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" + ); + }); + + 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" + ); + }); + + 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/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 e23174d..ec9f0ec 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(() => { @@ -27,6 +27,7 @@ describe("createState", () => { 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("createState", () => { children: [ elementState.useValueSelector( (element) => element.text, - (text) => createElement("span", { children: text }) + (text) => { + textSpy() + return createElement("span", { children: text }) + } ), ], }); @@ -88,6 +92,7 @@ describe("createState", () => { 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("createState", () => { 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("createState", () => { 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("createState", () => { children: [ elementState.useValueSelector( (element) => element.text, - (text) => createElement("span", { children: text }) + (text) => { + textSpy() + return createElement("span", { children: text }) + } ), ], }); @@ -181,13 +192,17 @@ describe("createState", () => { 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("createState", () => { 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("createState", () => { 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("createState", () => { 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("createState", () => { 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"); @@ -329,8 +357,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,13 +370,13 @@ 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")); 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"); @@ -356,7 +386,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"); @@ -380,12 +410,13 @@ describe("createState", () => { ]); expect(textSpy).toHaveBeenCalledTimes(5); - expect(indexSpy).toHaveBeenCalledTimes(7); + expect(indexSpy).toHaveBeenCalledTimes(6); await user.click(screen.getByTestId("button")); - expect(textSpy).toHaveBeenCalledTimes(7); - expect(indexSpy).toHaveBeenCalledTimes(8); + expect(textSpy).toHaveBeenCalledTimes(11); + expect(indexSpy).toHaveBeenCalledTimes(12); + 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 +433,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/integration-tests/new-renderer.test.ts b/integration-tests/new-renderer.test.ts new file mode 100644 index 0000000..4983cc1 --- /dev/null +++ b/integration-tests/new-renderer.test.ts @@ -0,0 +1,446 @@ +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" + ); + }); + + 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 79cb0c1..d4ddbeb 100644 --- a/src/_utils.ts +++ b/src/_utils.ts @@ -1,57 +1,243 @@ -import type { VelesComponent, VelesElement, VelesStringElement } from "./types"; +import { executeComponent } from "./create-element/parse-component"; +import { addPublicContext, popPublicContext } from "./context"; -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) { + addPublicContext(); + const componentTree = executeComponent(component); + const executedComponent = {} as ExecutedVelesComponent; + executedComponent.executedVelesComponent = true; + executedComponent.tree = renderTree(componentTree.tree); + popPublicContext(); + 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) { + const lastNode = insertNode({ + velesElement: newNode, + adjacentNode: component.insertAfter.html, + parentVelesElement, + }); + component.html = lastNode; + } 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 +257,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/context/index.ts b/src/context/index.ts new file mode 100644 index 0000000..38d9a42 --- /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-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..054b90e 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,17 @@ 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; + childComponent.parentVelesElement = velesNode; + childComponents.push(childComponent); + lastInsertedNode = childComponent; } else if ( typeof childComponent === "object" && childComponent && @@ -119,6 +97,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..88908e8 100644 --- a/src/create-state/index.ts +++ b/src/create-state/index.ts @@ -5,11 +5,11 @@ 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, - VelesComponent, + VelesComponentObject, VelesStringElement, } from "../types"; @@ -83,9 +83,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,12 +98,17 @@ function createState( ? createTextElement(returnedNode as string) : returnedNode; - const trackingSelectorElement = { + const currentContext = getCurrentContext(); + + node.needExecutedVersion = true; + + const trackingSelectorElement: TrackingSelectorElement = { selector, selectedValue, cb, node, comparator, + savedContext: currentContext, }; addUseValueMountHandler({ @@ -123,94 +128,74 @@ function createState( cb: (props: { elementState: State; indexState: State; - }) => VelesElement | VelesComponent + }) => VelesElement | VelesComponentObject ) { - let wasMounted = false; - const originalValue = value; - const children: [ - VelesElement | VelesComponent, - string, - State - ][] = []; - const elementsByKey: { - [key: string]: { - elementState: State; - indexState: State; - indexValue: number; - node: VelesElement | VelesComponent; - }; - } = {}; - - const elements = options.selector ? options.selector(value) : value; - - if (!Array.isArray(elements)) { - console.error("useValueIterator received non-array value"); - return null; - } + const currentContext = getCurrentContext(); + const trackingParams = {} as TrackingIterator; + trackingParams.savedContext = currentContext - (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 }); + 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) => @@ -224,10 +209,10 @@ function createState( }); }); + wrapperComponent.needExecutedVersion = true; + 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 e77522d..e635108 100644 --- a/src/create-state/types.d.ts +++ b/src/create-state/types.d.ts @@ -1,9 +1,10 @@ import type { VelesElement, - VelesComponent, + VelesComponentObject, VelesStringElement, AttributeHelper, } from "../types"; +import type { ComponentContext } from "../context/types"; export type State = { trackValue( @@ -29,19 +30,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 +52,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 +78,12 @@ 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; + savedContext: ComponentContext; }; export type TrackingAttribute = { @@ -95,19 +97,24 @@ export type TrackingIterator = { cb: (props: { elementState: State; indexState: State; - }) => VelesElement | VelesComponent; + }) => VelesElement | VelesComponentObject; selector?: (value: unknown) => any[]; - renderedElements: [VelesElement | VelesComponent, string, State][]; + renderedElements: [ + VelesElement | VelesComponentObject, + string, + State + ][]; key: string | ((options: { element: unknown; index: number }) => string); elementsByKey: { [key: string]: { elementState: State; indexState: State; indexValue: number; - node: VelesElement | VelesComponent; + node: VelesElement | VelesComponentObject; }; }; - wrapperComponent: VelesElement | VelesComponent; + 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 0389f01..4da8f34 100644 --- a/src/create-state/update-usevalue-selector-value.ts +++ b/src/create-state/update-usevalue-selector-value.ts @@ -1,12 +1,17 @@ -import { getComponentVelesNode, callMountHandlers } from "../_utils"; +import { + callMountHandlers, + callUnmountHandlers, + renderTree, + getExecutedComponentVelesNode, +} from "../_utils"; import { createTextElement } from "../create-element/create-text-element"; +import { addPublicContext, popPublicContext } from "../context"; 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, @@ -21,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; @@ -30,6 +35,7 @@ function updateUseValueSelector({ return; } + addPublicContext(savedContext); const returnednewNode = cb ? cb(newSelectedValue) : newSelectedValue == undefined @@ -40,11 +46,26 @@ function updateUseValueSelector({ ? createTextElement(returnednewNode as string) : returnednewNode; - const { velesElementNode: oldVelesElementNode } = getComponentVelesNode(node); - const { velesElementNode: newVelesElementNode } = - getComponentVelesNode(newNode); + const newRenderedNode = renderTree(newNode); + popPublicContext(); + newNode.executedVersion = newRenderedNode; - const parentVelesElement = oldVelesElementNode.parentVelesElement; + // `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 = node.parentVelesElement; + const parentVelesElementRendered = oldVelesElementNode.parentVelesElement; const newTrackingSelectorElement: TrackingSelectorElement = { selector, @@ -52,26 +73,31 @@ function updateUseValueSelector({ cb, node: newNode, comparator, + savedContext, }; - 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 - 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 +112,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 +147,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 ); @@ -146,21 +178,43 @@ 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) => (childComponent === node ? newNode : 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 - node._privateMethods._callUnmountHandlers(); + callUnmountHandlers(node.executedVersion); addUseValueMountHandler({ usedValue: value, @@ -170,7 +224,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) @@ -202,6 +256,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/create-state/update-usevalueiterator-value.ts b/src/create-state/update-usevalueiterator-value.ts index abad118..6795caf 100644 --- a/src/create-state/update-usevalueiterator-value.ts +++ b/src/create-state/update-usevalueiterator-value.ts @@ -1,6 +1,17 @@ -import { getComponentVelesNode, callMountHandlers } from "../_utils"; +import { + callMountHandlers, + renderTree, + callUnmountHandlers, + getExecutedComponentVelesNode, +} from "../_utils"; +import { addPublicContext, popPublicContext } from "../context"; -import type { VelesComponent, VelesElement } from "../types"; +import type { + ExecutedVelesComponent, + ExecutedVelesElement, + VelesComponentObject, + VelesElement, +} from "../types"; import type { TrackingIterator, State, @@ -23,14 +34,21 @@ function updateUseValueIteratorValue({ elementsByKey, wrapperComponent, selector, + savedContext } = trackingIterator; if (!wrapperComponent) { console.error("there is no wrapper component for the iterator"); 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 +63,7 @@ function updateUseValueIteratorValue({ // so we check manually if (Array.isArray(elements)) { const newRenderedElements: [ - VelesElement | VelesComponent, + VelesElement | VelesComponentObject, string, State ][] = []; @@ -54,7 +72,7 @@ function updateUseValueIteratorValue({ elementState: State; indexState: State; indexValue: number; - node: VelesElement | VelesComponent; + node: VelesElement | VelesComponentObject; }; } = {}; @@ -108,7 +126,15 @@ 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 + const renderedNode = renderTree(node) as + | ExecutedVelesComponent + | ExecutedVelesElement; + node.executedVersion = renderedNode; + popPublicContext() newRenderedElements.push([node, calculatedKey, elementState]); newElementsByKey[calculatedKey] = { @@ -141,7 +167,12 @@ function updateUseValueIteratorValue({ // to replace old wrapper's children to make sure they are removed correctly // on `useValue` unmount - const newChildComponents: (VelesComponent | VelesElement)[] = []; + 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 @@ -150,6 +181,7 @@ function updateUseValueIteratorValue({ let offset: number = 0; let currentElement: HTMLElement | Text | null = null; newRenderedElements.forEach((newRenderedElement, index) => { + 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 @@ -161,8 +193,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 +211,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 +232,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 +249,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 +259,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 +277,7 @@ function updateUseValueIteratorValue({ currentElement = newNodeVelesElement.html; newElementsCount = newElementsCount + 1; - callMountHandlers(newNode); + callMountHandlers(newNode.executedVersion); } }); @@ -252,28 +294,40 @@ 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"); } + + if ("velesNode" in wrapperComponent) { + wrapperComponent.childComponents = + wrapperComponent.childComponents.filter( + (childComponent) => childComponent !== oldNode + ); + } } }); } // 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) { - wrapperVelesElementNode.childComponents = newChildComponents; + if ("executedVelesNode" in wrapperVelesElementNode) { + wrapperVelesElementNode.childComponents = newChildRenderedComponents; + } + + if ("velesNode" in wrapperComponent) { + wrapperComponent.childComponents = newChildComponents; } // update the tracking info with new data 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"; diff --git a/src/types.d.ts b/src/types.d.ts index bedfafa..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 @@ -12,9 +10,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 +55,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 @@ -45,8 +90,26 @@ export type VelesStringElement = { // an internal representation of components in the tree export type VelesComponent = { velesComponent: true; + parentVelesElement?: VelesElement; + + 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: VelesElement | VelesComponent | VelesStringElement; + tree: + | ExecutedVelesElement + | ExecutedVelesComponent + | ExecutedVelesStringElement; // not intended to be used directly _privateMethods: { @@ -62,7 +125,7 @@ type velesChild = | string | number | VelesElement - | VelesComponent + | VelesComponentObject | VelesStringElement; export type VelesChildren = velesChild | velesChild[] | undefined | null; @@ -90,9 +153,29 @@ 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; + parentVelesElement?: VelesElement; + + needExecutedVersion?: boolean; + executedVersion?: ExecutedVelesComponent; + + // not intended to be used directly + _privateMethods: { + _addMountHandler: Function; + _callMountHandlers: Function; + _callUnmountHandlers: Function; + _addUnmountHandler: Function; + }; +};