JS/React/fetch: not getting all data - javascript

I'm building an app around the card game Magic, where you can paste a list of cards into a textbox and click a button to show card images. I use fetch and an external site API.
Logging shows no errors, but the result is very inconsistent - sometimes only 1 card is shown, sometimes 2, 4, 7 etc... I need to always render all the data. I've tried for days with this.
const handleClick = async () => {
textarea.split("\n").forEach(async (items) => {
try {
const response = await fetch(
`https://api.scryfall.com/cards/named?exact=${encodeURIComponent(
items
)}&pretty=true`
);
console.log("response.status: ", response.status);
if (!response.ok) {
throw new Error(`Error! ${response.status}`);
}
const result = await response.json();
console.log("result" + result);
objects.sort(() => Math.random() - 0.5);
objects.push(result);
} catch (err) {
console.log(err);
} finally {
setIsLoading(false);
setScrydata(objects);
}
});
};
From the console.log I see everything is fetched, but sometimes in "batches", for example first 2, then 5. This is the problem I think(?) cause only 2 cards are then rendered. Is Promise.all the solution somehow? I've tried it but couldn't get it to work, I changed
const result = await response.json();
to
const result = await Promise.all([response.json()]);
but it doesn't work, the results are still in "batches".
Big thank you in advance for any help.
Edit, this is how I render the images:
{scrydata.length > 0 && (
<img
src={scrydata[0].image_uris?.normal}
key={scrydata[0].id}
alt="asdf"
/>
)}
{scrydata.length > 1 && (
<img
src={scrydata[0].image_uris?.normal}
key={scrydata[0].id}
alt="asdf"
/>
)} ... etc
Up to seven, which is the max. This shows images, but it's inconsistent; I want seven every time. Maybe this code could be my problem.
Thanks again.

You want to split this up into two separate stages:
get all the images (with undefined or false for any failures)
update the UI once you have those.
But what you've written doesn't do that, instead it does:
for each card:
get its image
update the UI irrespective of whether that get succeeded
So rewrite your logic a little:
async function getCardImage(title = ``) {
// do we need to do anything?
if (title === ``) return false;
try {
const response = await fetch(...);
if (!response.ok) throw new Error(`Error! ${response.status}`);
return await response.json();
} catch (e) {
console.log(e);
}
// our function always returns "normal" data, it never exits by throwing.
// That way, we can easily filter failures our of the Promise.all result
return false;
}
async function fetchCardImages(titles = [], setIsLoading=()=>{}) {
// do we need to do anything?
if (titles.length === 0) return titles;
setIsLoading(true);
const results = (
await Promise.all(titles.map(getCardImage))
).filter(Boolean);
setIsLoading(false);
return results;
};
With handleClick changed to:
const handleClick = async (_evt) => {
const titles = textarea.split(`\n`).map(s => s.trim()).filter(Boolean);
// do we need to do anything?
if (titles.length === 0) return;
const updates = await fetchCardImages(titles, setIsLoading);
updateScryData(updates);
// Don't update `objects` until you're in the
// updateScryData function: your event handler
// itself should have zero code that directly
// manipulates any data.
//
// And of course, make sure `updateScryData` checks
// the passed argument, so that if it's an empty
// array, it just immediately returns because no
// work needs to be done.
}
Although I'd recommend not calling it "handle click" because the fact that you're pointing to it from an onClick React attribute already tells people that. Give functions names based on what they do: in this case, getNewCardImages or something.
And then, finally, you render your scryData using a map:
function generateCardElement({ id, image_uris, description }) {
const src = image_uris?.normal;
if (!src) return;
return <li key={id}><img src={src} alt={description}/></li>;
}
...
render() {
return
...
<ul className="card-images">{scryData.map(generateCardElement)}</ul>
...
}

Related

useeffect infinite loop even though state data is not changing

My program goes into an infinite loop constantly calling useEffect() everytime I start the app. I have one state that I don't think is changing other than in the retrieveItemStatus() function so I'm confused on why its going into a loop like it is.
const App = () => {
var items;
const [itemStatuses, updateStatuses] = useState({});
const retrieveItemStatus = async () => {
var tempStatuses;
try {
const value = await AsyncStorage.getItem("#item_Statuses");
if (value !== null) {
tempStatuses = await JSON.parse(value);
//console.log("123456");
} else {
tempStatuses = await JSON.parse(
JSON.stringify(require("../default-statuses.json"))
);
}
updateStatuses(tempStatuses);
} catch (error) {}
};
retrieveItemStatus();
useEffect(() => {
const copyData = async () => {
const itemsCopy = [];
const coll = await collection(db, "Items");
const querySnapshots = await getDocs(coll);
const docsArr = querySnapshots.docs;
docsArr.map((doc) => {
var data = doc.data();
if (itemStatuses[data.name] === "locked") return;
itemsCopy.push(data);
});
items = itemsCopy;
//getItems([...itemsCopy]);
};
copyData();
}, [itemStatuses]);
return (
<View style={styles.container}>
<Text>temp.......</Text>
</View>
);
};
It has nothing to do with useEffect. You're calling retrieveItemStatus unconditionally every time your component function is called to render the componennt. retrieveItemStatus calls updateStatuses which changes state. You see your useEffect callback get run repeatedly as a side-effect of that, because your useEffect callback has itemStatuses as a dependency.
I assume you only need the itemStatuses to get fetched once. If so, put the call in a useEffect callback with an empty dependency array:
useEffect(retrieveItemStatus, []);
Also, you have (note the ***):
const App = () => {
var items // ***
// ...
useEffect(() => {
const copyData = async () => {
// ...
items = itemsCopy; // ***
// ...
};
copyData();
}, [itemStatuses]);
};
That won't work, by the time you assign to items from the callback, anything you might have been trying to do with items will already have just used undefined (the value it gets when you don't give it one). If you need items to be retained, either put it in state (if you use it for rendering) or in a ref (if you don't).
In a comment you said:
Ok so I put retrieveItemStatus() call inside useEffect and removed the dependency which fixed the looping. But now there is an issue where itemStatuses state doesn't get updated before copyData() is called and itemStatuses is needed.. so it doesn't do anything until I manually refresh/render the whole thing again.
If copyData relies on the result from retrieveItemStatus, then put the calls to each of them in the same useEffect, not calling copyData until you get the results from retrieveItemStatus. Something along the lines of the below, though you'll need to tweak it of course as I don't have all the details (I've also made some other comments and changes in there I've flagged up):
// *** There's no need to recreate this function on every render, just
// have it return the information
const retrieveItemStatus = async () => {
try {
let tempStatuses; // *** Declare variables in the innermost scope you can
const value = await AsyncStorage.getItem("#item_Statuses");
if (value !== null) {
tempStatuses = await JSON.parse(value);
//console.log("123456");
} else {
// *** stringify + parse isn't a good way to copy an object,
// see your options at:
// https://stackoverflow.com/questions/122102/
tempStatuses = await JSON.parse(JSON.stringify(require("../default-statuses.json")));
}
return tempStatuses;
} catch (error) {
// *** Not even a `console.error` to tell you something went wrong?
}
};
// *** Similarly, just pass `itemStatuses` into this function
const copyData = async (itemStatuses) => {
const coll = await collection(db, "Items");
const querySnapshots = await getDocs(coll);
const docsArr = querySnapshots.docs;
// *** Your previous code was using `map` just as a loop,
// throwing away the array it creates. That's an anti-
// pattern, see my post here:
// https://thenewtoys.dev/blog/2021/04/17/misusing-map/
// Instead, let's just use a loop:
// (Alternatively, you could use `filter` to filter out
// the locked items, and then `map` to build `itemsCopy`,
// but that loops through twice rather than just once.)
const itemsCopy = []; // *** I moved this closer to where
// it's actually filled in
for (const doc of docsArr) {
const data = doc.data();
if (itemStatuses[data.name] !== "locked") {
itemsCopy.push(data);
}
}
//getItems([...itemsCopy]); // *** ?
return itemsCopy;
};
const App = () => {
// *** A new `items` is created on each render, you can't just
// assign to it. You have to make it a member of state (or use
// a ref if it's not used for rendering.)
const [items, setItems] = useState(null);
const [itemStatuses, setItemStatuses] = useState({});
// *** ^−−−−− the standard convention is `setXyz`.
// You don't have to follow convention, but it makes it easier
// for other people to read and maintain your code if you do.
useEffect(() => {
(async () => {
const newStatuses = await retrieveItemStatus();
const newItems = await copyData(newStatuses);
// *** Do you need `itemStatuses` to be in state at all? If it's
// only used for calling `copyData`, there's no need.
setItemStatuses(newStatuses);
setItems(newItems);
})().catch((error) => {
console.error(error);
});
}, []);
// *** You didn't show what you're using here, so it's hard to be
// sure what needs to be in state and what doesn't.
// Only put `items` or `itemStatuses` in state if you use them for
// rendering.
return (
<View style={styles.container}>
<Text>temp.......</Text>
</View>
);
};
Here are those links as links:
What is the most efficient way to deep clone an object in JavaScript?
Misusing map (on my blog)
#T.J. Crowder's answer is correct but I want to clarify a small point, so you understand why your useEffect didn't stop running.
As you you know, when the dependency in dependency array changes, useEffect runs. But the data in your itemStatuses doesn't change right(according to you)? So why does useEffect re-runs? Let's look at the below example:
const obj1 = {};
const obj2 = {};
const obj3 = {a:2};
const obj4 = {a:2};
console.log(obj1 === obj2)
console.log(obj3 === obj4)
console.log(obj3.a === obj4.a)
As you can see javascript doesn't think that an empty object is strictly equal to another empty object. It is because these objects refer to the different locations in memory regardless of their content.
So that's why every time retrieveItemStatus ran, it updated itemStatuses. Then because itemStatuses got updated(although the value is same), useEffect triggered a re-render, so again everything started all over.
Main reason is in calling retrieveItemStatus(); without any event.
If you want to call this function when page loading, you should like this.
...
useEffect(() => {
retrieveItemStatus()
}, [])
...
This will fix loop issue.

Array of filtered axios results from paginated API is empty

In my code below I get an empty array on my console.log(response) but the console.log(filterdIds) inside the getIds function is showing my desired data. I think my resolve is not right.
Note that I run do..while once for testing. The API is paged. If the records are from yesterday it will keep going, if not then the do..while is stopped.
Can somebody point me to the right direction?
const axios = require("axios");
function getToken() {
// Get the token
}
function getIds(jwt) {
return new Promise((resolve) => {
let pageNumber = 1;
const filterdIds = [];
const config = {
//Config stuff
};
do {
axios(config)
.then((response) => {
response.forEach(element => {
//Some logic, if true then:
filterdIds.push(element.id);
console.log(filterdIds);
});
})
.catch(error => {
console.log(error);
});
} while (pageNumber != 1)
resolve(filterdIds);
});
}
getToken()
.then(token => {
return token;
})
.then(jwt => {
return getIds(jwt);
})
.then(response => {
console.log(response);
})
.catch(error => {
console.log(error);
});
I'm also not sure where to put the reject inside the getIds function because of the do..while.
The fundamental problem is that resolve(filterdIds); runs synchronously before the requests fire, so it's guaranteed to be empty.
Promise.all or Promise.allSettled can help if you know how many pages you want up front (or if you're using a chunk size to make multiple requests--more on that later). These methods run in parallel. Here's a runnable proof-of-concept example:
const pages = 10; // some page value you're using to run your loop
axios
.get("https://httpbin.org") // some initial request like getToken
.then(response => // response has the token, ignored for simplicity
Promise.all(
Array(pages).fill().map((_, i) => // make an array of request promisess
axios.get(`https://jsonplaceholder.typicode.com/comments?postId=${i + 1}`)
)
)
)
.then(responses => {
// perform your filter/reduce on the response data
const results = responses.flatMap(response =>
response.data
.filter(e => e.id % 2 === 0) // some silly filter
.map(({id, name}) => ({id, name}))
);
// use the results
console.log(results);
})
.catch(err => console.error(err))
;
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
The network tab shows the requests happening in parallel:
If the number of pages is unknown and you intend to fire requests one at a time until your API informs you of the end of the pages, a sequential loop is slow but can be used. Async/await is cleaner for this strategy:
(async () => {
// like getToken; should handle err
const tokenStub = await axios.get("https://httpbin.org");
const results = [];
// page += 10 to make the snippet run faster; you'd probably use page++
for (let page = 1;; page += 10) {
try {
const url = `https://jsonplaceholder.typicode.com/comments?postId=${page}`;
const response = await axios.get(url);
// check whatever condition your API sends to tell you no more pages
if (response.data.length === 0) {
break;
}
for (const comment of response.data) {
if (comment.id % 2 === 0) { // some silly filter
const {name, id} = comment;
results.push({name, id});
}
}
}
catch (err) { // hit the end of the pages or some other error
break;
}
}
// use the results
console.log(results);
})();
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
Here's the sequential request waterfall:
A task queue or chunked loop can be used if you want to increase parallelization. A chunked loop would combine the two techniques to request n records at a time and check each result in the chunk for the termination condition. Here's a simple example that strips out the filtering operation, which is sort of incidental to the asynchronous request issue and can be done synchronously after the responses arrive:
(async () => {
const results = [];
const chunk = 5;
for (let page = 1;; page += chunk) {
try {
const responses = await Promise.all(
Array(chunk).fill().map((_, i) =>
axios.get(`https://jsonplaceholder.typicode.com/comments?postId=${page + i}`)
)
);
for (const response of responses) {
for (const comment of response.data) {
const {name, id} = comment;
results.push({name, id});
}
}
// check end condition
if (responses.some(e => e.data.length === 0)) {
break;
}
}
catch (err) {
break;
}
}
// use the results
console.log(results);
})();
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
(above image is an except of the 100 requests, but the chunk size of 5 at once is visible)
Note that these snippets are proofs-of-concept and could stand to be less indiscriminate with catching errors, ensure all throws are caught, etc. When breaking it into sub-functions, make sure to .then and await all promises in the caller--don't try to turn it into synchronous code.
See also
How do I return the response from an asynchronous call? and Why is my variable unaltered after I modify it inside of a function? - Asynchronous code reference which explain why the array is empty.
What is the explicit promise construction antipattern and how do I avoid it?, which warns against adding a new Promise to help resolve code that already returns promises.
To take a step back and think about why you ran into this issue, we have to think about how synchronous and asynchronous javascript code works together. Your
synchronous getIds function is going to run to completion, stepping through each line until it gets to the end.
The axios function invocation is returning a Promise, which is an object that represents some future fulfillment or rejection value. That Promise isn't going to resolve until the next cycle of the event loop (at the earliest), and your code is telling it to do some stuff when that pending value is returned (which is the callback in the .then() method).
But your main getIds function isn't going to wait around... it invokes the axios function, gives the Promise that is returned something to do in the future, and keeps going, moving past the do/while loop and onto the resolve method which returns a value from the Promise you created at the beginning of the function... but the axios Promise hasn't resolved by that point and therefore filterIds hasn't been populated.
When you moved the resolve method for the promise you're creating into the callback that the axios resolved Promise will invoke, it started working because now your Promise waits for axios to resolve before resolving itself.
Hopefully that sheds some light on what you can do to get your multi-page goal to work.
I couldn't help thinking there was a cleaner way to allow you to fetch multiple pages at once, and then recursively keep fetching if the last page indicated there were additional pages to fetch. You may still need to add some additional logic to filter out any pages that you batch fetch that don't meet whatever criteria you're looking for, but this should get you most of the way:
async function getIds(startingPage, pages) {
const pagePromises = Array(pages).fill(null).map((_, index) => {
const page = startingPage + index;
// set the page however you do it with axios query params
config.page = page;
return axios(config);
});
// get the last page you attempted, and if it doesn't meet whatever
// criteria you have to finish the query, submit another batch query
const lastPage = await pagePromises[pagePromises.length - 1];
// the result from getIds is an array of ids, so we recursively get the rest of the pages here
// and have a single level array of ids (or an empty array if there were no more pages to fetch)
const additionalIds = !lastPage.done ? [] : await getIds(startingPage + pages, pages);
// now we wait for all page queries to resolve and extract the ids
const resolvedPages = await Promise.all(pagePromises);
const resolvedIds = [].concat(...resolvedPages).map(elem => elem.id);
// and finally merge the ids fetched in this methods invocation, with any fetched recursively
return [...resolvedIds, ...additionalIds];
}

Async/Promise issues when adding a Gatsby node field

I'm having some issues adding some fields on to a Gatsby node. The real issue comes down to the fact that I just can't seem to wrap my head around the asynchronous situation, since I'm creating these fields from API call results. I'm still trying to learn about promises/async/etc.
I make one API call to an API to get location information and add it as a field (locationRequest, which is working just fine), and then run another call to get the orthodontists that work at that location.
When getOrthos runs, and it gets up to the console.log that should be spitting out an array of orthodontist entities, I'm getting this instead:
Created Ortho Node... [ Promise { <pending> }, Promise { <pending> } ]
What am I doing wrong? I've tried to go through some Promise tutorials, but I can't figure out the best way to do this where it returns the actual data rather than the promise.
Thank you for any guidance you can provide, and please excuse my ignorance.
const yextOrthos = node.acf.location_orthodontists;
const locationRequest = async () => {
const data = await fetch("https://FAKEURL.COM")
.then(response => response.json());
if( data && data.response && data.response.count === 1 ){
createNodeField({
node,
name: `yextLocation`,
value: data.response.entities[0]
});
} else {
console.log("NO LOCATIONS FOUND");
}
};
const getOrthos = async () => {
let orthodontists = await yextOrthos.map( async (ortho, i) => {
let orthoID = ortho.acf.yext_entity_ortho_id;
return await orthoRequest(orthoID);
});
if( orthodontists.length ){
createNodeField({
node,
name: `yextOrthos`,
value: orthodontists
});
console.log("Created Ortho Node...", orthodontists);
} else {
console.log("NO DOCTORS FOUND");
}
};
const orthoRequest = async (orthoID) => {
const dataPros = await fetch("https://FAKEURL.COM").then(response => response.json());
if( dataPros && dataPros.response && dataPros.response.count === 1 ){
return dataPros.response.entities[0];
} else {
return;
}
}
locationRequest();
getOrthos();
What you need to remember is that await should only stand before promise or something that returns promise. Array.prototype.map() returns array so you can't use await with it directly. Promise.all() on the other hand accepts an array and returns a promise. The example Jose Vasquez gave seems sufficient.
Good luck
You should use Promise.all() for arrays, on this line:
let orthodontists = await Promise.all(yextOrthos.map( async (ortho, i) => {...});
I hope it helps!
Edit:
A Promise which will be resolved with the value returned by the async
function, or rejected with an uncaught exception thrown from within
the async function.
If you wish to fully perform two or more jobs in parallel, you must
use await Promise.all([job1(), job2()]) as shown in the parallel
example.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

How to assign results of an http function within array.map() in javascript

I've read MANY articles, but I'm totally a newbie to this whole async thing and am having a very hard time wrapping my brain around how it all works. I want to map the filtered array of objects, and inside that, I'd like to return the result of a function (an amount) and set that as the value of pmtdue. I tried this a bunch of ways, but always get zoneawarepromise or observable when it's logged, or a ton of errors. This is probably the closest I've gotten, but it's still not right.
async today(day = null, status = null) {
this.logger.log(`show today's ${status} appts ${day}`);
// filter master list for today
const filtered = [...this.apptList].filter(appt => {
if (!status) {
return (
appt.scheduled >= this.helperService.dayStart(day) &&
appt.scheduled <= this.helperService.dayEnd(day) &&
appt.status.status !== 'Checked Out' &&
appt.status.status !== 'Scheduled'
);
} else {
return (
appt.scheduled >= this.helperService.dayStart(day) &&
appt.scheduled <= this.helperService.dayEnd(day) &&
appt.status.status === status
);
}
});
// calculate due amount and map it to pmtdue field
const dueappts = await this.getTotalDue(filtered).then(
res => {
// console.log(res);
this.ApptModels = res;
},
err => {
console.log(err);
}
);
// send the data to ng2-smart-table
console.log(`filtered ApptModels`, this.ApptModels);
}
This is the function that does the mapping and has the functions I want to work
// a.pmtduedate returns the correct value as there is no http call
// a.pmtdue returns a zoneawarepromise but I don't know how to get the VALUE
getTotalDue(appts: Array<any>): Promise<any> {
return Promise.all(
appts.map(async (a: any) => {
a.pmtduedate = await this.helperService.getDueDate(a);
a.pmtdue = await this.dataService.sendTotalDue(a);
console.log(a.pmtdue); // logs undefined
return a;
})
);
}
My data service function (I know sometimes code matters that I think is insignificant):
async sendTotalDue(appt) {
this.logger.log(`fetch amount ${appt.patientID.nickname} owes`);
return await this.http.post(`${SERVER_URL}/sendtotaldue`, appt);
}
And finally, the backend function(minus details on data). It logs the correct amount on the backend, I just can't get it to display on the frontend:
module.exports.sendTotalDue = (req, res) => {
const appt = req.body;
// callback function that handles returning data
function done(err, results) {
const totaldue = parseInt(results, 10);
console.log(`API sendTotalDue CALLBACK done...totaldue: ${totaldue}`);
if (err) {
console.log('ERROR getting total due: callback error', err);
res.sendStatus(500).json(err); // server error; it'd be good to be more specific if possible
} else {
// end the request, send totaldue to frontend
console.log(`SUCCESS send totaldue to frontend ${totaldue}`);
res.status(200).json(totaldue);
}
}
// run first function
console.log(`1. getAmtDue:`);
this.getAmtDue(appt, done);
};
module.exports.getAmtDue(appt, callback) {
... function finds past visits, past payment and due totals
}
module.exports.getCurrentDue(appt, pastdueamt, callback) {
... function finds current visits and payments. calculates current due and adds the past due
callback(null, totaldue);
}
Can someone please help me understand what I'm doing wrong? Feel free to dumb it down for me, cause that's how I feel at this point.
EDITED TO FIX ERRORS like missing await and return. It is now to the point where I can see the value returned in the data service, but I get undefined in the map function section.
Ooooh!!! I GOT IT! I still don't totally understand WHY it works, but I changed the data service as follows:
async sendTotalDue(appt): Promise<any> {
this.logger.log(`fetch amount ${appt.patientID.nickname} owes`);
try {
const result = await this.http
.post(`${SERVER_URL}/sendtotaldue`, appt)
.toPromise();
return result as any[];
} catch (error) {
console.log(error);
}
}
Changing my service to the above finally got my values to appear exactly where I wanted them in the data table! :)
I found this article, which helped figure out how to work with Observable
Angular Tutorial with Async and Await

Using chrome.tabs.executeScript to execute an async function

I have a function I want to execute in the page using chrome.tabs.executeScript, running from a browser action popup. The permissions are set up correctly and it works fine with a synchronous callback:
chrome.tabs.executeScript(
tab.id,
{ code: `(function() {
// Do lots of things
return true;
})()` },
r => console.log(r[0])); // Logs true
The problem is that the function I want to call goes through several callbacks, so I want to use async and await:
chrome.tabs.executeScript(
tab.id,
{ code: `(async function() {
// Do lots of things with await
return true;
})()` },
async r => {
console.log(r); // Logs array with single value [Object]
console.log(await r[0]); // Logs empty Object {}
});
The problem is that the callback result r. It should be an array of script results, so I expect r[0] to be a promise that resolves when the script finishes.
Promise syntax (using .then()) doesn't work either.
If I execute the exact same function in the page it returns a promise as expected and can be awaited.
Any idea what I'm doing wrong and is there any way around it?
The problem is that events and native objects are not directly available between the page and the extension. Essentially you get a serialised copy, something like you will if you do JSON.parse(JSON.stringify(obj)).
This means some native objects (for instance new Error or new Promise) will be emptied (become {}), events are lost and no implementation of promise can work across the boundary.
The solution is to use chrome.runtime.sendMessage to return the message in the script, and chrome.runtime.onMessage.addListener in popup.js to listen for it:
chrome.tabs.executeScript(
tab.id,
{ code: `(async function() {
// Do lots of things with await
let result = true;
chrome.runtime.sendMessage(result, function (response) {
console.log(response); // Logs 'true'
});
})()` },
async emptyPromise => {
// Create a promise that resolves when chrome.runtime.onMessage fires
const message = new Promise(resolve => {
const listener = request => {
chrome.runtime.onMessage.removeListener(listener);
resolve(request);
};
chrome.runtime.onMessage.addListener(listener);
});
const result = await message;
console.log(result); // Logs true
});
I've extended this into a function chrome.tabs.executeAsyncFunction (as part of chrome-extension-async, which 'promisifies' the whole API):
function setupDetails(action, id) {
// Wrap the async function in an await and a runtime.sendMessage with the result
// This should always call runtime.sendMessage, even if an error is thrown
const wrapAsyncSendMessage = action =>
`(async function () {
const result = { asyncFuncID: '${id}' };
try {
result.content = await (${action})();
}
catch(x) {
// Make an explicit copy of the Error properties
result.error = {
message: x.message,
arguments: x.arguments,
type: x.type,
name: x.name,
stack: x.stack
};
}
finally {
// Always call sendMessage, as without it this might loop forever
chrome.runtime.sendMessage(result);
}
})()`;
// Apply this wrapper to the code passed
let execArgs = {};
if (typeof action === 'function' || typeof action === 'string')
// Passed a function or string, wrap it directly
execArgs.code = wrapAsyncSendMessage(action);
else if (action.code) {
// Passed details object https://developer.chrome.com/extensions/tabs#method-executeScript
execArgs = action;
execArgs.code = wrapAsyncSendMessage(action.code);
}
else if (action.file)
throw new Error(`Cannot execute ${action.file}. File based execute scripts are not supported.`);
else
throw new Error(`Cannot execute ${JSON.stringify(action)}, it must be a function, string, or have a code property.`);
return execArgs;
}
function promisifyRuntimeMessage(id) {
// We don't have a reject because the finally in the script wrapper should ensure this always gets called.
return new Promise(resolve => {
const listener = request => {
// Check that the message sent is intended for this listener
if (request && request.asyncFuncID === id) {
// Remove this listener
chrome.runtime.onMessage.removeListener(listener);
resolve(request);
}
// Return false as we don't want to keep this channel open https://developer.chrome.com/extensions/runtime#event-onMessage
return false;
};
chrome.runtime.onMessage.addListener(listener);
});
}
chrome.tabs.executeAsyncFunction = async function (tab, action) {
// Generate a random 4-char key to avoid clashes if called multiple times
const id = Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
const details = setupDetails(action, id);
const message = promisifyRuntimeMessage(id);
// This will return a serialised promise, which will be broken
await chrome.tabs.executeScript(tab, details);
// Wait until we have the result message
const { content, error } = await message;
if (error)
throw new Error(`Error thrown in execution script: ${error.message}.
Stack: ${error.stack}`)
return content;
}
This executeAsyncFunction can then be called like this:
const result = await chrome.tabs.executeAsyncFunction(
tab.id,
// Async function to execute in the page
async function() {
// Do lots of things with await
return true;
});
This wraps the chrome.tabs.executeScript and chrome.runtime.onMessage.addListener, and wraps the script in a try-finally before calling chrome.runtime.sendMessage to resolve the promise.
Passing promises from page to content script doesn't work, the solution is to use chrome.runtime.sendMessage and to send only simple data between two worlds eg.:
function doSomethingOnPage(data) {
fetch(data.url).then(...).then(result => chrome.runtime.sendMessage(result));
}
let data = JSON.stringify(someHash);
chrome.tabs.executeScript(tab.id, { code: `(${doSomethingOnPage})(${data})` }, () => {
new Promise(resolve => {
chrome.runtime.onMessage.addListener(function listener(result) {
chrome.runtime.onMessage.removeListener(listener);
resolve(result);
});
}).then(result => {
// we have received result here.
// note: async/await are possible but not mandatory for this to work
logger.error(result);
}
});
For anyone who is reading this but using the new manifest version 3 (MV3), note that this should now be supported.
chrome.tabs.executeScript has been replaced by chrome.scripting.executeScript, and the docs explicitly state that "If the [injected] script evaluates to a promise, the browser will wait for the promise to settle and return the resulting value."

Categories