CodeVetter · Repo Unpack · Behavior traces
excalidraw/excalidraw
Three flows dominate the app: boot (service worker, Sentry, scene restore), a local drawing gesture (pointer down through action finalize into the store), and collaboration (room key, socket join, encrypted broadcast, Firebase reconcile). Local persistence is a side effect of onChange and is explicitly paused for the whole time a room is open.
- App boot sequence: excalidraw-app/index.tsx imports sentry.ts (Sentry.init, DSN only when hostname contains excalidraw.com, staging.excalidraw.com, or vercel.app and VITE_APP_DISABLE_SENTRY is not true), registerSW(), then renders ExcalidrawApp. A window beforeinstallprompt listener is installed at module scope in App.tsx so the install event is not missed.
- Editor constructor order: getDefaultAppState, refreshEditorInterface, nanoid id, new Library, new ActionManager(syncActionResult), new Scene, offscreen canvas plus rough.canvas, new Renderer, new Store, new History, new Fonts, registerAll(actions) plus undo/redo, then createExcalidrawAPI. componentDidMount rebuilds the API, in dev/test installs window.h.{state,setState,app,history,store,fonts}, subscribes store durable increments to history.record, calls scene.onUpdate(triggerRender), and unless ?web-share-target is set calls updateDOMRect(this.initializeScene).
- Host scene init effect waits until excalidrawAPI exists (and collabAPI exists unless iframes disable collab), calls initializeScene, then applies the result. It also listens for hashchange, unload (LocalData.flushSave), and blur/visibility/focus. beforeunload flushes local save and preventUnload when file storage still has unsaved images, unless VITE_APP_DISABLE_PREVENT_UNLOAD is true.
- Drawing a generic shape: canvas onPointerDown -> handleCanvasPointerDown. For tools other than text, arrow, line, freedraw, custom, frame, laser, autoshape, bucketfill, eraser, hand, or image, it calls createGenericElementOnPointerDown(activeTool.type). It then fires props.onPointerDown and onPointerDownEmitter, and if the editor is not in a non-capturing view mode registers window pointermove, pointerup, keydown, and keyup. Later pointer-up paths call actionManager.executeAction(actionFinalize).
- Action execution: ActionManager.handleKeyDown filters actions with keyTest, respecting UIOptions.canvasActions and keyPriority, and refuses the key if more than one action matches. registerAction stores by ActionName. The updater passed at construction is syncActionResult, which is wrapped in withBatchedUpdates. Navigation actions are blocked while viewport.isLockedTransitionPending.
- Starting a new room from the share dialog calls startCollaboration(null). That generates roomId and roomKey, pushState's #room=id,key, sets collaborating, and LocalData.pauseSave('collaboration'). It dynamically imports socket.io-client, opens Portal with transports websocket and polling, and for a brand-new room strips nothing but rewrites saved images to status pending and calls saveCollabRoomToFirebase. Joining an existing room calls excalidrawAPI.resetScene() first so local elements are not merged in.
- Socket lifecycle inside Portal.open: on init-room the client emits join-room; on new-user it broadcastScene(SCENE_INIT, elements, syncAll=true); on room-user-change it calls collab.setCollaborators. Incoming client-broadcast is decrypted in Collab.decryptPayload and switched on SCENE_INIT (reconcile, handleRemoteSceneUpdate, resolve the scene promise), SCENE_UPDATE (reconcile), or MOUSE_LOCATION (updateCollaborator). A 5s INITIAL_SCENE_UPDATE_TIMEOUT falls back to initializeRoom({fetchScene:true}) which reads Firebase.
- Stopping collaboration cancels queueBroadcastAllElements, queueSaveToFirebase, and loadImageFiles, then saveCollabRoomToFirebase of the current syncable elements. Unless keepRemoteState is false, a window.confirm asks before destroying the socket, resetBrowserStateVersions, and pushing history back to the origin. destroySocketClient closes the portal, clears collaborators, and LocalData.resumeSave('collaboration').
- Full-scene resync is periodic: SYNC_FULL_SCENE_INTERVAL_MS is 20000, separate from per-element version filtering in broadcastScene. Cursor broadcasts are volatile and throttled conceptually by CURSOR_SYNC_TIMEOUT 33ms. Deleted-element retention for the wire protocol is 24h.
- Unmount tears down the editor aggressively: it replaces get* API methods so they throw if called after isDestroyed, emits editor:unmount, destroys renderer, viewport, library, laser and eraser trails, clears both store emitters, and resets ShapeCache and SnapCache. The comment in the constructor says the API must be recreated in componentDidMount because StrictMode unmount would otherwise invalidate it.
- Web Share Target: if the query contains web-share-target, componentDidMount calls restoreFileFromShare instead of initializeScene. #url=<encoded> on the host fetches that URL, loadFromBlob's the response, and on failure sets appState.errorMessage to alerts.invalidSceneUrl.