Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions integration-tests/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,15 @@ describe("Context", () => {
});

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");
expect(listElement.children.length).toBe(2);
expect(listElement.children[0].textContent).toBe("first item 3");
expect(listElement.children[1].textContent).toBe("second item 6");

items$.set([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");
expect(listElement.children.length).toBe(3);
expect(listElement.children[0].textContent).toBe("first item 3");
expect(listElement.children[1].textContent).toBe("second item 6");
expect(listElement.children[2].textContent).toBe("third item 9");
});

it("another Context overrides same value for children correctly", () => {
Expand Down
163 changes: 137 additions & 26 deletions integration-tests/create-state/reach-each.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { screen } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";

import { attachComponent, createElement, createState, onUnmount } from "../../src";
import { attachComponent, createElement, createState, Fragment, onUnmount } from "../../src";

import type { State } from "../../src";

Expand Down Expand Up @@ -83,10 +83,10 @@ describe("state.renderEach", () => {
});

const listElement = screen.getByTestId("listComponent");
expect(listElement.childNodes.length).toBe(3);
const firstListElement = listElement.childNodes[0];
const secondListElement = listElement.childNodes[1];
const thirdListElement = listElement.childNodes[2];
expect(listElement.children.length).toBe(3);
const firstListElement = listElement.children[0];
const secondListElement = listElement.children[1];
const thirdListElement = listElement.children[2];
expect(textSpy).toHaveBeenCalledTimes(3);

expect(firstListElement.textContent).toBe(item1.text);
Expand All @@ -95,11 +95,11 @@ describe("state.renderEach", () => {

await user.click(screen.getByTestId("updateFirstItem"));
expect(unmountSpy).not.toHaveBeenCalled();
expect(listElement.childNodes[0].textContent).toBe("updated first value");
expect(listElement.children[0].textContent).toBe("updated first value");
expect(textSpy).toHaveBeenCalledTimes(4);

await user.click(screen.getByTestId("updateArrayButton"));
expect(listElement.childNodes.length).toBe(4);
expect(listElement.children.length).toBe(4);
expect(unmountSpy).toHaveBeenCalledTimes(1);
expect(textSpy).toHaveBeenCalledTimes(7);
});
Expand Down Expand Up @@ -178,10 +178,10 @@ describe("state.renderEach", () => {
});

const listElement = screen.getByTestId("listComponent");
expect(listElement.childNodes.length).toBe(3);
const firstListElement = listElement.childNodes[0];
const secondListElement = listElement.childNodes[1];
const thirdListElement = listElement.childNodes[2];
expect(listElement.children.length).toBe(3);
const firstListElement = listElement.children[0];
const secondListElement = listElement.children[1];
const thirdListElement = listElement.children[2];

expect(firstListElement.textContent).toBe(item1.text);
expect(secondListElement.textContent).toBe(item3.text);
Expand All @@ -191,11 +191,11 @@ describe("state.renderEach", () => {

await user.click(screen.getByTestId("updateFirstItem"));
expect(unmountSpy).not.toHaveBeenCalled();
expect(listElement.childNodes[0].textContent).toBe("updated first value");
expect(listElement.children[0].textContent).toBe("updated first value");
expect(textSpy).toHaveBeenCalledTimes(4);

await user.click(screen.getByTestId("updateArrayButton"));
expect(listElement.childNodes.length).toBe(4);
expect(listElement.children.length).toBe(4);
expect(unmountSpy).toHaveBeenCalledTimes(1);
expect(textSpy).toHaveBeenCalledTimes(7);
});
Expand Down Expand Up @@ -259,7 +259,7 @@ describe("state.renderEach", () => {
expect(textSpy).toHaveBeenCalledTimes(5);
expect(indexSpy).toHaveBeenCalledTimes(5);
const container = screen.getByTestId("container");
const children = container.childNodes;
const children = container.children;
expect(children.length).toBe(5);
expect(children[0].textContent).toBe("0.first item");
expect(children[1].textContent).toBe("1.second item");
Expand Down Expand Up @@ -311,12 +311,123 @@ describe("state.renderEach", () => {
});

const list = screen.getByTestId("list");
expect(list.childNodes.length).toBe(0);
expect(list.children.length).toBe(0);

await user.click(screen.getByTestId("button"));

expect(list.childNodes.length).toBe(1);
expect(list.childNodes[0].textContent).toBe("first item");
expect(list.children.length).toBe(1);
expect(list.children[0].textContent).toBe("first item");
});

test("inserts the first item at the empty iterator's original position", () => {
type Item = { id: number; text: string };
const items$ = createState<Item[]>([]);

function App() {
return createElement("main", {
"data-testid": "container",
children: [
createElement("i", { children: "before" }),
items$.renderEach({ key: "id" }, ({ elementState: element$ }) =>
createElement("b", {
children: element$.renderSelected((element) => element.text),
}),
),
createElement("i", { children: "after" }),
],
});
}

cleanup = attachComponent({
htmlElement: document.body,
component: createElement(App),
});

const container = screen.getByTestId("container");
expect(container.innerHTML).toBe("<i>before</i><i>after</i>");

items$.set([{ id: 1, text: "item" }]);

expect(container.innerHTML).toBe("<i>before</i><b>item</b><i>after</i>");

items$.set([]);
expect(container.innerHTML).toBe("<i>before</i><i>after</i>");

items$.set([{ id: 2, text: "second item" }]);
expect(container.innerHTML).toBe("<i>before</i><b>second item</b><i>after</i>");
});

test("renders Fragment-rooted iterator items without a phantom element", () => {
type Item = { id: number; text: string };
const items$ = createState<Item[]>([{ id: 1, text: "item" }]);

function App() {
return createElement("ul", {
"data-testid": "list",
children: items$.renderEach({ key: "id" }, ({ elementState: element$ }) =>
createElement(Fragment, {
children: createElement("li", {
children: element$.renderSelected((element) => element.text),
}),
}),
),
});
}

cleanup = attachComponent({
htmlElement: document.body,
component: createElement(App),
});

expect(screen.getByTestId("list").innerHTML).toBe("<li>item</li>");
});

test("supports numeric zero as a renderEach key", () => {
type Item = { id: number; text: string };
const items$ = createState<Item[]>([{ id: 0, text: "zero" }]);

function App() {
return createElement("ul", {
"data-testid": "list",
children: items$.renderEach({ key: "id" }, ({ elementState: element$ }) =>
createElement("li", {
children: element$.renderSelected((element) => element.text),
}),
),
});
}

cleanup = attachComponent({
htmlElement: document.body,
component: createElement(App),
});

expect(screen.getByTestId("list").innerHTML).toBe("<li>zero</li>");
});

test.each(["constructor", "__proto__", "toString"])("supports %s as a renderEach key", (key) => {
type Item = { id: string; text: string };
const items$ = createState<Item[]>([]);

function App() {
return createElement("ul", {
"data-testid": "list",
children: items$.renderEach({ key: "id" }, ({ elementState: element$ }) =>
createElement("li", {
children: element$.renderSelected((element) => element.text),
}),
),
});
}

cleanup = attachComponent({
htmlElement: document.body,
component: createElement(App),
});

items$.set([{ id: key, text: key }]);

expect(screen.getByTestId("list").innerHTML).toBe(`<li>${key}</li>`);
});

test("renderEach supports removing last element and adding a new one", async () => {
Expand Down Expand Up @@ -356,17 +467,17 @@ describe("state.renderEach", () => {
});

const list = screen.getByTestId("list");
expect(list.childNodes.length).toBe(1);
expect(list.childNodes[0].textContent).toBe("first item");
expect(list.children.length).toBe(1);
expect(list.children[0].textContent).toBe("first item");

await user.click(screen.getByTestId("removeButton"));

expect(list.childNodes.length).toBe(0);
expect(list.children.length).toBe(0);

await user.click(screen.getByTestId("addButton"));

expect(list.childNodes.length).toBe(1);
expect(list.childNodes[0].textContent).toBe("second item");
expect(list.children.length).toBe(1);
expect(list.children[0].textContent).toBe("second item");
});

test("renderEach does not update until mounted", async () => {
Expand Down Expand Up @@ -438,12 +549,12 @@ describe("state.renderEach", () => {
expect(indexSpy).toHaveBeenCalledTimes(0);

const container = screen.getByTestId("container");
const children = container.childNodes;
const children = container.children;

items$.set([item4, item2, item3, item1]);

// empty Text node
expect(children.length).toBe(1);
expect(container.childNodes.length).toBe(1);

expect(textSpy).toHaveBeenCalledTimes(0);
expect(indexSpy).toHaveBeenCalledTimes(0);
Expand Down Expand Up @@ -473,7 +584,7 @@ describe("state.renderEach", () => {
await user.click(screen.getByTestId("button"));

// empty Text node
expect(children.length).toBe(1);
expect(container.childNodes.length).toBe(1);

items$.set([item4, item5, item3, { ...item1, text: "1st item" }, item2, item6]);

Expand All @@ -496,7 +607,7 @@ describe("state.renderEach", () => {
await user.click(screen.getByTestId("button"));

// empty Text node
expect(children.length).toBe(1);
expect(container.childNodes.length).toBe(1);

items$.set([item3, item6]);

Expand Down
36 changes: 23 additions & 13 deletions src/create-state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import { StateCore, createCoreEquality, emptyValue } from "./state-core";

import type { VelesElement, VelesComponentObject, VelesStringElement } from "../types";

import type { State, TrackingIterator, StateTrackers, TrackingSelectorElement } from "./types";
import type {
IteratorKey,
State,
TrackingIterator,
StateTrackers,
TrackingSelectorElement,
} from "./types";

const STATE_CORE_PROPERTY = "__velesStateCore";

Expand Down Expand Up @@ -219,7 +225,7 @@ function createStateFromCore<T>(
*/
renderEach<Element>(
options: {
key: string | ((options: { element: any; index: number }) => string);
key: string | ((options: { element: any; index: number }) => IteratorKey);
selector?: (value: T) => Element[];
},
cb: (props: {
Expand All @@ -232,15 +238,19 @@ function createStateFromCore<T>(
trackingParams.savedContext = currentContext;

const wrapperComponent = createElement((_props, componentAPI) => {
const children: [VelesElement | VelesComponentObject, string, State<Element>][] = [];
const elementsByKey: {
[key: string]: {
const children: [VelesElement | VelesComponentObject, IteratorKey, State<Element>][] = [];
const anchor = createTextElement("");
anchor.needExecutedVersion = true;
trackingParams.anchor = anchor;
const elementsByKey = new Map<
IteratorKey,
{
elementState: State<Element>;
indexState: State<number>;
indexValue: number;
node: VelesElement | VelesComponentObject;
};
} = {};
}
>();
const stateValue = core.get() as T;
const elements = options.selector ? options.selector(stateValue) : stateValue;

Expand All @@ -252,14 +262,14 @@ function createStateFromCore<T>(
(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 = "";
let calculatedKey: IteratorKey | null = null;
if (
typeof options.key === "string" &&
typeof element === "object" &&
element !== null &&
options.key in element
) {
calculatedKey = (element as Record<string, string>)[options.key];
calculatedKey = (element as Record<string, IteratorKey>)[options.key];
} else if (typeof options.key === "function") {
calculatedKey = options.key({ element, index });
} else {
Expand All @@ -269,19 +279,19 @@ function createStateFromCore<T>(
const elementState = createState(element);
const indexState = createState(index);

if (!calculatedKey) {
if (calculatedKey == null) {
return;
}

let node = cb({ elementState, indexState });
node.needExecutedVersion = true;

elementsByKey[calculatedKey] = {
elementsByKey.set(calculatedKey, {
node,
indexState,
indexValue: index,
elementState,
};
});

children.push([node, calculatedKey, elementState]);
});
Expand All @@ -298,7 +308,7 @@ function createStateFromCore<T>(
});
return createElement("div", {
phantom: true,
children: children.map((child) => child[0]),
children: [...children.map((child) => child[0]), anchor],
});
});

Expand Down
Loading
Loading