From 910d112a524898683210481483abbda707534a08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 16:49:06 -0800 Subject: [PATCH] fix(web): expanding hidden diff context works after a diff refresh A diff refresh re-parses an unchanged patch into new objects with the same cacheKey. Pierre keeps rendering the object it already has, but applied the file contents loaded for an expand only when the item's object was that exact one. After a refresh plus anything that bumps the item version (unfolding the file, a comment), the two differ, so the loaded contents were dropped and the expand control did nothing. The patch now matches the pending contents to the item by cacheKey, the same rule Pierre uses to decide the diff is unchanged. Co-Authored-By: Claude Fable 5.2 --- .../codeViewContextExpansion.browser.test.ts | 59 +++++++++++++++++++ patches/@pierre__diffs@1.7.0-beta.3.patch | 16 ++++- pnpm-lock.yaml | 10 ++-- 3 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/diffs/codeViewContextExpansion.browser.test.ts diff --git a/apps/web/src/components/diffs/codeViewContextExpansion.browser.test.ts b/apps/web/src/components/diffs/codeViewContextExpansion.browser.test.ts new file mode 100644 index 0000000000000..0abdbd9322dc7 --- /dev/null +++ b/apps/web/src/components/diffs/codeViewContextExpansion.browser.test.ts @@ -0,0 +1,59 @@ +import { CodeView, DEFAULT_THEMES, parsePatchFiles, preloadHighlighter } from "@pierre/diffs"; +import { expect, it, vi } from "vite-plus/test"; + +const OLD_LINES = Array.from({ length: 60 }, (_, index) => `line ${index + 1}`); +const NEW_LINES = OLD_LINES.with(0, "changed 1").with(59, "changed 60"); +const PATCH = `diff --git a/notes.txt b/notes.txt +--- a/notes.txt ++++ b/notes.txt +@@ -1,4 +1,4 @@ +-line 1 ++changed 1 + line 2 + line 3 + line 4 +@@ -57,4 +57,4 @@ + line 57 + line 58 + line 59 +-line 60 ++changed 60 +`; + +function parsePartialDiff() { + return parsePatchFiles(PATCH, { cacheKeyPrefix: "notes" })[0]!.files[0]!; +} + +// A diff refresh re-parses an unchanged patch into a new object with the same cacheKey. +// Upstream keeps rendering the old object and then discards the file contents loaded for it. +it("expands hidden context after the host re-supplies an unchanged partial diff", async () => { + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["text"] }); + const root = document.createElement("div"); + root.style.cssText = "width: 800px; height: 600px; overflow: auto;"; + document.body.append(root); + const view = new CodeView({ + diffStyle: "unified", + loadDiffFiles: async () => ({ + oldFile: { name: "notes.txt", contents: `${OLD_LINES.join("\n")}\n` }, + newFile: { name: "notes.txt", contents: `${NEW_LINES.join("\n")}\n` }, + }), + }); + using _view = { + [Symbol.dispose]: () => { + view.cleanUp(); + root.remove(); + }, + }; + view.setup(root); + const shadowRoot = () => root.querySelector("diffs-container")?.shadowRoot; + const lines = () => + Array.from(shadowRoot()?.querySelectorAll("[data-line]") ?? [], (line) => line.textContent); + + view.setItems([{ id: "notes", type: "diff", fileDiff: parsePartialDiff(), version: 1 }]); + await vi.waitFor(() => expect(lines()).toContain("line 57")); + view.setItems([{ id: "notes", type: "diff", fileDiff: parsePartialDiff(), version: 2 }]); + + shadowRoot()!.querySelector("[data-expand-button]")!.click(); + + await vi.waitFor(() => expect(lines()).toContain("line 30")); +}); diff --git a/patches/@pierre__diffs@1.7.0-beta.3.patch b/patches/@pierre__diffs@1.7.0-beta.3.patch index 30ad06bd76e93..8ae33350d70d2 100644 --- a/patches/@pierre__diffs@1.7.0-beta.3.patch +++ b/patches/@pierre__diffs@1.7.0-beta.3.patch @@ -218,10 +218,20 @@ index 734ff9862cdfe65b8082599c6c221c4886436e01..f18798a3d56dd0749550b5aff62a06fb } catch (error) { if (disableErrorHandling) throw error; diff --git a/dist/components/VirtualizedFileDiff.js b/dist/components/VirtualizedFileDiff.js -index 984f049ff3694dd353fa6637b0f09fc7dce74992..8f659c06b70ba603f725cccdcaebe89c21b00f6b 100644 +index 984f049ff3694dd353fa6637b0f09fc7dce74992..4d6599905be0fe7b5b5c1a225c0b3ff8d810c72a 100644 --- a/dist/components/VirtualizedFileDiff.js +++ b/dist/components/VirtualizedFileDiff.js -@@ -626,7 +626,7 @@ var VirtualizedFileDiff = class extends FileDiff { +@@ -264,7 +264,8 @@ var VirtualizedFileDiff = class extends FileDiff { + const { pendingExpansions, pendingHydratedDiff, options: { collapsed = false } } = this; + if (pendingHydratedDiff != null) { + this.pendingHydratedDiff = void 0; +- if (pendingHydratedDiff.expectedDiff === fileDiff) { ++ // T3 patch: a host may re-supply an unchanged diff as a new object; it is still this diff. ++ if (areDiffTargetsEqual(pendingHydratedDiff.expectedDiff, fileDiff)) { + Object.assign(fileDiff, pendingHydratedDiff.nextDiff); + this.setHydratedState(pendingHydratedDiff.files); + this.installHydratedDiff(fileDiff); +@@ -626,7 +627,7 @@ var VirtualizedFileDiff = class extends FileDiff { rerender() { if (!this.enabled || this.getInputDiff() == null && this.additionFile == null && this.deletionFile == null) return; const workingDiff = this.getWorkingDiff(); @@ -230,7 +240,7 @@ index 984f049ff3694dd353fa6637b0f09fc7dce74992..8f659c06b70ba603f725cccdcaebe89c if (this.isSimpleMode()) this.pendingRender = void 0; this.forceRenderOverride = true; if (this.isSimpleMode() && this.requestRender()) return; -@@ -790,7 +790,7 @@ var VirtualizedFileDiff = class extends FileDiff { +@@ -790,7 +791,7 @@ var VirtualizedFileDiff = class extends FileDiff { const previousRenderedDiff = this.getRenderedDiff(); const { pendingRender } = this; const previousLayoutDiff = pendingRender?.diff ?? previousRenderedDiff; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72442ea5f6440..efd4f84496318 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,7 +361,7 @@ patchedDependencies: '@ff-labs/fff-node@0.11.0': cc71fcec015f4d39bc96ae2e2bf6b0ca678124c722c66a7841efce9d34b5983c '@legendapp/list@3.7.2': 3c68607b8bb7a531873bcbe0b197d163ef1fdb4d4cf52a4ddf9d2b3804671658 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 - '@pierre/diffs@1.7.0-beta.3': b927b5049250ce93163cd44b03d9d22d16b484a8aae74ae1664973c6aa8060e4 + '@pierre/diffs@1.7.0-beta.3': aea143f4a2e59744371219c4e7ae675ec739c9ba16f1732e2f11748b496ea05e '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': 6872ba71b9847c5a0210b1b896923dadc3860faedfe28d9f062bcedfe786ef1d '@react-navigation/native-stack@7.21.0': 990d575444c4af2ce8218b3b7a8280fe244c03abbdb32b822a2e862f791dbf42 @@ -549,7 +549,7 @@ importers: version: 0.3.0 '@pierre/diffs': specifier: 1.7.0-beta.3 - version: 1.7.0-beta.3(patch_hash=b927b5049250ce93163cd44b03d9d22d16b484a8aae74ae1664973c6aa8060e4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.7.0-beta.3(patch_hash=aea143f4a2e59744371219c4e7ae675ec739c9ba16f1732e2f11748b496ea05e)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@pierre/highlights': specifier: 'catalog:' version: 0.5.0 @@ -1061,7 +1061,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 1.7.0-beta.3 - version: 1.7.0-beta.3(patch_hash=b927b5049250ce93163cd44b03d9d22d16b484a8aae74ae1664973c6aa8060e4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.7.0-beta.3(patch_hash=aea143f4a2e59744371219c4e7ae675ec739c9ba16f1732e2f11748b496ea05e)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -1480,7 +1480,7 @@ importers: dependencies: '@pierre/diffs': specifier: 1.7.0-beta.3 - version: 1.7.0-beta.3(patch_hash=b927b5049250ce93163cd44b03d9d22d16b484a8aae74ae1664973c6aa8060e4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.7.0-beta.3(patch_hash=aea143f4a2e59744371219c4e7ae675ec739c9ba16f1732e2f11748b496ea05e)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@t3tools/contracts': specifier: workspace:* version: link:../contracts @@ -15392,7 +15392,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.7.0-beta.3(patch_hash=b927b5049250ce93163cd44b03d9d22d16b484a8aae74ae1664973c6aa8060e4)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.7.0-beta.3(patch_hash=aea143f4a2e59744371219c4e7ae675ec739c9ba16f1732e2f11748b496ea05e)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@pierre/highlights': 0.5.0 '@pierre/patches': 0.0.1