From fbdb6efbb448e95db7a47657f859c70cff5f7768 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 16 Sep 2026 16:04:07 -0700 Subject: [PATCH] fix(editor): draw a newly opened document tab on its first paint A newly opened tab showed an empty viewport until the user switched to another tab and back. The centre strip renders only the active tab panel, so every tab change mounts a new canvas container. The effect that moves viewer.container into it depended on the container ref's current value, which React reads during render, one commit behind. DocumentStore.open renders twice, once for the new tab and once for its activation, so the first render ran the effect as a no-op and the second read the same stale value and skipped it. The canvas stayed in the detached panel. A plain tab switch renders once, which is why switching away and back repaired it. Fix: a ref callback keyed on the viewer, so the mounted node itself reports its mount and the container moves on every mount. Verification: headless Chromium, three runs of four document kinds with a fresh session each: first paint blank 9 of 12 before, 3 of 12 after, and the remaining three are a material document that draws nothing before and after and on any tab, a separate bug. The new packages/kite3d/test/editor-open-tab.test.ts fails without the fix. npm test -w packages/kite3d 7 pass; typecheck, lint, build:editor pass. Evidence under ~/games/terminator-evidence/docs/evidence/kite3d-open-tab/. Co-Authored-By: Claude Fable 5.1 --- .../src/components/ThreeEditorComponent.tsx | 23 +++-- packages/kite3d/test/editor-open-tab.test.ts | 97 +++++++++++++++++++ 2 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 packages/kite3d/test/editor-open-tab.test.ts diff --git a/packages/editor/src/components/ThreeEditorComponent.tsx b/packages/editor/src/components/ThreeEditorComponent.tsx index 20de1550..e944a03c 100644 --- a/packages/editor/src/components/ThreeEditorComponent.tsx +++ b/packages/editor/src/components/ThreeEditorComponent.tsx @@ -1,4 +1,4 @@ -import React, {useContext, useEffect, useMemo, useReducer, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useReducer, useState} from 'react'; import {isPackageProject} from '../utils/projectUtils.ts' import {BlueprintJsUiPlugin2} from '../UiConfigRendererBlueprint2.tsx' import { @@ -193,15 +193,18 @@ export function ThreeEditorComponent() { // manager.features.refresh(editorMode) // }, []); - const canvasContainer = useRef(null) - // add viewer.container to canvasContainer when it changes - useEffect(() => { - if(canvasContainer.current && viewer && viewer.container.parentElement !== canvasContainer.current){ - canvasContainer.current.innerHTML = '' - canvasContainer.current.appendChild(viewer.container) - viewer.resize() - } - }, [canvasContainer.current, viewer]) + // The centre strip draws the active tab's panel alone, so every switch, and every tab an open + // adds, mounts a new empty container. React hands the node to this callback on the commit that + // mounts it, which is the only moment that is always right: a dependency list reads + // `ref.current` while it still holds the node of the last commit, and an open renders twice, so + // the second render, the one that swaps the panel, reads an unchanged value and the canvas + // stays behind in the panel that left. + const canvasContainer = useCallback((node: HTMLDivElement | null) => { + if(!node || !viewer || viewer.container.parentElement === node) return + node.innerHTML = '' + node.appendChild(viewer.container) + viewer.resize() + }, [viewer]) return !uiConfigRenderer || !viewer ? null : ( diff --git a/packages/kite3d/test/editor-open-tab.test.ts b/packages/kite3d/test/editor-open-tab.test.ts new file mode 100644 index 00000000..1858d1d0 --- /dev/null +++ b/packages/kite3d/test/editor-open-tab.test.ts @@ -0,0 +1,97 @@ +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {resolve} from 'node:path' +import {afterEach, expect, it} from 'vitest' +import {createDevServer} from '../src/server.ts' +import {KITE3D_VERSION} from '../src/versions.ts' + +const cleanup: Array<() => Promise> = [] + +afterEach(async () => { + while (cleanup.length) await cleanup.pop()!() +}) + +// Guards the owner's report: "newly opened tabs viewport remain empty until i switch to another +// viewport tab and then switch back to the newly opened one". The centre strip draws the active +// tab's panel alone, so the tab an open adds mounts a new canvas container, and the viewer's canvas +// has to move into it on that same commit. +it('leaves the viewer canvas mounted in the tab an open adds', async (context) => { + const chromium = await browserLauncher() + if (!chromium) { + context.skip('Playwright is not installed. Run npx playwright install chromium.') + return + } + const root = await temporaryProject() + const server = await createDevServer({projectRoot: root, port: 0}) + cleanup.push(() => server.close()) + const browser = await chromium.launch({headless: true}) + cleanup.push(() => browser.close()) + + const page = await browser.newPage({viewport: {width: 1280, height: 800}}) + await page.goto(server.url, {waitUntil: 'domcontentloaded', timeout: 60_000}) + await page.waitForFunction('window.kite3dProjectLoaded === true', undefined, {timeout: 60_000}) + await page.waitForSelector('.editorCanvasContainer canvas', {timeout: 30_000}) + expect(await viewportOnScreen(page)).toEqual({mounted: true, drawn: true}) + + // The Files panel double click, the way a user opens a file. + await page.dblclick('button.file-item-button[title="pixel.png"]', {timeout: 30_000}) + await page.waitForSelector('[role="tab"][aria-selected="true"] .document-tab-name:text-is("pixel.png")', { + timeout: 60_000, + }) + + // No tab switch, no second render: the canvas is in the panel that is on screen, or it is not. + expect(await viewportOnScreen(page)).toEqual({mounted: true, drawn: true}) +}) + +/** + * Whether the viewer's canvas is in the container the page shows, and has a size. A query finds + * mounted elements alone, so a canvas left behind in the panel that closed answers false. + */ +function viewportOnScreen(page: {evaluate: (script: string) => Promise}): Promise { + return page.evaluate(`(() => { + const mount = document.querySelector('.editorCanvasContainer') + const canvas = window.viewer.canvas + return {mounted: Boolean(mount && mount.contains(canvas)), drawn: canvas.clientWidth > 0} + })()`) +} + +async function browserLauncher() { + try { + const {chromium} = await import('playwright') + return chromium + } catch { + return undefined + } +} + +async function temporaryProject(): Promise { + const root = await mkdtemp(resolve(tmpdir(), 'kite3d-open-tab-')) + cleanup.push(() => rm(root, {recursive: true, force: true})) + await writeFile(resolve(root, 'package.json'), `${JSON.stringify({ + name: 'open-tab-test', + mainScene: 'assets/main.scene.gltf', + devDependencies: {'kite3d': KITE3D_VERSION}, + })}\n`) + await writeFile(resolve(root, 'assets.json'), '{"files":{},"version":1}\n') + await writeFile(resolve(root, 'main.js'), 'export async function main() {}\n') + // The second openable document. An image reads without the network, where an object or a + // material document fetches the preview environment before its first show settles. + await writeFile(resolve(root, 'pixel.png'), Buffer.from(ONE_PIXEL_PNG, 'base64')) + await mkdir(resolve(root, 'assets'), {recursive: true}) + await writeFile(resolve(root, 'assets/main.scene.gltf'), `${JSON.stringify({ + asset: {version: '2.0'}, + scene: 0, + scenes: [{nodes: [0]}], + nodes: [{name: 'Authored triangle', mesh: 0}], + meshes: [{primitives: [{attributes: {POSITION: 0}}]}], + accessors: [{bufferView: 0, componentType: 5126, count: 3, type: 'VEC3', min: [-1, -1, 0], max: [1, 1, 0]}], + bufferViews: [{buffer: 0, byteOffset: 0, byteLength: 36, target: 34962}], + buffers: [{byteLength: 36, uri: 'data:application/octet-stream;base64,AAAAAAAAgD8AAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAACAPwAAAAA='}], + })}\n`) + await mkdir(resolve(root, 'node_modules/@kite3d/engine/dist'), {recursive: true}) + await writeFile(resolve(root, 'node_modules/@kite3d/engine/package.json'), JSON.stringify({version: KITE3D_VERSION})) + await writeFile(resolve(root, 'node_modules/@kite3d/engine/dist/runtime.js'), 'installed runtime') + return root +} + +const ONE_PIXEL_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='