Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix bug with conditional returns in useValue #38

Merged
merged 1 commit into from
Jun 4, 2024
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
128 changes: 128 additions & 0 deletions integration-tests/create-state/create-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,132 @@ describe("createState", () => {
await user.click(btn);
expect(screen.getByTestId("container").textContent).toBe("new title");
});

test("supports state changes correctly when conditional is return true/false/true", async () => {
let newValue = "";
const user = userEvent.setup();
function StateComponent() {
const titleState = createState({ title: "title" });
return createElement("div", {
children: [
createElement("button", {
"data-testid": "button",
onClick: () => {
titleState.setValue({ title: newValue });
},
}),
createElement("div", {
"data-testid": "container",
children: titleState.useValueSelector(
(data) => data.title.length > 3,
(isLong) =>
isLong
? createElement(ConditionalComponent, { state: titleState })
: null
),
}),
],
});
}

function ConditionalComponent({
state,
}: {
state: State<{ title: string }>;
}) {
return createElement("div", {
children: [
createElement("div", {
"data-testid": "text",
children: state.useValue(
(value) => `length is ${value.title.length}`
),
}),
],
});
}

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

expect(screen.getByTestId("text").textContent).toBe("length is 5");
const btn = screen.getByTestId("button");

newValue = "";
await user.click(btn);

newValue = "new title";
await user.click(btn);
expect(screen.getByTestId("text").textContent).toBe("length is 9");

newValue = "another new title";
await user.click(btn);
expect(screen.getByTestId("text").textContent).toBe("length is 17");
});

test("supports state changes correctly when conditional is not rendered initially", async () => {
let newValue = "";
const user = userEvent.setup();
function StateComponent() {
const titleState = createState({ title: "" });
return createElement("div", {
children: [
createElement("button", {
"data-testid": "button",
onClick: () => {
titleState.setValue({ title: newValue });
},
}),
createElement("div", {
"data-testid": "container",
children: titleState.useValueSelector(
(data) => data.title.length > 3,
(isLong) =>
isLong
? createElement(ConditionalComponent, { state: titleState })
: null
),
}),
],
});
}

function ConditionalComponent({
state,
}: {
state: State<{ title: string }>;
}) {
return createElement("div", {
children: [
createElement("div", {
"data-testid": "text",
children: state.useValue(
(value) => `length is ${value.title.length}`
),
}),
],
});
}

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

const btn = screen.getByTestId("button");

newValue = "title";
await user.click(btn);
expect(screen.getByTestId("text").textContent).toBe("length is 5");

newValue = "new title";
await user.click(btn);
expect(screen.getByTestId("text").textContent).toBe("length is 9");

newValue = "another new title";
await user.click(btn);
expect(screen.getByTestId("text").textContent).toBe("length is 17");
});
});
138 changes: 73 additions & 65 deletions src/hooks/create-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getComponentVelesNode, callMountHandlers, identity } from "../utils";
import {
getComponentVelesNode,
callMountHandlers,
identity,
unique,
} from "../utils";
import { onUnmount, onMount } from "./lifecycle";
import { createElement } from "../create-element/create-element";
import { createTextElement } from "../create-element/create-text-element";
Expand Down Expand Up @@ -344,73 +349,76 @@ function createState<T>(
// TODO: remove it from this object completely
// and access it from closure
_triggerUpdates: () => {
trackingSelectorElements = trackingSelectorElements.map(
(selectorTrackingElement) => {
const { selectedValue, selector, cb, node, comparator } =
selectorTrackingElement;
const newSelectedValue = selector ? selector(value) : value;

if (comparator(selectedValue, newSelectedValue)) {
return selectorTrackingElement;
}
const newTrackingSelectorElements: typeof trackingSelectorElements = [];
trackingSelectorElements.forEach((selectorTrackingElement) => {
const { selectedValue, selector, cb, node, comparator } =
selectorTrackingElement;
const newSelectedValue = selector ? selector(value) : value;

const returnednewNode = cb
? cb(newSelectedValue)
: String(newSelectedValue);
const newNode =
!returnednewNode || typeof returnednewNode === "string"
? createTextElement(returnednewNode as string)
: returnednewNode;

const { velesElementNode: oldVelesElementNode } =
getComponentVelesNode(node);
const { velesElementNode: newVelesElementNode } =
getComponentVelesNode(newNode);

const parentVelesElement = oldVelesElementNode.parentVelesElement;

const newTrackingSelectorElement = {
selector,
selectedValue: newSelectedValue,
cb,
node: newNode,
comparator,
};

if (parentVelesElement) {
newVelesElementNode.parentVelesElement = parentVelesElement;
parentVelesElement.html.replaceChild(
newVelesElementNode.html,
oldVelesElementNode.html
);
// 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 : node
);
// we call unmount handlers right after we replace it
node._privateMethods._callUnmountHandlers();
// at this point the new Node is mounted, childComponents are updated
// and unmount handlers for the old node are called
callMountHandlers(newNode);

// right after that, we add the callback back
// the top level node is guaranteed to be rendered again (at least right now)
// if there were children listening, they should be cleared
// and added back into their respective unmount listeners if it is still viable
newNode._privateMethods._addUnmountHandler(() => {
trackingSelectorElements = trackingSelectorElements.filter(
(el) => el !== newTrackingSelectorElement
);
});
} else {
console.log("parent node was not found");
}
if (comparator(selectedValue, newSelectedValue)) {
newTrackingSelectorElements.push(selectorTrackingElement);
return;
}

const returnednewNode = cb
? cb(newSelectedValue)
: String(newSelectedValue);
const newNode =
!returnednewNode || typeof returnednewNode === "string"
? createTextElement(returnednewNode as string)
: returnednewNode;

const { velesElementNode: oldVelesElementNode } =
getComponentVelesNode(node);
const { velesElementNode: newVelesElementNode } =
getComponentVelesNode(newNode);
const parentVelesElement = oldVelesElementNode.parentVelesElement;

const newTrackingSelectorElement = {
selector,
selectedValue: newSelectedValue,
cb,
node: newNode,
comparator,
};

return newTrackingSelectorElement;
if (parentVelesElement) {
newVelesElementNode.parentVelesElement = parentVelesElement;
parentVelesElement.html.replaceChild(
newVelesElementNode.html,
oldVelesElementNode.html
);
// 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 : node
);
// we call unmount handlers right after we replace it
node._privateMethods._callUnmountHandlers();
// at this point the new Node is mounted, childComponents are updated
// and unmount handlers for the old node are called
callMountHandlers(newNode);

// right after that, we add the callback back
// the top level node is guaranteed to be rendered again (at least right now)
// if there were children listening, they should be cleared
// and added back into their respective unmount listeners if it is still viable
newNode._privateMethods._addUnmountHandler(() => {
trackingSelectorElements = trackingSelectorElements.filter(
(el) => el !== newTrackingSelectorElement
);
});
} else {
console.log("parent node was not found");
}

newTrackingSelectorElements.push(newTrackingSelectorElement);
});

trackingSelectorElements = unique(
trackingSelectorElements.concat(newTrackingSelectorElements)
);

// attributes
Expand Down
17 changes: 16 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,19 @@ function identity<T>(value1: T, value2: T) {
return value1 === value2;
}

export { getComponentVelesNode, identity, callMountHandlers };
// return an array with elements being there only one time total
// the first encountered value will be preserved
function unique<T>(arr: T[]): T[] {
const map = new Map<T, true>();
const resultArr: T[] = [];
arr.forEach((element) => {
if (map.has(element)) return;

map.set(element, true);
resultArr.push(element);
});

return resultArr;
}

export { getComponentVelesNode, identity, callMountHandlers, unique };