Customizing the app
Dark theme, state observation, session URLs, container sizing, and the web worker RPC.
On this page: Dark theme · Observe the session · Put the session in the URL · Fit the app to a container · Web worker RPC
Dark theme
Use the built-in dark theme via the config theme palette.
The configuration prop takes a
MUI theme palette.
palette.mode: 'dark' switches the whole app to the built-in dark theme;
primary / secondary override individual colors.
DNA base colors and the rest of the options are in the theming guide.
View source — 35 lines
import { JBrowse } from '@jbrowse/react-app2'
const base = 'https://jbrowse.org/code/jb2/main/test_data/volvox'
const assemblies = [{ name: 'volvox', uri: `${base}/volvox.2bit` }]
const tracks = [
{
type: 'AlignmentsTrack',
trackId: 'volvox_cram',
name: 'volvox-sorted.cram',
assemblyNames: ['volvox'],
adapter: { type: 'CramAdapter', uri: `${base}/volvox-sorted.cram` },
},
]
export default function DarkTheme() {
return (
<JBrowse
assemblies={assemblies}
tracks={tracks}
configuration={{ theme: { palette: { mode: 'dark' } } }}
views={[
{
type: 'LinearGenomeView',
init: {
assembly: 'volvox',
loc: 'ctgA:1..50000',
tracks: ['volvox_cram'],
},
},
]}
/>
)
}Observe the session
Wrap in mobx-react observer and read the session getters — which views are open, where each is looking.
The session is a MobX-state-tree node, so a
component outside the app can wrap in mobx-react’s observer and read it
directly. Anything marked #getter or #property on the
session model or a
view model is reactive:
which views are open, what each has, where each is looking. There is no
subscription to set up and nothing to unsubscribe.
That is also why there is no patch callback. createViewState used to take an
onChange(patch, reversePatch) that fired on every MST patch, and it was the
wrong shape for both things people reached for it with: to keep UI in sync you
want observer, which re-renders exactly the readers of what changed, and to
persist a session you want a snapshot rather than a stream of edits to one.
Saving the layout is the snapshot:
import { getSnapshot } from '@jbrowse/mobx-state-tree'
localStorage.setItem(
'jbrowse-session',
JSON.stringify(getSnapshot(viewState.session)),
)
and restoring it is the session option (or controller.setSession). A host
outside React — a notebook kernel, an R session — cannot run an observer, and
takes onSessionChange instead: same snapshot, delivered on a settled signal
rather than per keystroke. See
embedded components.
View source — 68 lines
import { JBrowseApp, useCreateViewState } from '@jbrowse/react-app2'
import { observer } from 'mobx-react'
const base = 'https://jbrowse.org/code/jb2/main/test_data/volvox'
const config = {
assemblies: [{ name: 'volvox', uri: `${base}/volvox.2bit` }],
tracks: [
{
type: 'AlignmentsTrack',
trackId: 'volvox_cram',
name: 'volvox-sorted.cram',
assemblyNames: ['volvox'],
adapter: { type: 'CramAdapter', uri: `${base}/volvox-sorted.cram` },
},
],
defaultSession: {
name: 'observe',
views: [
{
id: 'view-0',
type: 'LinearGenomeView',
init: {
assembly: 'volvox',
loc: 'ctgA:1..50000',
tracks: ['volvox_cram'],
},
},
],
},
}
// The session is a MobX-state-tree node, so anything outside the app can wrap in
// `observer` and re-render when the fields it reads change. No subscription, no
// event wiring: the getters are the API. Add a view or pan one and this updates.
const SessionSummary = observer(function SessionSummary({
viewState,
}: {
viewState: ReturnType<typeof useCreateViewState>
}) {
const { views } = viewState.session
return (
<div style={{ padding: 8, fontFamily: 'monospace', fontSize: 12 }}>
<div>{views.length} view(s) open</div>
<ul style={{ margin: 0 }}>
{views.map(view => (
<li key={view.id}>
{view.type} — {view.coarseVisibleLocStrings || 'no region'}
</li>
))}
</ul>
</div>
)
})
export default function ObserveSession() {
// `useCreateViewState`, not `useState(() => createViewState(…))`: React
// double-invokes a state initializer under StrictMode and throws the second
// result away, which for an engine is a whole orphaned worker pool per mount.
const viewState = useCreateViewState({ config })
return (
<div>
<SessionSummary viewState={viewState} />
<JBrowseApp viewState={viewState} />
</div>
)
}Put the session in the URL
Serialize the session with encodeSession and restore it with decodeSession, for a sharable link.
encodeSession serializes the live session — where the user navigated, which
tracks they opened, how each display is configured — into a compact URL-safe
string, and decodeSession turns one back into a snapshot for the session
prop.
- Use the hash fragment, not the query string. It never reaches the server, so a long session can’t come back as an HTTP 414.
- Only the session travels. The receiving page supplies
assembliesandtracks; a session naming atrackIdthe config lacks is dropped with a notification rather than failing the restore. viewsstill describes the starting state.sessiononly decides what opens now, so File → New session returns toviews.encodeSessiondoes more thangetSnapshot: display settings a user picked up from a promoted display-type default live in their browser rather than the session, and it folds those in first — otherwise the link renders differently for whoever opens it.
The encoding is JBrowse Web’s own, so links open in either. The save button has
to be yours — only your app knows the URL its page is served at; pass it as
headerButtons to land it where JBrowse Web puts Share.
To keep a session rather than share it, send the same snapshot to
localStorage. What tells you it changed is
observing the session, not a
callback.
View source — 140 lines
import { useEffect, useState } from 'react'
import {
JBrowseApp,
decodeSession,
encodeSession,
useCreateViewState,
} from '@jbrowse/react-app2'
import type { SessionSnapshot } from '@jbrowse/react-app2'
const base = 'https://jbrowse.org/code/jb2/main/test_data/volvox'
const config = {
assemblies: [{ name: 'volvox', uri: `${base}/volvox.2bit` }],
tracks: [
{
type: 'FeatureTrack',
trackId: 'volvox_gff3',
name: 'Volvox genes',
assemblyNames: ['volvox'],
adapter: { type: 'Gff3TabixAdapter', uri: `${base}/volvox.sort.gff3.gz` },
},
],
// what opens with no session in the URL, and what File > New session returns
// to. `<JBrowse>` spells this as a `views` prop; held as a config it is the
// same snapshot, and it is what the restored session below layers on top of
defaultSession: {
name: 'Session in URL',
views: [
{
id: 'view-0',
type: 'LinearGenomeView',
init: {
assembly: 'volvox',
loc: 'ctgA:1..50000',
tracks: ['volvox_gff3'],
},
},
],
},
}
// The session goes in the hash fragment rather than the query string. The
// fragment is never sent to the server, so a long session can't overflow the
// request line (HTTP 414) — the same reason jbrowse-web keeps its own there.
function readSessionParam() {
return (
new URLSearchParams(window.location.hash.slice(1)).get('session') ??
undefined
)
}
function writeSessionParam(value: string) {
const params = new URLSearchParams(window.location.hash.slice(1))
params.set('session', value)
window.history.replaceState(null, '', `#${params.toString()}`)
}
// The engine is built here rather than by `<JBrowse>` because the Save button
// below needs it *while rendering*, to close over it. A `ref` on `<JBrowse>`
// hands the engine back a render after mount, which is a render of the toolbar
// with a button that cannot do anything yet; holding the engine yourself means
// there is never a moment where it is missing. `<JBrowseApp>` is what the
// props component renders internally, so nothing is given up by dropping to it.
function App({ session, note }: { session?: SessionSnapshot; note: string }) {
const viewState = useCreateViewState({ config, session })
const [status, setStatus] = useState(note)
return (
<div>
<div style={{ padding: 8, fontSize: 13, background: '#8881' }}>
{status || 'navigate or open a track, then save from the app toolbar'}
</div>
<JBrowseApp
viewState={viewState}
// your own controls, rendered in the app's toolbar beside the session
// name — the slot JBrowse Web fills with its Share button. The button
// has to be yours because only your app knows the URL its page is
// served at, and whether that page restores a session at all.
headerButtons={
<button
type="button"
onClick={() => {
void encodeSession(viewState)
.then(encoded => {
writeSessionParam(encoded)
setStatus(
`saved to the URL (${encoded.length} chars) — copy the address bar, or reload to restore it`,
)
})
.catch((e: unknown) => {
console.error(e)
setStatus(`could not save the session to the URL: ${e}`)
})
}}
>
Save to URL
</button>
}
/>
</div>
)
}
export default function SessionInUrl() {
// undefined while the URL is being decoded, null once there is nothing to
// restore — so the app isn't built with an empty session first and replaced.
// With no session in the URL there is nothing to decode, so start at null
// rather than rendering nothing and setting it from the effect below.
const [session, setSession] = useState<SessionSnapshot | null | undefined>(
() => (readSessionParam() ? undefined : null),
)
const [note, setNote] = useState('')
useEffect(() => {
const param = readSessionParam()
if (!param) {
return
}
decodeSession(param)
.then(snap => {
setSession(snap)
setNote(`restored "${snap.name}" from the URL`)
})
.catch((e: unknown) => {
// a truncated or hand-edited link shouldn't strand the user on a
// blank app: fall back to the config's own views and say so
console.error(e)
setSession(null)
setNote(`could not restore the session in the URL: ${e}`)
})
}, [])
// the engine is built out of the decoded session, so it can't be built until
// there is one — hence the separate component, mounted once we know
return session === undefined ? null : (
<App session={session ?? undefined} note={note} />
)
}Fit the app to a container
Set the --jbrowse-app-height CSS variable to fit the app into a sized container.
The app root defaults to height: 100vh. To put it inside your own layout —
below a header, in a dashboard panel, in a split pane — set
--jbrowse-app-height on any ancestor:
.my-jbrowse-container {
--jbrowse-app-height: 100%;
}
The variable feeds height: var(--jbrowse-app-height, 100vh) on the app root.
For 100% to resolve, the container must have a definite height — here a
flex child with minHeight: 0 fills what’s left below the header. A fixed
600px works too and needs no sized ancestor.
View source — 87 lines
import { JBrowse } from '@jbrowse/react-app2'
const assemblies = [
{
name: 'volvox',
sequence: {
adapter: {
type: 'TwoBitAdapter',
uri: 'https://jbrowse.org/genomes/volvox/volvox.2bit',
},
},
refNameAliases: {
adapter: {
type: 'FromConfigAdapter',
adapterId: 'W6DyPGJ0UU',
features: [
{ refName: 'ctgA', uniqueId: 'alias1', aliases: ['A'] },
{ refName: 'ctgB', uniqueId: 'alias2', aliases: ['B'] },
],
},
},
},
]
const tracks = [
{
type: 'AlignmentsTrack',
trackId: 'volvox_cram',
name: 'volvox-sorted.cram',
assemblyNames: ['volvox'],
category: ['Alignments'],
adapter: {
type: 'CramAdapter',
uri: 'https://jbrowse.org/code/jb2/main/test_data/volvox/volvox-sorted.cram',
},
},
]
// The app root defaults to height:100vh (full window). Setting the
// --jbrowse-app-height variable on an ancestor with a definite height makes the
// app fit that box instead — here the flex child below a header bar. It's set
// in a stylesheet rule because a CSS custom property can't go in a typed React
// inline-style object without a cast.
export default function FitToContainer() {
return (
<>
<style>{`.jbrowseFitDemo { --jbrowse-app-height: 100%; }`}</style>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '8px 12px',
fontSize: 14,
// CSS system colours rather than the two fixed light tints this
// used to carry: the text in here inherits the host page's colour,
// so a hardcoded near-white bar met near-white text at 1.12:1 the
// moment the page was in dark mode. Yours would be whatever your
// app's header already is; the point of the demo is the height, not
// the paint.
background: 'color-mix(in srgb, CanvasText 8%, Canvas)',
borderBottom:
'1px solid color-mix(in srgb, CanvasText 20%, Canvas)',
}}
>
Your own app chrome lives here. The embedded JBrowse below fills the
remaining space instead of forcing the full viewport height.
</div>
<div className="jbrowseFitDemo" style={{ flex: 1, minHeight: 0 }}>
<JBrowse
assemblies={assemblies}
tracks={tracks}
views={[
{
type: 'LinearGenomeView',
init: {
assembly: 'volvox',
loc: 'ctgA:1..50000',
tracks: ['volvox_cram'],
tracklist: true,
},
},
]}
/>
</div>
</div>
</>
)
}Web worker RPC
Pass makeWorkerInstance to createViewState to offload data parsing/rendering to a web worker.
By default all parsing and rendering runs on the main thread, which hitches on
large alignments datasets. Passing makeWorkerInstance to <JBrowse> switches
RPC to the WebWorkerRpcDriver. Under Vite/Astro, construct the worker from the
package’s ?worker entry; under webpack/CRA, import the prebuilt
@jbrowse/react-app2/esm/makeWorkerInstance instead.
It is off by default only because of bundler requirements — enable it whenever your toolchain allows:
- webpack: set
output.publicPath: 'auto'so the worker resolves its own URL (guide). - Vite and other ESM bundlers: handled natively.
The worker is a separate JavaScript realm with its own plugin registry, so a
plugin contributing anything that runs there — an adapter, usually — must be
registered there too. It loads its own copy from the URL in the definition
each loadPlugins record carries, so pass those records through as-is:
plugins.map(p => p.plugin) throws the definition away and the plugin then
exists on the main thread only, where a track that needs it fails inside the
worker with an unknown-type error naming nothing about the cause.
One caveat: a UMD plugin loads in the worker via importScripts, which
module workers don’t support. A Vite build with worker.format: 'es' (as this
site is) can’t load UMD plugins worker-side; a classic worker can. The rpc
config block is RpcOptions.
View source — 63 lines
import { JBrowse } from '@jbrowse/react-app2'
// Vite/Astro apps construct the RPC worker with Vite's `?worker` suffix. (With
// a webpack/CRA setup you'd instead import the package's prebuilt
// `@jbrowse/react-app2/esm/makeWorkerInstance`.)
import RpcWorker from '@jbrowse/react-app2/esm/rpcWorker?worker'
const assemblies = [
{
name: 'volvox',
sequence: {
adapter: {
type: 'TwoBitAdapter',
uri: 'https://jbrowse.org/genomes/volvox/volvox.2bit',
},
},
refNameAliases: {
adapter: {
type: 'FromConfigAdapter',
adapterId: 'W6DyPGJ0UU',
features: [
{ refName: 'ctgA', uniqueId: 'alias1', aliases: ['A'] },
{ refName: 'ctgB', uniqueId: 'alias2', aliases: ['B'] },
],
},
},
},
]
const tracks = [
{
type: 'FeatureTrack',
trackId: 'volvox_gff3',
name: 'Volvox genes',
assemblyNames: ['volvox'],
adapter: {
type: 'Gff3TabixAdapter',
uri: 'https://jbrowse.org/code/jb2/main/test_data/volvox/volvox.sort.gff3.gz',
},
},
]
// Supplying makeWorkerInstance is the whole switch: RPC then defaults to the
// WebWorkerRpcDriver. (A config `rpc.defaultDriver` still overrides it, e.g. to
// force everything back onto the main thread while debugging.)
export default function WithWebWorker() {
return (
<JBrowse
assemblies={assemblies}
tracks={tracks}
makeWorkerInstance={() => new RpcWorker()}
views={[
{
type: 'LinearGenomeView',
init: {
assembly: 'volvox',
loc: 'ctgA:1..50000',
tracks: ['volvox_gff3'],
},
},
]}
/>
)
}