diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66012b72c..8ba67d668 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,18 @@
# Changelog
+## [0.1.2] - 2020-04-29
+### Fixed
+- Fixed Copy element tag not using Symbol.for #69
+- Fixed event listeners not being properly removed when component is unmounted #70
+- Prevented child components from causing parent components to rerender while it is already rerendering #70
+- Fixed keyed element logic when an unkeyed element is placed before multiple keyed elements previously rendered
+- Fixed a deadlock when errors are thrown back into async generator components: #77
+
## [0.1.1] - 2020-04-25
### Fixed
-- Correct boolean props not working correctly in html renderer #44
+- Corrected boolean props not working correctly in html renderer #44
- Guarded against potential xss in style objects #44
- Wrapped non-string iterables in an implicit Fragment element #63
-- Unmount stateless renders #63
+- Made sure stateless renders are unmounted #63
## [0.1.0] - 2020-04-14
### Added
diff --git a/examples/simple/.babelrc b/examples/simple/.babelrc
new file mode 100644
index 000000000..1d31ebc9b
--- /dev/null
+++ b/examples/simple/.babelrc
@@ -0,0 +1,5 @@
+{
+ "presets": [
+ "@babel/preset-react"
+ ]
+}
\ No newline at end of file
diff --git a/examples/simple/index.html b/examples/simple/index.html
new file mode 100644
index 000000000..f64ffab5d
--- /dev/null
+++ b/examples/simple/index.html
@@ -0,0 +1,12 @@
+
+
+
+
@@ -748,77 +741,6 @@ describe("render", () => {
expect(document.body.firstChild!.childNodes[6]).toBe(span7);
});
- test("reversed keyed array with copies", () => {
- let spans = [
-
2,
-
3,
-
4,
-
5,
-
6,
- ];
- renderer.render(
-
- 1
- {spans}
- 7
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "
1234567
",
- );
- const span1 = document.body.firstChild!.childNodes[0];
- const span2 = document.body.firstChild!.childNodes[1];
- const span3 = document.body.firstChild!.childNodes[2];
- const span4 = document.body.firstChild!.childNodes[3];
- const span5 = document.body.firstChild!.childNodes[4];
- const span6 = document.body.firstChild!.childNodes[5];
- const span7 = document.body.firstChild!.childNodes[6];
- spans = spans.reverse().map((el) =>
);
- renderer.render(
-
- 1
- {spans}
- 7
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "
1654327
",
- );
- renderer.render(
-
- 1
- {spans}
- 7
-
,
- document.body,
- );
- expect(document.body.firstChild!.childNodes[0]).toBe(span1);
- expect(document.body.firstChild!.childNodes[1]).toBe(span6);
- expect(document.body.firstChild!.childNodes[2]).toBe(span5);
- expect(document.body.firstChild!.childNodes[3]).toBe(span4);
- expect(document.body.firstChild!.childNodes[4]).toBe(span3);
- expect(document.body.firstChild!.childNodes[5]).toBe(span2);
- expect(document.body.firstChild!.childNodes[6]).toBe(span7);
- spans = spans.reverse().map((el) =>
);
- renderer.render(
-
- 1
- {spans}
- 7
-
,
- document.body,
- );
- expect(document.body.firstChild!.childNodes[0]).toBe(span1);
- expect(document.body.firstChild!.childNodes[1]).toBe(span2);
- expect(document.body.firstChild!.childNodes[2]).toBe(span3);
- expect(document.body.firstChild!.childNodes[3]).toBe(span4);
- expect(document.body.firstChild!.childNodes[4]).toBe(span5);
- expect(document.body.firstChild!.childNodes[5]).toBe(span6);
- expect(document.body.firstChild!.childNodes[6]).toBe(span7);
- });
-
test("keyed child added", () => {
renderer.render(
@@ -913,6 +835,62 @@ describe("render", () => {
);
});
+ test("unkeyed elements added in random spots", () => {
+ renderer.render(
+
+ 1
+ 2
+ 3
+ 4
+
,
+ document.body,
+ );
+ const span1 = document.body.firstChild!.childNodes[0];
+ const span2 = document.body.firstChild!.childNodes[1];
+ const span3 = document.body.firstChild!.childNodes[2];
+ const span4 = document.body.firstChild!.childNodes[3];
+ expect(document.body.innerHTML).toEqual(
+ "
1234
",
+ );
+ renderer.render(
+
+ 0.5
+ 1
+ 1.5
+ 2
+ 2.5
+ 3
+ 3.5
+ 4
+ 4.5
+
,
+ document.body,
+ );
+ expect(document.body.innerHTML).toEqual(
+ "
0.511.522.533.544.5
",
+ );
+ expect(span1).toEqual(document.body.firstChild!.childNodes[1]);
+ expect(span2).toEqual(document.body.firstChild!.childNodes[3]);
+ expect(span3).toEqual(document.body.firstChild!.childNodes[5]);
+ expect(span4).toEqual(document.body.firstChild!.childNodes[7]);
+ renderer.render(
+
+ 1
+ 2
+ 3
+ 4
+
,
+ document.body,
+ );
+ expect(document.body.innerHTML).toEqual(
+ "
1234
",
+ );
+ expect(span1).toEqual(document.body.firstChild!.childNodes[0]);
+ expect(span2).toEqual(document.body.firstChild!.childNodes[1]);
+ expect(span3).toEqual(document.body.firstChild!.childNodes[2]);
+ expect(span4).toEqual(document.body.firstChild!.childNodes[3]);
+ });
+
test("raw html", () => {
const html = '
Hi';
renderer.render(
diff --git a/src/__tests__/errors.tsx b/src/__tests__/errors.tsx
new file mode 100644
index 000000000..a2a77512e
--- /dev/null
+++ b/src/__tests__/errors.tsx
@@ -0,0 +1,142 @@
+/** @jsx createElement */
+import {createElement, Child, Context} from "../index";
+import {renderer} from "../dom";
+
+describe("errors", () => {
+ afterEach(async () => {
+ document.body.innerHTML = "";
+ await renderer.render(null, document.body);
+ });
+
+ test("sync function throws", () => {
+ const error = new Error("sync function throws");
+ function Thrower(): never {
+ throw error;
+ }
+
+ expect(() => renderer.render(
, document.body)).toThrow(error);
+ });
+
+ test("async function throws", async () => {
+ const error = new Error("async function throws");
+
+ async function Thrower(): Promise
{
+ throw error;
+ }
+
+ await expect(renderer.render(, document.body)).rejects.toBe(
+ error,
+ );
+ });
+
+ test("sync generator throws", () => {
+ const error = new Error("sync generator throws");
+ function* Thrower() {
+ yield 1;
+ yield 2;
+ throw error;
+ }
+
+ renderer.render(, document.body);
+ expect(document.body.innerHTML).toEqual("1");
+ renderer.render(, document.body);
+ expect(document.body.innerHTML).toEqual("2");
+ expect(() => renderer.render(, document.body)).toThrow(error);
+ });
+
+ test("async generator throws", async () => {
+ const error = new Error("async generator throws");
+ async function* Thrower(this: Context) {
+ let i = 1;
+ for await (const _ of this) {
+ if (i > 3) {
+ throw error;
+ }
+ yield i++;
+ }
+ }
+
+ await renderer.render(, document.body);
+ await renderer.render(, document.body);
+ await renderer.render(, document.body);
+ await expect(renderer.render(, document.body)).rejects.toBe(
+ error,
+ );
+ });
+
+ // TODO: figure out how to test for an unhandled promise rejection
+ // eslint-disable-next-line
+ test.skip("async generator throws independently", async () => {
+ const error = new Error("async generator throws independently");
+ async function* Thrower(this: Context) {
+ yield 1;
+ yield 2;
+ yield 3;
+ throw error;
+ }
+
+ renderer.render(, document.body);
+ await new Promise(() => {});
+ });
+
+ test("async generator throws in async generator", async () => {
+ const error = new Error("async generator throws in async generator");
+ /* eslint-disable require-yield */
+ async function* Thrower() {
+ throw error;
+ }
+ /* eslint-enable require-yield */
+
+ async function* Component(this: Context) {
+ for await (const _ of this) {
+ yield ;
+ }
+ }
+
+ await expect(renderer.render(, document.body)).rejects.toBe(
+ error,
+ );
+ });
+
+ test("sync function throws, sync generator catches", () => {
+ function Thrower(): Promise {
+ throw new Error("sync function throws, sync generator catches");
+ }
+
+ function* Component(): Generator {
+ try {
+ yield ;
+ } catch (err) {
+ return Error;
+ }
+ }
+
+ renderer.render(, document.body);
+ expect(document.body.innerHTML).toEqual("Error");
+ });
+
+ test("async function throws, sync generator catches", async () => {
+ async function Thrower(): Promise {
+ throw new Error("async function throws, sync generator catches");
+ }
+
+ function* Component(): Generator {
+ try {
+ yield ;
+ } catch (err) {
+ return Error;
+ }
+ }
+
+ await renderer.render(
+
+
+
,
+ document.body,
+ );
+
+ expect(document.body.innerHTML).toEqual("Error
");
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(document.body.innerHTML).toEqual("Error
");
+ });
+});
diff --git a/src/__tests__/index.tsx b/src/__tests__/index.tsx
index 9c8eeda36..9a9bcc0bc 100644
--- a/src/__tests__/index.tsx
+++ b/src/__tests__/index.tsx
@@ -1,7 +1,6 @@
/** @jsx createElement */
import "core-js";
import {
- Copy,
createElement,
Child,
Children,
@@ -11,7 +10,8 @@ import {
} from "../index";
import {renderer} from "../dom";
-// TODO: no-unreachable is throwing false positives in some tests
+// TODO: start splitting out these tests into separate files
+
/* eslint-disable no-unreachable */
describe("sync function component", () => {
afterEach(async () => {
@@ -41,27 +41,6 @@ describe("sync function component", () => {
});
test("rerender different return value", () => {
- function Component({message}: {message: string}): Element {
- return {message};
- }
-
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual("Hello
");
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual("Hello
");
- });
-
- test("rerender copy", () => {
function Component({ChildTag}: {ChildTag: string}): Element {
return Hello world;
}
@@ -113,19 +92,32 @@ describe("sync function component", () => {
expect(Child).toHaveBeenCalledTimes(4);
});
- test("children wrapped in an implicit fragment", () => {
- function Component({copy}: {copy?: boolean}) {
- if (copy) {
- return ;
- } else {
- return [1, 2, 3];
- }
+ test("event listeners are cleaned up", () => {
+ let ctx!: Context;
+ function Component(this: Context) {
+ ctx = this;
+ return Hello;
}
- renderer.render(, document.body);
- expect(document.body.innerHTML).toEqual("123");
- renderer.render(, document.body);
- expect(document.body.innerHTML).toEqual("123");
+ renderer.render(
+
+
+
,
+ document.body,
+ );
+ const listener1 = jest.fn();
+ const listener2 = jest.fn();
+ ctx.addEventListener("foo", listener1);
+ ctx.addEventListener("bar", listener1);
+ ctx.dispatchEvent(new Event("foo"));
+ expect(listener1).toHaveBeenCalledTimes(1);
+ expect(listener2).toHaveBeenCalledTimes(0);
+ const removeEventListener = jest.spyOn(ctx, "removeEventListener");
+ renderer.render(null, document.body);
+ expect(removeEventListener).toHaveBeenCalledTimes(2);
+ ctx.dispatchEvent(new Event("foo"));
+ expect(listener1).toHaveBeenCalledTimes(1);
+ expect(listener2).toHaveBeenCalledTimes(0);
});
});
@@ -837,299 +829,6 @@ describe("sync generator component", () => {
expect(mock).toHaveBeenCalledTimes(1);
});
- test("errors are caught", () => {
- function Thrower(): never {
- throw new Error("errors are caught");
- }
-
- function* Child(): Generator {
- yield 1;
- yield 2;
- yield ;
- }
-
- function* Component(): Generator {
- try {
- while (true) {
- yield (
-
-
-
- );
- }
- } catch (err) {
- return Error: {err.message};
- }
- }
-
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual("1
");
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual("2
");
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "Error: errors are caught
",
- );
- });
-
- test("errors caught and rerendered", () => {
- function Thrower(): never {
- throw new Error("errors caught and rerendered");
- }
-
- function* Child(): Generator {
- yield 1;
- yield 2;
- yield ;
- }
-
- function* Component(): Generator {
- try {
- while (true) {
- yield (
-
-
-
- );
- }
- } catch (err) {
- return Error: {err.message};
- }
- }
-
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual("1
");
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual("2
");
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "Error: errors caught and rerendered
",
- );
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "Error: errors caught and rerendered
",
- );
- });
-
- test("immediate errors are caught", () => {
- function Thrower(): never {
- throw new Error("immediate errors are caught");
- }
-
- function* Child(): Generator {
- yield ;
- }
-
- function* Component(): Generator {
- try {
- while (true) {
- yield (
-
-
-
- );
- }
- } catch (err) {
- return Error: {err.message};
- }
- }
-
- renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "Error: immediate errors are caught
",
- );
- });
-
- test("async errors are caught", async () => {
- async function Thrower(): Promise {
- throw new Error("async errors are caught");
- }
-
- function* Component(): Generator {
- try {
- yield ;
- } catch (err) {
- return Error: {err.message};
- }
- }
-
- await renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "Error: async errors are caught
",
- );
- await new Promise((resolve) => setTimeout(resolve, 100));
- expect(document.body.innerHTML).toEqual(
- "Error: async errors are caught
",
- );
- });
-
- test("immediate async errors caught and rerendered", async () => {
- async function Thrower(): Promise {
- throw new Error("immediate async errors caught and rerendered");
- }
-
- function* Component(): Generator {
- try {
- yield ;
- } catch (err) {
- return Error: {err.message};
- }
- }
-
- renderer.render(
-
-
-
,
- document.body,
- );
- await renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "Error: immediate async errors caught and rerendered
",
- );
- await new Promise((resolve) => setTimeout(resolve, 100));
- expect(document.body.innerHTML).toEqual(
- "Error: immediate async errors caught and rerendered
",
- );
- });
-
- test("immediate async errors are caught", async () => {
- async function Thrower(): Promise {
- throw new Error("immediate async errors are caught");
- }
-
- function* Child(): Generator {
- yield ;
- }
-
- function* Component(): Generator {
- try {
- while (true) {
- yield (
-
-
-
- );
- }
- } catch (err) {
- return Error: {err.message};
- }
- }
-
- await renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "Error: immediate async errors are caught
",
- );
- await new Promise((resolve) => setTimeout(resolve, 100));
- expect(document.body.innerHTML).toEqual(
- "Error: immediate async errors are caught
",
- );
- });
-
- test("async errors are caught in nested component", async () => {
- async function Thrower(): Promise {
- throw new Error("async errors are caught in nested component");
- }
-
- function* Child(): Generator {
- yield 1;
- yield 2;
- yield ;
- }
-
- function* Component(): Generator {
- try {
- while (true) {
- yield ;
- }
- } catch (err) {
- return Error: {err.message};
- }
- }
-
- await renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual("1
");
- await renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual("2
");
- await renderer.render(
-
-
-
,
- document.body,
- );
- expect(document.body.innerHTML).toEqual(
- "Error: async errors are caught in nested component
",
- );
- await new Promise((resolve) => setTimeout(resolve, 100));
- expect(document.body.innerHTML).toEqual(
- "Error: async errors are caught in nested component
",
- );
- });
-
test("multiple iterations without a yield throw", () => {
let i = 0;
function* Component(this: Context) {
@@ -1150,6 +849,7 @@ describe("sync generator component", () => {
expect(i).toBe(1);
});
+ // TODO: it would be nice to test this like other components
test("for await...of throws", async () => {
let ctx: Context;
function* Component(this: Context): Generator {
@@ -1964,3 +1664,103 @@ describe("async races", () => {
expect(document.body.innerHTML).toEqual("Slow
");
});
});
+
+describe("parent-child refresh cascades", () => {
+ afterEach(async () => {
+ document.body.innerHTML = "";
+ await renderer.render(null, document.body);
+ });
+
+ test("sync function parent and sync function child", () => {
+ return new Promise((done) => {
+ function Child(this: Context) {
+ this.dispatchEvent(new Event("test", {bubbles: true}));
+ return child;
+ }
+
+ function Parent(this: Context) {
+ this.addEventListener("test", () => {
+ try {
+ this.refresh();
+ done();
+ } catch (err) {
+ done(err);
+ }
+ });
+
+ return (
+
+
+
+ );
+ }
+
+ renderer.render(, document.body);
+ expect(document.body.innerHTML).toEqual("child
");
+ });
+ });
+
+ test("sync generator parent and sync function child", () => {
+ return new Promise((done) => {
+ function Child(this: Context) {
+ this.dispatchEvent(new Event("test", {bubbles: true}));
+ return child;
+ }
+
+ function* Parent(this: Context) {
+ this.addEventListener("test", () => {
+ try {
+ this.refresh();
+ done();
+ } catch (err) {
+ done(err);
+ }
+ });
+
+ while (true) {
+ yield (
+
+
+
+ );
+ }
+ }
+
+ renderer.render(, document.body);
+ expect(document.body.innerHTML).toEqual("child
");
+ });
+ });
+
+ test("sync generator parent and sync generator child", () => {
+ return new Promise((done) => {
+ function* Child(this: Context) {
+ while (true) {
+ this.dispatchEvent(new Event("test", {bubbles: true}));
+ yield child;
+ }
+ }
+
+ function* Parent(this: Context) {
+ this.addEventListener("test", () => {
+ try {
+ this.refresh();
+ done();
+ } catch (err) {
+ done(err);
+ }
+ });
+
+ while (true) {
+ yield (
+
+
+
+ );
+ }
+ }
+
+ renderer.render(, document.body);
+ expect(document.body.innerHTML).toEqual("child
");
+ });
+ });
+});
diff --git a/src/index.ts b/src/index.ts
index 40f2b69d5..69547aa8b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -83,7 +83,7 @@ export type Intrinsic = (
export const Fragment = Symbol.for("crank.Fragment") as any;
export type Fragment = typeof Fragment;
-export const Copy = Symbol("crank.Copy") as any;
+export const Copy = Symbol.for("crank.Copy") as any;
export type Copy = typeof Copy;
export const Portal = Symbol.for("crank.Portal") as any;
@@ -162,7 +162,7 @@ function* flatten(children: Children): Generator {
}
// This union exists because we needed to discriminate between leaf and parent
-// nodes using a property (host.internal).
+// nodes using a property (node.internal).
type Node = LeafNode | ParentNode;
// The shared properties between LeafNode and ParentNode
@@ -202,9 +202,9 @@ abstract class ParentNode implements NodeBase {
abstract readonly renderer: Renderer;
abstract parent: ParentNode | undefined;
// When children update asynchronously, we race their result against the next
- // update of children. The onNextChildren property is set to the resolve
+ // update of children. The onNextResult property is set to the resolve
// function of the promise which the current update is raced against.
- private onNextChildren:
+ private onNextResult:
| ((result?: Promise) => unknown)
| undefined = undefined;
protected props: Props | undefined = undefined;
@@ -307,8 +307,8 @@ abstract class ParentNode implements NodeBase {
// TODO: I bet we could simplify the algorithm further, perhaps by writing a
// custom a method which automatically zips up old and new nodes.
protected updateChildren(children: Children): MaybePromise {
- let host = this.firstChild;
- let nextSibling = host && host.nextSibling;
+ let node = this.firstChild;
+ let nextSibling = node && node.nextSibling;
let nextKeyedChildren: Map> | undefined;
let updates: Array> | undefined;
for (const child of flatten(children)) {
@@ -325,124 +325,141 @@ abstract class ParentNode implements NodeBase {
if (key != null) {
let nextNode = this.keyedChildren && this.keyedChildren.get(key);
if (nextNode === undefined) {
- nextNode = createNode(this, this.renderer, child);
+ if (tag !== Copy) {
+ nextNode = createNode(this, this.renderer, child);
+ }
} else {
this.keyedChildren!.delete(key);
- if (host !== nextNode) {
+ if (node !== nextNode) {
this.removeChild(nextNode);
}
}
- if (host === undefined) {
- this.appendChild(nextNode);
- } else if (host !== nextNode) {
- if (host.key == null) {
- this.insertBefore(nextNode, host);
- } else {
- this.insertBefore(nextNode, host.nextSibling);
+ if (nextNode !== undefined) {
+ if (node === undefined) {
+ this.appendChild(nextNode);
+ } else if (node !== nextNode) {
+ if (node.key == null) {
+ this.insertBefore(nextNode, node);
+ } else {
+ this.insertBefore(nextNode, node.nextSibling);
+ }
}
+
+ node = nextNode;
+ nextSibling = node.nextSibling;
+ }
+ } else if (node === undefined) {
+ // current parent has no more nodes
+ if (tag !== Copy) {
+ node = createNode(this, this.renderer, child);
+ this.appendChild(node);
+ }
+ } else if (node.key != null) {
+ // the current node is keyed but the child is not
+ while (node && node.key != null) {
+ node = nextSibling;
+ nextSibling = node && node.nextSibling;
}
- host = nextNode;
- nextSibling = host.nextSibling;
- } else if (host === undefined) {
- host = createNode(this, this.renderer, child);
- this.appendChild(host);
- } else if (host.key != null) {
- const nextNode = createNode(this, this.renderer, child);
- this.insertBefore(nextNode, host.nextSibling);
- host = nextNode;
- nextSibling = host.nextSibling;
+ if (node === undefined) {
+ if (tag !== Copy) {
+ node = createNode(this, this.renderer, child);
+ this.appendChild(node);
+ }
+ }
}
- if (tag !== Copy) {
- // TODO: figure out why do we do a check for unmounted hosts here
- if (host.tag === tag && !(host.internal && host.unmounted)) {
- if (host.internal) {
- const update = host.update((child as Element).props);
- if (update !== undefined) {
+ if (node !== undefined) {
+ if (tag !== Copy) {
+ // TODO: figure out why do we do a check for unmounted node here
+ if (node.tag === tag && !(node.internal && node.unmounted)) {
+ if (node.internal) {
+ const update = node.update((child as Element).props);
+ if (update !== undefined) {
+ if (updates === undefined) {
+ updates = [];
+ }
+
+ updates.push(update);
+ }
+ } else if (typeof child === "string") {
+ node.value = this.renderer.text(child);
+ } else {
+ node.value = undefined;
+ }
+ } else {
+ // TODO: async unmount for keyed nodes
+ if (node.internal) {
+ node.unmount();
+ }
+ const nextNode = createNode(this, this.renderer, child);
+ nextNode.clock = node.clock++;
+ let update: MaybePromise;
+ if (nextNode.internal) {
+ update = nextNode.update((child as Element).props);
+ } else if (typeof child === "string") {
+ nextNode.value = this.renderer.text(child);
+ } else {
+ nextNode.value = undefined;
+ }
+
+ if (update === undefined) {
+ this.replaceChild(nextNode, node);
+ node.replacedBy = nextNode;
+ } else {
if (updates === undefined) {
updates = [];
}
updates.push(update);
+ // node is reassigned so we need to capture its current value in
+ // node for the sake of the callback’s closure.
+ const node1 = node;
+ update.then(() => {
+ if (node1.replacedBy === undefined) {
+ this.replaceChild(nextNode, node1);
+ node1.replacedBy = nextNode;
+ } else if (
+ node1.replacedBy.replacedBy === undefined &&
+ node1.replacedBy.clock < nextNode.clock
+ ) {
+ this.replaceChild(nextNode, node1.replacedBy);
+ node1.replacedBy = nextNode;
+ }
+ });
}
- } else if (typeof child === "string") {
- host.value = this.renderer.text(child);
- } else {
- host.value = undefined;
- }
- } else {
- // TODO: async unmount for keyed hosts
- if (host.internal) {
- host.unmount();
- }
- const nextNode = createNode(this, this.renderer, child);
- nextNode.clock = host.clock++;
- let update: MaybePromise;
- if (nextNode.internal) {
- update = nextNode.update((child as Element).props);
- } else if (typeof child === "string") {
- nextNode.value = this.renderer.text(child);
- } else {
- nextNode.value = undefined;
}
+ }
- if (update === undefined) {
- this.replaceChild(nextNode, host);
- host.replacedBy = nextNode;
- } else {
- if (updates === undefined) {
- updates = [];
- }
-
- updates.push(update);
- // host is reassigned so we need to capture its current value in
- // host1 for the sake of the callback’s closure.
- const host1 = host;
- update.then(() => {
- if (host1.replacedBy === undefined) {
- this.replaceChild(nextNode, host1);
- host1.replacedBy = nextNode;
- } else if (
- host1.replacedBy.replacedBy === undefined &&
- host1.replacedBy.clock < nextNode.clock
- ) {
- this.replaceChild(nextNode, host1.replacedBy);
- host1.replacedBy = nextNode;
- }
- });
+ if (key !== undefined) {
+ if (nextKeyedChildren === undefined) {
+ nextKeyedChildren = new Map();
}
- }
- }
- if (key !== undefined) {
- if (nextKeyedChildren === undefined) {
- nextKeyedChildren = new Map();
+ nextKeyedChildren.set(key, node);
}
-
- nextKeyedChildren.set(key, host);
}
- host = nextSibling;
- nextSibling = host && host.nextSibling;
+ node = nextSibling;
+ nextSibling = node && node.nextSibling;
}
// unmount excess children
for (
;
- host !== undefined;
- host = nextSibling, nextSibling = host && host.nextSibling
+ node !== undefined;
+ node = nextSibling, nextSibling = node && node.nextSibling
) {
- if (host.key !== undefined && this.keyedChildren !== undefined) {
- this.keyedChildren.delete(host.key);
+ if (node.key !== undefined && this.keyedChildren !== undefined) {
+ this.keyedChildren.delete(node.key);
}
- if (host.internal) {
- host.unmount();
+ if (node.internal) {
+ node.unmount();
}
- this.removeChild(host);
+ this.removeChild(node);
}
// unmount excess keyed children
@@ -456,19 +473,19 @@ abstract class ParentNode implements NodeBase {
this.keyedChildren = nextKeyedChildren;
if (updates === undefined) {
this.commit();
- if (this.onNextChildren !== undefined) {
- this.onNextChildren();
- this.onNextChildren = undefined;
+ if (this.onNextResult !== undefined) {
+ this.onNextResult();
+ this.onNextResult = undefined;
}
} else {
const result = Promise.all(updates).then(() => void this.commit()); // void :(
- if (this.onNextChildren !== undefined) {
- this.onNextChildren(result);
- this.onNextChildren = undefined;
+ if (this.onNextResult !== undefined) {
+ this.onNextResult(result.catch(() => undefined)); // void :(
+ this.onNextResult = undefined;
}
const nextResult = new Promise(
- (resolve) => (this.onNextChildren = resolve),
+ (resolve) => (this.onNextResult = resolve),
);
return Promise.race([result, nextResult]);
}
@@ -476,12 +493,12 @@ abstract class ParentNode implements NodeBase {
protected unmountChildren(): void {
for (
- let host = this.firstChild;
- host !== undefined;
- host = host.nextSibling
+ let node = this.firstChild;
+ node !== undefined;
+ node = node.nextSibling
) {
- if (host.internal) {
- host.unmount();
+ if (node.internal) {
+ node.unmount();
}
}
}
@@ -632,7 +649,7 @@ class HostNode extends ParentNode {
*[Symbol.iterator]() {
while (!this.unmounted) {
if (this.iterating) {
- throw new Error("You must yield something each iteration over context");
+ throw new Error("You must yield for each iteration of this.");
}
this.iterating = true;
@@ -661,7 +678,8 @@ class ComponentNode extends ParentNode {
readonly parent: ParentNode;
readonly renderer: Renderer;
readonly ctx: Context;
- private available = true;
+ private stepping = false;
+ private available = false;
private iterator: ChildIterator | undefined = undefined;
// TODO: explain these properties
private componentType: ComponentType | undefined = undefined;
@@ -694,68 +712,106 @@ class ComponentNode extends ParentNode {
return super.updateChildren(children);
}
- private step(): [MaybePromise, MaybePromise] {
- if (this.finished) {
- return [undefined, undefined];
- } else if (this.iterator === undefined) {
- this.ctx.clearEventListeners();
- const value = new Pledge(() => this.tag.call(this.ctx, this.props!))
- .catch((err) => this.parent.catch(err))
- // type assertion because we shouldn’t get a promise of an iterator
- .execute() as ChildIterator | Promise | Child;
- if (isIteratorOrAsyncIterator(value)) {
- this.iterator = value;
- } else if (isPromiseLike(value)) {
- this.componentType = AsyncFn;
- const pending = value.then(() => undefined); // void :(
- const result = value.then((child) => this.updateChildren(child));
- return [pending, result];
- } else {
- this.componentType = AsyncGen;
- const result = this.updateChildren(value);
- return [undefined, result];
+ private run(): MaybePromise {
+ if (this.inflightPending === undefined) {
+ const [pending, result] = this.step();
+ if (isPromiseLike(pending)) {
+ this.inflightPending = pending.finally(() => this.advance());
}
+
+ this.inflightResult = result;
+ return this.inflightResult;
+ } else if (this.componentType === AsyncGen) {
+ return this.inflightResult;
+ } else if (this.enqueuedPending === undefined) {
+ let resolve: (value: MaybePromise) => unknown;
+ this.enqueuedPending = this.inflightPending
+ .then(() => {
+ const [pending, result] = this.step();
+ resolve(result);
+ return pending;
+ })
+ .finally(() => this.advance());
+ this.enqueuedResult = new Promise((resolve1) => (resolve = resolve1));
}
- const previousValue = Pledge.resolve(this.previousResult)
- .then(() => this.value)
- .execute();
- const iteration = new Pledge(() => this.iterator!.next(previousValue))
- .catch((err) => {
- // TODO: figure out why this is written like this
- return Pledge.resolve(this.parent.catch(err))
- .then(() => ({value: undefined, done: true}))
- .execute();
- })
- .execute();
- if (isPromiseLike(iteration)) {
- this.componentType = AsyncGen;
- const pending = iteration.then(
- (iteration) => {
- this.iterating = false;
- if (iteration.done) {
- this.finished = true;
+ return this.enqueuedResult;
+ }
+
+ private step(): [MaybePromise, MaybePromise] {
+ this.stepping = true;
+ try {
+ if (this.finished) {
+ return [undefined, undefined];
+ } else if (this.iterator === undefined) {
+ this.ctx.clearEventListeners();
+ const value = new Pledge(() => this.tag.call(this.ctx, this.props!))
+ .catch((err) => this.parent.catch(err))
+ // type assertion because we shouldn’t get a promise of an iterator
+ .execute() as ChildIterator | Promise | Child;
+ if (isIteratorOrAsyncIterator(value)) {
+ this.iterator = value;
+ } else if (isPromiseLike(value)) {
+ this.componentType = AsyncFn;
+ const pending = value.then(
+ () => undefined,
+ () => undefined,
+ ); // void :(
+ const result = value.then((child) => this.updateChildren(child));
+ return [pending, result];
+ } else {
+ this.componentType = SyncFn;
+ const result = this.updateChildren(value);
+ return [undefined, result];
+ }
+ }
+
+ const previousValue = Pledge.resolve(this.previousResult)
+ .then(() => this.value)
+ .execute();
+ const iteration = new Pledge(() => this.iterator!.next(previousValue))
+ .catch((err) => {
+ // TODO: figure out why this is written like this
+ return Pledge.resolve(this.parent.catch(err))
+ .then(() => ({value: undefined, done: true}))
+ .execute();
+ })
+ .execute();
+ if (isPromiseLike(iteration)) {
+ this.componentType = AsyncGen;
+ const pending = iteration.then(
+ (iteration) => {
+ this.iterating = false;
+ if (iteration.done) {
+ this.finished = true;
+ }
+
+ return undefined; // void :(
+ },
+ () => undefined, // void :(
+ );
+ const result = iteration.then((iteration) => {
+ const result = this.updateChildren(iteration.value);
+ if (isPromiseLike(result)) {
+ this.previousResult = result.catch(() => undefined); // void
}
- return undefined; // void :(
- },
- () => undefined, // void :(
- );
- const result = iteration.then((iteration) => {
- this.previousResult = this.updateChildren(iteration.value);
- return this.previousResult;
- });
+ return result;
+ });
- return [pending, result];
- } else {
- this.iterating = false;
- this.componentType = SyncGen;
- if (iteration.done) {
- this.finished = true;
- }
+ return [pending, result];
+ } else {
+ this.iterating = false;
+ this.componentType = SyncGen;
+ if (iteration.done) {
+ this.finished = true;
+ }
- const result = this.updateChildren(iteration.value);
- return [result, result];
+ const result = this.updateChildren(iteration.value);
+ return [result, result];
+ }
+ } finally {
+ this.stepping = false;
}
}
@@ -765,12 +821,18 @@ class ComponentNode extends ParentNode {
this.enqueuedPending = undefined;
this.enqueuedResult = undefined;
if (this.componentType === AsyncGen && !this.finished && !this.unmounted) {
- this.run();
+ Promise.resolve(this.run()).catch((err) => {
+ // We catch and rethrow the error to trigger an unhandled promise rejection.
+ if (!this.updating) {
+ throw err;
+ }
+ });
}
}
refresh(): MaybePromise {
- if (this.unmounted) {
+ if (this.stepping || this.unmounted) {
+ // TODO: we may want to log warnings when stuff like this happens
return;
}
@@ -784,68 +846,6 @@ class ComponentNode extends ParentNode {
return this.run();
}
- *[Symbol.iterator](): Generator {
- while (!this.unmounted) {
- if (this.iterating) {
- throw new Error("You must yield once per iteration over context");
- } else if (this.componentType === AsyncGen) {
- throw new Error("Use for await...of in async generator components.");
- }
-
- this.iterating = true;
- yield this.props!;
- }
- }
-
- async *[Symbol.asyncIterator](): AsyncGenerator {
- do {
- if (this.iterating) {
- throw new Error("You must yield once per iteration over context");
- } else if (this.componentType === SyncGen) {
- throw new Error("Use for...of in sync generator components.");
- }
-
- this.iterating = true;
- if (this.available) {
- this.available = false;
- yield this.props!;
- } else {
- const props = await new Promise(
- (resolve) => (this.publish = resolve),
- );
- if (!this.unmounted) {
- yield props;
- }
- }
- } while (!this.unmounted);
- }
-
- private run(): MaybePromise {
- if (this.inflightPending === undefined) {
- const [pending, result] = this.step();
- if (isPromiseLike(pending)) {
- this.inflightPending = pending.finally(() => this.advance());
- }
-
- this.inflightResult = result;
- return this.inflightResult;
- } else if (this.componentType === AsyncGen) {
- return this.inflightResult;
- } else if (this.enqueuedPending === undefined) {
- let resolve: (value: MaybePromise) => unknown;
- this.enqueuedPending = this.inflightPending
- .then(() => {
- const [pending, result] = this.step();
- resolve(result);
- return pending;
- })
- .finally(() => this.advance());
- this.enqueuedResult = new Promise((resolve1) => (resolve = resolve1));
- }
-
- return this.enqueuedResult;
- }
-
commit(): undefined {
const childValues = this.getChildValues();
this.ctx.setDelegates(childValues);
@@ -864,11 +864,12 @@ class ComponentNode extends ParentNode {
return;
}
+ this.updating = false;
this.unmounted = true;
+ this.ctx.clearEventListeners();
if (!this.finished) {
this.finished = true;
- // TODO: maybe we should return the async iterator rather than
- // republishing props
+ // helps avoid deadlocks
if (this.publish !== undefined) {
this.publish(this.props!);
this.publish = undefined;
@@ -895,6 +896,12 @@ class ComponentNode extends ParentNode {
) {
return super.catch(reason);
} else {
+ // helps avoid deadlocks
+ if (this.publish !== undefined) {
+ this.publish(this.props!);
+ this.publish = undefined;
+ }
+
return new Pledge(() => this.iterator!.throw!(reason))
.then((iteration) => {
if (iteration.done) {
@@ -931,6 +938,42 @@ class ComponentNode extends ParentNode {
this.provisions.set(name, value);
}
+
+ *[Symbol.iterator](): Generator {
+ while (!this.unmounted) {
+ if (this.iterating) {
+ throw new Error("You must yield for each iteration of this.");
+ } else if (this.componentType === AsyncGen) {
+ throw new Error("Use for await...of in async generator components.");
+ }
+
+ this.iterating = true;
+ yield this.props!;
+ }
+ }
+
+ async *[Symbol.asyncIterator](): AsyncGenerator {
+ do {
+ if (this.iterating) {
+ throw new Error("You must yield for each iteration of this.");
+ } else if (this.componentType === SyncGen) {
+ throw new Error("Use for...of in sync generator components.");
+ }
+
+ this.iterating = true;
+ if (this.available) {
+ this.available = false;
+ yield this.props!;
+ } else {
+ const props = await new Promise(
+ (resolve) => (this.publish = resolve),
+ );
+ if (!this.unmounted) {
+ yield props;
+ }
+ }
+ } while (!this.unmounted);
+ }
}
function createNode(
@@ -1056,23 +1099,23 @@ export class Renderer {
portal = createElement(Portal, {root}, child);
}
- let host: HostNode | undefined =
+ let rootNode: HostNode | undefined =
root != null ? this.cache.get(root) : undefined;
- if (host === undefined) {
- host = new HostNode(undefined, this, portal.tag);
+ if (rootNode === undefined) {
+ rootNode = new HostNode(undefined, this, portal.tag);
if (root !== undefined) {
- this.cache.set(root, host);
+ this.cache.set(root, rootNode);
}
}
- return Pledge.resolve(host.update(portal.props))
+ return Pledge.resolve(rootNode.update(portal.props))
.then(() => {
if (root === undefined) {
- host!.unmount();
+ rootNode!.unmount();
}
- return host!.value!;
+ return rootNode!.value!;
})
.execute();
}