Skip to content

Commit 6f07ce6

Browse files
authored
Merge pull request framer#406 from framer/global-search/index-changes
Global search: Indexer watches for change
2 parents 80328c4 + 02a8e04 commit 6f07ce6

7 files changed

Lines changed: 300 additions & 38 deletions

File tree

plugins/global-search/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function App() {
2828

2929
return (
3030
<ErrorBoundary FallbackComponent={ErrorScene}>
31-
<IndexerProvider projectId={projectInfo.id}>
31+
<IndexerProvider projectId={projectInfo.id} projectName={projectInfo.name}>
3232
{activeScene === "dev-tools" && <DevToolsScene />}
3333
{activeScene === "search" && <SearchScene />}
3434
</IndexerProvider>

plugins/global-search/src/components/SearchScene.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { RootNodeType } from "../utils/indexer/types"
88
import { useIndexer } from "../utils/indexer/useIndexer"
99
import { entries } from "../utils/object"
1010
import { getPluginUiOptions } from "../utils/plugin-ui"
11+
import { useMinimumDuration } from "../utils/useMinimumDuration"
1112
import { NoResults } from "./NoResults"
1213
import { ResultsList } from "./Results"
1314
import { SearchInput } from "./SearchInput"
@@ -20,6 +21,7 @@ export function SearchScene() {
2021
const [query, setQuery] = useState("")
2122
const { searchOptions, optionsMenuItems } = useOptionsMenuItems()
2223
const deferredQuery = useDeferredValue(query)
24+
const isIndexingWithMinimumDuration = useMinimumDuration(isIndexing, 500)
2325

2426
const { results, hasResults, error: filterError } = useAsyncFilter(deferredQuery, searchOptions, db, dataVersion)
2527

@@ -46,15 +48,15 @@ export function SearchScene() {
4648
)}
4749
>
4850
<SearchInput value={query} onChange={handleQueryChange} />
49-
{isIndexing && (
50-
// TODO: Discuss if we should add a tooltip to explain what's this.
51-
<span
52-
title="Indexing..."
53-
className="animate-[fade-in_150ms_forwards] [animation-delay:500ms] opacity-0"
54-
>
55-
<IconSpinner className="text-black dark:text-white animate-[spin_0.8s_linear_infinite]" />
56-
</span>
57-
)}
51+
52+
<span
53+
title="Indexing..."
54+
className="aria-hidden:opacity-0 transition"
55+
aria-hidden={!isIndexingWithMinimumDuration}
56+
>
57+
<IconSpinner className="text-black dark:text-white animate-[spin_0.8s_linear_infinite]" />
58+
</span>
59+
5860
<Menu items={optionsMenuItems}>
5961
<IconEllipsis className="text-framer-text-tertiary-light dark:text-framer-text-tertiary-dark" />
6062
</Menu>

plugins/global-search/src/utils/db.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface GlobalSearchDB extends DBSchema {
1111
type: string
1212
rootNodeType: string
1313
addedInIndexRun: number
14+
rootNodeIdVersion: [string, number]
1415
}
1516
}
1617
}
@@ -19,19 +20,27 @@ export class GlobalSearchDatabase implements ResumableAsyncIterable<IndexEntry>
1920
private db: IDBPDatabase<GlobalSearchDB> | null = null
2021
private readonly dbName: string
2122

22-
constructor(projectId: string) {
23-
this.dbName = `global-search-${projectId}`
23+
constructor(projectId: string, projectName: string) {
24+
this.dbName = `global-search-${projectName}-${projectId}`
2425
}
2526

2627
async open(): Promise<IDBPDatabase<GlobalSearchDB>> {
2728
if (this.db) return this.db
2829

29-
this.db = await openDB<GlobalSearchDB>(this.dbName, 1, {
30-
upgrade(db) {
31-
const entriesStore = db.createObjectStore("entries", { keyPath: "id" })
32-
entriesStore.createIndex("rootNodeType", "rootNodeType")
33-
entriesStore.createIndex("type", "type")
34-
entriesStore.createIndex("addedInIndexRun", "addedInIndexRun")
30+
this.db = await openDB<GlobalSearchDB>(this.dbName, 2, {
31+
upgrade(db, oldVersion, _newVersion, transaction) {
32+
if (oldVersion < 1) {
33+
const entriesStore = db.createObjectStore("entries", { keyPath: "id" })
34+
entriesStore.createIndex("rootNodeType", "rootNodeType")
35+
entriesStore.createIndex("type", "type")
36+
entriesStore.createIndex("addedInIndexRun", "addedInIndexRun")
37+
}
38+
39+
if (oldVersion < 2) {
40+
const entriesStore = transaction.objectStore("entries")
41+
// Add compound index for [rootNodeId, addedInIndexRun] - this is all we need
42+
entriesStore.createIndex("rootNodeIdVersion", ["rootNodeId", "addedInIndexRun"])
43+
}
3544
},
3645
})
3746

@@ -109,4 +118,25 @@ export class GlobalSearchDatabase implements ResumableAsyncIterable<IndexEntry>
109118

110119
await tx.done
111120
}
121+
122+
/**
123+
* Removes entries for a specific root node from a specific index run version.
124+
*
125+
* This is used for incremental updates when a specific canvas root changes.
126+
*/
127+
async clearEntriesForRootNodeAndSpecificVersion(rootNodeId: string, version: number): Promise<void> {
128+
const db = await this.open()
129+
const tx = db.transaction("entries", "readwrite")
130+
const store = tx.objectStore("entries")
131+
const index = store.index("rootNodeIdVersion")
132+
133+
// Use compound index to efficiently find entries with exact [rootNodeId, version] match
134+
let cursor = await index.openCursor(IDBKeyRange.only([rootNodeId, version]))
135+
while (cursor) {
136+
await cursor.delete()
137+
cursor = await cursor.continue()
138+
}
139+
140+
await tx.done
141+
}
112142
}

plugins/global-search/src/utils/indexer/IndexerProvider.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,25 @@ import { GlobalSearchIndexer } from "./indexer"
88
* Creates database and indexer instances and provides them to the children.
99
* The database manages its own version and the indexer depends on the database.
1010
*/
11-
export function IndexerProvider({ children, projectId }: { children: React.ReactNode; projectId: string }) {
11+
export function IndexerProvider({
12+
children,
13+
projectId,
14+
projectName,
15+
}: {
16+
children: React.ReactNode
17+
projectId: string
18+
projectName: string
19+
}) {
1220
const dbRef = useRef<GlobalSearchDatabase>()
13-
dbRef.current ??= new GlobalSearchDatabase(projectId)
21+
dbRef.current ??= new GlobalSearchDatabase(projectId, projectName)
1422
const db = dbRef.current
1523

1624
const indexerRef = useRef<GlobalSearchIndexer>()
1725
indexerRef.current ??= new GlobalSearchIndexer(db)
1826
const indexer = indexerRef.current
1927

2028
const [isIndexing, setIsIndexing] = useState(false)
29+
const [isCanvasRootChanging, setIsCanvasRootChanging] = useState(false)
2130
const [dataVersion, setDataVersion] = useState(0)
2231

2332
useEffect(() => {
@@ -42,12 +51,14 @@ export function IndexerProvider({ children, projectId }: { children: React.React
4251
const onAborted = () => {
4352
startTransition(() => {
4453
setIsIndexing(false)
54+
setIsCanvasRootChanging(false)
4555
})
4656
}
4757

4858
const onError = ({ error }: IndexerEvents["error"]) => {
4959
startTransition(() => {
5060
setIsIndexing(false)
61+
setIsCanvasRootChanging(false)
5162
console.error(error)
5263
})
5364
}
@@ -58,13 +69,28 @@ export function IndexerProvider({ children, projectId }: { children: React.React
5869
})
5970
}
6071

72+
const onCanvasRootChangeStarted = () => {
73+
startTransition(() => {
74+
setIsCanvasRootChanging(true)
75+
})
76+
}
77+
78+
const onCanvasRootChangeCompleted = () => {
79+
startTransition(() => {
80+
setIsCanvasRootChanging(false)
81+
setDataVersion(prev => prev + 1)
82+
})
83+
}
84+
6185
const unsubscribes = [
6286
indexer.on("progress", onProgress),
6387
indexer.on("restarted", onRestarted),
6488
indexer.on("aborted", onAborted),
6589
indexer.on("started", onStarted),
6690
indexer.on("completed", onCompleted),
6791
indexer.on("error", onError),
92+
indexer.on("canvasRootChangeStarted", onCanvasRootChangeStarted),
93+
indexer.on("canvasRootChangeCompleted", onCanvasRootChangeCompleted),
6894
]
6995

7096
void indexer.start()
@@ -75,8 +101,8 @@ export function IndexerProvider({ children, projectId }: { children: React.React
75101
}, [indexer, db])
76102

77103
const data = useMemo(
78-
() => ({ isIndexing, indexerInstance: indexer, db, dataVersion }),
79-
[isIndexing, indexer, db, dataVersion]
104+
() => ({ isIndexing: isIndexing || isCanvasRootChanging, indexerInstance: indexer, db, dataVersion }),
105+
[isIndexing, isCanvasRootChanging, indexer, db, dataVersion]
80106
)
81107

82108
return <IndexerContext.Provider value={data}>{children}</IndexerContext.Provider>

plugins/global-search/src/utils/indexer/indexer.ts

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
type AnyNode,
3+
type CanvasRootNode,
34
type Collection,
45
framer,
56
isComponentNode,
@@ -42,11 +43,12 @@ async function getNodeName(node: AnyNode): Promise<string | null> {
4243

4344
export interface IndexerEvents extends EventMap {
4445
error: { error: Error }
45-
progress: { processed: number; total?: number }
4646
started: { indexRun: number }
4747
completed: never
4848
restarted: never
4949
aborted: never
50+
canvasRootChangeStarted: never
51+
canvasRootChangeCompleted: never
5052
}
5153

5254
export class GlobalSearchIndexer {
@@ -58,6 +60,8 @@ export class GlobalSearchIndexer {
5860
// A smaller batch size will make showing results faster, but will also make the UI more laggy.
5961
private batchSize = 100
6062
private abortRequested = false
63+
private canvasSubscription: (() => void) | null = null
64+
private currentCanvasRootChangeAbortController: AbortController | null = null
6165

6266
constructor(private db: GlobalSearchDatabase) {}
6367

@@ -165,35 +169,84 @@ export class GlobalSearchIndexer {
165169
}
166170
}
167171

172+
private async handleCanvasRootChange(rootNode: CanvasRootNode) {
173+
if (this.abortRequested) return
174+
175+
this.currentCanvasRootChangeAbortController?.abort()
176+
177+
const abortController = new AbortController()
178+
this.currentCanvasRootChangeAbortController = abortController
179+
180+
this.eventEmitter.emit("canvasRootChangeStarted")
181+
182+
try {
183+
if (abortController.signal.aborted) return
184+
185+
const lastIndexRun = await this.db.getLastIndexRun()
186+
const currentIndexRun = lastIndexRun + 1
187+
await this.processNodes(currentIndexRun, [rootNode], abortController.signal)
188+
await this.db.clearEntriesForRootNodeAndSpecificVersion(rootNode.id, lastIndexRun)
189+
} catch (error) {
190+
this.eventEmitter.emit("error", { error: error instanceof Error ? error : new Error(String(error)) })
191+
} finally {
192+
if (this.currentCanvasRootChangeAbortController === abortController) {
193+
this.currentCanvasRootChangeAbortController = null
194+
}
195+
this.eventEmitter.emit("canvasRootChangeCompleted")
196+
}
197+
}
198+
199+
private async processNodes(
200+
currentIndexRun: number,
201+
rootNodes: readonly CanvasRootNode[],
202+
abortSignal?: AbortSignal
203+
) {
204+
const validRootNodes = rootNodes.filter(rootNode => isComponentNode(rootNode) || isWebPageNode(rootNode))
205+
206+
for await (const batch of this.crawlNodes(currentIndexRun, validRootNodes)) {
207+
if (this.abortRequested || abortSignal?.aborted) break
208+
await this.db.upsertEntries(batch)
209+
}
210+
}
211+
212+
private async processCollections(currentIndexRun: number) {
213+
const collections = await framer.getCollections()
214+
215+
for await (const batch of this.crawlCollections(currentIndexRun, collections)) {
216+
if (this.abortRequested) break
217+
await this.db.upsertEntries(batch)
218+
}
219+
}
220+
168221
async start() {
169222
// XXX: The indexer has no "locking mechanism" to prevent multiple instances from running at the same time in multiple tabs.
170223
try {
171224
const lastIndexRun = await this.db.getLastIndexRun()
172225
const currentIndexRun = lastIndexRun + 1
173226

174-
const [pages, components] = await Promise.all([
227+
const [pages, components, canvasRoot] = await Promise.all([
175228
framer.getNodesWithType("WebPageNode"),
176229
framer.getNodesWithType("ComponentNode"),
230+
framer.getCanvasRoot(),
177231
])
178232

179233
this.abortRequested = false
180234
this.eventEmitter.emit("started", { indexRun: currentIndexRun })
181235

182-
for await (const batch of this.crawlNodes(currentIndexRun, [...pages, ...components])) {
183-
// this isn't a unnecassary static expression, as the value could change during the async loop
184-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
185-
if (this.abortRequested) break
186-
await this.db.upsertEntries(batch)
187-
}
236+
this.canvasSubscription ??= framer.subscribeToCanvasRoot(rootNode => {
237+
void this.handleCanvasRootChange(rootNode)
238+
})
188239

189-
const collections = await framer.getCollections()
240+
// Remove the current open canvas root from the list of root nodes to index
241+
// as it's already being indexed by the canvas root watcher
242+
const rootNodesWithoutCurrentRoot = [...pages, ...components].filter(
243+
rootNode => rootNode.id !== canvasRoot.id
244+
)
190245

191-
for await (const batch of this.crawlCollections(currentIndexRun, collections)) {
192-
// this isn't a unnecassary static expression, as the value could change during the async loop
193-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
194-
if (this.abortRequested) break
195-
await this.db.upsertEntries(batch)
196-
}
246+
await Promise.all([
247+
this.processNodes(currentIndexRun, rootNodesWithoutCurrentRoot),
248+
this.processCollections(currentIndexRun),
249+
])
197250

198251
// this isn't a unnecassary static expression, as the value could change during the async loop
199252
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -207,13 +260,24 @@ export class GlobalSearchIndexer {
207260
}
208261

209262
async restart() {
210-
this.abortRequested = true
263+
this.abort()
264+
211265
this.eventEmitter.emit("restarted")
212266
return this.start()
213267
}
214268

215269
abort() {
216270
this.abortRequested = true
271+
272+
this.currentCanvasRootChangeAbortController?.abort()
273+
this.currentCanvasRootChangeAbortController = null
274+
275+
if (this.canvasSubscription) {
276+
this.canvasSubscription()
277+
this.canvasSubscription = null
278+
}
279+
280+
this.eventEmitter.emit("aborted")
217281
}
218282

219283
/**

0 commit comments

Comments
 (0)