0

I'm making a chrome extension that allows users to take notes on YouTube videos. The notes are stored using IndexedDB. I'm running into a problem where a promise returns undefined if I switch to another tab and then switch back. First, most of the code that I'm using to make the issue easier to understand.

// background.js

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if(request.message === 'get_notes') {
    let getNotes_request = get_records(request.payload)

        getNotes_request.then(res => { // This is where the error occurs, so the above function returns undefined
            chrome.runtime.sendMessage({
                message: 'getNotes_success',
                payload: res
            })
        })
    }
});

function create_database() {
    const request = self.indexedDB.open('MyTestDB');

    request.onerror = function(event) {
        console.log("Problem opening DB.");
    }

    request.onupgradeneeded = function(event) {
        db = event.target.result;

        let objectStore = db.createObjectStore('notes', {
            keypath: "id", autoIncrement: true
        });
        objectStore.createIndex("videoID, videoTime", ["videoID", "videoTime"], {unique: false});
    
        objectStore.transaction.oncomplete = function(event) {
            console.log("ObjectStore Created.");
        }
    }

    request.onsuccess = function(event) {
        db = event.target.result;
        console.log("DB Opened.")
    // Functions to carry out if successfully opened:
    
    // insert_records(notes); This is only done when for the first run, so I will have some notes to use for checking and debugging. The notes are in the form of an array.
    }

}

function get_records(vidID) {
    if(db) {
        const get_transaction = db.transaction("notes", "readonly");
        const objectStore = get_transaction.objectStore("notes");
        const myIndex = objectStore.index("videoID, videoTime");
        console.log("Pre-promise reached!");

        return new Promise((resolve, reject) => {
            get_transaction.oncomplete = function() {
                console.log("All Get Transactions Complete!");
            }

            get_transaction.onerror = function() {
                console.log("Problem Getting Notes!");
            }

            let request = myIndex.getAll(IDBKeyRange.bound([vidID], [vidID, []]));

            request.onsuccess = function(event) {
                console.log(event.target.result);
                resolve(event.target.result);
            }
        });

    }
}

create_database();

Now for the popup.js code:

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if (request.message === 'getNotes_success') {
        notesTable.innerHTML = ""; // Clear the table body
        if (request.payload) {
            // Code to display the notes.
        } else {
            // Display a message to add notes or refresh the table.
        }
    }
}

chrome.tabs.query({active: true, currentWindow: true}, (tabs) => {
    site = tabs[0].url; // Variables declared earlier, not shown here

    // Valid YouTube video page
    if (isYTVid.test(site)) { // isYTVid is a previously declared regex string

        refNotesbtn.click(); // Auto-click the refresh notes button
    }

refNotesbtn.addEventListener('click', function() {

    videoID = get_videoID();

    chrome.runtime.sendMessage({
        message: 'get_notes',
        payload: videoID
    });
});

My issue right now is that the above code to display the notes works fine most of the time, but if I switch to another tab, then switch back to the YouTube tab and open the extension, the function to retrieve the notes returns undefined, and the message for no notes found is displayed. If I click on the button to refresh the notes, they are displayed correctly. This bug could cause a major issue for the user if it happens with the insert, edit, or delete functions (not displayed here), so I want to resolve it before proceeding.

I've noticed that when the error occurs, the "Pre-promise reached!" message is also not displayed, so is the get_notes function not being triggered at all, or is the issue after it is triggered? Apologies for the wall of code, and thanks for any help.

A Ahad
  • 35
  • 5
  • Try debugging by setting breakpoints in the [background script's devtools](/a/10258029). – wOxxOm Sep 12 '21 at 16:53
  • @wOxxOm I used the debugger and found that the promise in the get_records function had the following issue: [[PromiseState]]: "rejected", [[PromiseResult]]: message: "Failed to execute 'getAll' on 'IDBIndex': The transaction is not active." How can I go about solving this issue? – A Ahad Sep 12 '21 at 17:15
  • Also, this error seems to occur every time I use breakpoints and debug, even after pressing the refresh notes button, which would normally fix the issue. – A Ahad Sep 12 '21 at 17:26
  • 1
    Listening for blocked events when opening the database might help, at least to rule it out. – Josh Sep 12 '21 at 21:29
  • 1
    I would also recommend the following: when returning a promise, do all work within the promise body. Refine `get_records` to always return a promise. It will highlight the fact that your db variable is not defined. It is not defined because you are assuming the database is always open and created by the time you query it. This signals a problem with your async code. – Josh Sep 12 '21 at 21:32
  • `db` is discovered asynchronously, therefore cache `Promise:db` (eg `const dbPromise`) rather than `db` itself. Then access `db` with `dbPromise.then(db => {/* much of the code belongs here */})`. With a little thought, the code will become bullet proof (and may even fix your issue :) ). – Roamer-1888 Sep 13 '21 at 13:10
  • @Josh I added listeners to both requests (first when creating the db, and then when retrieving the records using myIndex.getAll()) checking for 'blocked', but neither are triggered when the error occurs. – A Ahad Sep 13 '21 at 18:12
  • @Josh You're right about the db variable not being defined. I modified the following section: `if(db) {let getNotes_request =...} else {console.log("DB is missing!);}` and the message was triggered, so now I have an idea of where the error is occurring, but how do I go about solving this? This is my first project using promises and IndexedDB, so could you please provide some sample code to illustrate how I could make sure the db is opened and ready for querying in such a case. – A Ahad Sep 13 '21 at 18:29
  • @Roamer-1888 I'm afraid I have no idea about caching, as I'm still fairly new to JavaScript. Please provide me with a sample code, or some reading materials/videos that would explain how this works. – A Ahad Sep 13 '21 at 19:38
  • @Josh I tried adding `if(db === null) {create_database();}` just before the getNotesRequest section, but that also didn't work. For now, I am using the hackish solution of using a setTimeout function to execute `refNotesbtn.click();` a second time after about 100ms. That much delay shouldn't impact user experience, and will ensure that the db is loaded before the insert function is called. Still, I want to understand how to properly fix this error. You mentioned a problem with my async code. Please help me identify it and solve it. – A Ahad Sep 13 '21 at 19:53
  • @AAhad, "cache" in this context just means a javascript variable in an outer scope, just like your `db` variable but what I'm suggesting is that you cache `db` Promise-wrapped, not raw. I'll write you an answer. – Roamer-1888 Sep 13 '21 at 19:57

2 Answers2

0

As db is discovered asynchronously, you need to cache it Promise-wrapped (as eg const dbPromise = ...) rather than raw db. Then you can reliably access db with dbPromise.then(db => {...}) (or the async/await equivalent).

This is how I would write it. Plenty of explanatory comments in code.

// background.js

// cache for Promise-wrapped `db` object
let dbPromise = null;

// listener (top-level code)
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if(request.message === 'get_notes') {
        if(!dbPromise) {
            dbPromise = create_database()
            .catch(error => {
                console.log(error);
                throw error;
            });
        } else {
            // do nothing ... dbPromise is already cached.
        }
        dbPromise // read the cached promise and chain .then().then() .
        .then(db => get_records(db, request.payload)) // this is where you were struggling.
        .then(res => {
            chrome.runtime.sendMessage({
                'message': 'getNotes_success',
                'payload': res
            });
        })
        .catch(error => { // catch to avoid unhandled error exception
            console.log(error); // don't re-throw
        });
    } else {
        // do nothing ... presumably.
    }
});

function create_database() {
    return new Promise((resolve, reject) => {
        const request = self.indexedDB.open('MyTestDB'); // scope of self?
        request.onerror = function(event) {
            reject(new Error('Problem opening DB.'));
        }
        request.onupgradeneeded = function(event) {
            let db = event.target.result;
            let objectStore = db.createObjectStore('notes', {
                'keypath': 'id', 
                'autoIncrement': true
            });
            objectStore.createIndex('videoID, videoTime', ['videoID', 'videoTime'], {'unique': false});
            objectStore.transaction.oncomplete = function(event) {
                console.log('ObjectStore Created.');
                resolve(db);
            }
        }
        request.onsuccess = function(event) {
            let db = event.target.result;
            console.log('DB Opened.')
            // Functions to carry out if successfully opened:
            
            // insert_records(notes); This is only done when for the first run, so I will have some notes to use for checking and debugging. The notes are in the form of an array.
            resolve(db);
        }
    });
}

function get_records(db, vidID) {
    // first a couple of low-lwvel utility functions to help keep the high-level code clean.
    function getTransaction = function() {
        const transaction = db.transaction('notes', 'readonly');
        transaction.oncomplete = function() {
            console.log('All Get Transactions Complete!');
        }
        transaction.onerror = function() {
            console.log('Problem Getting Notes!');
        }
        return transaction;
    };
    function getAllAsync = function(transaction) {
        return new Promise((resolve, reject) {
            const objectStore = transaction.objectStore('notes');
            let request = objectStore.index('videoID, videoTime').getAll(IDBKeyRange.bound([vidID], [vidID, []]));
            request.onsuccess = function(event) {
                // console.log(event.target.result);
                resolve(event.target.result);
            }
            request.onerror = function(event) { // presumably
                reject(new Error('getAll request failed'));
            }
        }); 
    };
    return getAllAsync(getTransaction(db));
}

The only part I'm not really sure of is the interplay between request.onupgradeneeded and request.onsuccess. I am assuming that one or other of these events will fire. If they fire sequentially(?) then maybe the code needs to be slightly different.

Roamer-1888
  • 19,138
  • 5
  • 33
  • 44
  • Thanks for the answer. I'll try it properly tomorrow as it's pretty late here now. I'll add that `request.onupgradeneeded` is fired when the database is first created, or whenever its version number is changed, while `request.onsuccess` is fired every time, regardless of version change, as long as an error isn't encountered. So on the first run, both will be fired sequentially, whereas on later runs only the latter (`request.onsuccess`) will be fired, provided the version number isn't changed. Also, the scope of self is because that is required with the latest chrome extension versions. – A Ahad Sep 13 '21 at 20:47
  • As far as I can tell, my code will be OK if the `event.target.result` ('db') object exposed to each of the event handlers is the same, which seems reasonable. Whichever event occurs first will cause the `new Promise` to be resolved; the other event (if there is one) will cause `resolve(event.target.result);` to be called again but will have no effect as a subsequent `resolve()` or `reject()` called on a Promise is always ignored. – Roamer-1888 Sep 14 '21 at 18:53
  • I tried it and it works. Thank you very much. I made some minor edits to correct some of the functions. Apart from that, I'll just add that the database created by this code is a new DB, not the same as the one from my original code. Also, correct me if I'm wrong, but once the database is created (or re-created) for the "get_notes" command, it shouldn't need to be recreated again for any of the other functions, as the "get_notes" command will always be called first before any other commands, so the DB will definitely exist and be ready at that time. – A Ahad Sep 19 '21 at 16:52
  • Good question. I didn't really challenge your original code in that regard. In most cases, a database is a persistent thing that is created once. It is unusual to command a database to be created from javascript. However, that appears to be what you want (maybe for experimental/development purposes). As written (and if my understanding is correct), your `create_database()` function would be better named `create_or_open_database()`. In either case (create or open), the function should return a promise that resolves to a reference to a database connection. – Roamer-1888 Sep 20 '21 at 12:19
0

I suggest the following. Create a simple promise returning helper function for connecting to indexedDB.

function open(name, version, onupgradeneeded) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);
    request.onupgradeneeded = onupgradeneeded;
    request.onerror = event => reject(event.target.error);
    request.onsuccess = event => resolve(event.target.result);
  });
}

Revise get_records to accept a database parameter as its first parameter, to always return a promise, and to carry out all work within the promise executor callback function.

function get_records(db, vidID) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction("notes", "readonly");
    const store = tx.objectStore("notes");
    const index = store.index("videoID, videoTime");
    let request = index.getAll(IDBKeyRange.bound([vidID], [vidID, []]));
    request.onsuccess = function(event) {
      console.log(event.target.result);
      resolve(event.target.result);
    };
    request.onerror = event => reject(event.target.error);
  });
}

Compose the work when you need to do something:

function uponSomethingHappeningSomewhere() {
  open('mydb', 1, myOnUpgradeneeded).then(db => {
    const videoId = 1234;
    return get_records(db, videoId);
  }).then(results => {
    return chrome.runtime.sendMessage({
      'message': 'getNotes_success',
      'payload': res
    });
  }).catch(console.error);
}
Josh
  • 17,834
  • 7
  • 50
  • 68