DEV Community

David Newberry
David Newberry

Posted on

Hearsay, part 2: Props

My previous post on hearsay discusses how this problem came up for me.

In hearsay, I want to be able to pass data into components using the props attribute, and specifying a JavaScript object literal. Something like this:

<hear-say src="Comp.html" props="{ n: Math.random() }"></hear-say>
Enter fullscreen mode Exit fullscreen mode

Let's start with the fundamental task: the attribute should be interpreted as JavaScript code. So I created a custom getter:

get props()
{    
    const prop_att = this.getAttribute("props")?.trim() || "{}";
    const prop_func = Function("self", `return ${prop_att};`);
    return prop_func(this);
}
Enter fullscreen mode Exit fullscreen mode

This creates a new function with a single argument self, and the body of the function simply returns whatever the expression evaluates to. So every time this component accesses self.props.n, it will call Math.random() and return a new value.

This is good, but a straightforward setter turned out to be a problem.

set props(val)
{
    this.setAttribute("props", JSON.stringify(val));
}
Enter fullscreen mode Exit fullscreen mode

When I tried updating the value of props, I ran into a problem. A line of code like self.props = { ...self.props } (never mind accomplishing anything useful) would result in the value changing from { n: Math.random() } to, e.g., { n: 0.42 }. The code would be overwritten with a value.

It occurred to me that I could create another property, call it propsData, that could hold data written to props. This way you can write whatever you want to props, and it won't overwrite the code there.

I changed the setter to store data in propsData. And it places the data into a props-data attribute to make it easy to see when inspecting the DOM.

set props(val)
{
    this.propsData = val;
    this.setAttribute("props-data", JSON.stringify(val));
}
Enter fullscreen mode Exit fullscreen mode

It's a little weird, the behavior isn't immediately intuitive I'm afraid, but on balance I think it's a good approach. Now I just had to give myself more work.

I thought it would be nice if the user could update props (or propsData, really) by mutating it. The solution that came to mind was to create a special Proxy for props that also incorporates propsData.

What I want it to do is sort of synthesize props and propsData. When code references props.foo, I want it to check both props and propsData for foo. If it exists in both places, it will prefer the propsData version. If foo doesn't exist in propsData, it will return whatever props.foo holds (if anything).

So far so good. But what if props holds nested objects? Code that sets, e.g., props.foo.bar.baz = 42 will run into an issue. As it goes down the props chain (getting first foo and then bar) the corresponding chain under propsData is empty (there is no propsData.foo, much less propsData.foo.bar). To compensate, if the getter finds an object property in the props chain and no corresponding propsData property, it will fill it in with an empty object.

This is basically how the current version of the code works, with one more provision. When evaluating an expression like props.foo.bar = 42, foo will return an object wrapped in a Proxy, which basically holds two things: the value for foo inside props, and inside propsData. This is exactly what it does for the props chain, but the propsData chain needs to be a little different.

At first I had it construct an object with one field for the props chain and one for the propsData chain. The Proxy target for foo in props.foo, for example, would look like { props: {bar: 42}, propsData: {} }. But that leaves the propsData chain disconnected from propsData. Instead, the Proxy target changed: a prop property was added. Now instead of the propsData property holding the value of, e.g., foo.bar, it would hold the value of foo, and prop would be bar.

This setup allows a backwards chaining effect. The Proxy for bar would look like { props: 42, propsData: propsData.foo, prop: "bar" }. The Proxy's setter runs target.propsData[target.prop] = val. target.propsData will be propsData.foo, so the effect is the same as setting propsData.foo.bar = val.

I hope you found this post somewhat useful. I fear this explanation is not as clear as I had hoped. If you have any questions, please feel free to comment.

Top comments (0)