@ryupold/vode 1.8.7 → 1.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/test/mocks.ts CHANGED
@@ -13,84 +13,178 @@ const NodeConstants = {
13
13
  NOTATION_NODE: 12,
14
14
  };
15
15
 
16
- export class MockElement {
16
+ class FakeNodeList implements NodeListOf<ChildNode> {
17
+ [index: number]: ChildNode;
18
+ public readonly data: ChildNode[] = [];
19
+
20
+ constructor() {
21
+ let self = this;
22
+
23
+ return new Proxy(this, {
24
+ get(target, prop) {
25
+ const key: string = typeof prop === "symbol" ? String(prop) : prop;
26
+
27
+ if (<any>Number(key) == key && !(prop in target)) {
28
+ return self.data[parseInt(key)];
29
+ }
30
+ return target[prop as any];
31
+ }
32
+ });
33
+ }
34
+
35
+
36
+ item(index: number): ChildNode {
37
+ return this.data[index] ?? null;
38
+ }
39
+ forEach(callbackfn: (value: ChildNode, key: number, parent: NodeListOf<ChildNode>) => void, thisArg?: any): void {
40
+ for (let i = 0; i < this.length; i++) {
41
+ callbackfn.bind(thisArg)(this.data[i], i, this);
42
+ }
43
+ }
44
+ entries(): ArrayIterator<[number, ChildNode]> {
45
+ return new Array(this.length).fill(0).map((_, i) => [i, this[i]] as [number, ChildNode])[Symbol.iterator]();
46
+ }
47
+ keys(): ArrayIterator<number> {
48
+ return new Array(this.data.length).fill(0).map((_, i) => i)[Symbol.iterator]();
49
+ }
50
+ values(): ArrayIterator<ChildNode> {
51
+ return new Array(this.data.length).fill(0).map((_, i) => this[i])[Symbol.iterator]();
52
+ }
53
+ [Symbol.iterator](): ArrayIterator<ChildNode> {
54
+ return new Array(this.data.length).fill(0).map((_, i) => this[i])[Symbol.iterator]();
55
+ }
56
+ get length() {
57
+ return this.data.length;
58
+ }
59
+ }
60
+
61
+ export class FakeElement {
62
+ public fakeAttributes: Record<string, string> = {};
63
+
17
64
  nodeType = NodeConstants.ELEMENT_NODE;
18
- childNodes: (MockElement | MockText)[] = [];
19
- children: (MockElement | MockText)[] = [];
20
- parentElement: MockElement | null = null;
21
- get attributes() {
22
- return Object.entries(this._attrs).map(([name, value]) => ({ name, value }));
65
+ parentElement: HTMLElement | null = null;
66
+ childNodes: NodeListOf<ChildNode> = new FakeNodeList();
67
+ get children(): HTMLCollection {
68
+ return this.childNodes as unknown as HTMLCollection;
23
69
  }
24
70
  style: { cssText: string } = { cssText: "" };
25
- tagName = "UNKNOWN";
26
- private _attrs: Record<string, string> = {};
71
+
72
+ readonly tagName: string;
27
73
 
28
74
  constructor(public tag?: string) {
29
- if (tag) this.tagName = tag.toUpperCase();
75
+ this.tagName = tag?.toUpperCase() || "???";
30
76
  }
31
77
 
32
78
  get firstChild() { return this.childNodes[0] ?? null; }
33
79
  get lastChild() { return this.childNodes[this.childNodes.length - 1] ?? null; }
34
80
  get nextSibling() { return null; }
81
+ get attributes() {
82
+ return Object.entries(this.fakeAttributes).map(([name, value]) => ({ name, value })) as any;
83
+ }
35
84
 
36
- hasAttributes() { return Object.keys(this._attrs).length > 0; }
85
+ hasAttributes() { return Object.keys(this.fakeAttributes).length > 0; }
37
86
  hasChildNodes() { return this.childNodes.length > 0; }
38
- setAttribute(name: string, value: string) { this._attrs[name] = value; }
39
- removeAttribute(name: string) { delete this._attrs[name]; }
40
- appendChild(child: MockElement | MockText) {
41
- this.childNodes.push(child); this.children.push(child); if (child.parentElement !== undefined) child.parentElement = this; return child;
87
+ setAttribute(name: string, value: string) { this.fakeAttributes[name] = value; }
88
+ removeAttribute(name: string) { delete this.fakeAttributes[name]; }
89
+
90
+ appendChild(child: FakeElement | FakeTextNode): FakeElement | FakeTextNode {
91
+ (this.childNodes as FakeNodeList).data.push(child as any);
92
+ (child as any).parentElement = this;
93
+ return child;
42
94
  }
43
95
  remove() {
44
- if (this.parentElement) { const i = this.parentElement.childNodes.indexOf(this); if (i >= 0) this.parentElement.childNodes.splice(i, 1); }
96
+ if (this.parentElement) {
97
+ const i = (this.parentElement.childNodes as FakeNodeList).data.indexOf(this as any);
98
+ if (i >= 0)
99
+ (this.parentElement.childNodes as FakeNodeList).data.splice(i, 1);
100
+ }
45
101
  }
46
- replaceWith(...nodes: (MockElement | MockText)[]) {
102
+ replaceWith(...nodes: (FakeElement | FakeTextNode)[]) {
47
103
  const parent = this.parentElement;
48
104
  if (parent) {
49
- const i = parent.childNodes.indexOf(this);
50
- if (i >= 0) { parent.childNodes.splice(i, 1, ...nodes); }
51
- for (const n of nodes) n.parentElement = parent;
105
+ const i = (<FakeNodeList>parent.childNodes).data.indexOf(this as any);
106
+ if (i >= 0) {
107
+ (<FakeNodeList>parent.childNodes).data.splice(i, 1, ...nodes as any);
108
+ }
109
+ for (const n of nodes) {
110
+ n.parentElement = parent;
111
+ }
52
112
  }
53
113
  }
54
- before(...nodes: (MockElement | MockText)[]) {
114
+ before(...nodes: (FakeElement | FakeTextNode)[]) {
55
115
  const parent = this.parentElement;
56
116
  if (parent) {
57
- const i = parent.childNodes.indexOf(this);
58
- if (i >= 0) { parent.childNodes.splice(i, 0, ...nodes); }
59
- for (const n of nodes) n.parentElement = parent;
117
+ const i = (<FakeNodeList>parent.childNodes).data.indexOf(this as any);
118
+ if (i >= 0) {
119
+ for (const n of nodes) {
120
+ if (n === this) continue;
121
+ if (n.parentElement) {
122
+ const ni = (<FakeNodeList>n.parentElement.childNodes).data.indexOf(n as any);
123
+ if (ni >= 0) (<FakeNodeList>n.parentElement.childNodes).data.splice(ni, 1);
124
+ }
125
+ }
126
+ const filtered = nodes.filter(n => n !== this);
127
+ (<FakeNodeList>parent.childNodes).data.splice(i, 0, ...filtered as any);
128
+ for (const n of filtered) {
129
+ n.parentElement = parent;
130
+ }
131
+ }
60
132
  }
61
133
  }
62
- get [Symbol.iterator]() { return Array.prototype[Symbol.iterator].bind(this.children); }
134
+ get [Symbol.iterator]() {
135
+ return Array.prototype[Symbol.iterator].bind(this.children);
136
+ }
63
137
  }
64
138
 
65
- export class MockText {
139
+ export class FakeTextNode {
66
140
  nodeType = NodeConstants.TEXT_NODE;
67
- parentElement: MockElement | null = null;
141
+ parentElement: HTMLElement | null = null;
68
142
  constructor(public nodeValue: string) { }
69
143
  get wholeText() { return this.nodeValue; }
70
- remove() { if (this.parentElement) { const i = this.parentElement.childNodes.indexOf(this); if (i >= 0) this.parentElement.childNodes.splice(i, 1); } }
71
- replaceWith(...nodes: (MockElement | MockText)[]) {
144
+ remove() {
145
+ if (this.parentElement) {
146
+ const i = (<FakeNodeList>this.parentElement.childNodes).data.indexOf(this as any);
147
+ if (i >= 0)
148
+ (<FakeNodeList>this.parentElement.childNodes).data.splice(i, 1);
149
+ }
150
+ }
151
+ replaceWith(...nodes: (FakeElement | FakeTextNode)[]) {
72
152
  const parent = this.parentElement;
73
153
  if (parent) {
74
- const i = parent.childNodes.indexOf(this);
75
- if (i >= 0) { parent.childNodes.splice(i, 1, ...nodes); }
76
- for (const n of nodes) n.parentElement = parent;
154
+ const i = (<FakeNodeList>parent.childNodes).data.indexOf(this as any);
155
+ if (i >= 0) { (<FakeNodeList>parent.childNodes).data.splice(i, 1, ...nodes as any); }
156
+ for (const n of nodes) {
157
+ n.parentElement = parent;
158
+ }
77
159
  }
78
160
  }
79
- before(...nodes: (MockElement | MockText)[]) {
161
+ before(...nodes: (FakeElement | FakeTextNode)[]) {
80
162
  const parent = this.parentElement;
81
163
  if (parent) {
82
- const i = parent.childNodes.indexOf(this);
83
- if (i >= 0) { parent.childNodes.splice(i, 0, ...nodes); }
84
- for (const n of nodes) n.parentElement = parent;
164
+ const i = (<FakeNodeList>parent.childNodes).data.indexOf(this as any);
165
+ if (i >= 0) {
166
+ for (const n of nodes) {
167
+ if (n === this) continue;
168
+ if (n.parentElement) {
169
+ const ni = (<FakeNodeList>n.parentElement.childNodes).data.indexOf(n as any);
170
+ if (ni >= 0) (<FakeNodeList>n.parentElement.childNodes).data.splice(ni, 1);
171
+ }
172
+ }
173
+ const filtered = nodes.filter(n => n !== this);
174
+ (<FakeNodeList>parent.childNodes).data.splice(i, 0, ...filtered as any);
175
+ for (const n of filtered) {
176
+ n.parentElement = parent;
177
+ }
178
+ }
85
179
  }
86
180
  }
87
181
  }
88
182
 
89
183
  export function resetMocks() {
90
184
  const mockDoc: any = {
91
- createElement: (tag: string) => new MockElement(tag),
92
- createTextNode: (text: string) => new MockText(text),
93
- createElementNS: (ns: string, tag: string) => new MockElement(tag),
185
+ createElement: (tag: string) => new FakeElement(tag),
186
+ createTextNode: (text: string) => new FakeTextNode(text),
187
+ createElementNS: (ns: string, tag: string) => new FakeElement(tag),
94
188
  hidden: false,
95
189
  };
96
190
  const mockWin: any = {
package/test/tests-app.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { expect } from "./helper";
2
- import { app, ARTICLE, DIV, P, SPAN } from "../index";
2
+ import { app, ARTICLE, BUTTON, createState, DIV, P, SPAN, SECTION } from "../index";
3
3
 
4
4
  export default {
5
5
  "app(): successful initialization": () => {
@@ -223,4 +223,120 @@ export default {
223
223
 
224
224
  expect(state.x).toEqual(1);
225
225
  },
226
+
227
+ "app(): isolated state of multiple independent vode app instances": () => {
228
+ const root = document.createElement("div");
229
+
230
+ // APP 1 (foo) //
231
+ const containerFoo = document.createElement("div");
232
+ root.appendChild(containerFoo);
233
+ const stateFoo = createState({ count: 0 });
234
+ const patchFoo = app<typeof stateFoo>(containerFoo, stateFoo, (s) => [
235
+ DIV,
236
+ [P, `App 1 count: ${s.count}`],
237
+ [BUTTON, {
238
+ onclick: () => {
239
+ // sync state2 from app1 via the returned patch function
240
+ patchBar({ count: stateBar.count + 1 });
241
+ return { count: s.count + 1 };
242
+ }
243
+ }, "Sync +1"],
244
+ ]);
245
+ /////////////////
246
+
247
+ // APP 2 (bar) //
248
+ const containerBar = document.createElement("div");
249
+ root.appendChild(containerBar);
250
+ const stateBar = createState({ count: 0 });
251
+ const patchBar = app<typeof stateBar>(containerBar, stateBar, (s) => [
252
+ DIV,
253
+ [P, `App 2 count: ${s.count}`],
254
+ ]);
255
+ /////////////////
256
+
257
+ expect(containerFoo).toMatch(
258
+ [DIV,
259
+ [P, "App 1 count: 0"],
260
+ [BUTTON, "Sync +1"],
261
+ ]
262
+ );
263
+
264
+ expect(containerBar).toMatch(
265
+ [DIV,
266
+ [P, "App 2 count: 0"],
267
+ ]
268
+ );
269
+
270
+ // Patch state1 independently: no effect on state2
271
+ patchFoo({ count: 5 });
272
+
273
+ expect(containerFoo).toMatch(
274
+ [DIV,
275
+ [P, "App 1 count: 5"],
276
+ [BUTTON, "Sync +1"],
277
+ ]
278
+ );
279
+
280
+ expect(containerBar).toMatch(
281
+ [DIV,
282
+ [P, "App 2 count: 0"],
283
+ ]
284
+ );
285
+
286
+ // Patch state2 independently: no effect on state1
287
+ patchBar({ count: 3 });
288
+
289
+ expect(containerFoo).toMatch(
290
+ [DIV,
291
+ [P, "App 1 count: 5"],
292
+ [BUTTON, "Sync +1"],
293
+ ]
294
+ );
295
+
296
+ expect(containerBar).toMatch(
297
+ [DIV,
298
+ [P, "App 2 count: 3"],
299
+ ]
300
+ );
301
+
302
+ // Sync state2 via the returned patch function
303
+ patchBar({ count: 10 });
304
+
305
+ expect(containerBar).toMatch(
306
+ [DIV,
307
+ [P, "App 2 count: 10"],
308
+ ]
309
+ );
310
+ },
311
+
312
+ "app(): root tag changes between renders": () => {
313
+ const root = document.createElement("div");
314
+ const container = document.createElement("div");
315
+ root.appendChild(container);
316
+ const state = createState({ useSection: false });
317
+
318
+ const patch = app<typeof state>(container, state, (s) =>
319
+ s.useSection ? [SECTION, "section mode"] : [DIV, "div mode"]
320
+ );
321
+
322
+ expect(container).toMatch([DIV, "div mode"]);
323
+
324
+ patch({ useSection: true });
325
+
326
+ expect(root).toMatch([DIV, [SECTION, "section mode"]]);
327
+ },
328
+
329
+ "app(): event handler with object patch": () => {
330
+ const root = document.createElement("div");
331
+ const container = document.createElement("div");
332
+ root.appendChild(container);
333
+ const state: any = { count: 0 };
334
+
335
+ app(container, state, (s: any) =>
336
+ [DIV, { onclick: { count: 42 } }, "click me"]
337
+ );
338
+
339
+ const el = (container as any)._vode.vode.node;
340
+ expect(el.onclick).toBeA("function");
341
+ },
226
342
  };
@@ -0,0 +1,160 @@
1
+ import { expect } from "./helper";
2
+ import { app, createState, DIV, ARTICLE, SECTION, P } from "../index";
3
+
4
+ function setup() {
5
+ const root = document.createElement("div");
6
+ const container = document.createElement("div");
7
+ root.appendChild(container);
8
+ return container;
9
+ }
10
+
11
+ export default {
12
+ "catch: function fallback renders instead of broken component": () => {
13
+ const container = setup();
14
+ const broken = () => { throw new Error("boom"); };
15
+
16
+ app(container, {}, () =>
17
+ [DIV,
18
+ [SECTION,
19
+ { catch: (s: unknown, err: Error) => [P, `caught: ${err.message}`] },
20
+ broken
21
+ ]
22
+ ]
23
+ );
24
+
25
+ expect(container).toMatch(
26
+ [DIV,
27
+ [P, "caught: boom"]
28
+ ]
29
+ );
30
+ },
31
+
32
+ "catch: static vode fallback renders instead of broken component": () => {
33
+ const container = setup();
34
+ const broken = () => { throw new Error("boom"); };
35
+
36
+ app(container, {}, () =>
37
+ [DIV,
38
+ [SECTION,
39
+ { catch: [ARTICLE, "error occurred"] },
40
+ broken
41
+ ]
42
+ ]
43
+ );
44
+
45
+ expect(container).toMatch(
46
+ [DIV,
47
+ [ARTICLE, "error occurred"]
48
+ ]
49
+ );
50
+ },
51
+
52
+ "catch: nested error boundaries — inner catch handles inner error": () => {
53
+ const container = setup();
54
+ const broken = () => { throw new Error("inner boom"); };
55
+
56
+ app(container, {}, () =>
57
+ [DIV,
58
+ [SECTION,
59
+ [P,
60
+ {
61
+ catch: [ARTICLE, "inner fallback"]
62
+ },
63
+ broken
64
+ ]
65
+ ]
66
+ ]
67
+ );
68
+
69
+ expect(container).toMatch(
70
+ [DIV,
71
+ [SECTION,
72
+ [ARTICLE, "inner fallback"]
73
+ ]
74
+ ]
75
+ );
76
+ },
77
+
78
+ "catch: nested error boundaries — outer catches when inner has no handler": () => {
79
+ const container = setup();
80
+ const broken = () => { throw new Error("boom"); };
81
+
82
+ app(container, {}, () =>
83
+ [DIV,
84
+ [SECTION,
85
+ { catch: [P, "outer caught it"] },
86
+ [ARTICLE, broken]
87
+ ]
88
+ ]
89
+ );
90
+
91
+ expect(container).toMatch(
92
+ [DIV,
93
+ [P, "outer caught it"]
94
+ ]
95
+ );
96
+ },
97
+
98
+ "catch: error propagates when no handler exists on entire tree": () => {
99
+ const container = setup();
100
+ const broken = () => { throw new Error("crash"); };
101
+ let threw = false;
102
+
103
+ try {
104
+ app(container, {}, () =>
105
+ [DIV, [P, broken]]
106
+ );
107
+ } catch {
108
+ threw = true;
109
+ }
110
+
111
+ expect(threw).toEqual(true);
112
+ },
113
+
114
+ "catch: catch handler changed on A→A path": () => {
115
+ const container = setup();
116
+ const state = createState({ catchValue: "v1", showBroken: false });
117
+ const broken = () => { throw new Error("boom"); };
118
+
119
+ const patch = app<typeof state>(container, state, (s) =>
120
+ [DIV,
121
+ [SECTION,
122
+ { catch: [P, s.catchValue] },
123
+ s.showBroken ? broken : "ok"
124
+ ]
125
+ ]
126
+ );
127
+
128
+ expect(container).toMatch(
129
+ [DIV, [SECTION, "ok"]]
130
+ );
131
+
132
+ patch({ catchValue: "v2", showBroken: true });
133
+
134
+ expect(container).toMatch(
135
+ [DIV, [P, "v2"]]
136
+ );
137
+ },
138
+
139
+ "catch: error in one sibling doesn't affect the other": () => {
140
+ const container = setup();
141
+ const broken = () => { throw new Error("boom"); };
142
+
143
+ app(container, {}, () =>
144
+ [DIV,
145
+ [SECTION,
146
+ { catch: [P, "whoops"] },
147
+ broken
148
+ ],
149
+ [ARTICLE, "i am fine"]
150
+ ]
151
+ );
152
+
153
+ expect(container).toMatch(
154
+ [DIV,
155
+ [P, "whoops"],
156
+ [ARTICLE, "i am fine"]
157
+ ]
158
+ );
159
+ },
160
+ };
@@ -70,5 +70,26 @@ export default {
70
70
 
71
71
  expect(state.patch)
72
72
  .toEqual(undefined);
73
- }
73
+ },
74
+
75
+ "defuse(): clears event listeners from child vodes without _vode": () => {
76
+ const root = document.createElement("div");
77
+ const container = document.createElement("div");
78
+ root.appendChild(container);
79
+ app(container, {}, () => [DIV, { onclick: () => ({}) },
80
+ [DIV, { onclick: () => ({}) }]
81
+ ] as any);
82
+ const v = (container as any)._vode.vode;
83
+ const child1 = (v as any).node;
84
+ const child1onclick = child1.onclick;
85
+ const child2 = (v as any)[2].node;
86
+ expect(typeof child1onclick).toEqual("function");
87
+ expect(typeof child2.onclick).toEqual("function");
88
+ defuse(container as any);
89
+
90
+ expect(child1.onclick)
91
+ .toEqual(null);
92
+ expect(child2.onclick)
93
+ .toEqual(null);
94
+ },
74
95
  };