Let Value Viewers be dynamic

I’m trying to use a Value Viewer to automagically pretty-print instances of a class. This is what I keep trying:

const { ValueViewerSymbol } = require("@runkit/value-viewer");
class MyClass {
  get [ValueViewerSymbol]() { /* ... render code ... */ }
}

But I’m running into some problems:

  • ValueViewerSymbol must be an own property of the object, it can’t live on Class.prototype
  • It doesn’t work when it’s a getter, it has to be a static value (so if the class instance’s properties change over its lifetime, it may wind up out of date)
  • It also fails if the HTML property has a getter

The use case I have in mind is pretty printing on npm.runkit.com. A package’s runkitExample could look like this:

const { Dog } = require('my-cool-dog');
require('@runkit/my-username/inject-dog-viewer/1.0.0')(Dog);

new Dog();

…where the imported notebook would inject a Value Viewer onto my class. I was able to find a workaround with my package because it already returns a JS Proxy, but it required RunKit-specific behavior on my published package, and Proxies are a blunt tool if all you want to do is enable pretty printing.

I realize this must be an edge case if nobody’s mentioned it until now, but allowing dynamic value viewers could unlock the potential for seamless rich output on custom data.

Hi there!

Thanks for bringing this use case to our attention. For some context, we had always planned on having a more dynamic way to render your own value viewers. Those plans were put on the back burner for us because we didn’t think usage would be that hight compared to the amount of work we’d need to go into it.

So why don’t we just allow functions? RunKit does everything we can to not have observable side effects on the code that we run. We jump through a lot of hoops in some places to make that happen, but the goal is for the code that runs in RunKit to behave exactly like it would if you ran it in regular Node. Unfortunately, because JavaScript has no way of ensuring a function is “pure”, our runtime can’t invoke user functions without potentially causing side effects. Even simply accessing members of your object in the function might cause side effects (though getters).

Our plan for these dynamic viewers is to serialize your function as a string and run it in a sandboxed iframe on the client, which we already have most of the code to do, but that leads to a few issues:

  1. We’d need to expose our internal serialization format (which isn’t the easiest to work with, but we have ways to make that a little better).
  2. When the code is run on the server, the developer will have access to all of npm. So if you wanted to use tools like react or require your own package’s color scheme you can just require them. That’s not possible without a client side version of our module-fs technology. That’s not a dealbreaker though, we could just suggest users use the various CDNs that exist out there.
  3. And finally, our value viewers right now expect that we render things synchronously. We’d need to check to make sure the provided function didn’t crash and find the right way to let the user know or automatically switch back to our standard viewers. There’s also potentially some edge cases around some asynchronous actions that the function could take.

We’d certainly be interested in any feedback you might have on our constraints here as they relate to your use case!

Also, as for the proxy object working, that’s actually pretty interesting. Proxies can be tricky for us because it’s easy to accidentally invoke a trap, which is exactly what’s happening here. We really shouldn’t be triggering the trap, but we are for some reason. (Don’t worry, we have no intention of fixing this/breaking your viewer right now, but note that we might end up fixing it sometime in the future)

The proxy object only works because I specifically wrote the Proxy’s getOwnPropertyDescriptor trap to lie and say it’s a value instead of a getter :sweat_smile: echo/generateEcho.ts at master · mrjacobbloom/echo · GitHub

Regarding the “simple” Value Viewers we have now: I can see how those limitations prevent using dynamic getters, but not SomeClass.prototype[ValueViewerSymbol]. The standard Properties Viewer already walks the prototype chain, so theoretically there’s no reason not to have support for this (besides added complexity on your end). But since having it be a function is a no-go, it’s a moot point anyway.

For point (1), by “serialization format,” I assume you mean the format used to serialize the object being rendered, not to serialize the the render function? Either way, I’ll take your word for it since I don’t know anything about this

In point (3), I assume those asynchronous edge cases are things like window.alert()? There has to be a way to sidestep that… maybe running it in a promise and racing it against a timeout of some kind? It’s an interesting problem, but since you have the standard viewers to fall back on, hopefully it shouldn’t have to be a showstopper

Another potential idea: you already have the infrastructure to snapshot the state of a Node environment and “fork” it… Instead of avoiding calling user functions at all costs, could you just fork the environment to run them in? I assume the reason you’re not already doing this is that either (a) the “fork” operation is too heavy to do on every value view, or (b) “disappearing/ignoring” side-effects in user functions is a no-go.

The proxy object only works because I specifically wrote the Proxy’s getOwnPropertyDescriptor trap to lie and say it’s a value instead of a getter

Yeah, that was a clever hack :slight_smile: I’m just saying RunKit should not be triggering your trap at all, because triggering it violate the constraint of not running code that could result in side effects.

For point (1), by “serialization format,” I assume you mean the format used to serialize the object being rendered, not to serialize the the render function? Either way, I’ll take your word for it since I don’t know anything about this

Yeah that value serialization format. It’s just a developer experience issue that we’d have to solve. We’re serializing every field of your value, including preserving circular references, metadata about the fields like if their getters/setters/enumerable, etc. It’s just not the most friendly format to work with, so we’d want to expose something a little better. Not a showstopper for this though.

In point (3), I assume those asynchronous edge cases are things like window.alert() ? There has to be a way to sidestep that…

Not exactly, so at a high level: the render function will need to be sandboxed in an iframe. We already do this with rendered HTML in a secure way. But the iframe still needs to be able to communicate back to the parent window to let it know things like “hey, I’m done rendering”, “hey, my height changed”, or “whoops, I crashed”. So the iframe will postMessage() back to the parent window with those messages. (RunKit will control the messages).

maybe running it in a promise and racing it against a timeout of some kind? It’s an interesting problem, but since you have the standard viewers to fall back on, hopefully it shouldn’t have to be a showstopper

So (with the architecture I described above) from RunKit’s perspective all render() functions will be asynchronous. Currently all our value viewer stuff is written in a way that assumes the render will never crash (since they’re 1st party viewers) and that they can be rendered synchronously (because they’re being rendered in the main window’s context). So it’s not trivial for us to just fallback to our 1st party viewers. These concerns kind of already exist for the “simple viewers” since you can still run JS in them too, but since they’re geared more towards just providing static HTML, we didn’t build in some of those checks (they’re just being rendered as a special kind of “rendered html” viewer). So yeah, I agree, this should not be a showstopper either.

And while we’re on the topic we’ve talked about two other related things. We think it would be really nice if we could give 3rd party developers access to our own built in viewers. So you could render part of your value in a custom way, but then for the less interesting parts of your object, you could just revert to our 1st party viewers. Unsure how useful that would actually be, and it’s got it’s own usability issue too. Also we think it would be cool if the objects themselves didn’t have to have the custom symbol. So instead a package could “register” a viewer and describe the shape of the value it would match. We think this would be useful because it now lets your API return non-object values and still be match. For example, our built in coordinate viewer will match on an array of numbers that look like lat-long values. If you had a list of Dog objects, you might want to render that list as a whole rather than each individual Dog object. Anyway, that’s a whole separate issue.

Instead of avoiding calling user functions at all costs, could you just fork the environment to run them in?

Yeah, this is also something we’ve considered. Also for letting the user trigger getters. It has a ton of advantages like giving you access to all of npm. The second point you made about disappearing side effects would be my main concern. I could imagine a bunch of ways that could end up with some confusing behavior. But I think it’s potentially a good strategy.

Sorry for the long post, I wanted to kind of lay out the constraints on our end and the weighed concerns/advantages. I appreciate your thoughts and insights here!

- Randy

Thank you for the very detailed responses, this has been super informative and it’s cool to get insights into how everything works and why. RunKit is an awesome product and clearly a lot of thought and love went into it.

It’s understandable that dynamic custom viewers haven’t been a development priority, given how much work any of these solutions would take. I appreciate you taking the time to talk me through the problem space anyway.

We think it would be cool if the objects themselves didn’t have to have the custom symbol. So instead a package could “register” a viewer and describe the shape of the value it would match.

That sounds like a perfect solution for my use case. From the perspective of a package maintainer, package.json runkitViewer (or whatever) could be a great companion to runkitExample in providing a polished playground for a package. In my specific case, I could rip out the hacky injection stuff I’m doing now, and avoid having an extra notebook imported into my package.json’s runkitExample. Consider this a huge +1 from me on moving that direction.

But I can see why you’re concerned that usage would be too low to justify the effort. I’m sure you have stats on how many packages actually use runkitExample and I can’t imagine it’s a huge number.

I assume there would be an equivalent you could use in normal notebooks too? Like, a notebook could import another notebook that installs the viewer?

We think it would be really nice if we could give 3rd party developers access to our own built in viewers. So you could render part of your value in a custom way, but then for the less interesting parts of your object, you could just revert to our 1st party viewers. Unsure how useful that would actually be, and it’s got it’s own usability issue too.

Sure, that seems like it could be really powerful. You could almost start to treat the builtin viewers like a component library. Not particularly relevant to my use case but a very cool idea!

Instead of avoiding calling user functions at all costs, could you just fork the environment to run them in?

Yeah, this is also something we’ve considered. Also for letting the user trigger getters. It has a ton of advantages like giving you access to all of npm. The second point you made about disappearing side effects would be my main concern. I could imagine a bunch of ways that could end up with some confusing behavior. But I think it’s potentially a good strategy.

The idea of “registering” a value viewer sidesteps a lot of the reason I wanted this anyway, but it’s still nice to have access to an object’s getters and methods in a custom value viewer (regardless of whether it’s on the backend or on the value serialization format) so I’m glad to hear this is being considered.

Yeah, I’d echo your concerns over disappearing side-effects. I can see it being super confusing when, say, I’m trying to debug my viewer and log lines disappear, etc. Maybe there’s a way to expose an opt-in viewer debug mode of some kind? That could itself be confusing though…