preferences
Keep trader and desk settings in versioned JSON slots, with boundaries for desk templates, personal settings, and session values.
import { useState } from "react"
import { createPreferences, diffPreferences, readSlot, setSlot } from "@/lib/preferences"
const baseline = setSlot(createPreferences(), "threshold", 5_000_000)
export default function PreferencesDemo() {
const [prefs, setPrefs] = useState(baseline)
const threshold = readSlot<number>(prefs, "threshold") ?? 0
const diff = diffPreferences(baseline, prefs)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs((current) => setSlot(current, "threshold", 10_000_000))}>
Set threshold to 10mm
</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs(baseline)}>
Restore baseline
</button>
</div>
<div className="w-72 max-w-full space-y-2 text-sm lining-nums tabular-nums">
<p>Threshold: {threshold.toLocaleString("en-US")}</p>
<p role="status" className="text-muted-foreground">
{diff.changed.length ? `Changed: ${diff.changed.join(", ")}` : "Matches the baseline."}
</p>
</div>
</>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/preferences#v2.0.0
npx shadcn@latest add tradecn/ui/preferences#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/preferences#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/preferences#v2.0.0
-
Copy the files into your project:
lib/preferences.ts // The envelope everything a trader or a desk may change without a build travels in: a workspace // layout, a grid's column state, hotkey overrides, rules, a threshold. Each is a slot with a version // and a JSON value, and the envelope says in its payload which slots may leave as a desk template, // which belong to one person, and which never leave a session, in the language the workspace layout's // boundaries already use. The items keep their controlled props; the consumer wires a slot to a prop. // This is not a store and not a provider, and where the envelope lives (localStorage, a file in a // desktop shell, a server) is the consumer's. Nothing here touches storage, and a stored envelope comes // back through `parsePreferences`, which takes nothing on trust. export const PREFERENCES_MARK = "preferences" export const PREFERENCES_VERSION = 1 export type PreferencesJson = string | number | boolean | null | PreferencesJson[] | { [key: string]: PreferencesJson } /** A slot: the shape's version, as the item that owns the shape numbers it, and the value as JSON. */ export interface PreferenceSlot { version: number value: PreferencesJson } /** * Where a slot may go. `template`: shareable as a desk template, handed to a colleague, restored next * quarter. `user`: belongs to the person, whichever desk they sit at. `session`: never exported, and * an import skips slots the incoming envelope marks session — though an import can replace and * reclassify the receiving desk's own session slot, since boundaries are read from the envelope. */ export type PreferenceBoundary = "template" | "user" | "session" export type PreferenceBoundaries = { readonly [K in PreferenceBoundary]: readonly string[] } export const PREFERENCE_BOUNDARIES: readonly PreferenceBoundary[] = ["template", "user", "session"] /** A slot named under no boundary is the person's. */ export const DEFAULT_BOUNDARY: PreferenceBoundary = "user" export interface Preferences { tradecn: typeof PREFERENCES_MARK version: typeof PREFERENCES_VERSION slots: Record<string, PreferenceSlot> /** Which slots go where, by name, at the time the envelope was written. */ boundaries: PreferenceBoundaries } /** How a consumer brings an older slot up to the shape it reads today. */ export interface PreferenceMigrator { /** The version the consumer's code reads. */ version: number /** The value at `from`, returned at `version`; null drops the slot. Called only for a slot older than `version`. */ migrate: (value: PreferencesJson, from: number) => PreferencesJson | null } export type PreferenceMigrators = Record<string, PreferenceMigrator> function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) } /** The value as JSON stores it: functions and undefined gone, a copy, or null when it cannot be stored. */ export function toJson(value: unknown): PreferencesJson | null { if (value === undefined) return null try { const stored: unknown = JSON.parse(JSON.stringify(value)) return stored === undefined ? null : (stored as PreferencesJson) } catch { return null } } function sameJson(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b) } function readBoundaries(value: unknown): PreferenceBoundaries { const out: Record<PreferenceBoundary, string[]> = { template: [], user: [], session: [] } if (!isObject(value)) return out for (const key of PREFERENCE_BOUNDARIES) { const list = value[key] if (Array.isArray(list)) out[key] = list.filter((entry): entry is string => typeof entry === "string") } return out } /** An empty envelope, with the boundaries you name. */ export function createPreferences(boundaries: Partial<{ [K in PreferenceBoundary]: readonly string[] }> = {}): Preferences { return { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots: {}, boundaries: { template: [...(boundaries.template ?? [])], user: [...(boundaries.user ?? [])], session: [...(boundaries.session ?? [])] }, } } /** * A stored envelope, checked. Takes the object or the JSON text of one, and returns null for anything * that is not a version 1 tradecn preferences envelope. A slot that is not an object with a whole-number * version and a JSON value is dropped, the rest are kept, and what comes back is a copy. */ export function parsePreferences(value: unknown): Preferences | null { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return null } } if (!isObject(raw) || raw.tradecn !== PREFERENCES_MARK || raw.version !== PREFERENCES_VERSION || !isObject(raw.slots)) return null const slots: [string, PreferenceSlot][] = [] for (const [name, slot] of Object.entries(raw.slots)) { if (!isObject(slot) || typeof slot.version !== "number" || !Number.isInteger(slot.version) || slot.version < 0 || !("value" in slot)) continue const stored = toJson(slot.value) if (stored === null && slot.value !== null) continue slots.push([name, { version: slot.version, value: stored }]) } // Defined rather than assigned, so a slot named `__proto__` is kept as a slot and doesn't set the object's prototype. return { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots: Object.fromEntries(slots), boundaries: readBoundaries(raw.boundaries) } } // A slot is read by its own key, so a name such as `constructor` or `toString` is a slot like any other and never // a member of Object.prototype. function slotNamed(prefs: Preferences, name: string): PreferenceSlot | undefined { return Object.prototype.hasOwnProperty.call(prefs.slots, name) ? prefs.slots[name] : undefined } /** The slot, or undefined. */ export function getSlot(prefs: Preferences, name: string): PreferenceSlot | undefined { return slotNamed(prefs, name) } /** * The envelope with the slot set. The value is stored as JSON at a version: the number given, or the version of * the migrator given, which is the one to pass when the value was read through `readSlot` with it; else the * slot's own, else 1. A value read through a migrator is at the migrator's version, and stored under the slot's * older one it would be migrated again on the next read. A value and version that change nothing hand back the * same envelope, so a subscriber or an autosave comparing by identity sees no change. */ export function setSlot(prefs: Preferences, name: string, value: unknown, version?: number | PreferenceMigrator): Preferences { const stored = toJson(value) if (stored === null && value !== null) return prefs const current = slotNamed(prefs, name) const at = typeof version === "object" && version !== null ? version.version : version const next: PreferenceSlot = { version: at ?? current?.version ?? 1, value: stored } if (current && current.version === next.version && sameJson(current.value, next.value)) return prefs return { ...prefs, slots: { ...prefs.slots, [name]: next } } } /** The envelope without the slot; the same envelope when there was none. */ export function removeSlot(prefs: Preferences, name: string): Preferences { if (!slotNamed(prefs, name)) return prefs const slots = { ...prefs.slots } delete slots[name] return { ...prefs, slots } } /** The boundary a slot is named under, `user` when it is under none. */ export function boundaryOf(prefs: Preferences, name: string): PreferenceBoundary { for (const key of PREFERENCE_BOUNDARIES) if (prefs.boundaries[key].includes(name)) return key return DEFAULT_BOUNDARY } /** The envelope with the slot named under one boundary and no other. */ export function withBoundary(prefs: Preferences, name: string, boundary: PreferenceBoundary): Preferences { if (boundaryOf(prefs, name) === boundary && prefs.boundaries[boundary].includes(name)) return prefs const boundaries = { template: [...prefs.boundaries.template], user: [...prefs.boundaries.user], session: [...prefs.boundaries.session] } for (const key of PREFERENCE_BOUNDARIES) boundaries[key] = boundaries[key].filter((entry) => entry !== name) boundaries[boundary].push(name) return { ...prefs, boundaries } } export interface PreferencesDiff { /** In `b` and not in `a`. */ added: string[] /** In `a` and not in `b`. */ removed: string[] /** In both with a different version or value. */ changed: string[] /** In both and the same. */ same: string[] } /** Which slots differ between two envelopes, by name, for a support view or a save prompt. */ export function diffPreferences(a: Preferences, b: Preferences): PreferencesDiff { const out: PreferencesDiff = { added: [], removed: [], changed: [], same: [] } const names = new Set([...Object.keys(a.slots), ...Object.keys(b.slots)]) for (const name of [...names].sort()) { const x = slotNamed(a, name) const y = slotNamed(b, name) if (x && !y) out.removed.push(name) else if (!x && y) out.added.push(name) else if (x && y) (x.version === y.version && sameJson(x.value, y.value) ? out.same : out.changed).push(name) } return out } /** The slots allowed out under a boundary: a template carries `template` slots only; the person's export carries `template` and `user`; nothing carries `session`. */ export function exportableSlots(prefs: Preferences, boundary: "template" | "user" = "user"): string[] { return Object.keys(prefs.slots).filter((name) => { const where = boundaryOf(prefs, name) return where === "template" || (boundary === "user" && where === "user") }) } /** * The envelope as JSON text, for a file or a clipboard. `template` keeps the slots shareable as a desk * template; `user` (the default) keeps those and the person's own. A `session` slot never goes out. * The boundaries travel with it, so the reader knows what it holds. */ export function exportPreferences(prefs: Preferences, options: { boundary?: "template" | "user"; indent?: number } = {}): string { const keep = new Set(exportableSlots(prefs, options.boundary ?? "user")) const slots = Object.fromEntries(Object.entries(prefs.slots).filter(([name]) => keep.has(name))) const out: Preferences = { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots, boundaries: prefs.boundaries } return JSON.stringify(out, null, options.indent ?? 2) } /** * Another envelope's slots merged into this one: the incoming slot wins where both have one, under the * boundary allowed in (`user` by default, `template` for a desk template), and never a `session` slot. * The boundaries the incoming envelope names for the slots it brings are taken; the rest stay. Null when * the text is not an envelope. */ export function importPreferences(target: Preferences, incoming: unknown, options: { boundary?: "template" | "user" } = {}): Preferences | null { const parsed = parsePreferences(incoming) if (!parsed) return null const allowed = new Set(exportableSlots(parsed, options.boundary ?? "user")) let out = target for (const [name, slot] of Object.entries(parsed.slots)) { if (!allowed.has(name)) continue out = setSlot(out, name, slot.value, slot.version) out = withBoundary(out, name, boundaryOf(parsed, name)) } return out } /** * Every slot brought up to the version its migrator reads: a slot older than `migrator.version` goes * through `migrate`, one it returns null for is dropped, a slot with no migrator or already current is * left alone, and a slot newer than the migrator's version is left alone too, since a newer build wrote * it. The same envelope comes back when nothing moved. */ export function migratePreferences(prefs: Preferences, migrators: PreferenceMigrators): Preferences { let out = prefs for (const [name, migrator] of Object.entries(migrators)) { const slot = slotNamed(prefs, name) if (!slot || slot.version >= migrator.version) continue const value = migrator.migrate(slot.value, slot.version) out = value === null ? removeSlot(out, name) : setSlot(out, name, value, migrator.version) } return out } /** * One slot's value at the version the consumer reads, through its migrator when it is older, or * undefined when the slot is missing, newer than the migrator reads, or dropped by the migration. The * envelope itself is not changed; call `migratePreferences` to write the result back. */ export function readSlot<V = PreferencesJson>(prefs: Preferences, name: string, migrator?: PreferenceMigrator): V | undefined { const slot = slotNamed(prefs, name) if (!slot) return undefined if (!migrator) return slot.value as V if (slot.version > migrator.version) return undefined if (slot.version === migrator.version) return slot.value as V const value = migrator.migrate(slot.value, slot.version) return value === null ? undefined : (value as V) }
Usage
import { useState } from "react"
import { createPreferences, diffPreferences, readSlot, setSlot } from "@/lib/preferences"
const baseline = setSlot(createPreferences(), "threshold", 5_000_000)
export default function ThresholdPreference() {
const [prefs, setPrefs] = useState(baseline)
const threshold = readSlot<number>(prefs, "threshold") ?? 0
const diff = diffPreferences(baseline, prefs)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs((current) => setSlot(current, "threshold", 10_000_000))}>
Set threshold to 10mm
</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs(baseline)}>
Restore baseline
</button>
</div>
<div className="w-72 max-w-full space-y-2 text-sm lining-nums tabular-nums">
<p>Threshold: {threshold.toLocaleString("en-US")}</p>
<p role="status" className="text-muted-foreground">
{diff.changed.length ? `Changed: ${diff.changed.join(", ")}` : "Matches the baseline."}
</p>
</div>
</>
)
}
setSlot returns an updated envelope; readSlot reads the value you pass to your application. This example keeps a numeric threshold and compares it with an in-memory baseline. An unlisted slot belongs to the user boundary. No storage is written.
For persistence, serialize the envelope with JSON.stringify and read it back with parsePreferences. Your application owns the storage calls and save timing. A parsed envelope still needs each setting's domain validation before use; the numeric slot above is created entirely within the example.
Export boundaries
A desk template carries shared settings. A personal export includes those and the user's settings; both omit session values. This example keeps one slot per boundary, with each slot's version visible. Switch the export boundary, hide quantity in the blotter, or raise the session threshold and inspect the JSON. Changing the threshold leaves either export unchanged.
The JSON retains all boundary lists, so threshold still appears as a name under boundaries.session. Its value is absent from slots.
import { useState } from "react"
import { boundaryOf, createPreferences, exportableSlots, exportPreferences, readSlot, setSlot } from "@/lib/preferences"
const columns = {
order: ["symbol", "qty", "account"],
widths: {},
hidden: ["account"],
}
let baseline = createPreferences({
template: ["columns:blotter"],
user: ["hotkeys"],
session: ["threshold"],
})
baseline = setSlot(baseline, "columns:blotter", columns)
baseline = setSlot(baseline, "hotkeys", { "rfq.send": "mod+shift+enter" })
baseline = setSlot(baseline, "threshold", 5_000_000)
export default function PreferencesBoundariesDemo() {
const [prefs, setPrefs] = useState(baseline)
const [boundary, setBoundary] = useState<"template" | "user">("template")
const threshold = readSlot<number>(prefs, "threshold") ?? 0
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button
type="button"
className="rounded border px-2 py-1"
onClick={() =>
setPrefs((current) =>
setSlot(current, "columns:blotter", {
...columns,
hidden: ["account", "qty"],
}),
)
}
>
Hide quantity
</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs((current) => setSlot(current, "threshold", 10_000_000))}>
Raise session threshold
</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs(baseline)}>
Restore settings
</button>
</div>
<div className="w-96 max-w-full space-y-3 text-xs lining-nums tabular-nums">
<table className="w-full text-left">
<thead className="text-muted-foreground">
<tr>
<th scope="col" className="py-1 font-medium">
Slot
</th>
<th scope="col" className="font-medium">
Boundary
</th>
<th scope="col" className="text-right font-medium">
Version
</th>
</tr>
</thead>
<tbody>
{Object.entries(prefs.slots).map(([name, slot]) => (
<tr key={name} className="border-t border-border">
<th scope="row" className="py-1 font-normal">
{name}
</th>
<td>{boundaryOf(prefs, name)}</td>
<td className="text-right">{slot.version}</td>
</tr>
))}
</tbody>
</table>
<p>Session threshold: {threshold.toLocaleString("en-US")}</p>
<label className="flex flex-wrap items-center gap-2">
Export boundary
<select className="rounded border border-border bg-background p-1" value={boundary} onChange={(event) => setBoundary(event.target.value === "template" ? "template" : "user")}>
<option value="template">Desk template</option>
<option value="user">Personal settings</option>
</select>
</label>
<p role="status" className="text-muted-foreground">
Exported slots: {exportableSlots(prefs, boundary).join(", ")}
</p>
<pre role="region" aria-label="Exported preferences" tabIndex={0} className="max-h-64 overflow-auto rounded-md border border-border bg-card p-3 font-(family-name:--tradecn-font-mono)">
{exportPreferences(prefs, { boundary })}
</pre>
</div>
</>
)
}
Import settings
Import merges allowed slots into the receiving desk. The sample personal export hides account and quantity columns and changes the send shortcut. Import template applies only the columns; Import personal settings applies the columns and shortcut. The receiving desk keeps its session threshold of 1,000,000 in both cases.
Restore the receiving desk between imports to compare them. Importing a template after personal settings keeps the already-imported shortcut: slots outside that transfer stay as they are. In an application, pass the JSON text from a file or clipboard to importPreferences; an invalid envelope returns null, leaving the current state intact in this example's handler.
import { useState } from "react"
import { createPreferences, diffPreferences, exportPreferences, importPreferences, setSlot } from "@/lib/preferences"
function desk(hidden: string[], shortcut: string, threshold: number) {
let prefs = createPreferences({
template: ["columns:blotter"],
user: ["hotkeys"],
session: ["threshold"],
})
prefs = setSlot(prefs, "columns:blotter", {
order: ["symbol", "qty", "account"],
widths: {},
hidden,
})
prefs = setSlot(prefs, "hotkeys", { "rfq.send": shortcut })
return setSlot(prefs, "threshold", threshold)
}
const incoming = exportPreferences(desk(["account", "qty"], "mod+shift+enter", 5_000_000))
const baseline = desk([], "mod+enter", 1_000_000)
export default function PreferencesImportDemo() {
const [prefs, setPrefs] = useState(baseline)
const diff = diffPreferences(baseline, prefs)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button
type="button"
className="rounded border px-2 py-1"
onClick={() =>
setPrefs(
(current) =>
importPreferences(current, incoming, {
boundary: "template",
}) ?? current,
)
}
>
Import template
</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs((current) => importPreferences(current, incoming) ?? current)}>
Import personal settings
</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => setPrefs(baseline)}>
Restore receiving desk
</button>
</div>
<div className="w-96 max-w-full space-y-3 text-xs lining-nums tabular-nums">
<p role="status" className="text-muted-foreground">
{diff.changed.length ? `Changed: ${diff.changed.join(", ")}` : "Receiving desk unchanged."}
</p>
<pre role="region" aria-label="Receiving desk slots" tabIndex={0} className="max-h-80 overflow-auto rounded-md border border-border bg-card p-3 font-(family-name:--tradecn-font-mono)">
{JSON.stringify(prefs.slots, null, 2)}
</pre>
</div>
</>
)
}
API Reference
The envelope
Preferences is { tradecn: "preferences", version: 1, slots, boundaries }. The marker and envelope version are exported as PREFERENCES_MARK and PREFERENCES_VERSION.
slots is a Record<string, PreferenceSlot>. Each slot has its own numeric version and a value of type PreferencesJson: a JSON primitive, array, or object. The item that owns the value defines its shape and version, separately from the envelope version.
In the functions below, prefs, target, a, and b are Preferences. A slot name is any string, including constructor and __proto__.
| Function | Inputs / defaults | Result |
|---|---|---|
createPreferences(boundaries?) |
Partial PreferenceBoundaries; omitted lists default to [] |
Empty envelope with copied boundary lists. |
parsePreferences(value) |
unknown; object or JSON text |
A checked copy, or null for an invalid envelope. |
getSlot(prefs, name) |
Slot name | Stored PreferenceSlot, or undefined. |
setSlot(prefs, name, value, version?) |
value: unknown; version is a number or the PreferenceMigrator you read with, and defaults to the existing slot's version, then 1 |
Envelope with a JSON copy of the value. Pass the migrator when the value came through readSlot with it: the value is at the migrator's version, and written under the slot's older one it would be migrated again on the next read. |
removeSlot(prefs, name) |
Slot name | Envelope without that slot; leaves boundary lists intact. |
diffPreferences(a, b) |
Before and after envelopes | PreferencesDiff: sorted name lists in added, removed, changed, and same. |
toJson(value) |
unknown |
JSON round-trip copy, or null when serialization fails. |
parsePreferences requires the marker, envelope version 1, and an object for slots. It drops malformed slots individually: each needs a nonnegative integer version and a serializable value. A non-null value that serializes to null, such as NaN, is also dropped; literal null is valid. Missing or malformed boundary lists become empty lists; nonstring entries are removed.
JSON serialization removes functions and undefined from objects, turns them into null in arrays, and converts dates to strings. setSlot ignores a value that cannot be serialized or becomes null, unless the input is literal null. It returns the same envelope when the version and serialized value match; otherwise it returns a new one. removeSlot returns the same envelope when the slot is absent.
diffPreferences compares slot versions and serialized values, ignoring boundaries. Both diffing and setSlot compare JSON.stringify output, so object key order matters. Use the diff for a support view or save prompt; identity checks can skip unchanged saves.
Boundaries
Like workspace's layout, the envelope records its persistence boundaries in the payload. PreferenceBoundary names the three categories below; PreferenceBoundaries maps each to a readonly list of slot names.
| Boundary | Purpose |
|---|---|
"template" |
Shared desk settings; keep personal and session data out. |
"user" |
Personal settings that follow a trader between desks. |
"session" |
Never exported. Import skips slots the incoming envelope marks session, but can replace and reclassify a receiving desk's own session slot: boundaries are read from the envelope. |
Name each slot under one boundary. An unlisted slot uses DEFAULT_BOUNDARY, "user". If lists overlap, the first match in PREFERENCE_BOUNDARIES wins: template, then user, then session.
| Function | Result |
|---|---|
boundaryOf(prefs, name) |
Effective PreferenceBoundary for the slot name, even if no slot exists. |
withBoundary(prefs, name, boundary) |
Same envelope if the requested boundary is already effective and explicitly lists the name; otherwise a new envelope with the name removed from every list and added to the requested one. |
Export and import
The transfer boundary accepts "template" or "user", defaulting to "user":
| Slot's effective boundary | Template transfer | User transfer |
|---|---|---|
"template" |
Included | Included |
"user" or unlisted |
Excluded | Included |
"session" |
Excluded | Excluded |
| Function | Inputs / defaults | Result |
|---|---|---|
exportableSlots(prefs, boundary?) |
Transfer boundary | Names of existing slots allowed by the table. |
exportPreferences(prefs, options?) |
boundary; numeric indent defaults to 2 |
JSON text containing allowed slots and all boundary lists. Use indent: 0 for compact text. |
importPreferences(target, incoming, options?) |
incoming: unknown, object or JSON text; boundary |
Merged envelope, or null when parsing fails. |
Export filters slot values, not the boundary lists: excluded slots' names can still appear in the JSON. Import checks the incoming envelope's boundaries. Each allowed slot replaces the target's same-named slot and takes the incoming boundary, even if the target classified it as session. Other target slots and their boundaries stay intact.
Import returns the original target when no allowed values, versions, or boundary assignments change; importing a slot the target does not list yet still returns a new object, since it adds the listing. Parsing and importing do not migrate slot versions; use a migrator when reading or updating older values.
Versions and migrators
Supply a PreferenceMigrator when a slot's shape changes:
| Field | Type | Purpose |
|---|---|---|
version |
number |
Required target version your code reads. |
migrate |
(value: PreferencesJson, from: number) => PreferencesJson | null |
Required function that converts an older value directly to the target version; null rejects it. |
PreferenceMigrators is a record of slot names to migrators. migratePreferences(prefs, migrators) returns an updated envelope; readSlot<V>(prefs, name, migrator?) returns one value without writing the result back.
| Stored slot | migratePreferences |
readSlot |
|---|---|---|
| Missing | Leave absent | undefined |
| No migrator | Keep slot | Stored value, regardless of version |
| Older than migrator | Pass migrated value to setSlot at target version; remove slot on null |
Migrated value, or undefined on null |
| Same version | Keep slot | Stored value |
| Newer than migrator | Keep slot | undefined |
migratePreferences applies setSlot's JSON conversion and returns the same envelope when nothing changes. readSlot returns a non-null migrator result directly. Migrators run only for older slots; thrown errors propagate.
readSlot returns V | undefined, with V defaulting to PreferencesJson; the generic supplies a TypeScript type, not runtime validation.
The slots are the items' own shapes
Envelope parsing checks JSON structure, not the value's domain schema. Use the owning item's parser, such as parseWorkspaceLayout, or readRules for grid rules, before passing a slot value to a controlled prop.
| Setting | Value shape |
|---|---|
| Workspace layout | WorkspaceLayout |
| Grid columns | ColumnState |
| Hotkey overrides | HotkeyOverrides |
| Grid rules | GridRules |
| Threshold | number |
Use a separate slot name for each controlled value, such as columns:blotter and columns:stack for two grids. The boundary example needs only one grid, hotkey overrides, and a threshold to show the three categories.
What it does not do
These functions do not subscribe, notify, or persist. Your application owns storage and save timing, whether it uses localStorage, a desktop file, or a server. Keep settings in your own state and wire readSlot and setSlot to each item's controlled props.
This page is docs/preferences.md at v2.0.0.