import { Component, Env } from "../../src/component/component";
import { useState } from "../../src/hooks";
import { xml } from "../../src/tags";
import { makeDeferred, makeTestEnv, makeTestFixture, nextTick } from "../helpers";
//------------------------------------------------------------------------------
// Setup and helpers
//------------------------------------------------------------------------------
// We create before each test:
// - fixture: a div, appended to the DOM, intended to be the target of dom
// manipulations. Note that it is removed after each test.
// - env: a WEnv, necessary to create new components
let fixture: HTMLElement;
let env: Env;
beforeEach(() => {
fixture = makeTestFixture();
env = makeTestEnv();
Component.env = env;
});
afterEach(() => {
fixture.remove();
});
describe("mount targets", () => {
test("can attach a component to an existing node (if same tagname)", async () => {
class App extends Component {
static template = xml`
app
`;
}
const div = document.createElement("div");
fixture.appendChild(div);
const app = new App();
await app.mount(div, { position: "self" });
expect(fixture.innerHTML).toBe("app
");
});
test("cannot attach a component to an existing node (if not same tagname)", async () => {
class App extends Component {
static template = xml`app`;
}
const div = document.createElement("div");
fixture.appendChild(div);
const app = new App();
let error;
try {
await app.mount(div, { position: "self" });
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Cannot attach 'App' to target node (not same tag name)");
});
test("can mount a component (with position='first-child')", async () => {
class App extends Component {
static template = xml`app
`;
}
const span = document.createElement("span");
fixture.appendChild(span);
const app = new App();
await app.mount(fixture, { position: "first-child" });
expect(fixture.innerHTML).toBe("app
");
});
test("can mount a component (with position='last-child')", async () => {
class App extends Component {
static template = xml`app
`;
}
const span = document.createElement("span");
fixture.appendChild(span);
const app = new App();
await app.mount(fixture, { position: "last-child" });
expect(fixture.innerHTML).toBe("app
");
});
test("default mount option is 'last-child'", async () => {
class App extends Component {
static template = xml`app
`;
}
const span = document.createElement("span");
fixture.appendChild(span);
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("app
");
});
});
describe("unmounting and remounting", () => {
test("widget can be unmounted and remounted", async () => {
const steps: string[] = [];
class MyWidget extends Component {
static template = xml`Hey
`;
async willStart() {
steps.push("willstart");
}
mounted() {
steps.push("mounted");
}
willUnmount() {
steps.push("willunmount");
}
patched() {
throw new Error("patched should not be called");
}
}
const w = new MyWidget();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("Hey
");
expect(steps).toEqual(["willstart", "mounted"]);
w.unmount();
expect(fixture.innerHTML).toBe("");
expect(steps).toEqual(["willstart", "mounted", "willunmount"]);
await w.mount(fixture);
expect(fixture.innerHTML).toBe("Hey
");
expect(steps).toEqual(["willstart", "mounted", "willunmount", "mounted"]);
});
test("widget can be mounted twice without ill effect", async () => {
const steps: string[] = [];
class MyWidget extends Component {
static template = xml`Hey
`;
async willStart() {
steps.push("willstart");
}
mounted() {
steps.push("mounted");
}
willUnmount() {
steps.push("willunmount");
}
}
const w = new MyWidget();
await w.mount(fixture);
await w.mount(fixture);
expect(fixture.innerHTML).toBe("Hey
");
expect(steps).toEqual(["willstart", "mounted"]);
});
test("state changes in willUnmount do not trigger rerender", async () => {
const steps: string[] = [];
class Child extends Component {
static template = xml`
`;
state = useState({ n: 2 });
__render(f) {
steps.push("render");
return super.__render(f);
}
willPatch() {
steps.push("willPatch");
}
patched() {
steps.push("patched");
}
willUnmount() {
steps.push("willUnmount");
this.state.n = 3;
}
}
class Parent extends Component {
static template = xml`
`;
static components = { Child };
state = useState({ val: 1, flag: true });
}
const widget = new Parent();
await widget.mount(fixture);
expect(steps).toEqual(["render"]);
expect(fixture.innerHTML).toBe("12
");
widget.state.flag = false;
await nextTick();
// we make sure here that no call to __render is done
expect(steps).toEqual(["render", "willUnmount"]);
});
test("state changes in willUnmount will be applied on remount", async () => {
class TestWidget extends Component {
static template = xml`
`;
state = useState({ val: 1 });
willUnmount() {
this.state.val = 3;
}
}
const widget = new TestWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("1
");
widget.unmount();
expect(fixture.innerHTML).toBe("");
await nextTick(); // wait for changes to be detected before remounting
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("3
");
// we want to make sure that there are no remaining tasks left at this point.
expect(Component.scheduler.tasks.length).toBe(0);
});
test("sub component is still active after being unmounted and remounted", async () => {
class Child extends Component {
static template = xml`
`;
state = useState({ value: 1 });
}
class Parent extends Component {
static components = { Child };
static template = xml`
`;
}
const w = new Parent();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("");
fixture.querySelector("p")!.click();
await nextTick();
expect(fixture.innerHTML).toBe("");
w.unmount();
await nextTick();
await w.mount(fixture);
expect(fixture.innerHTML).toBe("");
fixture.querySelector("p")!.click();
await nextTick();
expect(fixture.innerHTML).toBe("");
});
test("change state just before mounting component", async () => {
const steps: number[] = [];
class TestWidget extends Component {
static template = xml`
`;
state = useState({ val: 1 });
__render(f) {
steps.push(this.state.val);
return super.__render(f);
}
}
TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render);
const widget = new TestWidget();
widget.state.val = 2;
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("2
");
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1);
// unmount and re-mount, as in this case, willStart won't be called, so it's
// slightly different
widget.unmount();
widget.state.val = 3;
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("3
");
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(2);
expect(steps).toEqual([2, 3]);
});
test("change state while mounting component", async () => {
const steps: number[] = [];
class TestWidget extends Component {
static template = xml`
`;
state = useState({ val: 1 });
__render(f) {
steps.push(this.state.val);
return super.__render(f);
}
}
TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render);
TestWidget.prototype.__patch = jest.fn(TestWidget.prototype.__patch);
const widget = new TestWidget();
let prom = widget.mount(fixture);
widget.state.val = 2;
await prom;
expect(fixture.innerHTML).toBe("2
");
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1);
// unmount and re-mount, as in this case, willStart won't be called, so it's
// slightly different
widget.unmount();
prom = widget.mount(fixture);
widget.state.val = 3;
await prom;
expect(fixture.innerHTML).toBe("3
");
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(3);
expect(TestWidget.prototype.__patch).toHaveBeenCalledTimes(2);
expect(steps).toEqual([2, 2, 3]);
});
test("change state while component is unmounted", async () => {
let child;
class Child extends Component {
static template = xml``;
state = useState({
val: "C1"
});
constructor(parent, props) {
super(parent, props);
child = this;
}
}
class Parent extends Component {
static components = { Child };
static template = xml`
`;
state = useState({ val: "P1" });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("P1C1
");
parent.unmount();
expect(fixture.innerHTML).toBe("");
parent.state.val = "P2";
child.state.val = "C2";
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("P2C2
");
});
test("unmount component during a re-rendering", async () => {
const def = makeDeferred();
class Child extends Component {
static template = xml``;
willUpdateProps() {
return def;
}
}
Child.prototype.__render = jest.fn(Child.prototype.__render);
class Parent extends Component {
static template = xml`
`;
static components = { Child };
state = useState({ val: 1 });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("1
");
expect(Child.prototype.__render).toBeCalledTimes(1);
parent.state.val = 2;
await nextTick();
expect(fixture.innerHTML).toBe("1
");
parent.unmount();
expect(fixture.innerHTML).toBe("");
def.resolve();
await nextTick();
expect(fixture.innerHTML).toBe("");
expect(Child.prototype.__render).toBeCalledTimes(1);
});
test("widget can be mounted on different target", async () => {
const steps: string[] = [];
class MyWidget extends Component {
static template = xml`Hey
`;
async willStart() {
steps.push("willstart");
}
mounted() {
steps.push("mounted");
}
willUnmount() {
steps.push("willunmount");
}
patched() {
throw new Error("patched should not be called");
}
}
const div = document.createElement("div");
const span = document.createElement("span");
fixture.appendChild(div);
fixture.appendChild(span);
const w = new MyWidget();
await w.mount(div);
expect(fixture.innerHTML).toBe("");
await w.mount(span);
expect(fixture.innerHTML).toBe("Hey
");
});
});