Skip to content

Instantly share code, notes, and snippets.

@fcamblor
Last active May 25, 2021 19:14
Show Gist options
  • Save fcamblor/2674a9c37aae9ca31959e993b89ee24a to your computer and use it in GitHub Desktop.
Save fcamblor/2674a9c37aae9ca31959e993b89ee24a to your computer and use it in GitHub Desktop.
Resolving promise multiple times

Context

I often meet the pattern where I need to either retrieve data from cache or fetch it from the server (both applies as well). I tried to implement such data loading policy in a generic fashion, but I cannot found an ideal design for this purpose, so I decided to share it and let the JS community help me to improve it :)

In this project, I will use a DataLoader class intended to implement this policy, fetching some data from 2 sources :

  • Either from a backend (server)
  • Or from localstorage (if a previous call to the backend was made "recently", "recently" corresponding to still having such data in memory)

I'd like to implement a single method utility allowing to address both data resolutions at the same time, afterall, we should consider this as a single promise which may be resolved multiple times :

  • Only once if localstorage is empty (the first time we run the application)
  • Only once if localstorage is filled and we already called the backend "recently"
  • Twice if localstorage is filled and the backend was called some time ago, in that particular case, I'd like :
    • To quickly return data coming from cache
    • Run in the background an XHR to the server, and resolve the promise again once data is fetched (thus, in the case where backend is not reachable -for instance because user has connectivity issues-, user will still have presentations coming from cache)

I guess this is a very common pattern of "serve my data from the cache first, then try to refresh it from an http call"

Currently, I didn't achieved to find any library on the www, allowing to achieve this goal in a beautiful way.
My current implementation takes my callback (called potentially twice) as the loadEventsThen() method.
However, this doesn't look like a promise, and is then not composable at all : I cannot call loadEventsThen() in an async/promisified call since callback may be called twice.

If you have any idea on how to improve this design, don't hesitate to share it, I would be happy to discuss this with you on this gist :-)

What I DO want is :

  • Keep the way I call DataLoader : passing a unique callback which may be called everytime I'm able to access the data
  • Improve this in order to make it more promise friendly
  • Maybe refactor the whole thing if you think there is a design issue, but keep in mind that data may come from various locations (in my case, 3 locations : server, localstorage or memory) and everytime data is made available, I'd like to execute the exact same callback (in my case, display number of voxxrin events available)
/**
* Utility class allowing to load data from 3 levels :
* - Remote server
* - Browser's Local storage
* - In memory cache
*
* It provides a loadEventsThen(dataAvailableCallback) method which will trigger
* dataAvailableCallback once data will be retrieved at some point
* The first time it is called, in-memory cache will be empty (thus, callback will never be called on it)
*/
let DataLoader = (function (_super) {
Object.assign(DataLoader, _super);
function DataLoader(){
_super.apply(this, arguments);
}
let EVENTS_RESETTED_EVENT_NAME = "events:resetted";
DataLoader.prototype._cachedEvents = undefined;
DataLoader.prototype._onceEventsResettedListeners = [];
DataLoader.prototype.removeEventsResettedListener = function(listener) {
document.removeEventListener(EVENTS_RESETTED_EVENT_NAME, listener);
};
DataLoader.prototype.clearEventsResettedListeners = function() {
this._onceEventsResettedListeners.forEach(this.removeEventsResettedListener);
};
DataLoader.prototype.onceEventsResetted = function(dataAvailableCallback) {
let eventsResettedListener = (event) => {
let source = event.detail.source;
let events = event.detail.events;
dataAvailableCallback(events, source);
};
// Tracking every event listeners we registered, in order to easily remove it when needed
this._onceEventsResettedListeners.push(eventsResettedListener);
document.addEventListener(EVENTS_RESETTED_EVENT_NAME, eventsResettedListener);
return eventsResettedListener;
};
DataLoader.prototype._resetEventsTo = function(events, source) {
this._cachedEvents = this._cachedEvents || [];
if(this._cachedEvents !== events) {
this._cachedEvents.length = 0;
Array.prototype.push.apply(this._cachedEvents, events);
}
var eventsResettedEvent = new CustomEvent(EVENTS_RESETTED_EVENT_NAME, { detail: { source, events } });
document.dispatchEvent(eventsResettedEvent);
};
DataLoader.prototype.refreshEvents = function() {
let promises = [ ];
let cachedData = this._cachedEvents;
let cachedDataRetrievedPromise;
if(cachedData) { // Already resolved cached data in localstorage in the past.. returning it as is
console.log("Resolved events from cache");
cachedDataRetrievedPromise = Promise.resolve(cachedData)
.then(
(events) => { this._resetEventsTo(events, 'cache'); },
(err) => { /* should never happen */ throw new Error(`Error when trying to retrieve cached data : ${err}`); }
);
} else { // Trying to resolve cached data in localstorage...
cachedDataRetrievedPromise = new Promise((resolve, reject) => {
let stringifiedEvents = localStorage.getItem("events");
if(stringifiedEvents) {
console.log("Resolved events from localstorage");
let events = JSON.parse(stringifiedEvents);
resolve(events);
} else {
console.info("Cannot find any events in localstorage");
reject();
}
}).then(
(events) => { this._resetEventsTo(events, 'localstorage'); },
() => {
/* this case should not be considered as an error ... but should not trigger dataAvailableCallback() either */
console.info(`No events found in localstorage`);
return Promise.resolve();
}
);
}
promises.push(cachedDataRetrievedPromise);
if(!cachedData) { // If no cached data have been resolved yet, trying to fetch data from server (at least once)
console.log("Fetching events ...");
let remoteDataFetchedPromise = fetch('http://appv2.voxxr.in/api/events').then(
(fetchResult) => {
console.log("Events fetched !");
return new Promise((resolve, reject) => {
fetchResult.json().then((events) => {
localStorage.setItem("events", JSON.stringify(events));
resolve(events);
}, reject);
});
},
(err) => { throw new Error(`Error when fetching remote data : ${err}`); }
).then(
(events) => { this._resetEventsTo(events, 'server'); },
(err) => { throw new Error(`Error when storing fetched remote data : ${err}`); }
);
promises.push(remoteDataFetchedPromise);
}
};
return DataLoader;
})(Object);
<html>
<body>
<pre id="content">
Initializing...
</pre>
<button onclick="localStorage.removeItem('events');">Clear localstorage</button>
<button onclick="refreshEvents()">Load again</button>
<script type="text/javascript" src="DataLoader.js"></script>
<script type="text/javascript">
let content = document.getElementById('content');
let dataLoader = new DataLoader();
dataLoader.onceEventsResetted((events, source) => {
console.log(`Presentation layer updated with new data (${events.length}) from ${source}`);
content.innerHTML += `Events (${source}) : ${events.length}\n`;
});
let refreshEvents = () => {
dataLoader.refreshEvents();
};
refreshEvents();
</script>
</body>
</html>
@DavidBruant
Copy link

a single promise which may be resolved multiple times

A promise is the wrong tool for your need here. A Promise can only be fulfilled once. You need/want events here.
Your callback approach is equivalent to emitting event. Cool ^^

@DavidBruant
Copy link

Maybe an API as follows:

{
  getFromCache(): Promise
  getFromLocalStorage(): Promise
  getFromServer(): Promise
}

It heavier on the caller, but it's what it takes.
Note that it leaves the choice to the caller to make the calls sequentially or in parallel which could be useful in different contexts.

@fcamblor
Copy link
Author

@DavidBruant yes and maybe do something like :

[dataLoader.getFromCache(), dataLoader.getFromLocalStorage(), dataLoader.getFromServer()].forEach((promise) => {
  promise.then((events) => updateMyUI(events));
});

My concern is ... my example is ratherly simple here since my data access layer is near my view ... in reality, there are some business logics between both ... and converting a promise-based code to event-based code is not that straightforward :'/

@datrine
Copy link

datrine commented Apr 23, 2020

A promise should resolve only once, according to ECMAScript spec. However there's this concept of "multiple resolves" in NodeJs process API. I think I'll dig into that concept.

PS: this comment is probably, for most intent and purposes, belated.

@jasonk
Copy link

jasonk commented Jun 24, 2020

FYI: The "multiple resolves" in the NodeJS process API is not an API that will allow you to resolve multiple times, it's an error event that gets emitted when you do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment