Webアプリケーションなら、ユーザーが誤ってデータを消してしまっても「バックエンドの論理削除フラグを戻す」あるいは「データベースの日次スナップショットから復元する」といった救済措置が取れる。
しかし、ブラウザの拡張機能やローカルファーストなデスクトップアプリには、助けてくれるサーバーが存在しない。データはすべてユーザーのブラウザ内(chrome.storage.local)にしかなく、一度上書きしてしまえば、その瞬間に地球上から完全に消え去る。
自作の新しいタブダッシュボード「ZenithTab」を運用する中で最も恐れていたのは、「誤って大事なメモや何十時間もかけて組んだレイアウトを消してしまった」というユーザーの絶望だった。直前のリリースで「ウィジェットを追加したら再インストールまで復帰不能」という致命的な不具合(#20)を出したこともあり、次のリリース(v1.8.0)では「誤操作からの復元」を最優先課題に据えた(#21)。
クラウドに依存せず、外部通信を一切行わないプライバシーを守りながら、ユーザーのミスや不慮の事故からデータを確実に守り抜くにはどうすればいいか。
行き着いた答えが、**「秒単位」「月単位」「世代単位」の3つの時間軸でデータを守る「3層データ保護アーキテクチャ」**だった。
第1層:秒単位の即時保護(Undo / Redo スタック + トースト通知)
ユーザーが最も頻繁に遭遇するのは「あ、間違えて消した」「さっき動かしたウィジェットの位置を戻したい」という直後の誤操作だ。
この数秒から数十秒の間のミスを救うのが、第1層のインメモリUndoシステムである。
ZustandによるインメモリUndoストア
Undo/Redoは、ダッシュボード全体の状態を丸ごと履歴配列に溜め込むような雑な作り方をすると、メモリを大量に消費し、状態更新のたびにパフォーマンスが劣化する。
ZenithTabでは、src/store/useUndoStore.ts という独立した軽量ストアをZustandで作成し、「実行された操作の逆操作(クロージャ)」を最大20件(MAX_UNDO_ENTRIES)までスタックするように設計した。
export interface UndoEntry {
id: string;
/** トーストに表示する、翻訳済みの説明文 */
label: string;
createdAt: number;
undo: () => void;
redo?: () => void;
}
例えば、ウィジェットが削除されたときの処理は次のようになる(src/store/slices/widgetSlice.ts)。
removeWidget: (id) => {
const removed = removeWidgetInternal(id); // 1. ストアから外す
if (!removed) return;
const entry = createTrashedWidget(removed.widget, removed.layouts, removed.pageId, pageName);
addTrash(entry); // 2. 第2層のゴミ箱へ送る
pushUndo(
fmt(tr().undo.removedWidget, getLocalizedWidgetTitle(removed.widget, tr())),
() => { // undo: 元の位置に戻し、ゴミ箱からも消す
restoreWidgetInternal(removed);
removeTrashEntry(entry.id);
},
() => { // redo: もう一度消してゴミ箱へ
removeWidgetInternal(id);
addTrash(entry);
}
);
},
ウィジェットが削除された瞬間、画面下部に「ウィジェットを削除しました [元に戻す]」というトースト通知がフワッと浮き出る。
ユーザーはトーストの「元に戻す」ボタンをクリックするか、あるいはキーボードの Ctrl + Z(Macなら Cmd + Z)を押すだけで、一瞬で元の状態に戻せる。Ctrl + Shift + Z / Ctrl + Y でやり直し(Redo)もできる。レイアウトのドラッグやリサイズも useLayoutUndo フックが「操作開始時のレイアウト」を記憶して同じスタックに積むので、うっかり動かしたウィジェットも1回の Undo で戻る。
マルチタブ環境での衝突を防ぐ差分復元
ここで考慮しなければならないのが、「ブラウザの新しいタブは複数同時に開かれる」という事実だ。
タブAでウィジェットを消し、タブBで別のメモを編集していた場合、タブAでUndoを押したときに「ダッシュボード全体を古いJSONで上書き」してしまうと、タブBで行ったメモの編集が吹き飛んでしまう。
そのため、ZenithTabのUndoクロージャは「影響を受けた部分だけを、いまのストアの状態に対して差分適用する」作りになっている。削除されたウィジェットだけを現在のストアに差し戻し、他のウィジェットや他タブでの変更(useStorageSync が取り込んだもの)には一切手を触れない。
また、Undo の実行はまずスタックからエントリを外してから entry.undo() を呼ぶ。復元処理が例外を投げても同じエントリを永遠に再試行することがないようにするためだ。インポートやリセットのように「ダッシュボード全体を置き換える操作」の後は、古いクロージャが意味を失うのでスタックを clear() する。
第2層:月単位の個別保護(30日間保持する「ゴミ箱」システム)
Undoのトースト通知は数秒で消える。翌日になって「やっぱりあのウィジェットを残しておけばよかった」「誤って消したページの中に大事なメモがあった」と気づいた場合、Undoスタックはすでに空になっている。
そこで第2の防衛線として導入したのが、OSのゴミ箱と同じ概念を持つ src/services/trashService.ts だ。
クロージャを持たない純粋データ構造
Undoスタックはメモリ上の関数(クロージャ)で動作するため、ブラウザを閉じれば消える。これに対し、ゴミ箱(Trash)はブラウザを再起動しても残り続けなければならない。
そのため、ゴミ箱に入るデータは完全にJSONシリアライズ可能な「純粋なデータオブジェクト」として定義した。
export interface TrashedWidget {
id: string;
kind: 'widget';
deletedAt: number;
widget: DashboardWidget;
layouts: Partial<Record<GridBreakpoint, Layout>>;
sourcePageId: string;
sourcePageName: string;
}
export interface TrashedPage {
id: string;
kind: 'page';
deletedAt: number;
pageMeta: DashboardPageMeta;
pageData: DashboardPageData;
}
削除されたウィジェットやページは、即座に抹消されるのではなく、この TrashEntry に変換されて chrome.storage.local の trash キー配下へと送られる。ストレージから読み戻す際は sanitizeTrash が「期待する形をしたエントリだけ」を通し、壊れたエントリでゴミ箱画面が落ちないようにしている。
自動クリーンアップ(Prune)の仕組み
ゴミ箱を放置し続けると、ローカルストレージの容量を圧迫してしまう。そこで、以下の2つのルールで自動的に古いデータを破棄する pruneTrash を設計した。
export const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
export const MAX_TRASH_ENTRIES = 50;
export function pruneTrash(entries: TrashEntry[], now = Date.now()): TrashEntry[] {
const kept = entries.filter((e) => now - e.deletedAt < TRASH_RETENTION_MS);
const capped = kept.length > MAX_TRASH_ENTRIES
? [...kept].sort((a, b) => b.deletedAt - a.deletedAt).slice(0, MAX_TRASH_ENTRIES)
: kept;
// 何も変わらなければ同じ参照を返し、無駄な書き戻しを避ける
return capped.length === entries.length ? entries : capped;
}
- 保持期間は30日間: 削除日時から30日を経過したアイテムは自動消去。
- 最大件数は50件: 30日以内であっても、件数が50件を超えた場合は古いものから押し出し削除。
このパージ処理は、新しいタブが開かれた初期化時(initialize())に走り、何かが変わったときだけストレージへ書き戻す。Service Worker では走らせない——読み込む側とパージする側が競合しないように、データを読むページだけが責任を持つ。
ゴミ箱内のアイテムは、設定画面からいつでも一覧でき、「復元」ボタンを押せば元のページ・当時のレイアウト座標へとそのまま舞い戻る。元のページ自体がすでに無ければ、現在のページに復元される。
第3層:世代単位の全体保護(自動スナップショット)
Undoとゴミ箱があれば、日常的な誤削除はほぼ救える。
しかし、最も危険な操作は別にある。
「ダッシュボードを初期化(リセット)した」
「バックアップJSONファイルを新しくインポートした」
「デザインを何時間もあれこれ弄った結果、収拾がつかなくなって元の配置に戻したくなった」
このような「ダッシュボード全体の構造がガラリと変わる破壊的操作」から身を守る最終防衛線が、第3層の src/services/snapshotService.ts である。
「弄り始める前の状態」を自動で残す
バックアップを手動で取らせようとしても、大半のユーザーはそんな面倒なことはしない。「何かやらかした後」に初めてバックアップの不在を後悔するものだ。
したがって、スナップショットは完全に全自動で作成されなければならない(手動で「今すぐバックアップ」も用意してあるが、それは補助だ)。
とはいえ、文字を1文字打つたび、ウィジェットを1ミリ動かすたびに全体バックアップを取っていてはストレージがパンクする。そこで、「定期的に今の状態を保存する」のではなく、**「静かな時間が続いた後の、最初の変更の直前の状態を保存する」**という考え方を採った(src/hooks/useAutoSnapshot.ts)。
/** 前回の自動スナップショットからこれだけ経っていれば、次の変更で新しく撮る */
export const AUTO_SNAPSHOT_MIN_INTERVAL_MS = 30 * 60 * 1000;
// useAutoSnapshot: ストアが変わるたびに「変更前 (prev)」の状態を候補として差し出す
return useDashboardStore.subscribe((state, prev) => {
if (!prev.isInitialized || !state.backupSettings.autoSnapshot) return;
if (!WATCHED.some((key) => state[key] !== prev[key])) return;
void snapshotService.maybeTakeAuto(snapshotDataFromState(prev));
});
// snapshotService.maybeTakeAuto: 前回の auto から30分経っていなければ何もしない
const lastAuto = (await readAll())
.filter((s) => s.reason === 'auto')
.reduce((latest, s) => Math.max(latest, s.takenAt), 0);
if (now - lastAuto < AUTO_SNAPSHOT_MIN_INTERVAL_MS) return null;
return await this.take('auto', source, now);
ポイントは、ストアの subscribe に渡される prev(変更前の状態) をスナップショットの中身にしていることだ。ユーザーが作業を終えて30分以上放置し、再びダッシュボードを弄り始めた瞬間——「編集作業を開始した最初の一撃」の直前の完全な状態が裏で自動保存される。連続して操作している間は30分の間隔チェックに引っかかって何も起きないので、1クリックごとにスナップショットが増えることはない。
これにより、ユーザーは何もしなくても常に「今日の作業を始める前の状態」「昨晩の安定していた状態」へと1クリックで巻き戻せるようになる。
破壊的操作の直前トラップ
自動スナップショットに加え、以下の操作を実行する直前には、例外なく強制的にスナップショットを作成するトリガーを仕込んでいる。reason フィールドで種類を区別する。
- 設定画面で「ダッシュボードを初期化」を押した直前(
before-reset) - JSONファイルを「インポート」して上書きする直前(
before-import) - 過去のスナップショットを「復元」する直前(
before-restore) - スキーマ移行の直前(
before-migration)
万が一、インポートしたJSONファイルが壊れていて画面が崩れてしまっても、慌てる必要はない。「インポート直前」の自動スナップショットを選んで復元ボタンを押せば、インポート前の世界へ完全にタイムトラベルできる。
世代管理と容量の上限
スナップショットが無限に溜まらないよう、reason ごとの保持数と全体の容量上限を設けている。
export const SNAPSHOT_RETENTION = {
auto: 10,
manual: 5,
'before-reset': 3,
'before-import': 3,
'before-restore': 3,
};
/** 全スナップショットの data 合計がこれを超えたら、古い auto から消す */
export const MAX_SNAPSHOT_TOTAL_BYTES = 20 * 1024 * 1024;
before-migration だけは自動削除の対象外で、次のリリースまで残す。また、ユーザーがアップロードしたカスタム壁紙(data: URL で数MBになる)はスナップショットから省く。壁紙1枚で容量上限を食い潰しては、肝心のレイアウトやメモの世代が残らないからだ。復元直前にも before-restore を撮るので、「復元したけどやっぱり戻したい」も救える。
3つの層を組み合わせた防御の連鎖
この3層アーキテクチャの強みは、ユーザーの心理的距離感と時間軸に完全にフィットしている点にある。
| レイヤー | 対象時間 | 保護対象 | 保存場所 | ユーザーの心理 |
|---|---|---|---|---|
| 第1層: Undo | 数秒〜数分 | 直前の操作差分(最大20件) | メモリ (Zustand) | 「あ、押し間違えた!すぐ戻したい」 |
| 第2層: ゴミ箱 | 数時間〜30日 | 個別のウィジェット/ページ(最大50件) | ストレージ (trash) |
「先週消したあのメモ、やっぱり必要だった」 |
| 第3層: スナップショット | 数日〜数週間 | ダッシュボード全体の完全状態(auto 10世代ほか) | ストレージ (snapshots) |
「いろいろ弄りすぎて壊れた。昨日の状態に戻したい」 |
さらに、3層すべての「読み込み」には別の防壁がある。initialize() はストレージの各読み込みを5秒のタイムアウトと競わせ、ハングした読み込みがあっても安全な既定値でダッシュボードを必ず起動させる(v1.3.1 で「ページ追加後にローディング画面から抜けなくなる」報告に対応して入れたものだ)。守る仕組み自体がユーザーをロックアウトしては本末転倒だからだ。
クラウド上にユーザーアカウントを持たないローカル完結型アプリケーションだからこそ、「ユーザーの不注意」や「アプリ自身の潜在的な不具合」によってデータを失わせることは絶対に許されない。
秒単位のUndo、月単位のゴミ箱、世代単位の自動スナップショット。この3層のセーフティネットを張り巡らせることで、ユーザーはデータの消失を恐れることなく、いつでも安心してダッシュボードを自由にカスタマイズできる自由を手に入れる。
さいごに:ZenithTabについて
本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。
ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。
- 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
- 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
- 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備
Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!
- 🌐 Chrome ウェブストアでインストール:
ZenithTab - Chrome ウェブストア - 🐙 GitHub リポジトリ(完全オープンソース):
miyabiver39/ZenithTab
ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!



