| 1 | // Run: node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/file-navigation-lifecycle.test.tsx |
| 2 | // |
| 3 | // The historical defect was a request object rebuilt during render and merged |
| 4 | // into the panel's props: every render produced a new object, the panel wrote |
| 5 | // state from it, and the pair looped until React reported #301. This drives the |
| 6 | // real dock chain and asserts the replacement behaves: a navigation happens |
| 7 | // once, re-renders neither navigate nor read, and a StrictMode mount does not |
| 8 | // double a read. |
| 9 | |
| 10 | import assert from "node:assert/strict"; |
| 11 | import React, { StrictMode, act } from "react"; |
| 12 | import { renderFilesWorkspace, waitFor, flushPromises } from "./workspace-panel-test-harness"; |
| 13 | import { WorkspaceDockRegion, type WorkspaceDockRegionProps } from "../app-shell/WorkspaceDockRegion"; |
| 14 | import { PresentedFiles } from "../components/PresentedFiles"; |
| 15 | import { LocaleProvider } from "../lib/i18n"; |
| 16 | import { performResourceAction } from "../lib/fileNavigationCommands"; |
| 17 | import { fileNavigationOwner } from "../lib/fileNavigationCommands"; |
| 18 | import { fileNavigationKey } from "../lib/fileNavigationOwner"; |
| 19 | import { useActivityBarStore } from "../store/activityBar"; |
| 20 | |
| 21 | const reads: string[] = []; |
| 22 | const preview = (path: string) => { |
| 23 | reads.push(path); |
| 24 | return { path, body: `CONTENT ${path}`, size: 12, truncated: false, binary: false }; |
| 25 | }; |
| 26 | const { dom, root, dockTabId } = await renderFilesWorkspace({ |
| 27 | ReadFileForTab: async (_tab, path) => preview(path), |
| 28 | ReadPresentedFileForTab: async (_tab, _tool, path) => preview(path), |
| 29 | ReadPresentedFileSourceForTab: async (_tab, _tool, path) => preview(path), |
| 30 | }); |
| 31 | const props: WorkspaceDockRegionProps = { |
| 32 | visible: true, overlay: false, mode: "files", showContext: false, |
| 33 | t: key => key, onPickEntry: () => {}, remote: { onClose: () => {} }, context: {} as WorkspaceDockRegionProps["context"], |
| 34 | workspace: { open: true, tabId: "tab-a", cwd: "/repo", maximized: false, onClose: () => {}, onToggleMaximized: () => {} }, |
| 35 | workspaceKey: "lifecycle", workspaceRoot: "/repo", fileNavigation: fileNavigationOwner(), |
| 36 | }; |
| 37 | let paints = 0; |
| 38 | function Lifecycle({ generation }: { generation: number }) { |
| 39 | paints += 1; |
| 40 | return <> |
| 41 | <PresentedFiles tabId="tab-a" hostId="local" files={[{ path: "app.ts", toolCallId: "call", description: "d" }]} /> |
| 42 | <span data-generation={generation} /> |
| 43 | <WorkspaceDockRegion {...props} /> |
| 44 | </>; |
| 45 | } |
| 46 | const record = () => fileNavigationOwner().getSnapshot(fileNavigationKey({ sessionTabId: "tab-a", dockTabId })); |
| 47 | const paint = (generation: number) => act(async () => { |
| 48 | root.render(<StrictMode><LocaleProvider><Lifecycle generation={generation} /></LocaleProvider></StrictMode>); |
| 49 | await flushPromises(); |
| 50 | }); |
| 51 | |
| 52 | // StrictMode replays mount effects; the dock must still open the file once. |
| 53 | await paint(0); |
| 54 | await act(async () => { |
| 55 | document.querySelector<HTMLButtonElement>(".presented-file__main") |
| 56 | ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); |
| 57 | await flushPromises(); |
| 58 | }); |
| 59 | await waitFor("presented preview", () => document.body.textContent?.includes("CONTENT app.ts") === true); |
| 60 | const afterOpen = record()!; |
| 61 | assert.equal(afterOpen.selected?.resource.path, "app.ts"); |
| 62 | assert.equal(reads.length, 1, `one command reads the file once, got ${JSON.stringify(reads)}`); |
| 63 | assert.equal(afterOpen.navigation?.revision, 1, "one command is one navigation revision"); |
| 64 | |
| 65 | // Repeated parent renders must not navigate, read again, or loop. StrictMode |
| 66 | // renders each commit twice, so the bound is two paints per driven render; a |
| 67 | // render loop would overshoot it by orders of magnitude. |
| 68 | const readsAfterOpen = reads.length; |
| 69 | const paintsAfterOpen = paints; |
| 70 | for (let generation = 1; generation <= 12; generation += 1) await paint(generation); |
| 71 | assert.equal(reads.length, readsAfterOpen, `parent re-renders must not read again, got ${JSON.stringify(reads)}`); |
| 72 | assert.equal(record()!.navigation?.revision, 1, "parent re-renders never advance a navigation revision"); |
| 73 | assert(paints - paintsAfterOpen <= 24, `twelve driven renders paint at most twice each, got ${paints - paintsAfterOpen}`); |
| 74 | |
| 75 | // A second click on the same row re-delivers the navigation but not the read. |
| 76 | await act(async () => { |
| 77 | document.querySelector<HTMLButtonElement>(".presented-file__main") |
| 78 | ?.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); |
| 79 | await flushPromises(); |
| 80 | }); |
| 81 | await waitFor("repeat preview", () => record()!.navigation!.revision === 2); |
| 82 | assert.equal(reads.length, readsAfterOpen, "reopening the same file in the same mode keeps the valid read"); |
| 83 | |
| 84 | // A remount restores the committed selection without replaying the command. |
| 85 | await act(async () => { root.render(<StrictMode><LocaleProvider><span /></LocaleProvider></StrictMode>); await flushPromises(); }); |
| 86 | const readsAfterUnmount = reads.length; |
| 87 | await paint(13); |
| 88 | await waitFor("restored preview", () => document.body.textContent?.includes("CONTENT app.ts") === true); |
| 89 | assert.equal(record()!.navigation?.revision, 2, "a remount restores the record instead of issuing a command"); |
| 90 | assert.equal(reads.length, readsAfterUnmount + 1, "a remount reads the restored selection exactly once"); |
| 91 | assert.equal(record()!.dockTabId, useActivityBarStore.getState().activeTabId); |
| 92 | |
| 93 | await act(async () => root.unmount()); |
| 94 | dom.window.close(); |
| 95 | console.log("PASS dock navigation is command-driven: no render-time requests, no re-render reads, no StrictMode replay"); |
| 96 |