Skip to content

Commit

Permalink
do not track attributes value until mounted (#58)
Browse files Browse the repository at this point in the history
  • Loading branch information
Bloomca authored Jun 9, 2024
1 parent e080257 commit f7f5d63
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 74 deletions.
35 changes: 0 additions & 35 deletions integration-tests/create-state/create-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,41 +276,6 @@ describe("createState", () => {
expect(unmountSpy).toHaveBeenCalledTimes(3);
});

test("useAttribute does not re-mount the component", async () => {
const user = userEvent.setup();
const spyFn = jest.fn();
function StateComponent() {
onUnmount(spyFn);
const valueState = createState(0);
return createElement("div", {
children: [
createElement("button", {
"data-testvalue": valueState.useAttribute((value) => String(value)),
"data-testid": "button",
onClick: () => {
valueState.setValue((currentValue) => currentValue + 1);
},
}),
],
});
}

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

const btn = screen.getByTestId("button");
expect(btn).toHaveAttribute("data-testvalue", "0");

await user.click(btn);
expect(btn).toHaveAttribute("data-testvalue", "1");

await user.click(btn);
expect(btn).toHaveAttribute("data-testvalue", "2");
expect(spyFn).not.toHaveBeenCalled();
});

test("supports strings as returned value in useValue", async () => {
const user = userEvent.setup();
function StateComponent() {
Expand Down
109 changes: 109 additions & 0 deletions integration-tests/create-state/use-attribute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { screen } from "@testing-library/dom";
import userEvent from "@testing-library/user-event";

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

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

describe("createState", () => {
let cleanup: Function | undefined;

afterEach(() => {
cleanup?.();
cleanup = undefined;
});

test("useAttribute does not re-mount the component", async () => {
const user = userEvent.setup();
const spyFn = jest.fn();
function StateComponent() {
onUnmount(spyFn);
const valueState = createState(0);
return createElement("div", {
children: [
createElement("button", {
"data-testvalue": valueState.useAttribute((value) => String(value)),
"data-testid": "button",
onClick: () => {
valueState.setValue((currentValue) => currentValue + 1);
},
}),
],
});
}

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

const btn = screen.getByTestId("button");
expect(btn).toHaveAttribute("data-testvalue", "0");

await user.click(btn);
expect(btn).toHaveAttribute("data-testvalue", "1");

await user.click(btn);
expect(btn).toHaveAttribute("data-testvalue", "2");
expect(spyFn).not.toHaveBeenCalled();
});

test("does not track updates in useAttribute until mounted", async () => {
const user = userEvent.setup();
const spyFn = jest.fn();

const valueState = createState("initialValue");
function App() {
const showState = createState(false);
const content = createElement("div", {
"data-testid": "attributeTest",
"data-value": valueState.useAttribute((value) => {
spyFn();
return value;
}),
});
return createElement("div", {
children: [
createElement("div", {
children: "whatever",
}),
createElement("button", {
"data-testid": "button",
onClick: () => showState.setValue((currentValue) => !currentValue),
}),
showState.useValue((shouldShow) => (shouldShow ? content : null)),
],
});
}

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

expect(spyFn).toHaveBeenCalledTimes(1);
valueState.setValue("newValue1");
expect(spyFn).toHaveBeenCalledTimes(1);

await user.click(screen.getByTestId("button"));
expect(spyFn).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("attributeTest").getAttribute("data-value")).toBe(
"newValue1"
);
valueState.setValue("newValue2");
expect(spyFn).toHaveBeenCalledTimes(3);
expect(screen.getByTestId("attributeTest").getAttribute("data-value")).toBe(
"newValue2"
);

// remove the element again to see that subscriptions are correctly removed
await user.click(screen.getByTestId("button"));
valueState.setValue("newValue3");
expect(spyFn).toHaveBeenCalledTimes(3);
});
});
5 changes: 2 additions & 3 deletions src/create-element/parse-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { createTextElement } from "./create-text-element";

import type {
VelesComponent,
VelesStringElement,
VelesElementProps,
ComponentAPI,
ComponentFunction,
Expand Down Expand Up @@ -55,7 +54,7 @@ function parseComponent({
componentAPI.onUnmount(mountCbResult);
}
});
// componentMountCbs = [];
componentMountCbs = [];
},
_callUnmountHandlers: () => {
// this should trigger recursive checks, whether it is a VelesNode or VelesComponent
Expand All @@ -66,7 +65,7 @@ function parseComponent({

// we execute own unmount callbacks after children, so the order is reversed
componentUnmountCbs.forEach((cb) => cb());
// componentUnmountCbs = [];
componentUnmountCbs = [];
},
},
};
Expand Down
21 changes: 16 additions & 5 deletions src/create-state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createElement } from "../create-element/create-element";
import { createTextElement } from "../create-element/create-text-element";
import { triggerUpdates } from "./trigger-updates";
import { updateUseValueSelector } from "./update-usevalue-selector-value";
import { updateUseAttributeValue } from "./update-useattribute-value";

import type {
VelesElement,
Expand Down Expand Up @@ -237,6 +238,7 @@ function createState<T>(
// It should be a separate subscription.
},
useAttribute: (cb?: (value: T) => any) => {
const originalValue = value;
const attributeValue = cb ? cb(value) : value;

const attributeHelper = (
Expand All @@ -254,12 +256,21 @@ function createState<T>(
attributeName,
attributeValue,
};
trackers.trackingAttributes.push(trackingElement);

node._privateMethods._addUnmountHandler(() => {
trackers.trackingAttributes = trackers.trackingAttributes.filter(
(trackingAttribute) => trackingAttribute !== trackingElement
);
node._privateMethods._addMountHandler(() => {
trackers.trackingAttributes.push(trackingElement);

if (value !== originalValue) {
// since the `element` will be modified in place, we don't need to
// replace it in the array or anything
updateUseAttributeValue({ element: trackingElement, value });
}

node._privateMethods._addUnmountHandler(() => {
trackers.trackingAttributes = trackers.trackingAttributes.filter(
(trackingAttribute) => trackingAttribute !== trackingElement
);
});
});

return attributeValue;
Expand Down
32 changes: 2 additions & 30 deletions src/create-state/trigger-updates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getComponentVelesNode, callMountHandlers, unique } from "../_utils";
import { updateUseValueSelector } from "./update-usevalue-selector-value";
import { updateUseAttributeValue } from "./update-useattribute-value";

import type { VelesElement, VelesComponent } from "../types";
import type {
Expand Down Expand Up @@ -35,36 +36,7 @@ function triggerUpdates<T>({
// attributes
// the HTML node does not change, so we don't need to modify the array
trackers.trackingAttributes.forEach((element) => {
const { cb, htmlElement, attributeName, attributeValue } = element;
const newAttributeValue = cb ? cb(value) : value;

if (typeof newAttributeValue === "boolean") {
if (newAttributeValue) {
htmlElement.setAttribute(attributeName, "");
} else {
htmlElement.removeAttribute(attributeName);
}
} else if (attributeName.startsWith("on")) {
// if the value is the same, it is either not set
// or we received the same event handler
// either way, no need to do anything
if (attributeValue === newAttributeValue) {
return;
}

const eventName =
attributeName[2].toLocaleLowerCase() + attributeName.slice(3);
if (attributeValue) {
htmlElement.removeEventListener(eventName, attributeValue);
}
if (newAttributeValue && typeof newAttributeValue === "function") {
htmlElement.addEventListener(eventName, newAttributeValue);
}
// not the best approach, but it should work as expected
element.attributeValue = newAttributeValue;
} else {
htmlElement.setAttribute(attributeName, newAttributeValue);
}
updateUseAttributeValue({ element, value });
});

// tracked values
Expand Down
2 changes: 1 addition & 1 deletion src/create-state/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export type TrackingSelectorElement = {
node: VelesElement | VelesComponent | VelesStringElement;
};

type TrackingAttribute = {
export type TrackingAttribute = {
cb?: Function;
htmlElement: HTMLElement;
attributeName: string;
Expand Down
43 changes: 43 additions & 0 deletions src/create-state/update-useattribute-value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { TrackingAttribute } from "./types";

function updateUseAttributeValue<T>({
element,
value,
}: {
element: TrackingAttribute;
value: T;
}) {
const { cb, htmlElement, attributeName, attributeValue } = element;
const newAttributeValue = cb ? cb(value) : value;

if (typeof newAttributeValue === "boolean") {
if (newAttributeValue) {
htmlElement.setAttribute(attributeName, "");
} else {
htmlElement.removeAttribute(attributeName);
}
} else if (attributeName.startsWith("on")) {
// if the value is the same, it is either not set
// or we received the same event handler
// either way, no need to do anything
if (attributeValue === newAttributeValue) {
return;
}

const eventName =
attributeName[2].toLocaleLowerCase() + attributeName.slice(3);
if (attributeValue) {
htmlElement.removeEventListener(eventName, attributeValue);
}
if (newAttributeValue && typeof newAttributeValue === "function") {
htmlElement.addEventListener(eventName, newAttributeValue);
}
// not the best approach, but it should work as expected
// basically, update the array value in-place
element.attributeValue = newAttributeValue;
} else {
htmlElement.setAttribute(attributeName, newAttributeValue);
}
}

export { updateUseAttributeValue };

0 comments on commit f7f5d63

Please sign in to comment.