In this weekly series, Martin describes a question that was raised by a Fonto developer, how it was resolved and why Fonto behaved like that in the first place. This week, a partner is wondering how to do something asynchronous in a template!
A support issue came in that went a little like this:
Hey there!
We are working with an image that we are generating from an endpoint. We have made it so that it serves an image when we request it on an endpoint. We generate the url for the endpoint based on =the values of various descendant elements in the figure element. We then render an
A Fonto partner<img src="<the endpoint url>">
using thecreateInnerJsonMl
callback in aconfigureAsObject
. This works fine when the image can be set up without issue. However, we want to handle errors in a graceful way. So far I have set up analt
text, and I tried to add anonerror
callback, however these are not great to work with. Do you have any advice? I added what we have so far to the support ticket.
First of all, that’s a very elegant solution with rendering an <img>
element with a generated URL. The code so far looks like this:
configureAsObject(sxModule, 'self::image[@outputclass = "graphic"]', undefined, { createInnerJsonMl: (sourceNode, renderer) => { const dataAboutNode = evaluateXPathToFirstNode('ancestor::fig[1]/data-about', sourceNode, readOnlyBlueprint); const src = getGraphicsURL(dataAboutNode); return [ 'img', { src } ]; } });
The partner wants to add some kind of error checking, because right now if the src
attribute turns out to get an invalid value, the editor just renders a normal placeholder.
The error checking feels like an easy problem to fix, just use the fetch
API to load an image and render it in place. However, the renderInnerJsonMl
callback of configureAsObject
can not be asynchronous, so this will not work. What we have to do instead is use an API called addExternalValue
. This API adds a way to store ‘some data’ linked to a value that can later be retrieved using an XPath function. If new data is set, the XPath function invalidates, invalidating the template or observed query or anywhere else where dependencies are tracked. The API docs of addExternalValue explain more on how this API works.
The addExternalValue
API can be called at a random point in time to set some data, for example a dataUrl
of an image. Using a dataUrl
has the added benefit that the image will never be retrieved more than once when the image is rendered. To get hold of the dataUrl, we can use the getImageDataFromUrl API.
The getImageDataFromUrl
function needs to be executed at some point. A logical place is inside the createInnerJsonMl
callback of the configureAsObject
. This callback is called once whenever an image element is rendered, and called again if the dependencies of the template change.
The first attempt to fix this looked like this:
// The fallback image if nothing could be loaded const loadingImg = 'assets/images/graphic-loading-fallback.svg'; const fallbackImg = 'assets/images/graphic-failure-fallback.svg'; const setGraphicUrl = addExternalValue('http://app', 'getGraphicUrl', 'xs:string', 'xs:string', loadingImg); configureAsObject(sxModule, 'self::image[@outputclass = "graphic"]', undefined, { createInnerJsonMl: (sourceNode, renderer) => { const dataAboutNode = evaluateXPathToFirstNode('ancestor::fig[1]/data-about', sourceNode, readOnlyBlueprint); const rawUrl = getGraphicsURL(dataAboutNode); getImageDataFromUrl(window.document, rawUrl) .then(data => setGraphicUrl(rawUrl, data.dataUrl)) .catch(() => setGraphicUrl(rawUrl, fallbackImg)); return [ 'img', { src: evaluateXPathToString(xq`getGraphicUrl(${rawUrl})`, null, readOnlyBlueprint) } ]; } });
It worked! But with one downside: the image kept re-rendering over and over. That is because the code retrieves an image data url, immediately reads whatever the old version was and renders it. When then the image is downloaded, the external value is updated, causing an update to be scheduled. This update then in turn retrieves a data url, which sets it, requesting a new update, finishing another loop.
To resolve this, we should only set a value once. The easiest way to do this is to create a Set of all the urls that are set. If a URL is already in the set, we can skip the image download.
// The fallback image if nothing could be loaded const loadingImg = 'assets/images/graphic-loading-fallback.svg'; const fallbackImg = 'assets/images/graphic-failure-fallback.svg'; const setGraphicUrl = addExternalValue('http://app', 'getGraphicUrl', 'xs:string', 'xs:string', loadingImg); const resolvedUrls = new Set<string>(); configureAsObject(sxModule, 'self::image[@outputclass = "graphic"]', undefined, { createInnerJsonMl: (sourceNode, renderer) => { const dataAboutNode = evaluateXPathToFirstNode('ancestor::fig[1]/data-about', sourceNode, readOnlyBlueprint); const rawUrl = getGraphicsURL(dataAboutNode); if (!resolvedUrls.has(rawUrl)) { getImageDataFromUrl(window.document, rawUrl) .then(data => { setGraphicUrl(rawUrl, data.dataUrl); resolvedUrls.add(rawUrl); }) .catch(() => { setGraphicUrl(rawUrl, fallbackImg); resolvedUrls.add(rawUrl); }); } return [ 'img', { src: evaluateXPathToString(xq`getGraphicUrl(${rawUrl})`, null, readOnlyBlueprint) } ]; } });
This was tested with the partner, and it worked to their satisfaction.
I hope this explained how Fonto works and why it works like that. During the years we built quite the product, and we are aware some parts work in unexpected ways for those who have not been with it from the start. If you have any points of Fonto you would like some focus on, we are always ready and willing to share! Reach out on Twitter to Martin Middel or file a support issue!
Developer advocate / Evangelist. Has been with Fonto since it all began in 2013. He’s currently designing the next steps in Fonto Developer APIs with the input of our valuable partners.
In his spare time, Martin is an avid home brewer.
Receive updates on new Fonto Why & How blog posts by email