perf(editor): stop repeating work the editor already did - #25
Draft
pythonlearner1025 wants to merge 361 commits into
Draft
pythonlearner1025 wants to merge 361 commits into
pythonlearner1025 wants to merge 361 commits into
Conversation
… Hono router, template copy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The board holds working notes and local paths. It now lives in blitz-cloud/docs/PLAN-REVIEW.md, which is private. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
What changed: the edit viewer keeps a permanent animation loop. When the editor is stopped and nothing changes it still ran the full frame pipeline at display rate: plugins, timeline, UI refresh, stats. The stopped loop is now held at 15 callbacks a second through threepipe's frameWaitTime countdown, released to full rate on any viewer update, and parked while the tab is hidden. A change still renders within one tick. Why: with several editor tabs open at high DPR, Chrome's compositor fell behind and intermittently skipped UI paint and input while the DOM stayed correct: toolbar icons present but not painted, clicks ignored. Measured about 60 callbacks a second before and 13 after. The blanking did not reproduce in headless Chrome, so the fix rests on the measured pressure and a known Chromium report on macOS with 120 Hz canvas work. Verification: editor Playwright suite 31 of 31 including the new idle-cap test (5 to 20 callbacks a second while idle, zero renders, an on-demand render within 100 ms); lint 3 of 3; typecheck; CI runs 34654625600 and 34654610739 green on the branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
What changed: the right panel has two tabs. Inspector shows the selection with a name line (object name, type chip, path), the object's sections in one row style (96 px label column, 28 px rows, 3 px radius, uppercase 11 px section labels), one Components section listing attached components with their fields and an Add control, and for generator nodes one Generator section with Module, Params, and Bake as the single primary button. With nothing selected it shows a one-line prompt and a Scene summary: scene file, the scene's own camera, object count, last save, last check result; rows are omitted when the data does not exist. Project lists Scripts (label kept), Plugins, and Dependencies including dev dependencies, each row with a real status chip; a script that fails to load shows the error and line. The Settings tab is gone: the viewer's Rendering and Timeline config and the Import and Preview mode options live in an Editor settings popover under the header cog, with a viewport-bound height and inner scroll. Memory is hidden behind ?memory=1 or a localStorage flag, documented in packages/editor/README.md. The panel body is the only scroll container; the vendored renderer's overflow-y: scroll is overridden from our stylesheet and our text rows use overflow: auto. The frame-loop cap from 68947cf is merged in. Why: four tabs with viewer settings in the Inspector fallback was information overload with three row styles and two scrollbars. The owner approved the mockup; Scripts stays Scripts. Verification: typecheck, lint, build; editor Playwright 33 of 33 twice (serial run included), kite3d 163 of 163; screenshots of five states at 1400 by 900 reviewed against the mockup; vendored uiconfig-blueprint untouched; no em dashes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
On Linux a stdout pipe is written synchronously, so a reader can see the Project line and send SIGINT before the next statement installs the handler, and the process dies by the signal. The CI test that stops kite3d dev with SIGINT hit exactly that on the redesign commit. Register SIGINT and SIGTERM first, then print. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Playwright clears test-results at the start of every run, so the five committed redesign screenshots showed as deleted after each suite run and blocked the release's clean-tree check. The private board keeps the visual evidence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The upgrade fixture hard-coded a future migration at 0.16.0, so the 0.16.0 bump made it current and two upgrade tests failed with future migration ran. The fixture now uses the next major version. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…nd drop
Dragging or double-clicking an item from the asset library failed with
"RangeError: Invalid typed array length" and a createObjectURL error.
Two causes on main:
- ExternalFilesGrid hard-coded draggable={false} and the viewer did not
install CanvasFileDropHandler, so library items were not draggable.
- Both asset URL modifiers (editor and runtime) dropped everything after
the asset id, so /kite3d/@id/Camera_01.bin and every texture resolved
to the root .gltf. The loader then read glTF JSON as a Float32Array.
What changed:
- Library glTF imports download every external buffer and image, keep
safe relative paths, rewrite unsafe or colliding ones, and register the
whole bundle in assets.json in one write (rollback on failure).
- assets.json entries can carry a files map from glTF URI to project path.
- The engine exports createProjectAssetURLModifier; editor and runtime
share it. Unknown files under a registered asset raise a clear error.
- Library drag and drop and double-click placement work again; clones
keep the persistent root reference without exporting imported children.
- .gltf, .phmatgltf and .hdr are accepted library asset types.
Verification (worktree library-drop, codex GPT 5.6 Sol pass):
- typecheck 3 workspaces, lint 3 workspaces, build: pass
- npm test -w packages/editor: 34/34, run twice
- npm test -w packages/kite3d: 163/163
- engine unit tests: 30/30 (new projectFormat.test.ts)
- new Playwright test "persists a dropped library glTF with its buffer
and texture" fails without the fix (draggable="false")
- live check against the asset library proxy: Camera_01 imported with
11 files, 5 meshes, 4 textured, saved and reloaded, zero errors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The Inspector's source editor was a plain textarea. It now mounts a CodeMirror 6 view with line numbers, active line, bracket matching, indentation with Tab, and syntax highlighting through Lezer's classHighlighter (stable tok-* classes). Languages by extension: JavaScript (.js, .mjs, .cjs), TypeScript (.ts, .tsx), JSON (.json, .gltf, assets.json), HTML, CSS, XML (.xml, .mjcf), Markdown. Everything else opens as plain text. The editor root carries data-language. Kept: the aria-label "Source editor: <path>", the 1 MiB limit, the draft and saved states, Cmd/Ctrl+S, Revert, the Reload/Overwrite conflict flow, and external file changes applied without losing the scroll position. Known binary formats still show metadata only; other text extensions are now editable. CodeMirror loads on demand in its own chunk (540 KB, 187 KB gzip); the main editor chunk grows by 191 bytes. Editor tarball grows 16 percent. Verification (worktree source-highlight, codex GPT 5.6 Sol pass): - typecheck 3 workspaces, lint 3 workspaces, build: pass - source-editor.spec.ts: 9/9 (editing, language, tokens, binary, scroll, save, conflict) - npm test -w packages/editor: 35/35, run twice - npm test -w packages/kite3d: 163/163 - lockfile keeps every platform optional entry (24 rollup, 25 esbuild) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…ssword (#5) `kite3d claim [--no-open]` prints one line per unclaimed deploy, `Claim <slug>: <claim URL>`, and opens each URL in the default browser unless --no-open is given. The claim page on blitz.dev signs the user in with Google and claims the game. The old --email, --password, and --login options are removed: the backend no longer has a password flow, and a password on the command line landed in shell history and logs. The claim URL comes from the publish response and is stored in .kite3d/deploys.json as claim_url; older entries get one built from the backend URL, the slug, and the claim secret. Before printing, claim and status ask the backend for each game and mark entries claimed when the game no longer expires. That check is best-effort: a failed request keeps the local state. Written by the owner's codex session on feat/claim-opens-browser. Verification (detached worktree at 7cbf603): - build, typecheck, lint: pass - npm test -w packages/kite3d: 172/172 (163 on main, 9 new claim tests) - the existing test still proves a failed publish prints no deploy token or claim secret - CI run 34679398382 on the PR: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
#3) `kite3d skills` prints one line per bundled skill: the name, a tab, and the absolute SKILL.md path. `--json` prints [{name, path}]. It works from any directory with no project, from an npx cache, and from a global install: the CLI resolves ../skills/ from its own module URL. The first skill, kite3d-project, tells an agent to read the project AGENTS.md, run `kite3d doctor` and `kite3d sources`, and read .kite3d/check.json after `kite3d check`. The skills directory ships in the package. README, docs/agents.md, and the template AGENTS.md document the command. Ported from the draft on proposal/cli-skills (78 commits behind, old template path), then rebased onto the claim change (#5) with no conflicts. The draft's command policy table was not ported; the current command checks stay, and `skills` joins the no-project and legacy-project exemptions. Verification (worktree cli-skills, codex GPT 5.6 Sol pass, rerun after the rebase): - build, typecheck, lint: pass - npm test -w packages/kite3d: 178/178 in 12 files (172 on main) - without the feature five tests fail: "kite3d: Unknown command: skills" - packed tarball installed into an empty temp directory; `npx --no-install kite3d skills` there and the binary from /tmp print the same readable path Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…l schema export (#7) A generator module may export a flat `params` schema: type, label, help, default, options, min, max, step. Types: boolean, number, integer, string, select, vector, color, json; an omitted type is inferred from options or the default. runGenerator parses the export safely and stores it on the component; the editor passes it through the generator state the Inspector reads. The Generator card renders one row per declared param in schema order, then an inferred row per undeclared saved key, then a collapsed "Edit as JSON" section with Apply and an inline error. Absent declared params show their default. Switch and select write at once; other fields on blur or Enter. Wrong types and out-of-range numbers show an inline error and do not write. A focused draft survives an external file change. Invalid schema entries are dropped with one console warning. docs/agents.md and the template AGENTS.md document the export. Verification (worktree generator-params, codex GPT 5.6 Sol pass): - build, typecheck, lint: pass - engine unit tests 35/35 (30 on main); kite3d 178/178 - npm test -w packages/editor: 37/37, two full runs (36 on main) - without the feature the new test fails: Shading label not found - PR CI run: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
A Library drop or double-click opens the "Add library asset" dialog before any scene change. The target is the selected hierarchy object, else the object under the cursor, else the scene root; the dialog names it and says which rule picked it. Models add under the target or at the root. Materials apply to the selected mesh or to every mesh under a group. Textures apply to a chosen slot: base color, normal, roughness, metalness, emissive, occlusion; a multi-material mesh asks which one. Environment maps set the environment, the background, or both. A misfit target offers import only. "Remember my choice for <type>" stores the action and slot in localStorage (kite3d.editor.dropChoices) and skips the dialog next time; "Reset drop prompts" in Editor settings clears it. Every action runs through the undo manager. Cancel and Escape run no command. Finder file drops keep their immediate behavior. Verification (worktree library-drop-dialog, codex GPT 5.6 Sol pass): - build, typecheck, lint: pass - npm test -w packages/editor: 37/37 in three full runs (36 on main) - without the feature the new test fails: library-drop-dialog not found - PR CI run: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…guide (#9) * feat!: remove Generator components * docs: replace the generator sections with scene asset management bullets The guide no longer documents Generator components, bake, or the params schema. A new Scene asset management section gives concise rules: assets are files under assets/, the scene is a list of placed assets edited by scripts, a script that makes an asset lives beside its outputs and runs by hand, props stay individual nodes with human names, speed work never changes the files, and assets are registered in assets.json. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk * fix(engine): point the removed-generator notice at the guide section Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…PNG (#10) `kite3d screenshot [--name] [--headless] [--full] [--width] [--height] [--json]` asks the connected editor to render once and capture its viewport, composited over the viewport background, and saves the PNG under .kite3d/screenshots/<timestamp>-<name>.png. The absolute path is the only stdout line. With no editor connected it falls back to a headless render through the launcher `check` already uses; --full captures the whole editor window. The server relays the request over its event stream with a bounded wait; the editor posts the PNG back. The guide and the bundled skill tell agents to look before and after visual changes. Verification (worktree screenshot, codex GPT 5.6 Sol passes): - build, typecheck, lint: pass - npm test -w packages/kite3d: 180/180 (176 on main); new screenshot.test.ts covers the connected capture, hidden tab, JSON, headless fallback, --full, the timeout, and the composite - npm test -w packages/editor: 38/38 - by hand: a fresh project with a box, connected capture shows the dark viewport and the box - PR CI: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…ind kite3d open (#13) The CLI side of the project hub. A project index in ~/.kite3d/projects.json, written by init and every dev start. Git facts from git: repo root, the shared git directory that groups worktrees, the worktree list, the branch. `kite3d dev --detach` and `--stop` with the log in .kite3d/dev.log. `kite3d open` always opens the launcher, a hub server on port 4320 that serves the editor with no project; `open --stop` ends it. Hub routes on the hub and on every dev server: list projects grouped by repo with worktrees, list folders under home, start, stop, create, add. Two fixes found while the owner tried it: the token cookie is now scoped per port (kite3d-token-<port>), because browsers key cookies by host and two servers overwrote each other's cookie; and start runs any pinned version's plain `dev --no-open` with the launcher doing the detaching, because a project pinned to 0.18.0 has no --detach. Failures answer 502 with the log's last line or 409 with the install instruction, never a bare 500. Verification (worktree hub-cli, codex GPT 5.6 Sol passes): - build, typecheck, lint: pass - npm test -w packages/kite3d: 191/191 in 17 files - runtime 25 plus 11, gates 7, editor 38 - by hand: the hub started, answered state, projects, and folders, refused a path outside home; start on the owner's real 0.18.0 project returned a URL in under a second, its editor loaded, stop ended it - PR CI: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…rktrees section (#12) The editor side of the project hub. In hub mode the editor shows the welcome dialog at once. Its Projects tab: New Project and Open Project, Active editors, and Projects grouped by repo with a row per worktree. Open always opens a new tab, starting the server first when needed with a spinner; Stop stops it. Open Project is a folder list from the local server with the kite icon on projects; New Project picks the parent the same way and asks only for a name. The four old file-based actions are gone. The navbar project button opens a Blueprint menu with the same lists. The Project tab gets a Worktrees section above Scripts. No running dots, no ports; a running project shows the RUNNING chip; a detached worktree shows its short commit; server error text shows inline. Verification (worktree hub-editor, codex GPT 5.6 Sol passes): - build, typecheck, lint: pass - npm test -w packages/editor: 44/44 (38 on main); new hub.spec.ts with real servers and real git worktrees - npm test -w packages/kite3d: 191/191 - by hand: the launcher ran against the owner's real projects and started a 0.18.0 worktree from a click - PR CI: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The library drop dialog test in dev-server.spec.ts started the material drag right after the first model drop, before the imported mesh existed in the group, so on a loaded runner the dialog counted one mesh instead of two. The test now waits on the real condition: both meshes under the live group and the imported hierarchy row. No production code changes. Verification (worktree flake-drop-count, codex GPT 5.6 Sol at medium): - the fixed test alone with --repeat-each=5: 5/5 - npm test -w packages/editor: 44/44 - PR CI: success on both runs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Edit-mode camera keys. `]` doubles the WASD flight speed and `[` halves it, clamped to 0.0625 through 64, with Shift and Control still multiplying on top; a SPEED label shows the value for one second and the value persists per editor in local storage. `/` toggles isolate for the whole current selection: selected objects, their parents, and their children stay visible, everything else under the model root is hidden, lights and cameras never. Isolate remembers and restores every previous visibility value, persists across selection changes, and exits on the chip, the menu item, Play, scene load, or edit-mode disable. The hierarchy right-click menu gets Isolate, or Exit Isolate while active. Isolate never dirties the scene or adds an undo entry, and a save while isolated writes the pre-isolate visibility and keeps isolate on. Cmd+S now reaches Save Scene from the Kite3D toolbar; it was shown but never wired. Verification (worktree keys, codex GPT 5.6 Sol at medium, pr-skill applied: manual headless walkthrough first, screenshots viewed, S1 to S10 pass, five questions answered): - build, typecheck, lint: pass - npm test -w packages/editor: 43/43 (38 on main); new keys.spec.ts with 5 tests, each naming the assertion that fails without the feature - npm test -w packages/kite3d: 180/180 - screenshots of every added UI state: https://claude.ai/code/artifact/6388d799-3096-4db7-bffa-5cc6dc15a94b - PR CI: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
The scene save dropped hidden objects. serializeSceneGltf exported with onlyVisible true, so hiding a wall with the eye icon and saving removed it from the scene file, the source of truth. The exporter now runs with onlyVisible false and the engine passes one predicate that omits non-authored objects by identity (excludeFromExport, widget roots, widgets) whatever their visibility. Hidden objects round-trip through the existing WEBGI_object3d_extras visible flag. The hierarchy eye icon reads object.visible, so a reloaded hidden object shows the crossed eye. Verification (worktree hidden-save, codex GPT 5.6 Sol at medium, pr-skill applied): - by hand, headless: hide, save, reload; the node stays in the file and the hierarchy shows it hidden; screenshots viewed - one regression guard, hidden-objects.spec.ts, fails without the fix at the node lookup; the serializer unit tests were dropped per the no-proactive-tests rule - build, typecheck, lint: pass; engine 25/25; editor spec 1/1 - PR CI: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…17) Library drops failed silently, as the owner reported. Three causes, reproduced on a scratch 0.18.0 project: a drop that landed before the background download and import finished returned with nothing added; a failed load was swallowed by the importer's empty result; a material applied by drop never dirtied the object, so it was never saved. Each drag now owns one import promise and a landed drop waits for it, with the spinner up and drag end unable to cancel it. The manager throws the loader's reason when the importer returns nothing, and the drag and double-click paths show it in the editor's danger toast with the asset name. Material apply and undo dirty their targets. Verification (worktree drop-fix, codex GPT 5.6 Sol at medium, pr-skill applied): - by hand, headless: a real pointer drag landing before a five-second download adds the model; a 500 on the file route shows the red toast; a material drop saves; Escape, two drags, outside drop, reimport, and remembered choice with a failed import checked - three regression guards in library-drop-flow.spec.ts, each failing without its fix; the double-click duplicate was dropped per the no-proactive-tests rule - build, typecheck, lint: pass; editor 42/42; kite3d 191/191 - PR CI: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
…nt rules (#18) The CI suites held 275 tests, most of them proactive: written with the feature, guarding nothing a human saw fail, flaking under load, and passing on fixtures that rendered nothing. The owner's rule from now on: a test exists only to guard behaviour that was flaky in manual testing by a human or a bug a user reported, and proof of a change is a manual walkthrough with screenshots that show the change. 257 tests are deleted and 18 stay, each with a comment naming the report or the manual observation it guards: security guards for token and secret output, the local server defenses, the Ctrl-C hang, the Linux watcher miss, split-write journaling, the cookie 401 report, the launcher 500 report, init and the guide contract, the persistence gate, the three check gates through the editor, the dropped GLB and library glTF reports, the three drop guards from PR #17, the hidden-objects guard from PR #16, and the project creation smoke. The racing drop dialog test is deleted on purpose; PR #17's material guard covers its path. Unused fixtures and helpers go with the deleted files. The root AGENTS.md states the repository rules in ten bullets. Verification (worktree test-cut, codex GPT 5.6 Sol at medium): - kite3d 9/9, runtime 1/1 with zero unit tests accepted, editor 8/8 - build, typecheck, lint: pass; guide equality: pass - PR CI: success on both runs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
A prerelease channel so testers get alphas without moving latest. set-version accepts an explicit x.y.z-word.n version. release publishes a prerelease with --tag next and a stable version with no tag, logs the full publish command per package, and skips the global agent guide upload on a prerelease so nobody else's guide changes; runtime registration is per version and still runs. The CLI orders prerelease versions (0.18.0 < 0.19.0-alpha.1 < 0.19.0-alpha.2 < 0.19.0), allows the upgrade from an alpha to the stable with the migration applied once, and accepts an exact prerelease pin. README and the guide (with its template mirror) document npx kite3d@next init and upgrade. Verification (worktree prerelease, codex GPT 5.6 Sol at medium; no tests added per the repo rule): - release dry run at 0.19.0-alpha.1: --tag next on all three publishes, guide upload skipped, tag v0.19.0-alpha.1 - comparator run on the built CLI for the four orderings and the alpha-to-stable upgrade - test:scripts 7/7; build, typecheck, lint: pass; guide equality: pass - PR CI: success Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146XF2FFPGYrSAB1UajiERk
Two independent redundancies on editor hot paths. 1. ViewerInstanceManager dispatched one stateChange per sceneUpdate. The scene fires one of those per changed object, and 13 components re-render on each dispatch. React batches a synchronous burst on its own, so a gizmo drag was never the real cost. Updates that arrive in separate tasks, the way asset loads and watcher reloads do, are not batched, and each one paid for a full editor re-render. Collapse them to one dispatch per frame and cancel the pending frame on dispose. 2. compareMaterials rebuilt flattenMaterial for both materials inside the per property loop, so a material with P properties was walked 2P times instead of twice. flattenMaterial recurses the whole material, which made the scene diff quadratic. That diff is awaited on the PUT of the main scene file, so it blocks the editor's save. Measured, same build both sides: 40 updates in separate tasks, React self time 135.7 ms to 0 ms, total JS 511 ms to 76 ms. 20 materials with 64 properties, diff 143.94 ms to 2.85 ms; with 256 properties, 2198.89 ms to 12.11 ms. Tests: 2 new playwright cases in packages/editor and 4 new vitest cases in packages/kite3d. 1 and 2 of those fail without the respective change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T623SnndzSVwCSmuQrVRj2
pythonlearner1025
force-pushed
the
main
branch
from
September 15, 2026 23:23
f87b8c6 to
d12d7ad
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What part this touches
Two editor hot paths. They sit in different packages and are independent of each other. Both are the same shape of bug: work the editor already did, done again.
ViewerInstanceManagerinpackages/editorowns the editor's viewer. It listens tosceneUpdateon the scene. That event fires once per object that changed. Its handler callschanged(), which dispatchesstateChange.useManagerVersionturns everystateChangeinto asetVersion(v => v + 1)for 13 components, including the rootApp.scene-diff.tsinpackages/kite3dcompares the old and new main scene file so the dev server can write a line to the scene journal. The dev server awaits that diff inside thePUT /files/<scene>.gltfhandler, so it sits between the editor pressing Save and the response coming back.The problem
One, the dispatch per object
Twenty objects changing in one frame produced twenty
stateChangedispatches, and twenty rounds of 13setStatecalls.The audit that opened this said the cost was 3.3 ms of React per moved object per frame. That was wrong, and measuring the fix is what caught it. React 18 batches updates that arrive in the same task, so a synchronous burst was already collapsing into one render. Measured on a 2,405 node project, 20 objects moved per synchronous frame: React self time 161.2 ms before against 142.0 ms after, over 40 frames. That is 0.48 ms per frame. With one object moved there is no difference at all.
The real cost is where React cannot batch: updates arriving in separate tasks. Asset loads, watcher reloads and nested asset instantiation all arrive that way. Forty updates in forty separate tasks cost 135.7 ms of React work and 511 ms of total JavaScript.
Two, the material flattened once per property
flattenMaterialrecurses the whole material and builds a freshMap. Calling it inside the loop walks the material once per property compared. A material with P properties is walked 2P times instead of twice.The audit called this real but cheap. That was also wrong. The audit's synthetic materials put their properties under
extras, andflattenMaterialskipsextrasat the top level:So the audit benchmarked a loop that never ran. With properties where a real material carries them, 20 materials with 64 properties took 143.94 ms, and with 256 properties 2198.89 ms. That is over two seconds blocking a save.
The fix
Coalesce scene updates to one
stateChangeper animation frame, keyed on a single pending frame handle.Cancel that pending frame in
dispose(), so a torn down manager cannot dispatch.Leave every other caller of
changed()immediate. Status text, save completion and play transitions are one off, not per object.Hoist the two
flattenMaterialcalls out of the property loop intooldFlatandnewFlat.The risk trade
The coalescing costs one frame of latency on the editor's reaction to a scene change. The change itself is applied immediately; only the React re-render waits. A drag already renders at frame rate, so nothing visible moves later than it did.
The failure mode worth naming is coalescing that drops rather than delays. A test covers exactly that: after a single coalesced update, Save Scene must become enabled.
The alternative considered and rejected: move the coalescing inside
changed()so every caller benefits. Rejected because status and lifecycle transitions are already one per event, so it would add a frame of latency to messages like "Scene saved" for no gain.The
flattenMaterialhoist has no behaviour to trade. The output is identical, which two of the four new tests assert directly.On why these ship together: the owner asked for fewer PRs, and this is one two line change plus one seven line change, both removing repeated work on an editor path, both with no behaviour change. If a reviewer prefers them split, the two commits are separable by file with no conflict.
Tests
packages/editor/test/editor/scene-update-coalescing.spec.ts, 2 playwright cases.packages/kite3d/test/scene-diff.test.ts, 4 vitest cases. They pass a material wrapped in aProxythat counts key enumerations, so the assertion is a call count and not a timing.One honest note on method. The first run of the editor tests passed against the unfixed source, because
runDevserves the builtpackages/editor/distand the revert had not been rebuilt. Rebuilding after the revert produced the real failure, 20 against 1. Every before number below comes from a rebuilt bundle.Measured, same build on both sides, profiling project of 2,405 nodes, headless Chromium with swiftshader:
Full CI mirrored locally on this branch: build, typecheck, lint,
test:kite3d13 passed,test:editor10 passed,test:runtime1 passed.Deploy
The coalescing ships in
@kite3d/editor, which the dev server serves. The hoist ships inkite3d, the CLI and dev server package. Neither touches@kite3d/engine, so published games are unaffected.Then the owner's usual release. Users pick it up when they bump
kite3d.Rollback: revert this commit and release again. No data change, no format change, no migration. The scene journal keeps writing the same lines it wrote before.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T623SnndzSVwCSmuQrVRj2