Skip to content
Advertisement

Why does startAfter in this firebase query isn’t working?

I have the next function using firebase in an JavaScript app. The query works fine the first time because lastDoc isn’t defined so it gets the first 7 documents in the database, but the next time when there’s a lastDoc, the query keeps returning the same first 7 docs. The lastDoc variable is updated and indeed gets the value of the next doc.

const getPosts = async ({loadMore=false, lastDoc}: { loadMore: boolean, lastDoc?: any}) => {
    let col = collection(db, "posts");

    let q = query(col,
            orderBy("updateDate", "desc"),
            limit(7),
            startAfter(lastDoc));
    
    const querySnapshot = await getDocs(q);

    let newLastDoc = querySnapshot.docs[querySnapshot.size-1];

    let posts = querySnapshot.docs.map(doc => {
        if(doc.data().inactive == false || !doc.data().inactive) return {...doc.data(), id: doc.id}
        else return null
    }).filter((post: any) => post !== null);

    return {posts, lastDoc: newLastDoc};
}

The first time the lastDoc is undefined, and returns docs 1, 2, 3, 4, 5, 6 and 7. The second time the lastDoc is:

{
  _firestore: {...},
  _userDataWriter: {...},
  _key: {...},
  _document: {..., data: {DATA_FROM_THE_7th_DOC}},
  _converter: null
}

, and keeps returning docs 1, 2, 3, 4, 5, 6 and 7

Why isn’t it working?

Advertisement

Answer

In the legacy version of the JavaScript SDK, startAfter was defined as:

Query<T>#startAfter(...fieldValues: any[]): Query<T>;
Query<T>#startAfter(snapshot: DocumentSnapshot<any>): Query<T>;

In the modern JavaScript SDK, startAfter is defined as:

function startAfter(...fieldValues: unknown[]): QueryStartAtConstraint;
function startAfter(snapshot: DocumentSnapshot<any>): QueryStartAtConstraint;

This means that your code should be functioning as expected. HOWEVER, based on the provided project code from the comments, this is for an Express API, not client-side JavaScript.

Your query isn’t working as expected because lastDoc is not a true DocumentSnapshot object at all and is instead a JavaScript version of it churned out by res.json(). Instead of returning a mangled DocumentSnapshot object to the client, we should instead just send back the data we need to pass as the startAfter arguments. In our case, we’ll send back just the updateDate parameter inside of lastDocData:

return {
    posts,
    lastDocData: {
        updateDate: newLastDoc.get("updateDate")
    }
};

Then when the data comes back to the API, we’ll pass it to startAfter:

startAfter(lastDocData.updateDate)) // Assumes lastDocData is { updateDate: number }

While reviewing methods/getPosts.ts, I noticed lines similar to the following:

let q = !loadMore
    ? category && category !== "TODOS"
        ? query(
            col,
            where("school", "==", school?.code),
            where("category", "==", category),
            where("inactive", "==", false),
            orderBy("updateDate", "desc"),
            limit(7)
        )
        : query(
            col,
            where("school", "==", school.code),
            where("inactive", "==", false),
            orderBy("updateDate", "desc"),
            limit(7)
        )
    : category && category === "TODOS" // note: condition does not match above
        ? query(
            col,
            where("school", "==", school?.code),
            where("inactive", "==", false),
            orderBy("updateDate", "desc"),
            limit(7),
            startAfter(lastDocData.updateDate))
        )
        : query(
            col,
            where("school", "==", school.code),
            where("category", "==", category),
            where("inactive", "==", false),
            orderBy("updateDate", "desc"),
            limit(7),
            startAfter(lastDocData.updateDate)
        );

Because query takes a variable number of arguments, you can use the spread operator (...) to optionally include some arguments. As a simplified example:

let includeC = false,
    result = [];

result.push(
  'a',
  'b',
  ...(includeC ? ['c'] : []),
  'd'
);

console.log(result); // => ['a', 'b', 'd']

includeC = true;
result = [];

result.push(
  'a',
  'b',
  ...(includeC ? ['c'] : []),
  'd'
);

console.log(result); // => ['a', 'b', 'c', 'd']

This allows you to use the following query builder instead:

const q = query(
    colRef,
    ...(school?.code ? [where("school", "==", school.code)] : []), // include school code filter, only if provided
    ...(category && category !== "TODOS" ? [where("category", "==", category)] : []), // include category filter, only if provided and not "TODOS"
    where("inactive", "==", false),
    orderBy("updateDate", "desc"),
    limit(7),
    ...(lastDocData?.updateDate ? [startAfter(lastDocData.updateDate)] : []) // Assumes lastDocData is a { updateDate: number }
);

Bringing this all together gives:

// methods/getPosts.ts
import { School } from '../../types';
import app from '../../firebaseconfig';
import { getFirestore, collection, query, where, getDocs, limit, orderBy, startAfter } from 'firebase/firestore/lite';

const db = getFirestore(app);

const getPosts = ({category, school, dataSavingMode=false, lastDocData}: {category: string, school: School, dataSavingMode: boolean, lastDocData?: { updateDate: number }}) => {
    const colRef = collection(db, dataSavingMode ? "dataSavingPosts" "posts");

    const q = query(
        colRef,
        ...(school?.code ? [where("school", "==", school.code)] : []),
        ...(category && category !== "TODOS" ? [where("category", "==", category)] : []),
        where("inactive", "==", false),
        orderBy("updateDate", "desc"),
        limit(7),
        ...(lastDocData?.updateDate ? [startAfter(lastDocData.updateDate)] : []),
    );

    const querySnap = await getDocs(q),
        postDocsArr = querySnap.docs,
        lastDoc = postDocsArr[postDocsArr.length - 1],
        noMorePosts = postDocsArr.length < 7;
        
    const postDataArr = postDocsArr
        .filter(docSnap => !docSnap.get("inactive")) // shouldn't be needed with above filter
        .map(docSnap => ({ ...docSnap.data(), id: docSnap.id }));

    return {
        posts: postDataArr,
        noMorePosts,
        lastDocData: { updateDate: lastDoc.get("updateDate") }
    }
}

export default getPosts;
// routes/getPosts.ts
import express from 'express';
import { School } from '../../types';

const router = express.Router();

// Methods
import getPosts from '../methods/getPosts';

router.post('/', async (req, res) => {
    const reqBody: {
        school: School,
        category: string,
        lastDocData?: any,
        dataSavingMode?: boolean
    } = req.body;

    const {posts, lastDocData, noMorePosts} = await getPosts({
        category: reqBody.category,
        school: reqBody.school,
        dataSavingMode: reqBody.dataSavingMode,
        lastDocData: reqBody.lastDocData
    });

    res.json({posts, lastDocData, noMorePosts});
})

export default router;
Advertisement