Back

The Double Encoded VNode

📖 tl;dr: I've received a few messages asking about how to debug an issue the past days, so I thought I'd be a good idea to write down my thinking process. Sure enough the next issue didn't take long to appear and deals with a strange object passed to createElement() 🤔

Someone in our Preact Slack channel (join here) was running into an issue where the page would be blank as soon as the page loaded. Luckily we got a nice exception logged in the browser console. Error stack traces are awesome as they point you directly to the portion where the error occured and allow you to follow back the trail to see how the error came to be.

preact.mjs:256 Uncaught DOMException:

Failed to execute 'createElement' on 'Document': The tag name provided
('[object Object]') is not a valid name.

at C (webpack-internal:///../node_modules/preact/dist/preact.mjs:213:155)
at x (webpack-internal:///../node_modules/preact/dist/preact.mjs:175:16)
at b (webpack-internal:///../node_modules/preact/dist/preact.mjs:108:83)
at j (webpack-internal:///../node_modules/preact/dist/preact.mjs:262:32)
at eval (webpack-internal:///./main.debug.tsx:9:54)
at Module../main.debug.tsx (http://localhost:8080/app.bundle.js:1126:1)
at __webpack_require__ (http://localhost:8080/app.bundle.js:724:30)
at fn (http://localhost:8080/app.bundle.js:101:20)
at Object.0 (http://localhost:8080/app.bundle.js:1139:18)
at __webpack_require__ (http://localhost:8080/app.bundle.js:724:30)

Unfortunately the code is minified and the sourcemaps didn't seem to work which explains the single letter function names. Nonetheless the error message gives us a few clues. It looks like there is a function called createElement somwhere that complained about receiving an invalid argument.

Knowing a thing or two about virtual dom libraries (hint I work on Preact) this is very likely about the JSX constructor function. It's the one responsible for turning JSX into virtual-dom nodes (short: vnode) that represent a sort-of template. Most projects transpile it via babel or the TypeScript compiler to plain function calls that js engines can understand.

<div /> -> createElement('div');

// with props
<div class="foo" /> -> createElement('div', { class: 'foo' });

// with children
<div>foo</div> -> createElement('div', null, 'foo');

These in turn convert the arguments to a vnode:

const vnode = createElement("div", { class: "foo" });
console.log(vnode);
// Logs:
// {
// type: 'span',
// props: { class: 'foo' }
// text: null,
// }

Note: Nearly all virtual-dom based frameworks work with a similar shaped object under the hood. Some rename properties like nodeName instead of type but that's mostly it.

So it's this function is called with something it doesn't like. Let's look at the code that triggers it:

// foo.js
export default <div />;

// app.js
import { render } from "preact";
import Foo from "./foo";

render(<Foo />, document.getElementById('root));

Nothing out of the ordinary and it looks like a typical setup for any modern web app. The browser's debugger was pointing at the highlighted line where the render function is called. It seems innocent, but something must be up with it. Since the render function strangely didn't turn up as part of the stacktrace we'll have trouble finding the right place to set a breakpoint. Following imports is usually not much fun when you can't cmd/ctrl+click a variable like you can in vscode or similar IDEs.

In these situations there is one neat little trick we can do without having to dive into the code of the library in use (that's Preact in this example).

import { render } from "preact";
import Foo from "./foo";

// Wrap it and log first argument
function render2(arg1, dom) {
console.log(arg1);
render(arg1, dom);
}

// call our wrapped function
render2(<Foo />, document.getElementById('root));

And sure enough we receive something suspicious:

{
"type": {
"type": "span",
"props": {},
"text": null
},
"props": {},
"text": null
}

Well that's odd, it seems like the type-property isn't just the string span but holds another object that looks like another vnode. This nesting is probably invalid, so let's look again at our code and search for any hints. To make this easier for myself I disabled sourcemaps in the browser's devtools.

// foo.js
import { createElement } from "preact";
export default createElement("div", null);

// app.js
import { render } from "preact";
import Foo from "./foo";

render(createElement(Foo, null), document.getElementById("root"));

Wait a second, two calls to createElement? Do we double encode our JSX element? Yup, we do! We already apply our JSX constructor in foo.js, so we don't need to pass it through createElement again. The fix is easy:.

// replace this...
render(<App />, document.getElementById('root'));

// ...with this
render(App, document.getElementById('root));

So what did we just do there? How did we go from an error to finding the cause?

1. Identification 💥

The most important part of any debugging story is finding a way to get to an error or into an incorrect state. Only after one has identified the error is one able to fix it. In our case it was quite easy as just firing up the browser lead to a nice stack trace and we didn't have to go through any additional steps to reproduce it. We're not always that lucky.

2. Isolation🔧

Once the issue has been identified we need a way to reduce the amount of code we neeed to step through. The less code that causes the error, the better. If you have a stack trace you can often walk it backwards and ignore the rest of your code. For frontend applications you can usually narrow it down to a few or even a single component.

3. The Fix ✅

At this point you should have found the error and are able to reproduce it in isolation. Now comes the hard part of finding a fix for it. This is highly specific to the code you're working on so it's hard to make some general rules here. What I find most useful is trying to understand the state or context of the currently executing function.

When there is an issue in Preact we need to be very aware of the shape the current vnode tree is in. Knowing that is usually half the work to finding a proper fix.

Conclusion

We fixed it! With some ciritical thinking we were quickly able to pin-point the issue to a vnode being encoded twice. That's one more happy Preact user 🎉 Over the years I've taken the 3 steps to debugging by heart and they are still the same principles I apply when working with unfamiliar code bases🍀