From 956f8dbbea5dcdc29398f7628fa6ce13411459e9 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:37:27 +0500 Subject: [PATCH] core: prevent db.lookup.notesWithHighlighting from mutating the passed in selector The FilteredSelector passed into notesWithHighlighting was being mutated. This wasn't an issue in the apps/web because a fresh selector was passed in for every query. But on apps/mobile, the same selector is reused for every query on the search page. The bug only became apparent when passing in a query like `tag:one`, then after the results came in, continuing the query: `tag:one tag:two`. This only showed results from tag:one since the selector was mutated by the previous lookup. The mutation happens in FilteredSelector's where method. So, the bug is fixed by cloning the selector before the where method call in notesWithHighlighting. Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- packages/core/__tests__/lookup.test.js | 35 ++++++++++++++++++++ packages/core/src/api/lookup.ts | 7 ++++ packages/core/src/database/sql-collection.ts | 11 ++++++ 3 files changed, 53 insertions(+) diff --git a/packages/core/__tests__/lookup.test.js b/packages/core/__tests__/lookup.test.js index 0c646d8be..30d593c56 100644 --- a/packages/core/__tests__/lookup.test.js +++ b/packages/core/__tests__/lookup.test.js @@ -209,4 +209,39 @@ describe("notesWithHighlighting", () => { ); expect(searchWithDiacritics.length).toBe(4); })); + + test("should not reuse filters (aka mutate selector) between searches", () => + databaseTest().then(async (db) => { + const note1Id = await db.notes.add({ title: "note 1" }); + const note2Id = await db.notes.add({ title: "note 2" }); + const note3Id = await db.notes.add({ title: "note 3" }); + const tag1Id = await db.tags.add({ title: "daily" }); + const tag2Id = await db.tags.add({ title: "academia" }); + await db.relations.add( + { id: tag1Id, type: "tag" }, + { id: note1Id, type: "note" } + ); + await db.relations.add( + { id: tag2Id, type: "tag" }, + { id: note2Id, type: "note" } + ); + + /** + * this test ensures that the selector passed to `notesWithHighlighting` is not mutated between searches + */ + const selector = db.notes.all; + + const tag1SearchResults = await db.lookup.notesWithHighlighting( + "tag:daily", + selector + ); + expect(await tag1SearchResults.ids()).toEqual([note1Id]); + expect(await selector.ids()).toEqual([note1Id, note2Id, note3Id]); + const bothTagResults = await db.lookup.notesWithHighlighting( + "tag:daily tag:academia", + selector + ); + expect(await bothTagResults.ids()).toEqual([note1Id, note2Id]); + expect(await selector.ids()).toEqual([note1Id, note2Id, note3Id]); + })); }); diff --git a/packages/core/src/api/lookup.ts b/packages/core/src/api/lookup.ts index 2096d10b8..71130041e 100644 --- a/packages/core/src/api/lookup.ts +++ b/packages/core/src/api/lookup.ts @@ -155,6 +155,13 @@ export default class Lookup { : []; const defaultVault = await this.db.vaults.default(); + + /** + * `notes` is a FilteredSelector whose `where` method mutates the selector. + * Thus the selector passed into `notesWithHighlighting` is mutated, which can cause unexpected results. + * To avoid this, we clone the selector before `where`. + */ + notes = notes.clone(); notes = notes.where((eb) => { const exprs = []; const tagsFilter = this.db.relations diff --git a/packages/core/src/database/sql-collection.ts b/packages/core/src/database/sql-collection.ts index 4c3e11da1..f6fcff074 100644 --- a/packages/core/src/database/sql-collection.ts +++ b/packages/core/src/database/sql-collection.ts @@ -388,6 +388,17 @@ export class FilteredSelector { this.filter = filter; } + clone() { + const selector = new FilteredSelector( + this.type, + this.filter, + this.batchSize + ); + selector._fields = this._fields.slice(); + selector._limit = this._limit; + return selector; + } + fields(fields: AnyColumnWithTable[]) { this._fields = fields; return this;