---
title: History panel
description: The version history of a room for your app, as in Notion. VersionHistory lists the room's versions with their editors, previews one read-only with the blocks added, removed and changed since highlighted, and restores it or undoes the restore; the panel's markup is yours.
icon: history
---

`VersionHistory` is the page history of Notion for a document kept by the room's [version history](/docs/server/history): the versions, newest first, the selected one previewed read-only, and **Restore version**. The preview highlights what changed since that version: the blocks added since in green, the blocks removed since in red, the blocks changed since in yellow. The package ships the controller; the panel's markup is yours (the demo's `HistoryPanel.svelte`, in the repository's [`src/routes/chrome`](https://github.com/beynar/edytor/tree/master/src/routes/chrome), is a complete one: the list on the right, the preview on the left).

```svelte title="Versions.svelte" check
<script lang="ts">
	import { untrack } from 'svelte';
	import {
		Edytor,
		VersionHistory,
		type EdytorDocument,
		type HistoryClient,
		type VersionHistoryStatus
	} from 'edytor';

	let { client, document }: { client: HistoryClient; document: EdytorDocument } = $props();
	// Made in the component's script: its effects list the versions and follow the document.
	const history = untrack(() => new VersionHistory({ client, document }));
	/** Each outcome in your words. */
	const said: Record<VersionHistoryStatus, string> = {
		'': '',
		restoring: 'Restoring…',
		restored: 'Version restored.',
		unchanged: 'The page already matches this version.',
		unavailable: 'This version is no longer available.',
		undone: 'Restore undone.',
		nothingToUndo: 'There is no restore to undo.',
		denied: 'You do not have access to this history.',
		failed: 'The history could not be reached.'
	};
</script>

<section aria-label="Version history">
	<ul role="listbox" aria-label="Versions" {@attach history.keys}>
		{#each history.versions ?? [] as version, index (version.key)}
			<li role="presentation">
				<button type="button" {@attach history.option(index)} onclick={() => void history.select(version.key)}>
					{history.dateOf(version)} · {version.slot === 'am' ? 'Morning' : 'Evening'} ·
					{version.editors.join(', ')}
				</button>
			</li>
		{/each}
	</ul>
	{#if history.preview}
		{#key history.previewKey}
			<Edytor readonly value={history.preview} plugins={history.previewPlugins} />
		{/key}
	{/if}
	<button disabled={history.busy || !history.shown} onclick={() => void history.restore()}>
		Restore version
	</button>
	{#if history.undoable}
		<button onclick={() => void history.undo()}>Undo restore</button>
	{/if}
	<p role="status">{history.status && said[history.status]}</p>
</section>
```

```svelte title="Page.svelte" check
<script lang="ts">
	import { Edytor, createDocument, createHistoryClient } from 'edytor';
	import Versions from './Versions.svelte';

	let { id, token }: { id: string; token: string } = $props();

	const document = createDocument();
	const client = createHistoryClient({
		server: 'https://example.com/history',
		room: id,
		params: { token }
	});
	let open = $state(false);
</script>

<Edytor {document} server="wss://example.com/rooms" room={id} params={{ token }} />

<button onclick={() => (open = true)}>Version history</button>
{#if open}
	<dialog open>
		<Versions {client} {document} />
		<button onclick={() => (open = false)}>Close</button>
	</dialog>
{/if}
```

The controller needs two things: a **client**, which reads and restores the versions, and the **document** the page edits, which it compares each version with. Pass the same `plugins` the page's editor uses (`new VersionHistory({ client, document, plugins })`) so every kind renders in the preview as it does on the page. Its options are read once: a new client is a new `VersionHistory` (a `{#key client}` around your panel).

## The client

The controller asks a `HistoryClient` for everything it shows and does:

| Method | Answer |
| --- | --- |
| `list()` | The versions, newest first: `HistoryVersion[]`, the shape the room's `listHistory()` returns (`{ key, date, slot, bytes, blocks, editors, more, at, expiresAt }`). |
| `read(key)` | That version as a `JSONDoc`, or `null` when it is gone. |
| `restore(key)` | Restore it: `{ status: 'applied' \| 'noop' \| 'refused', key, … }`. |
| `undo()` | Undo the last restore: `{ status: 'applied' \| 'noop' }`. |

`createHistoryClient({ server, room, params, fetch })` is the client for the HTTP door the room ships, [`routeDocumentHistory`](/docs/server/history#over-http). Its requests go to `<server>/<room>`, the room percent-encoded as one path segment, as the provider sends it (a `ws:` or `wss:` server reads as `http:` or `https:`):

| Method | Request | Reads |
| --- | --- | --- |
| `list()` | `GET <url>` | the JSON list |
| `read(key)` | `GET <url>?key=<key>` | the JSON version; `404` is `null` |
| `restore(key)` | `POST <url>?restore=<key>` | the JSON result; `404` is `{ status: 'refused', key }` |
| `undo()` | `POST <url>?undo` | the JSON result |

`params` are added to every request (a token): an object, or a function read again at each request, so a refreshed token is used at once. Any other refusal throws a `HistoryRequestError` whose `status` is the HTTP status (`401` expired, `403` denied or a read-only identity's restore, `404` no history, `503` the room cannot serve it). A room id no request can carry (empty, `.` or `..`, over 256 characters, or with a lone surrogate) throws a `TypeError` at once.

Any object with the four methods is a client: your own API routes, a SvelteKit remote function, Durable Object RPC behind your server, or a stub in tests.

```ts title="history.ts" check
import type { HistoryClient } from 'edytor';

/** The versions through the app's own API, which calls the room over RPC. */
export const historyOf = (documentId: string): HistoryClient => {
	const base = `/api/documents/${encodeURIComponent(documentId)}/versions`;
	const json = async (response: Response) => {
		if (!response.ok) throw new Error(`versions: ${response.status}`);
		return response.json();
	};
	return {
		list: () => fetch(base).then(json),
		read: async (key) => {
			const response = await fetch(`${base}/${encodeURIComponent(key)}`);
			return response.status === 404 ? null : json(response);
		},
		restore: (key) =>
			fetch(`${base}/${encodeURIComponent(key)}/restore`, { method: 'POST' }).then(json),
		undo: () => fetch(`${base}/undo`, { method: 'POST' }).then(json)
	};
};
```

## The list

`versions` holds the versions, newest first (`null` while the first list loads). The controller lists them when it is made and selects the newest; `refresh()` lists them again, keeping the selection while it is listed. Write a row with `titleOf(version)`, its date and half of the day (`Oct 6, 2026 · Morning`), in the room's calendar (the history's `timeZone`); `editorsOf(version)`, its editors (`ada, bob +2`, or `No editors` for a slot only the server edited); and `timeOf(version)`, when it was saved, in the reader's time zone. Attach `option(index)` to each row: it is an `option` of your `listbox`, its one tab stop when selected (its highlight is yours, from `version.key === history.selected`); give the list `{@attach history.keys}`: <kbd>↑</kbd> and <kbd>↓</kbd> move the selection, <kbd>Home</kbd> and <kbd>End</kbd> go to the newest and the oldest. A click on a row calls `select(key)`.

## The preview

The selected version (`selected`) is read (`client.read(key)`, `reading` meanwhile) and is `preview`: render it in a read-only `<Edytor>` of its own, `value={history.preview}` and `plugins={history.previewPlugins}` (the change highlights, then your `plugins`), keyed by `previewKey` (an editor reads its `value` once). It is a separate document: nothing done in the preview reaches the page. A version that is gone by the time it is read sets `status` to `labels.unavailable`.

### Changes since the version

With `document`, the controller compares the version with the page, block by block, matching blocks by id (a restore keeps ids, so a block keeps its id across versions):

| Highlight | The block | A restore |
| --- | --- | --- |
| Added since (green) | is on the page, not in the version | deletes it |
| Removed since (red) | is in the version, not on the page | brings it back |
| Changed since (yellow) | is in both, with another type, data or text (marks included) | rewrites it |

While `highlight` is on (the default; it is writable, `bind:checked={history.highlight}` drives it), the preview shows the version with the blocks added since inserted where the page has them, so all three show, and `diff` counts them (`added`, `removed`, `changed`; the demo's legend says `Same as the current page` when there are none). Off, it shows the version alone. Each highlight is a tint over the block's own row (not its children), with a bar on its left, drawn in the preview's overlay, never inside its blocks.

The comparison follows the page: an edit on the page, yours or a collaborator's, updates the highlights at once. A move is not a change, and neither is a change of a block's children alone (the child is highlighted, not its parent); the document's own data is not compared. A block inside a closed toggle shows no highlight. Past 2,000 changed blocks, only the first 2,000 are highlighted; the legend counts them all.

`versionDiff(version, current)` is the comparison alone: `{ preview, changes, added, removed, changed }`, `changes` a `Map` from block id to `'added' | 'removed' | 'changed'` and `preview` the version with the added blocks inserted. Use it to show changes in a UI of your own.

## Restore and undo

`restore()` calls `client.restore(key)` for the selected version (`busy` meanwhile). The room writes the version as a forward edit that every client receives ([what a restore does](/docs/server/history#what-a-restore-does)), so the page updates through its sync and the highlights clear. `status` is then `restored` (`noop`: `unchanged`; `refused`: `unavailable`, the version is gone, the list is read again). After a restore applied from this controller, `undoable` is true: `undo()` calls `client.undo()`, which undoes the room's last restore (one level, whoever made it).

A `401` or `403` (a reader's restore, an expired session) sets `status` to `denied`, any other failure to `failed`, and `failed` to true; `onError` receives the error. For readers, render no Restore. `status` is a code (`VersionHistoryStatus`: `''`, `restoring`, `restored`, `unchanged`, `unavailable`, `undone`, `nothingToUndo`, `denied`, `failed`): say it in your words in a polite live region, so screen readers announce each outcome.

## Options

| Option | Type | |
| --- | --- | --- |
| `client` | `HistoryClient` | Where the versions come from. |
| `document` | `EdytorDocument` | The page's document, compared with each version. Without it, no change is highlighted. |
| `plugins` | `Plugin[]` | The preview's plugins: the page editor's. |
| `locale` | `string \| string[]` | The locale of dates and times (default the browser's). |
| `id` | `string` | The rows' id prefix (page-unique: `$props.id()`). |
| `onRestore` | `(result) => void` | After each restore the room answered. |
| `onUndo` | `(result) => void` | After each undo the room answered. |
| `onError` | `(error) => void` | A request failed. |

### Words

The controller writes no word: your panel writes them, and formats a version's day and time with `dateOf(version)` and `timeOf(version)`, in the controller's `locale` (`version.slot` is its half of the day, `am` or `pm`). The demo's panel keeps its words with the rest of its chrome's (`src/routes/chrome/words.svelte.ts` in the repository).

## Styling

The panel's look is yours (the demo's is two columns, the list `--edytor-history-side-width` wide, stacked below 640px of its own width). The change highlights are the controller's, drawn in the preview's overlay, never inside its blocks: a tint over each block's own row (not its children), with a bar on its left. These variables set their colors:

| Variable | |
| --- | --- |
| `--edytor-version-added`, `--edytor-version-added-bar` | The tint and bar of a block added since. |
| `--edytor-version-removed`, `--edytor-version-removed-bar` | Of a block removed since. |
| `--edytor-version-changed`, `--edytor-version-changed-bar` | Of a block changed since. |

The defaults are a light theme (Notion's colors, at AA contrast). For a dark theme, set the variables; the tints blend with `multiply`, so give them values that read on a dark page.
