mcpField
L
linear-local-first-architecture

linear-local-first-architecture

github.com/boraoztunc開発431176 回インストール11 週間前に更新
リポジトリ

linear-local-first-architecture について

Linear の技術分析をもとに、ローカルファースト設計を解説するスキルです。応答性の高い生産性ツールを作るチーム向けに、ローカルデータや同期、読み込み、性能を扱います。

主な機能

  1. 01IndexedDB へのワークスペース保存
  2. 02同期前にローカルで変更を反映
  3. 03認証確認前にローカルデータを描画
  4. 04コードを細かなチャンクに分割
  5. 05細粒度のオブザーバブルを利用
  6. 06キーボード操作を優先

活用シーン

  1. 01オフライン対応の生産性アプリ設計
  2. 02ネットワークを待たない更新応答の実現
  3. 03初期読み込みの短縮とキャッシュ再利用
  4. 04キーボード中心の高速なワークフロー構築

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/boraoztunc/skills --skill linear-local-first-architecture

インストール後、Claude Code で/linear-local-first-architectureを入力して読み込まれたか確認します。

詳細

このスキルを使う場面

  • 体感速度がユーザー体験を大きく左右する、生産性向上ツールを構築している
  • ネットワーク遅延は妥当な範囲なのに、アプリが「遅く感じる」とユーザーから報告されている
  • ユーザーのワークフローから読み込みスピナーやスケルトン画面をなくす必要がある
  • オフライン機能を備えたローカルファーストのアプリケーションを設計している
  • 即座に反応するように感じられる楽観的更新を実装したい
  • パワーユーザー向けにキーボード操作を優先したインターフェースを設計している

基本原則

  1. ボトルネックはネットワークです。可能な限り排除しましょう。 ネットワークリクエストのたびに数百ミリ秒かかります。最善の最適化は、ローカル状態から読み取ってリクエスト自体をなくすことです。

  2. ブラウザーを各ユーザーのデータベースとして扱いましょう。 ワークスペース全体を IndexedDB に保存し、メモリー内の監視可能なグラフにハイドレートします。UI はサーバーではなく、ローカル状態から読み取ります。

  3. 変更はまずローカルに適用し、非同期で同期しましょう。 ローカルの監視可能な状態をすぐに更新して UI を同期的に再レンダリングし、その後、トランザクションをキューに追加してバックグラウンドでサーバーと同期します。

  4. まずレンダリングし、その後に認証しましょう。 ローカルデータがあればすぐにレンダリングし、バックグラウンドでセッションを検証します。サーバーに拒否された場合にのみ、ログイン画面へリダイレクトします。

  5. コードを細かく分割して、出荷するコードを減らしましょう。 積極的なコード分割、モダンブラウザーのみを対象とする設定、パッケージごとのベンダーチャンクにより、初期ペイロードを減らし、キャッシュの粒度を高めます。

  6. 合成されるプロパティのみアニメーションしましょう。 GPU で処理できるよう、アニメーションは transform と opacity に限定します。width、height、margin など、レイアウトを発生させるプロパティはアニメーションさせないでください。

テクニック

ローカルファーストのデータアーキテクチャを構築する

ワークスペースを IndexedDB に保存し、メモリー内の監視可能なストア(Linear の場合は MobX)にハイドレートします。UI はサーバーではなく、ローカルストアにクエリします。

// A traditional web app updating the server
async function updateIssue({ issue }) {
  showSpinner();
  const response = await fetch(`/api/issues/${issue.id}`, {
    method: "PATCH",
    body: JSON.stringify({ title: issue.title }),
  });
  const updated = await response.json();
  setIssue(updated)
  hideSpinner();
}

// vs Linear
issue.title = "Faster app launch";
issue.save();

1 行目では、メモリー内のデータストア(MobX の監視可能な状態)を更新します。2 行目では、同期エンジンがまとめてサーバーに送信するトランザクションをキューに追加します。ローカルの更新に基づいて UI が同期的に再レンダリングされるため、スピナーも待ち時間もありません。

標準ライブラリーで楽観的更新を実装する

カスタムの同期エンジンを構築しない場合は、楽観的更新に対応した Tanstack Query や SWR などのライブラリーを使います。

// optimistic mutation with SWR
mutate(
  `/api/issues/${issue.id}`,
  { ...issue, title: "Faster app launch" },
  false
);

// vs Linear
issue.title = "Faster app launch";
issue.save();

重要なのは、UI の応答性をネットワーク遅延に左右されないようにすることです。ユーザーが速度を感じるのは、サーバーの応答速度ではなく、インターフェースがどれだけ早く反応するかによります。

モダンブラウザー向けビルドでバンドルサイズを削減する

モダンブラウザーのみを対象にし、レガシーなポリフィルを削除して、積極的にコードを分割します。

// vite.config.ts (reconstruction; matches observed chunk graph)
export default defineConfig({
  plugins: [react()],
  build: {
    target: "esnext",            // no legacy syntax, no polyfills
    cssMinify: "lightningcss",
    modulePreload: { polyfill: false },
    rollupOptions: {
      output: {
        // One chunk per npm package > ~3 KB. Cache invalidation
        // becomes per-library instead of per-app-revision.
        manualChunks(id) {
          if (id.includes("node_modules")) {
            const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
            if (pkg) return `vendor-${pkg}`;
          }
        },
      },
    },
  },
});

npm パッケージをすべて個別のチャンクに分割します。従来の vendor.js では、依存関係に変更があると依存関係グラフ全体が無効になります。パッケージごとにチャンクを分割すれば、キャッシュの無効化を細かく制御できます。

重要なチャンクを並列でプリロードする

<head> で重要なチャンクをすべて宣言し、「フェッチ → 解析 → インポートのフェッチ」というウォーターフォールをなくします。

<script type=module crossorigin
  src="https://static.linear.app/client/assets/html.2_JBQs3Q.js"></script>
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/vendor-mobx.Crhy2qQc.js">
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/SyncWebSocket.Djw6l_Op.js">
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/DatabaseManager.DKssGAN8.js">

各プリロードの crossorigin 属性をエントリースクリプトの crossorigin と一致させることで、ブラウザーがキャッシュ済みのフェッチを再利用します。コールドロードのタイムラインは、順次実行されるウォーターフォールから、単一の並列バッチに短縮されます。

残りのアセットをサービスワーカーで事前キャッシュする

最初のページ読み込み後、サービスワーカーを使ってバックグラウンドでルートレベルのチャンクを事前キャッシュします。

// The service worker has a precache manifest baked into its source,
// around 1,200 hashed assets covering route chunks, icons, and fonts.
// It pulls them down lazily after the first page load.

ログイン画面を表示してから数秒以内に、アプリ全体がキャッシュに保存されます。以降のナビゲーションではネットワークを完全にスキップし、サービスワーカーがキャッシュから直接応答します。IndexedDB と組み合わせることで、アプリをオフラインで使えるようになります。

重要な CSS と起動処理をインライン化する

読み込み状態を描画するのに必要な最小限の CSS をインライン化し、外部スタイルシートのフェッチを避けます。

<style>
  :root {
    --bg-color: #f5f5f5;
    --bg-base-color: #fcfcfd;
    --bg-border-color: #e0e0e0;
    --sidebar-width: 244px;
  }
  html { background: var(--bg-color); height: 100%; }
  body { font-family: "Inter Variable", Arial, Helvetica, sans-serif; }

  #appBorders {
    border: 1px solid var(--bg-border-color);
    background: var(--bg-base-color);
    margin: 8px 8px 8px var(--sidebar-width);
    border-radius: 12px;
  }

  #logo { transform: translateZ(0); }

  @keyframes logoBackgroundPulse {
    0%   { opacity: 0; transform: scale(0.8); }
    70%  { opacity: 1; }
    100% { opacity: 0; transform: scale(1.0); }
  }
</style>
<script>performance.mark("appStart");</script>

インライン JavaScript で、最後に使用したシェルのトークン(サイドバーの背景色、幅、ダークモード)を描画前に復元します。

<script>
// Electron context — lets CSS branch on native chrome.
if (navigator.userAgent.includes("Electron") && navigator.userAgent.includes("Linear")) document.documentElement.classList.add("electron");

// No local store → no workspace data → render the auth layout.
if (localStorage.getItem("ApplicationStore") === null) document.documentElement.classList.add("logged-out");

// Restore last-known shell tokens (sidebar bg, width, dark mode) before paint.
const c = JSON.parse(localStorage.getItem("splashScreenConfig") || "{}");
if (c.bgSidebarColor) document.documentElement.style.setProperty("--bg-sidebar-color", c.bgSidebarColor);
if (c.sidebarWidth) document.documentElement.style.setProperty("--sidebar-width", c.sidebarWidth + "px");
if (c.darkMode) document.documentElement.classList.add("dark");

// Compact sidebar to a sliver when the user opens links in the desktop app.
if (JSON.parse(localStorage.getItem("userSettings") || "{}").openLinksInDesktop) document.documentElement.style.setProperty("--sidebar-width", "8px");

</script>

最初の JavaScript バンドルが読み込まれる頃には、読み込み画面のテーマ、サイズ、位置がすでに正しく設定されています。

フォントの読み込みを最適化する

二重取得を避けるため、適切な CORS モードで可変フォントをプリロードします。


<link rel="preload"
      href="https://static.linear.app/fonts/InterVariable.woff2?v=4.1"
      as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preconnect" href="https://static.linear.app" crossorigin>
@font-face {
  font-family: "Inter Variable";
  font-weight: 100 900;
  font-display: swap;
  src: url(https://static.linear.app/fonts/InterVariable.woff2?v=4.1)
       format("woff2");
}
/* Italic and Berkeley Mono follow the same shape, single woff2 each. */

可変フォントは、1 つの woff2 で 100〜900 のウェイト軸全体をカバーします。font-display: swap を指定すると、フォールバックのフォントスタックをすぐに表示し、Inter の読み込み後に切り替えます。プリロードに crossorigin="anonymous" を指定すると、CSS からフォントが参照されたときにブラウザーがキャッシュ済みのフォントを再利用します。

認証済みと仮定し、バックグラウンドで検証する

セッションの検証を待って処理をブロックするのではなく、ローカルデータが存在するか確認します。

if (localStorage.getItem("ApplicationStore") === null) {
  document.documentElement.classList.add("logged-out");
}

ApplicationStore が存在する場合、そのユーザーは以前にアプリを使ったことがあり、ワークスペースは IndexedDB にあります。すぐに表示します。実際のセッショントークンは Cookie にあります。セッションが期限切れの場合、次のリクエスト(WebSocket ハンドシェイク、同期差分、任意の HTTP 呼び出し)が 401 で失敗した時点でログイン画面にリダイレクトします。

このフローで確認するのは「表示できるものがあるか」であり、「有効なセッションがあるか」ではありません。

細粒度のオブザーバブルでピンポイントに再レンダリングする

すべてのモデルの各プロパティを個別のオブザーバブルにします。サーバーから差分が届いたら、対応するオブザーバブルに書き込みます。フレームワーク(MobX)は、どのコンポーネントがどのフィールドに依存しているかを正確に把握し、該当するコンポーネントだけを再レンダリングします。

1 つの課題の 1 つのフィールドを更新する変更では、そのフィールドを読み取るコンポーネントだけが再レンダリングされます。親のリストでも、サイドバーでもなく、1 つのセルだけです。50 件の課題の更新では、リストの再レンダリングではなく、50 個のセルの再レンダリングが行われます。

キーボードを優先した操作を設計する

よく使う操作すべてにショートカットを用意します。単一のキーでフォーカス中の課題を編集し、2 文字の組み合わせで移動し、修飾キーでグローバル操作を実行できるようにします。UI のあらゆる場所でショートカットを見えるようにします。

アクション、課題、プロジェクト、ラベル、ステータス変更、ナビゲーション、設定を検索できるグローバルコマンドパレット(⌘ k)を実装します。検索はローカルの MobX オブジェクトプールを対象に行うため、サーバーリクエストは不要で、結果はすぐに表示されます。

合成されるプロパティだけをアニメーションする

ブラウザーでのプロパティ変更には、次の 3 つの段階があります。

  • 合成されるプロパティ(transform、opacity):GPU で高速化され、メインスレッドから独立します
  • ペイントを発生させるプロパティ(color、background-color、border-color、fill):レイアウトをスキップしますが、ピクセルを再描画します
  • レイアウトを発生させるプロパティ(width、height、top、left、margin、padding):後続するすべての要素の再計算を強制します

レイアウトを発生させるプロパティはアニメーションしないでください。

/* What Linear does */
.row:hover {
  background-color: var(--color-bg-hover);
  transition: background-color 0.12s;
}
.icon-arrow {
  transform: translateX(0);
  transition: transform 0.15s;
}

/* What you'd write if you didn't know better */
.row:hover {
  margin-left: 2px;       /* triggers layout for every row beneath */
  transition: all 0.2s;   /* and now you're animating margin */
}
アニメーション時間を短く保つ

業界標準より短いトランジションをデフォルトにします。

/* variables form Linear's stylesheet */

--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;

非対称なタイミングを使います。呼び出されたときは即座に表示し、閉じられたときは 150ms かけてフェードアウトします。これにより、ユーザーの意図にすぐ応えるインターフェースになります。

アンチパターン

❌ UI を更新する前にネットワークリクエストを待たないでください。 まずローカルで変更を適用し、バックグラウンドで同期して、サーバーが拒否した場合にのみロールバックします。

❌ width、height、margin、padding、top、left など、レイアウトを発生させるプロパティをアニメーションしないでください。 transform と opacity を使います。

❌ 初回レンダリングを認証のためにブロックしないでください。 ローカルデータがある場合はすぐに表示し、非同期でセッションを検証します。

❌ すべてのベンダーコードを 1 つのチャンクにまとめないでください。 npm パッケージごとに独自のチャンクに分割し、キャッシュの無効化がアプリのリビジョン単位ではなく、ライブラリ単位になるようにします。

❌ 頻繁に行われる操作に長いアニメーション時間(>250ms)を使わないでください。 トランジションを短くするとアプリがより速く感じられます。長い時間は、頻度が低く影響の大きい場面に限って使います。

❌ すでにローカルにあるデータを、ナビゲーションのたびに取得しないでください。 起動時に IndexedDB からデータを読み込み、重いテーブルは必要に応じて遅延読み込みします。サーバーは UI の信頼できる情報源ではなく、同期先として扱います。

出典

Linear はなぜこんなに速いのか? 技術的な解説

コメント