JEPA4Japan · チュートリアル

第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界

4,955文字 15分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Invalidationで描画を駆動し、React Strict ModeでもEngine、Observer、Listener、rAFを常に1組だけ動かします。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 公開中
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 公開中
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 現在のレッスン
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 公開中
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 公開中

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 公開中
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 公開中
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 公開中
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 公開中

5歳児にもわかるゲームから始めよう

積み木で小さな舞台を作ります。青い紙は背景、3体の人形は俳優、透明板上の黄色い円は「誰が選択されているか」です。一人の子どもを舞台監督、もう一人をTicket Sellerにします。観客が瞬きをするたびにTicket Sellerが「俳優全員、もう一度最初から演じて」と叫んだら、どうなるでしょう。全員が忙しくなり、Ticketを売れません。代わりに、背景、俳優、黄色い円が本当に変わったときだけ対応するLampを点け、監督がそれを見て次の開幕前に1回だけ処理するようにします。

まず予想してください。同じ1秒間に「黄色い円のLamp」が10回連続して点いたら、10回開幕する必要があるでしょうか。それとも1回でしょうか。監督が帰宅した後もLampは監督を呼び出せるでしょうか。Animationする車が動き続ける間、Lampはどう点き続けるべきでしょう。

  1. ReactのTicket HallToolbarとInspector
  2. Dirty Lampを点けるどのLayerが変わったか記録する
  3. 次のFrameで開幕する複数の通知をまとめる
  4. 対称に仕事を終えるLamp、Listener、Observerを解除する
Product UIはTicket販売を、高頻度の舞台はEngineが管理します。赤いLampが点いたときだけ描き直します。

本章で伝える唯一の真実は、ReactがProduct UIを管理し、Canvas Engineが高頻度Interactionと描画を管理する。両者の境界は明確でなければならないということです。

おもちゃをCanvasに翻訳する

舞台遊びの概念Engineeringの概念所有者
Ticket HallReact / Next.js UIToolbar、Inspector、Dialog
舞台KernelImperative Canvas EngineDocument、Input、Camera、Renderer
赤・黄・青のLampDirty Flag / Change SetRender Scheduler
次の開幕requestAnimationFrame()Browser Frame Scheduling
背景紙、俳優、透明な円Layered Canvas / Render PassRenderer
Animation用StopwatchAnimation ClockScheduler/Simulation
Ticket Boardの「2個を選択中」低頻度Derived StateExternal Store → React
監督の出勤/退勤setup / cleanupEffect Lifecycle

比喩には限界があります。BrowserはrequestAnimationFrameが正確に16.67msごとに実行されるとは保証しません。Background Tabでは通常Pauseまたは頻度低下が起きます。React Strict ModeのDevelopment Checkは「2人の監督が永久に同時勤務する」という意味ではなく、対称性を検査するためsetup→cleanup→setupを追加で実行します。Layered Canvasは複数の本物の<canvas>を意味することも、1つのCanvasに複数Passがあることもあります。前者は局所的な再描画を減らせますが、Compositing、Dimension同期、MemoryのCostが増えます。

まず誤った直感を捨てる

  • 「最もDeclarativeにするため、React renderのたびにEngineを再構築する」。EngineはListener、Observer、rAFを所有します。繰り返し構築するとDocumentを失い、ResourceがLeakします。
  • 「InspectorをReal-timeにするため、すべてのpointermoveでsetStateする」。240HzのInputがProduct UI全体を駆動してはいけません。Engine内でTransient StateをUpdateし、Product価値のある低頻度Snapshotだけを公開します。
  • 「永久rAF loopが最も簡単だ」。Static Editorでは無意味に電力を消費します。DefaultはInvalidation-Basedにし、本物のAnimation中だけContinuousにします。
  • 「Strict Modeの二重初期化はReactのBugだからStrictModeを削除する」。非対称なcleanupを発見しているのです。Resource Ownershipを修正します。
  • 「Backgroundから戻ったら巨大なdtですべてのAnimationを追いつかせる」。JumpやSimulation Spiralが起きます。Variable TimestepをClampし、Fixed TimestepのCatch-up Step数も制限します。
  • 「Client ComponentはどんなServer Objectでも受け取れる」。Server/Client Boundaryを越えるpropsはSerialize可能でなければなりません。Context、Function、Map、Engine Instanceは渡せません。

本番用バックパック

前提となる契約

前提は第5章のRuntimeDocument/Session分離です。Engineは構築時に既存Canvas、Overlay root、Read-only Document Storeを受け取ります。破棄は冪等です。Reactが受け取るのはJSONでSerialize可能な初期WireDocumentだけで、Mount後にEngineを作ります。EngineはStableなDerivedSnapshot={selectionCount,canUndo,saveStatus}を公開し、Pointer座標やDocument全体を毎Frame Reactへ送りません。

正式な知識:Scheduler

requestAnimationFrame(callback)は、次のBrowser Paint前にCallbackをScheduleし、共通のHigh-resolution Time BaseからTimestampを渡します。CallbackはOne-shotであり、Continuous Renderingでは再度Requestする必要があります。Invalidation-Based RenderingはStateがDirtyになったときFrameをRequestし、複数回のInvalidationをまとめます。Dirty FlagはBackground、Scene、OverlayをBitmaskで表せます。Change SetはどのRecordが変わったかを運び、将来Layered CanvasやCacheで必要な部分だけをUpdateできるようにします。

Render Passは第3章の順序を維持します。複数のPhysical CanvasはHost DimensionとCameraを共有しなければなりません。DefaultのStatic StateではSchedulerにPending Frameがありません。Animation開始時にcontinuous=trueとし、停止後はDirtyでなければRequestしません。Duplicate Loop Preventionの中心は、唯一のPending rAF IDを保存し、非nullなら新しくRequestしないことです。

Animation Clockは「毎Frame 1を足す」ものではありません。Variable Timestepは上限でClampしたdtをVisual Animationへ渡します。Fixed TimestepはAccumulatorを使い、固定StepでDeterministic SimulationをUpdateしてから補間描画します。Background TabのPause後はdtが巨大になることがあります。最大Deltaと最大Catch-up Step数を制限し、Visibility変更時にTimeをRebaseすることもできます。Time PolicyはEngineに属し、React stateへ置きません。

正式な知識:React / Next.js

Next.js App Routerでは、DOM/Canvasを使うEntry Fileへ'use client'を付けます。refがCanvasとOverlay DOM Nodeを提供します。Browser Paint前に完了すべきHost/Engine SetupにはuseLayoutEffectが適し、通常のNetwork SubscriptionやTelemetryにはuseEffectを使えます。どちらもsetup/cleanupが対称でなければなりません。DevelopmentのStrict ModeはEffect setupとcleanupを追加実行します。正しい結果は、任意の瞬間にActive Engine/Loopが最大1つであることです。

External StoreはuseSyncExternalStore(subscribe,getSnapshot,getServerSnapshot)を通して低頻度でStableなSnapshotをReactへ公開します。Dataが変わらないとき、getSnapshotは同じObjectを返し、Infinite renderを防がなければなりません。DOM Overlayには通常のAbsolute Position Childを使えるほか、createPortalでText EditorをOverlay rootへ送れます。Visual PositionはEngineから低頻度または必要時にProjectionし、FocusとInputは引き続きDOMが管理します。

Imperative Engine Lifecycleは次のとおりです。Effect mountで作成→Document subscription/Pointer/ResizeをBinding→最初のinvalidate。cleanupではrAFをCancelし、Unsubscribeし、Listenerを削除し、Hostを破棄します。React rerenderはmountではなく、Documentを再構築してはいけません。SSR中はModule Top-levelからwindow、document、devicePixelRatio、getContextへAccessしてはいけません。

根拠と互換性

上記Sourceの確認日は2026-08-29です。BackgroundでのrAF PolicyはBrowserが決めるため、Testを固定Frequencyへ依存させてはいけません。ReactのDevelopment CheckはProduction Buildと異なりますが、cleanupの対称性は両方で成立しなければなりません。

この章のエンジニアリング増分

開始点: React Component内で直接描画し、Pointer Moveごとにstateを書きます。到達点: 独立したScheduler/EngineをNodeでTestでき、ShellはDOM Hostと低頻度Subscriptionだけを担当し、任意の瞬間にActive Loopが1つだけあります。

canvas-lab/src/lab/ch06/
  render-scheduler.ts
  canvas-engine.ts
  CanvasEditorShell.tsx
  render-scheduler.test.ts
  CanvasEditorShell.test.tsx

完全なSchedulerは、invalidation、continuous、fixed/variable clock、冪等な破棄をまとめて表現します。

export const Dirty = { Background: 1, Scene: 2, Overlay: 4, All: 7 } as const;
export type Frame = {
  now: number;
  variableDt: number;
  dirty: number;
  fixedSteps: number;
  alpha: number;
};
export type FramePlatform = {
  request(callback: FrameRequestCallback): number;
  cancel(id: number): void;
};
const browserFrames: FramePlatform = {
  request: (callback) => window.requestAnimationFrame(callback),
  cancel: (id) => window.cancelAnimationFrame(id),
};

export class RenderScheduler {
  private pending: number | null = null;
  private dirty = 0;
  private continuous = false;
  private disposed = false;
  private lastNow: number | null = null;
  private accumulator = 0;
  private readonly fixedStep = 1000 / 60;

  constructor(
    private readonly render: (frame: Frame) => void,
    private readonly platform: FramePlatform = browserFrames,
  ) {}

  invalidate(mask: number): void {
    if (this.disposed) return;
    this.dirty |= mask;
    this.ensureFrame();
  }
  setContinuous(value: boolean): void {
    if (this.disposed || this.continuous === value) return;
    this.continuous = value;
    if (value) this.ensureFrame();
  }
  rebaseClock(): void {
    this.lastNow = null;
    this.accumulator = 0;
  }
  dispose(): void {
    if (this.disposed) return;
    this.disposed = true;
    if (this.pending !== null) this.platform.cancel(this.pending);
    this.pending = null;
    this.dirty = 0;
    this.continuous = false;
    this.rebaseClock();
  }
  private ensureFrame(): void {
    if (this.pending !== null || this.disposed) return;
    this.pending = this.platform.request((now) => this.onFrame(now));
  }
  private onFrame(now: number): void {
    this.pending = null;
    if (this.disposed) return;
    const rawDt = this.lastNow === null ? 0 : now - this.lastNow;
    const variableDt = Math.max(0, Math.min(100, rawDt));
    this.lastNow = now;
    this.accumulator = Math.min(250, this.accumulator + variableDt);
    let fixedSteps = 0;
    while (this.accumulator >= this.fixedStep && fixedSteps < 5) {
      this.accumulator -= this.fixedStep;
      fixedSteps += 1;
    }
    if (fixedSteps === 5 && this.accumulator >= this.fixedStep) {
      this.accumulator %= this.fixedStep;
    }
    const dirty = this.dirty;
    this.dirty = 0;
    this.render({ now, variableDt, dirty, fixedSteps, alpha: this.accumulator / this.fixedStep });
    if (this.continuous || this.dirty !== 0) this.ensureFrame();
  }
}

Fixed Stepが5回へ達したら、1 Step以上のBacklogを捨て、1 Step未満の余りだけを残します。そうしなければ、「1 Frameあたり最大5 Step」でもBackgroundで生じた負債を後続の多数Frameへ持ち越し、遅いCatch-up Spiralを作りかねません。Business LogicがStepを捨てられないなら、無限に追いつこうとするのではなくPauseしてAuthoritative Timeへ再同期します。

Engineは高頻度Resourceを所有し、Change SetをLayerへMapします。Snapshot Objectは値が変わったときだけ置き換えます。

import { Dirty, RenderScheduler, type FramePlatform } from './render-scheduler';
export type DocumentChangeSet = Readonly<{
  backgroundChanged: boolean;
  changedShapeIds: readonly string[];
}>;
export type DocumentStore = {
  getSnapshot(): unknown;
  subscribe(listener: (change: DocumentChangeSet) => void): () => void;
};
export type DerivedSnapshot = Readonly<{
  selectionCount: number;
  canUndo: boolean;
  saveStatus: 'saved' | 'dirty';
}>;
export class CanvasEngine {
  private scheduler: RenderScheduler;
  private snapshot: DerivedSnapshot = { selectionCount: 0, canUndo: false, saveStatus: 'saved' };
  private listeners = new Set<() => void>();
  private abort = new AbortController();
  private unsubscribeDocument: () => void;
  private destroyed = false;

  constructor(
    private readonly canvas: HTMLCanvasElement,
    documentStore: DocumentStore,
    private readonly renderPasses: (dirty: number) => void,
    frames?: FramePlatform,
  ) {
    this.scheduler = new RenderScheduler((frame) => this.renderPasses(frame.dirty), frames);
    this.unsubscribeDocument = documentStore.subscribe(this.onDocumentChange);
    canvas.addEventListener('pointermove', this.onPointerMove, { signal: this.abort.signal });
    document.addEventListener('visibilitychange', this.onVisibility, { signal: this.abort.signal });
    this.scheduler.invalidate(Dirty.All);
  }
  private onPointerMove = (_event: PointerEvent): void => {
    if (!this.destroyed) this.scheduler.invalidate(Dirty.Overlay);
  };
  private onVisibility = (): void => {
    if (document.visibilityState === 'visible') {
      this.scheduler.rebaseClock();
      this.scheduler.invalidate(Dirty.All);
    }
  };
  private onDocumentChange = (changeSet: DocumentChangeSet): void => {
    this.publish({ ...this.snapshot, canUndo: true, saveStatus: 'dirty' });
    let dirty = Dirty.Overlay;
    if (changeSet.backgroundChanged) dirty |= Dirty.Background;
    if (changeSet.changedShapeIds.length > 0) dirty |= Dirty.Scene;
    this.scheduler.invalidate(dirty);
  };
  setSelection(ids: ReadonlySet<string>): void {
    this.publish({ ...this.snapshot, selectionCount: ids.size });
    this.scheduler.invalidate(Dirty.Overlay);
  }
  subscribe = (listener: () => void): (() => void) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };
  getSnapshot = (): DerivedSnapshot => this.snapshot;
  private publish(next: DerivedSnapshot): void {
    if (
      next.selectionCount === this.snapshot.selectionCount &&
      next.canUndo === this.snapshot.canUndo &&
      next.saveStatus === this.snapshot.saveStatus
    )
      return;
    this.snapshot = next;
    for (const listener of this.listeners) listener();
  }
  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    this.unsubscribeDocument();
    this.abort.abort();
    this.scheduler.dispose();
    this.listeners.clear();
  }
}

ここでDocumentStore.subscribeは、第5章のCommand BoundaryにあるRead-only Subscription Portです。Engineはrecordsを直接変更しません。Change Setは単なる「変わった」というBooleanではありません。Background変更とShape ID変更を別のDirty LayerへMapするからこそ、後でLayered Canvas、Cache、Local Invalidationを安全に追加できます。Production StoreではUnsubscribe Functionも冪等にすべきです。Engineのdestroy()はそれを1回だけ呼ぶことを保証します。

ShellはClient Boundaryであり、initialDocumentはJSONであってRuntime Mapではありません。次のCodeに、必要な4 Layer StructureとPortalを示します。

'use client';
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { CanvasEngine, type DerivedSnapshot, type DocumentStore } from './canvas-engine';

type WireDocument = { records: readonly { id: string; type: string }[] };
const empty: DerivedSnapshot = { selectionCount: 0, canUndo: false, saveStatus: 'saved' };
const subscribeEmpty = (): (() => void) => () => undefined;
const getEmpty = (): DerivedSnapshot => empty;
const staticStore = (document: WireDocument): DocumentStore => ({
  getSnapshot: () => document,
  subscribe: () => () => undefined,
});
const defaultCreateEngine = (canvas: HTMLCanvasElement, document: WireDocument): CanvasEngine =>
  new CanvasEngine(canvas, staticStore(document), () => undefined);

export function CanvasEditorShell({
  initialDocument,
  createEngine = defaultCreateEngine,
}: {
  initialDocument: WireDocument;
  createEngine?: (canvas: HTMLCanvasElement, document: WireDocument) => CanvasEngine;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const overlayRef = useRef<HTMLDivElement>(null);
  const [engine, setEngine] = useState<CanvasEngine | null>(null);
  useLayoutEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const next = createEngine(canvas, initialDocument);
    setEngine(next);
    return () => {
      next.destroy();
    };
  }, [createEngine, initialDocument]);
  const snapshot = useSyncExternalStore(
    engine?.subscribe ?? subscribeEmpty,
    engine?.getSnapshot ?? getEmpty,
    getEmpty,
  );
  return (
    <section className="canvas-editor-shell">
      <div className="canvas-host">
        <canvas ref={canvasRef}>業務Process Canvas</canvas>
      </div>
      <div ref={overlayRef} className="dom-overlay-layer" />
      <nav className="toolbar" aria-label="Canvas Tool">
        <button type="button">選択</button>
        <button type="button">Rectangle</button>
      </nav>
      <aside className="inspector" aria-live="polite">
        {snapshot.selectionCount}個を選択中;{snapshot.saveStatus}
      </aside>
      {overlayRef.current
        ? createPortal(<textarea aria-label="編集中のText" />, overlayRef.current)
        : null}
    </section>
  );
}

defaultCreateEngineはComponent外で定義しなければなりません。Default Arrow FunctionをParameter Listへ書くと、renderのたびに新しいReferenceになります。Engine作成後のsetEngineが次のrenderを起こし、Dependency変更によって再び破棄/再構築され、Loopになります。実際のApplicationでもCustom createEngineとinitialDocumentのReferenceをStableにします。Server Componentが渡すのはPlain JSONだけです。Parentが毎回新しいObjectを作るならDocument IDでStoreをLoadし、Effect Dependencyを抑えて本当のData Updateを無視してはいけません。cleanupはunmount時にReact stateを書きません。そのsetupが実際に作ったInstanceだけを破棄します。

Fake Frameを使うScheduler Testで、10回のinvalidationが1つのRequestへまとまり、破棄でCancelされることを証明します。

import { describe, expect, it, vi } from 'vitest';
import { Dirty, RenderScheduler, type FramePlatform } from './render-scheduler';
describe('RenderScheduler', () => {
  it('coalesces dirty changes and owns one active request', () => {
    let callback: FrameRequestCallback | null = null;
    const cancel = vi.fn();
    const platform: FramePlatform = {
      request: vi.fn((cb) => {
        callback = cb;
        return 41;
      }),
      cancel,
    };
    const render = vi.fn();
    const scheduler = new RenderScheduler(render, platform);
    for (let i = 0; i < 10; i += 1) scheduler.invalidate(Dirty.Overlay);
    expect(platform.request).toHaveBeenCalledTimes(1);
    (callback as FrameRequestCallback)(100);
    expect(render).toHaveBeenCalledWith(expect.objectContaining({ dirty: Dirty.Overlay }));
    expect(platform.request).toHaveBeenCalledTimes(1);
    scheduler.invalidate(Dirty.Scene);
    scheduler.dispose();
    scheduler.dispose();
    expect(cancel).toHaveBeenCalledTimes(1);
  });
  it('clamps a background-tab time jump', () => {
    const callbacks: FrameRequestCallback[] = [];
    const platform: FramePlatform = {
      request: (cb) => {
        callbacks.push(cb);
        return callbacks.length;
      },
      cancel: () => undefined,
    };
    const render = vi.fn();
    const scheduler = new RenderScheduler(render, platform);
    scheduler.setContinuous(true);
    callbacks.shift()!(10);
    callbacks.shift()!(10_000);
    expect(render.mock.calls[1][0].variableDt).toBe(100);
    expect(render.mock.calls[1][0].fixedSteps).toBeLessThanOrEqual(5);
    expect(render.mock.calls[1][0].alpha).toBeLessThan(1);
    scheduler.dispose();
  });
});

npx vitest run src/lab/ch06/render-scheduler.test.tsを実行し、2 passedを期待します。React Testing Libraryでは<StrictMode>内でShellを2回mount/unmountし、create count === destroy count、同時active最大値が1であることをAssertします。React Profilerを使って5秒間Dragし、Canvasは高頻度にUpdateする一方、Shellのrender回数はselectionCount/saveStatusが変わったときだけ増えることを確認します。

舞台へ戻ると、Ticket Sellerが表示するのは「いくつ選択したか、Undoできるか」だけです。俳優が1mm動くたびにTicket全体を印刷し直すことはありません。舞台監督は10回の同色通知を次の1回の開幕へまとめ、退勤時にはすべての鍵を返します。

わざと壊す

注入する故障症状根拠修正Regression Test復旧
Strict Mode setupでcleanupしないDevelopmentでListener/Loopが二重になるactive counter最大値が2Effectが冪等なdestroyを返すStrict mount cycle最大active=1残存Instanceを消す
Mount/Unmountを2回行う2回目はEventが二重発火するlistener/render呼び出しが倍になるEngineがAbortControllerを独占する2 Cycle後create=destroy再mountして利用可能か確認する
ResizeObserverを解放しないPageを離れてもCallbackされるdetached nodeがretainedになるHost.destroyでdisconnectするheap/spyが0になる残存Hostを手動破棄する
Pointer listenerが重複する1回の移動で複数回invalidateするEvent traceが重複するsignalで一括解除する1 Eventが1 Entry古いcontrollerをabortする
rAF loopが重複するIdleでもCPUが高く、Frame数が倍になるpending ID/Performance trace唯一のpending guard10回invalidateして1 request余分なIDをcancelする
SSR Top-levelでwindowを読むServer Buildがwindow is not definedSSR stack trace'use client' + Effect内でAccessserver render/import testModule Side Effectを削除する
Pointer MoveをReact Stateへ書くPageが毎秒120–240回renderするReact Profiler高頻度TransientをEngineに残す5秒Dragのrender budget悪いstate bridgeを削除する
Backgroundの巨大dt復帰時に瞬間移動/停止するdt≈数万msclamp、fixed step cap、rebase10→10000 time testAnimation Clockを再起動する

毎回、故障Switchを1つだけ有効にし、Active Engine、Pending rAF、Listener、Observer、React Commit数を記録します。修正後はUnit Test、Strict Lifecycle Test、Browser Profilerを実行します。最後に意図的にunmountし、すべての数値が0へ戻って初めて復旧完了です。

根拠を示して合格する

Gate自動/手動根拠
Active Loopは常に1つ自動pending rAF Unit Testとactive counter
EngineをReactなしでTestできる自動SchedulerはPure Node Test、EngineはjsdomでReact importなし
React rerenderがDocumentを再構築しない自動/手動Profiler + Inspector renderでEngine create countが増えない
高頻度InteractionがPageをrerenderしない手動Pointer traceは多く、React commitは少ない
Lifecycleが対称自動Strict setup/cleanup、Observer/listener/rAFがすべて1:1
Background復帰を制御できる自動dt clamp、fixed steps cap、visibility rebase test
  • Invalidation、Dirty Flag、Change Set、固定Passを実装した。
  • ContinuousはAnimation中だけ有効で、Static時にはrAFがない。
  • Fixed/Variable TimestepとBackground Tab Policyに決定論的なTestがある。
  • <CanvasEditorShell>にHost、DOM Overlay、Toolbar、Inspectorが含まれる。
  • 'use client'、ref、Effect/LayoutEffect、Strict Mode、External Store、Portal、Serialization Boundaryの役割が明確である。
  • Engineをmountで作成、unmountで破棄し、Reactへ公開するのは低頻度Derived Stateだけである。

5歳児に説明する

「React」「Engine」「rAF」「Dirty Flag」「Effect」という言葉を使わずに答えてください。観客が瞬きをするたびに、すべての俳優が演じ直してはいけないのはなぜでしょう。監督が帰宅した後、別の監督が密かにLampを点け続けていないと、どう確認するでしょう。

専門用語を使わない合格回答を開く

瞬きでは背景も俳優も黄色い円も変わらないので、演じ直すのは力の無駄です。何かが変わったら、対応する色のLampを点けます。短い間に10回点いても、次の開幕でまとめて処理します。俳優が走り続ける間は一時的にLampを点け続け、止まったら消せます。監督は出勤時に、Timer、定規、Doorbellという鍵の束を受け取ります。退勤時には一つずつ返さなければなりません。検査では、出勤してすぐ退勤し、もう一度出勤してもらいます。どの瞬間にも鍵を持つ人が一人だけなら、密かに働く2人目はいません。