Skip to content

Commit f08cab0

Browse files
committedJun 19, 2021
add tests for vdom jsx parser and fix reconciliation bug
1 parent c79921f commit f08cab0

File tree

7 files changed

+461
-41
lines changed

7 files changed

+461
-41
lines changed
 

‎.gitignore

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
progress/
33
node_modules/
44
package-lock.json
5-
data/
5+
data/
6+
dev/

‎playground/index.html

+2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
</head>
55

66
<body>
7+
<script src="../src/vdom.js"></script>
78
<script src="../src/poseidon.js"></script>
9+
<!-- Will eventually replace with one script tag but to prevent constantly copying-->
810
<script src="./main.js"></script>
911
</body>
1012
</html>

‎playground/main.js

+56-24
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,6 @@ class Form extends Component {
7373
console.log(this.store);
7474
}
7575

76-
styles() {
77-
return css`
78-
div {
79-
background-color: blue;
80-
margin: 0;
81-
padding-bottom: 10px;
82-
}
83-
`
84-
}
85-
8676
create() {
8777
return {tag: "div",
8878
children: [
@@ -123,20 +113,63 @@ class App extends Component {
123113
console.log(this.node);
124114
}
125115

116+
styles() {
117+
return css`
118+
div {
119+
background-color: yellow;
120+
margin: 0;
121+
padding-bottom: 10px;
122+
}
123+
`
124+
}
125+
126126
create() {
127-
return {
128-
tag: "div",
129-
attributes: {style: "color: green; "} ,
130-
children: [
131-
{tag: "h1",
132-
children: [
133-
{
134-
tag: "TEXT_ELEMENT",
135-
nodeValue: "Hello world"
136-
}
137-
]
138-
},
139-
this.form.node]
127+
return vdom`<div className = "test" id ="oi">
128+
<h1> Hello world </h1>
129+
<img src="../docs/gcd.png />
130+
<br/>
131+
<input value = "" placeholder = "cheeky" />
132+
<p> This looks and feels like <strong>HTML and JSX</strong></p>
133+
<a href = "https://google.com">Link</a>
134+
<div>
135+
<ul>
136+
<li> Hello </li>
137+
<li> What's up </li>
138+
</ul>
139+
<table style="width:100%">
140+
<tr>
141+
<th>Firstname</th>
142+
<th>Lastname</th>
143+
<th>Age</th>
144+
</tr>
145+
<tr>
146+
<td>Jill</td>
147+
<td>Smith</td>
148+
<td>50</td>
149+
</tr>
150+
<tr>
151+
<td>Eve</td>
152+
<td>Jackson</td>
153+
<td>94</td>
154+
</tr>
155+
</table>
156+
</div>
157+
</div>`
158+
159+
// {
160+
// tag: "div",
161+
// attributes: {style: "color: green; "} ,
162+
// children: [
163+
// {tag: "h1",
164+
// children: [
165+
// {
166+
// tag: "TEXT_ELEMENT",
167+
// nodeValue: "Hello world"
168+
// }
169+
// ]
170+
// },
171+
// this.form.node]
172+
// }
140173
// {tag: "button",
141174
// children: [
142175
// {
@@ -145,7 +178,6 @@ class App extends Component {
145178
// }
146179
// ],
147180
// events: {"click": () => this.render()}}]
148-
}
149181
}
150182
}
151183

‎src/index.js

+25-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
11
//eventually put stuff here to require as entry to module
22
const {
3-
Component
4-
} = require("./poseidon.js")
3+
Component,
4+
List,
5+
Atom,
6+
ListOf,
7+
CollectionStore,
8+
CollectionStoreOf,
9+
Router,
10+
css
11+
} = require("./poseidon.js")
12+
13+
const {
14+
vdom
15+
} = require("./vdom.js");
16+
17+
const exports = {
18+
Component,
19+
List,
20+
Atom,
21+
ListOf,
22+
CollectionStore,
23+
CollectionStoreOf,
24+
Router,
25+
css,
26+
vdom
27+
} = module.exports;

‎src/poseidon.js

+37-14
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ const printDOMTree = (node, tabs = "") => {
1111
return prettyPrint;
1212
}
1313
}
14-
15-
14+
1615
function updateDOMProperties(node, prevVNode, nextVNode) {
1716
//if this is a text node, update the text value
1817
if (prevVNode.tag == "TEXT_ELEMENT" && nextVNode.tag == "TEXT_ELEMENT") {
@@ -22,7 +21,11 @@ function updateDOMProperties(node, prevVNode, nextVNode) {
2221
//remove attributes
2322
Object.keys(prevVNode.attributes || [])
2423
.forEach((key, _) => {
25-
node.removeAttribute(key);
24+
//for some special cases like className, need to access the property as a key
25+
//otherwise removeAttribute will not work
26+
//TODO: potentially change this into a map, are there other attributes this need to
27+
//be done for?
28+
key === 'className' ? node.removeAttribute('class') : node.removeAttribute(key);
2629
});
2730

2831
//remove old event listeners
@@ -35,6 +38,7 @@ function updateDOMProperties(node, prevVNode, nextVNode) {
3538
//add attributes
3639
Object.keys(nextVNode.attributes || [])
3740
.forEach((key, _) => {
41+
//NOTE className not class
3842
node[key] = nextVNode.attributes[key];
3943
});
4044

@@ -148,7 +152,7 @@ const renderVDOM = (newVNode, prevVNode, nodeDOM) => {
148152
var node = normalize(null);
149153
//same node, only update properties
150154
if (sameType) {
151-
//means we have an element loaded in a list node
155+
//means we have an element loaded in a list node since list nodes hand over fully rendered DOM nodes
152156
if (newVNode.tag === undefined) {
153157
updateQueue.push({op: REPLACE, details: {dom: nodeDOM, previous: prevVNode, node: newVNode}});
154158
node = newVNode;
@@ -176,15 +180,26 @@ const renderVDOM = (newVNode, prevVNode, nodeDOM) => {
176180
//note if the DOM node is undefined, then that node has already been handled i.e. removed or added in a previous iteration
177181
if (nodeDOM) {
178182
updateQueue.push({op: DELETE, details: {parent: nodeDOM.parentNode, node: nodeDOM}});
183+
//Note we want to to return here (i.e. not perform any work yet) to avoid removing DOM nodes before
184+
//we have processed all of the children (i.e. sibilings of the current node). This means we defer the `performWork` operation
185+
//to be called by the parent. Note there is no scenario where we would encounter
186+
//an empty newVNode that reaches this block without being called by a parent.
187+
return node;
179188
}
180189
} else if (prevVNode.tag == "") {
181-
// create new node
190+
//Double check: is this bit already implemented?
191+
//-----------
192+
//uses a similar heuristic to the React diffing algorithm
193+
//if the nodes are dif create new node
194+
//-----------
195+
//create new node
182196
node = instantiate(newVNode);
183197
if (nodeDOM) {
184198
//return child, parent will handle the add to the queue
185199
return node;
186200
}
187201
updateQueue.push({op: APPEND, details: {parent: null, node: node}});
202+
188203
} else {
189204
//node has changed, so replace
190205
updateQueue.push({op: REPLACE, details: {dom: nodeDOM, previous: prevVNode, node: newVNode}});
@@ -282,7 +297,6 @@ class Component {
282297
//helper method for adding component-defined styles
283298
addStyle() {
284299
//write own css parser
285-
//guide e.g. https://medium.com/@benjamin.d.johnson/create-a-css-parser-using-template-literals-md-3905905a569e
286300
const style = document.createElement('style');
287301
const userStyles = this.styles();
288302
if (userStyles) {
@@ -304,7 +318,6 @@ class Component {
304318
} else {
305319
cssNode.appendChild(document.createTextNode(text));
306320
}
307-
console.log(cssNode);
308321
document.getElementsByTagName('head')[0].appendChild(cssNode);
309322
}
310323
}
@@ -337,12 +350,15 @@ class Component {
337350
class Listening {
338351
constructor() {
339352
this.handlers = new Set();
353+
//represent the current state of the data
354+
//used to determine when a change has happened and execute the corresponding handler
355+
this.state = null;
340356
}
341-
//represent the current state of the data
342-
//used to determine when a change has happened and execute the corresponding handler
343-
state;
344357
//return summary of state
345-
summarize;
358+
summarize() {
359+
return null;
360+
}
361+
346362
//used to listen to and execute handlers on listening to events
347363
fire() {
348364
this.handlers.forEach(handler => {
@@ -590,12 +606,19 @@ class Router {
590606
//connects patterns to callable objects (components or DOM elements to be rendered)
591607
}
592608

593-
module.exports = {
609+
const exposed = {
594610
Component,
595611
List,
596612
Atom,
597613
ListOf,
598614
CollectionStore,
599615
CollectionStoreOf,
600-
Router
601-
}
616+
Router,
617+
css
618+
}
619+
620+
if (typeof window === 'object') {
621+
Object.assign(window, exposed)
622+
} else {
623+
module.exports = exposed;
624+
}

‎src/vdom.js

+245
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
//jsx like parser written in Javascript for Poseidon's vdom
2+
3+
//TODO:
4+
//Interpolate all template literals with string values and store them in a map
5+
//Then build node out, when a string is encountered, go retrieve that value as the tree is being constructed
6+
//makes the algorithm more efficient since only one traversal is done
7+
8+
9+
//Reader class to abstract lexing and scanning of a vdom template string
10+
class Reader {
11+
constructor(string) {
12+
this.string = string;
13+
this.index = 0;
14+
this.length = string.length;
15+
//set of special characters to return when getNextWord is called
16+
this.specialCharacters = new Set([' ', '=', '<', '>']);
17+
}
18+
19+
peek() {
20+
if (this.index < this.length - 1) {
21+
return this.string[this.index + 1];
22+
}
23+
return null;
24+
}
25+
26+
//gets the next word, keeps moving forward until until it encounters one of the special tags or a closing '/>'
27+
getNextWord() {
28+
var currIndex = this.index;
29+
var finalIndex = currIndex;
30+
var quoteCount = 0;
31+
while (!this.specialCharacters.has(this.currentChar)) {
32+
//if we have quotes, skip them
33+
//TODO: add more robust type checking we have the same type of quote
34+
if (this.currentChar === '"' || this.currentChar === "'") {
35+
//adjust starting point of returned work if we encounter an opening quote
36+
if (quoteCount === 0) {
37+
quoteCount++;
38+
currIndex = this.index + 1;
39+
} else if (quoteCount === 1) {
40+
finalIndex = this.index - 1;
41+
break;
42+
}
43+
} else if (this.currentChar === '/') {
44+
//handle special case where next word might be adjacent to a /> tag so return the word before
45+
//this tag
46+
//otherwise, since this is
47+
if (this.peek() === '>') break
48+
} else {
49+
finalIndex = this.index;
50+
}
51+
this.consume();
52+
}
53+
//skip any spaces for future
54+
this.skipSpaces();
55+
return this.string.substring(currIndex, finalIndex + 1);
56+
}
57+
58+
get currentChar() {
59+
return this.string[this.index];
60+
}
61+
62+
//skip all white spaces and new line characters
63+
skipSpaces() {
64+
while (this.currentChar === " " || this.currentChar === '\n') {
65+
this.consume();
66+
}
67+
}
68+
69+
consume() {
70+
return this.string[this.index++];
71+
}
72+
73+
//combination of consume and skipping white space since this pattern crops up frequently
74+
skipToNextChar() {
75+
this.consume();
76+
this.skipSpaces();
77+
}
78+
79+
//helper method to keep moving pointer until the current char is the provided one
80+
getUntilChar(char) {
81+
const currIndex = this.index;
82+
var finalIndex = currIndex;
83+
while (this.currentChar != char) {
84+
this.consume();
85+
finalIndex = this.index;
86+
}
87+
return this.string.substring(currIndex, finalIndex);
88+
}
89+
90+
//keep moving pointer forward until AFTER we encounter a char (i.e pointer now points to character after matching provided)
91+
skipPastChar(char) {
92+
var text = this.getUntilChar(char);
93+
text += this.consume();
94+
this.skipSpaces();
95+
return text;
96+
}
97+
}
98+
99+
100+
//recursively loop checking children
101+
const parseChildren = (closingTag, reader) => {
102+
try {
103+
let children = [];
104+
//check in the scenario where we have an empty HTML node with no children
105+
if (foundClosingTag(closingTag, reader)) {
106+
return children;
107+
}
108+
reader.skipSpaces();
109+
var nextChild = parseTag(reader);
110+
111+
while (nextChild) {
112+
children.push(nextChild);
113+
if (foundClosingTag(closingTag, reader)) break;
114+
nextChild = parseTag(reader);
115+
}
116+
return children;
117+
} catch (e) {
118+
throw e;
119+
}
120+
}
121+
122+
123+
//helper method to check if we've encountered the closing tag of a node
124+
//return true if we have and false if we have not encountered the closing tag
125+
const foundClosingTag = (closingTag, reader) => {
126+
if (reader.currentChar === '<' && reader.peek() === '/') {
127+
//if we encounter closing tag i.e. '</' then end parsing of this tag
128+
reader.skipPastChar('/');
129+
const closingTag = reader.getNextWord();
130+
reader.skipPastChar('>');
131+
if (closingTag !== closingTag) throw 'Error parsing the body of an HTML node!'
132+
return true;
133+
}
134+
return false
135+
}
136+
137+
138+
//parse a complete HTML node tag from opening <> to closing </>
139+
//TODO: handle special cases like </br> and img
140+
const parseTag = (reader) => {
141+
//if the current char is not a < tag, then either we've finished parsing valid tags or this is a text node
142+
if (reader.currentChar !== '<') {
143+
const word = reader.getUntilChar('<');
144+
if (!word) return null;
145+
//otherwise, we've found a text node!
146+
return {tag: "TEXT_ELEMENT", nodeValue: word};
147+
} else if (reader.peek() === '/') {
148+
//just encountered a '</' indicating a closing tag so return
149+
return null;
150+
}
151+
//skip < tag
152+
reader.consume();
153+
const name = reader.getNextWord();
154+
const node = {
155+
tag: name,
156+
children: [],
157+
attributes: {},
158+
events: {}
159+
}
160+
//boolean variable to handle special children and not parse the children
161+
var specialChar = false;
162+
//Match key-value pairs in initial node definition (i.e. from first < to first > tag, recall closing node tag is </)
163+
while (reader.currentChar !== '>') {
164+
const key = reader.getNextWord();
165+
//handle special self-closing tags like <br/> and <img />
166+
if (key === '/' && reader.peek() === '>') {
167+
reader.consume();
168+
specialChar = true;
169+
break;
170+
}
171+
//key on its own is still valid, so check if we need to map to a specific value
172+
if (reader.currentChar !== '=') {
173+
node.attributes[key] = null;
174+
continue;
175+
}
176+
//skip equal sign
177+
reader.skipToNextChar();
178+
//get value associated with this key, TODO: not sure about this bit, what if mapping to a non-template literal like a variable
179+
const value = reader.getNextWord();
180+
//if the key starts with an on, this is an event, so we should save it accordingly
181+
if (key.startsWith("on")) {
182+
node.events[key] = value;
183+
} else {
184+
//otherwise, this is an attribute so add it there
185+
node.attributes[key] = value;
186+
}
187+
}
188+
//skip closing >
189+
//note we don't skip any white-spaces here to perserve integraity when DOM rendering as the vdom was actual html
190+
//more info here- TODO:???
191+
reader.skipToNextChar();
192+
//match actual body of the node
193+
if (!specialChar) node.children = parseChildren(name, reader);
194+
reader.skipSpaces();
195+
//return JSON-formatted vdom node
196+
return node;
197+
}
198+
199+
200+
//take advantage of Javascript template literals which gives us a string and a list of interpolated values
201+
const vdom = (templates, ...values) => {
202+
//first pass over templates to create JSON AST
203+
//HTML parsing
204+
const reader = new Reader(templates[0]);
205+
//Switch on different tokens
206+
//recursively parse children
207+
try {
208+
for (;;) {
209+
reader.skipSpaces();
210+
switch (reader.currentChar) {
211+
case '<':
212+
//opening tag, do stuff
213+
return parseTag(reader);
214+
215+
//loop and map keys to values
216+
//then recurse on children
217+
case '>':
218+
//closing tag, do other stuff
219+
case '{':
220+
//other stuff
221+
case '}':
222+
//other other stuff
223+
224+
}
225+
}
226+
} catch (e) {
227+
console.error(e);
228+
}
229+
230+
//populate this
231+
let tag;
232+
let children = [];
233+
let events = [];
234+
let attributes = {};
235+
}
236+
237+
238+
//expose reader initially for debugging and testing purposes
239+
//named exposedV so different from poseidon since for now have two script tags for the playground
240+
const exposedV = {
241+
vdom,
242+
parseTag,
243+
Reader
244+
}
245+
module.exports = exposedV;

‎tests/test6jsxparser.js

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
const {
2+
Component,
3+
List,
4+
Atom,
5+
ListOf,
6+
CollectionStore,
7+
CollectionStoreOf,
8+
css
9+
} = require("../src/poseidon.js");
10+
11+
const {
12+
vdom
13+
} = require("../src/vdom.js")
14+
15+
16+
class App extends Component {
17+
init(res) {
18+
this.data = res;
19+
}
20+
styles() {
21+
return css`
22+
.stuff {
23+
margin: 0;
24+
}
25+
`
26+
}
27+
create(res) {
28+
return res;
29+
}
30+
}
31+
const test = require('ava');
32+
const browserEnv = require("browser-env");
33+
browserEnv(["document"]);
34+
35+
test.beforeEach(t => {
36+
let root = document.getElementById("root");
37+
if (!root) {
38+
root = document.createElement("div");
39+
root.id = "root";
40+
document.body.appendChild(root);
41+
}
42+
t.context.root = root;
43+
});
44+
45+
test('testHTMLElementParsing', t => {
46+
const root = t.context.root;
47+
//div with nested elements
48+
const testDiv = vdom`
49+
<div className = "stuff" id = "smt">
50+
<h1> Hello </h1>
51+
<p> What's popping my fellow friend </p>
52+
</div>
53+
`
54+
const app = new App(testDiv);
55+
root.appendChild(app.node);
56+
t.is(root.innerHTML, `<div class="stuff" id="smt"><h1>Hello </h1><p>What's popping my fellow friend </p></div>`);
57+
58+
//div with list elements
59+
//intentionally use inconsistent spacing to make sure parser works in all cases
60+
const testList = vdom`
61+
<div className="wrapper">
62+
<ul>
63+
<li>Item 1</li>
64+
<li>Item 2</li>
65+
<li>Item 3</li>
66+
</ul>
67+
</div>
68+
`
69+
app.render(testList);
70+
t.is(root.innerHTML, `<div class="wrapper"><ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul></div>`);
71+
72+
//test special self-closing HTML tags
73+
const selfClosing = vdom`
74+
<div>
75+
<img src="../docs/gcd.png" />
76+
<p> Hello is a break working </p>
77+
<br/>
78+
<input />
79+
<p> Yes it's working</p>
80+
</div>`;
81+
app.render(selfClosing);
82+
t.is(root.innerHTML, `<div><img src="../docs/gcd.png"><p>Hello is a break working </p><br><input><p>Yes it's working</p></div>`)
83+
console.log(app.vdom);
84+
//more HTML tags, strong and a
85+
const strongAndA = vdom`
86+
<div>
87+
<p>This looks and feels like <strong>HTML and JSX</strong></p>
88+
<a href = "https://google.com">Link</a>
89+
</div>
90+
`
91+
app.render(strongAndA);
92+
console.log(root.innerHTML);
93+
t.is(root.innerHTML, `<div><p>This looks and feels like <strong>HTML and JSX</strong></p><a href="https://google.com">Link</a></div>`)
94+
});

0 commit comments

Comments
 (0)
Please sign in to comment.