Skip to content

Commit f3c7adc

Browse files
authored
Add ability to reload local files across page using FileSystemFileHandle API (#5419)
1 parent bcc1369 commit f3c7adc

File tree

28 files changed

+1462
-226
lines changed

28 files changed

+1462
-226
lines changed

packages/core/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@
131131
"./util/useMeasure": "./src/util/useMeasure.ts",
132132
"./ui/ReturnToImportFormDialog": "./src/ui/ReturnToImportFormDialog.tsx",
133133
"./util/nanoid": "./src/util/nanoid.ts",
134-
"./ReExports/list": "./src/ReExports/list.ts"
134+
"./ReExports/list": "./src/ReExports/list.ts",
135+
"./util/fileHandleStore": "./src/util/fileHandleStore.ts"
135136
},
136137
"files": [
137138
"esm"
@@ -177,6 +178,7 @@
177178
"fast-deep-equal": "^3.1.3",
178179
"file-saver-es": "^2.0.5",
179180
"generic-filehandle2": "^2.0.18",
181+
"idb": "^8.0.3",
180182
"librpc-web-mod": "^2.1.1",
181183
"load-script": "^2.0.0",
182184
"mobx": "^6.15.0",
@@ -633,6 +635,10 @@
633635
"./ReExports/list": {
634636
"types": "./esm/ReExports/list.d.ts",
635637
"import": "./esm/ReExports/list.js"
638+
},
639+
"./util/fileHandleStore": {
640+
"types": "./esm/util/fileHandleStore.d.ts",
641+
"import": "./esm/util/fileHandleStore.js"
636642
}
637643
}
638644
},

packages/core/scripts/generateExports.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ const repoRoot = join(packageRoot, '../..')
99
const srcDir = join(packageRoot, 'src')
1010

1111
// Exports to keep even if not used internally (for backwards compatibility)
12-
const preservedExports = ['@jbrowse/core/util/nanoid']
12+
const preservedExports = [
13+
'@jbrowse/core/util/nanoid',
14+
'@jbrowse/core/ReExports/list',
15+
'@jbrowse/core/util/fileHandleStore',
16+
]
1317

1418
// Scan the codebase for all @jbrowse/core imports
1519
function findAllImports() {

packages/core/src/pluggableElementTypes/RpcMethodType.test.ts

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import PluginManager from '../PluginManager.ts'
2-
import RpcMethodType from './RpcMethodType.ts'
2+
import RpcMethodType, { convertFileHandleLocations } from './RpcMethodType.ts'
3+
import { clearFileFromCache, setFileInCache } from '../util/tracks.ts'
34

45
const pluginManager = new PluginManager()
56

@@ -51,3 +52,172 @@ test('test serialize arguments with augmentLocationObject', async () => {
5152
'',
5253
)
5354
})
55+
56+
describe('convertFileHandleLocations', () => {
57+
afterEach(() => {
58+
clearFileFromCache('test-handle-1')
59+
clearFileFromCache('test-handle-2')
60+
clearFileFromCache('test-handle-nested')
61+
})
62+
63+
test('converts FileHandleLocation to BlobLocation in object property', () => {
64+
const mockFile = new File(['test content'], 'test.bam', {
65+
type: 'application/octet-stream',
66+
})
67+
setFileInCache('test-handle-1', mockFile)
68+
69+
const obj = {
70+
adapter: {
71+
fileLocation: {
72+
locationType: 'FileHandleLocation',
73+
handleId: 'test-handle-1',
74+
name: 'test.bam',
75+
},
76+
},
77+
}
78+
const blobMap: Record<string, File> = {}
79+
80+
convertFileHandleLocations(obj, blobMap)
81+
82+
const converted = obj.adapter.fileLocation as any
83+
expect(converted.locationType).toBe('BlobLocation')
84+
expect(converted.name).toBe('test.bam')
85+
expect(converted.blobId).toBe('fh-blob-test-handle-1')
86+
expect(blobMap[converted.blobId]).toBe(mockFile)
87+
})
88+
89+
test('converts FileHandleLocation in array', () => {
90+
const mockFile = new File(['test'], 'array-file.vcf')
91+
setFileInCache('test-handle-1', mockFile)
92+
93+
const obj = {
94+
locations: [
95+
{
96+
locationType: 'FileHandleLocation',
97+
handleId: 'test-handle-1',
98+
name: 'array-file.vcf',
99+
},
100+
],
101+
}
102+
const blobMap: Record<string, File> = {}
103+
104+
convertFileHandleLocations(obj, blobMap)
105+
106+
const converted = obj.locations[0] as any
107+
expect(converted.locationType).toBe('BlobLocation')
108+
expect(converted.name).toBe('array-file.vcf')
109+
expect(blobMap[converted.blobId]).toBe(mockFile)
110+
})
111+
112+
test('converts multiple FileHandleLocations', () => {
113+
const mockFile1 = new File(['content1'], 'file1.bam')
114+
const mockFile2 = new File(['content2'], 'file2.bam.bai')
115+
setFileInCache('test-handle-1', mockFile1)
116+
setFileInCache('test-handle-2', mockFile2)
117+
118+
const obj = {
119+
bamLocation: {
120+
locationType: 'FileHandleLocation',
121+
handleId: 'test-handle-1',
122+
name: 'file1.bam',
123+
},
124+
indexLocation: {
125+
locationType: 'FileHandleLocation',
126+
handleId: 'test-handle-2',
127+
name: 'file2.bam.bai',
128+
},
129+
}
130+
const blobMap: Record<string, File> = {}
131+
132+
convertFileHandleLocations(obj, blobMap)
133+
134+
expect((obj.bamLocation as any).locationType).toBe('BlobLocation')
135+
expect((obj.indexLocation as any).locationType).toBe('BlobLocation')
136+
expect(Object.keys(blobMap).length).toBe(2)
137+
})
138+
139+
test('handles deeply nested FileHandleLocation', () => {
140+
const mockFile = new File(['nested'], 'nested.gff')
141+
setFileInCache('test-handle-nested', mockFile)
142+
143+
const obj = {
144+
level1: {
145+
level2: {
146+
level3: {
147+
location: {
148+
locationType: 'FileHandleLocation',
149+
handleId: 'test-handle-nested',
150+
name: 'nested.gff',
151+
},
152+
},
153+
},
154+
},
155+
}
156+
const blobMap: Record<string, File> = {}
157+
158+
convertFileHandleLocations(obj, blobMap)
159+
160+
const converted = obj.level1.level2.level3.location as any
161+
expect(converted.locationType).toBe('BlobLocation')
162+
expect(blobMap[converted.blobId]).toBe(mockFile)
163+
})
164+
165+
test('leaves non-FileHandleLocation objects unchanged', () => {
166+
const obj = {
167+
uriLocation: {
168+
locationType: 'UriLocation',
169+
uri: 'https://example.com/file.bam',
170+
},
171+
blobLocation: {
172+
locationType: 'BlobLocation',
173+
blobId: 'existing-blob',
174+
name: 'existing.bam',
175+
},
176+
}
177+
const blobMap: Record<string, File> = {}
178+
179+
convertFileHandleLocations(obj, blobMap)
180+
181+
expect(obj.uriLocation.locationType).toBe('UriLocation')
182+
expect(obj.blobLocation.locationType).toBe('BlobLocation')
183+
expect(obj.blobLocation.blobId).toBe('existing-blob')
184+
expect(Object.keys(blobMap).length).toBe(0)
185+
})
186+
187+
test('throws error when file not in cache', () => {
188+
const obj = {
189+
location: {
190+
locationType: 'FileHandleLocation',
191+
handleId: 'nonexistent-handle',
192+
name: 'missing.bam',
193+
},
194+
}
195+
const blobMap: Record<string, File> = {}
196+
197+
expect(() => {
198+
convertFileHandleLocations(obj, blobMap)
199+
}).toThrow(/File not in cache for handleId: nonexistent-handle/)
200+
})
201+
202+
test('handles circular references without infinite loop', () => {
203+
const mockFile = new File(['circular'], 'circular.bam')
204+
setFileInCache('test-handle-1', mockFile)
205+
206+
const obj: Record<string, unknown> = {
207+
location: {
208+
locationType: 'FileHandleLocation',
209+
handleId: 'test-handle-1',
210+
name: 'circular.bam',
211+
},
212+
}
213+
obj.self = obj
214+
215+
const blobMap: Record<string, File> = {}
216+
217+
convertFileHandleLocations(obj, blobMap)
218+
219+
const converted = obj.location as any
220+
expect(converted.locationType).toBe('BlobLocation')
221+
expect(Object.keys(blobMap).length).toBe(1)
222+
})
223+
})

packages/core/src/pluggableElementTypes/RpcMethodType.ts

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,76 @@
11
import PluggableElementBase from './PluggableElementBase.ts'
22
import mapObject from '../util/map-obj/index.ts'
33
import { isRpcResult } from '../util/rpc.ts'
4-
import { getBlobMap, setBlobMap } from '../util/tracks.ts'
4+
import { getBlobMap, getFileFromCache, setBlobMap } from '../util/tracks.ts'
55
import {
66
RetryError,
77
isAppRootModel,
88
isAuthNeededException,
9+
isFileHandleLocation,
910
isUriLocation,
1011
} from '../util/types/index.ts'
1112

1213
import type PluginManager from '../PluginManager.ts'
13-
import type { UriLocation } from '../util/types/index.ts'
14+
import type { FileHandleLocation, UriLocation } from '../util/types/index.ts'
1415

1516
export type RpcMethodConstructor = new (pm: PluginManager) => RpcMethodType
1617

18+
// Note: We use custom recursion instead of mapObject here because mapObject's
19+
// mapper function only receives object properties, not array items directly.
20+
// FileHandleLocation objects can appear as array elements (e.g., in adapter
21+
// configs with multiple file locations), so we need direct array item access.
22+
export function convertFileHandleLocations(
23+
obj: unknown,
24+
blobMap: Record<string, File>,
25+
seen = new WeakSet<object>(),
26+
) {
27+
const convertLocation = (loc: FileHandleLocation) => {
28+
const file = getFileFromCache(loc.handleId)
29+
if (!file) {
30+
throw new Error(
31+
`File not in cache for handleId: ${loc.handleId}. ` +
32+
`The file "${loc.name}" may need to be reopened.`,
33+
)
34+
}
35+
// Use deterministic blobId based on handleId so the same FileHandleLocation
36+
// always converts to the same BlobLocation. This ensures adapter config
37+
// hashes remain stable across render calls.
38+
const blobId = `fh-blob-${loc.handleId}`
39+
blobMap[blobId] = file
40+
return { locationType: 'BlobLocation' as const, name: loc.name, blobId }
41+
}
42+
43+
const convert = (current: unknown): void => {
44+
if (!current || typeof current !== 'object' || seen.has(current)) {
45+
return
46+
}
47+
seen.add(current)
48+
49+
if (Array.isArray(current)) {
50+
for (let i = 0; i < current.length; i++) {
51+
const item = current[i]
52+
if (isFileHandleLocation(item)) {
53+
current[i] = convertLocation(item)
54+
} else {
55+
convert(item)
56+
}
57+
}
58+
} else {
59+
const record = current as Record<string, unknown>
60+
for (const key of Object.keys(record)) {
61+
const val = record[key]
62+
if (isFileHandleLocation(val)) {
63+
record[key] = convertLocation(val)
64+
} else {
65+
convert(val)
66+
}
67+
}
68+
}
69+
}
70+
71+
convert(obj)
72+
}
73+
1774
export default abstract class RpcMethodType extends PluggableElementBase {
1875
pluginManager: PluginManager
1976

@@ -127,20 +184,15 @@ export default abstract class RpcMethodType extends PluggableElementBase {
127184
) {
128185
const rootModel = this.pluginManager.rootModel
129186

130-
// Skip expensive deep traversal only if we have a valid root model with no internet accounts
131-
// (Don't skip if rootModel isn't set up - let serializeNewAuthArguments handle that case)
132-
if (isAppRootModel(rootModel) && rootModel.internetAccounts.length === 0) {
133-
return thing
134-
}
135-
136187
const uris = [] as UriLocation[]
188+
const blobMap = getBlobMap()
137189

138-
// exclude renderingProps from deep traversal - it is only needed
139-
// client-side for React components and can contain circular references
140-
// (e.g. d3 hierarchy nodes) or non-serializable objects like callbacks
190+
// Convert FileHandleLocation to BlobLocation in-place
191+
// Skip renderingProps as it may have circular references
141192
const { renderingProps, ...rest } = thing
193+
convertFileHandleLocations(rest, blobMap)
142194

143-
// using map-obj avoids cycles, seen in circular view svg export
195+
// Collect UriLocations for auth augmentation using map-obj (handles cycles)
144196
mapObject(
145197
rest,
146198
(key, val: unknown) => {
@@ -158,6 +210,12 @@ export default abstract class RpcMethodType extends PluggableElementBase {
158210
},
159211
{ deep: true },
160212
)
213+
214+
// Skip URI auth augmentation if we have a valid root model with no internet accounts
215+
if (isAppRootModel(rootModel) && rootModel.internetAccounts.length === 0) {
216+
return thing
217+
}
218+
161219
for (const uri of uris) {
162220
await this.serializeNewAuthArguments(uri, rpcDriverClassName)
163221
}

packages/core/src/pluggableElementTypes/renderers/LayoutSession.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,15 @@ export class LayoutSession implements LayoutSessionLike {
5757
}
5858

5959
cachedLayoutIsValid(cachedLayout: CachedLayout) {
60-
return (
61-
cachedLayout.props.bpPerPx === this.props.bpPerPx &&
62-
deepEqual(
63-
readConfObject(this.props.config),
64-
readConfObject(cachedLayout.props.config),
65-
) &&
66-
deepEqual(this.props.filters, cachedLayout.props.filters)
60+
const bpPerPxMatch = cachedLayout.props.bpPerPx === this.props.bpPerPx
61+
const currentConfig = readConfObject(this.props.config)
62+
const cachedConfig = readConfObject(cachedLayout.props.config)
63+
const configMatch = deepEqual(currentConfig, cachedConfig)
64+
const filtersMatch = deepEqual(
65+
this.props.filters,
66+
cachedLayout.props.filters,
6767
)
68+
return bpPerPxMatch && configMatch && filtersMatch
6869
}
6970

7071
get layout(): BaseMultiLayout {

packages/core/src/rpc/methods/CoreRender.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { validateRendererType } from './util.ts'
2-
import RpcMethodType from '../../pluggableElementTypes/RpcMethodType.ts'
2+
import RpcMethodType, {
3+
convertFileHandleLocations,
4+
} from '../../pluggableElementTypes/RpcMethodType.ts'
35
import { renameRegionsIfNeeded } from '../../util/index.ts'
6+
import { getBlobMap, setBlobMap } from '../../util/tracks.ts'
47

58
import type {
69
RenderArgs,
@@ -41,10 +44,18 @@ export default class CoreRender extends RpcMethodType {
4144
throw new Error('must pass a unique session id')
4245
}
4346

47+
// Convert FileHandleLocation to BlobLocation for consistent adapter caching.
48+
// Even though we're on the main thread and don't need to transfer files,
49+
// the adapter config hash must match what the serialized path produces.
50+
const { renderingProps, ...rest } = renamedArgs
51+
const blobMap = getBlobMap()
52+
convertFileHandleLocations(rest, blobMap)
53+
setBlobMap(blobMap)
54+
4455
return validateRendererType(
4556
rendererType,
4657
this.pluginManager.getRendererType(rendererType),
47-
).renderDirect(renamedArgs)
58+
).renderDirect({ ...rest, renderingProps } as RenderArgs)
4859
}
4960

5061
async execute(

0 commit comments

Comments
 (0)