authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 20:31:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 20:31:16-07:00
logb01771c2383bbcdf154b8c4e9f1a7d5e3867e2d0
tree789a6f9a5472d80efa28350e2a48029c7f79441f
parent363cde4114c2186c496ed97cafba4a8737ea5585
signature Signed by PGP key B5690EEEBB952194

fix(web): keep file autosaves active after effect replay (#9878)


3 files changed, 231 insertions(+), 35 deletions(-)

apps/web/src/components/files/FilePreviewPanel.tsx+1-35
......@@ -47,7 +47,6 @@ import { buildFileReviewComment } from "~/reviewCommentContext";
4747import { assetEnvironment } from "~/state/assets";
4848import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments";
4949import { previewEnvironment } from "~/state/preview";
50import { projectEnvironment } from "~/state/projects";
5150import { useAtomCommand } from "~/state/use-atom-command";
5251import { useAtomQueryRunner } from "~/state/use-atom-query-runner";
5352
......@@ -72,9 +71,8 @@ import {
7271 setMarkdownTaskChecked,
7372 shouldShowFileExplorer,
7473} from "./filePreviewMode";
75import { FileSaveCoordinator } from "./fileSaveCoordinator";
74import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
7675import {
77 confirmProjectFileQueryData,
7876 getOptimisticProjectFileQueryData,
7977 setProjectFileQueryData,
8078 useProjectFileQuery,
......@@ -101,7 +99,6 @@ interface FilePreviewPanelProps {
10199const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen";
102100const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown";
103101const RENDER_BROWSER_FILE_STORAGE_KEY = "t3code.renderBrowserFile";
104const FILE_SAVE_DEBOUNCE_MS = 500;
105102const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal";
106103const FILE_LINK_REVEAL_UNSAFE_CSS = `
107104 ${DIFF_SURFACE_THEME_UNSAFE_CSS}
......@@ -613,37 +610,6 @@ interface FileSelectionOverride {
613610 range: SelectedLineRange | null;
614611}
615612
616function useFileSaveCoordinator({
617 environmentId,
618 cwd,
619 relativePath,
620 onPendingChange,
621}: Pick<
622 EditableFileSurfaceProps,
623 "environmentId" | "cwd" | "relativePath" | "onPendingChange"
624>): FileSaveCoordinator {
625 const writeFile = useAtomCommand(projectEnvironment.writeFile);
626 const coordinator = useMemo(
627 () =>
628 new FileSaveCoordinator({
629 debounceMs: FILE_SAVE_DEBOUNCE_MS,
630 onPendingChange: (pending) => onPendingChange(relativePath, pending),
631 persist: (nextContents) =>
632 writeFile({
633 environmentId,
634 input: { cwd, relativePath, contents: nextContents },
635 }),
636 onConfirmed: (confirmedContents) => {
637 confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents);
638 },
639 }),
640 [cwd, environmentId, onPendingChange, relativePath, writeFile],
641 );
642
643 useEffect(() => () => coordinator.dispose(), [coordinator]);
644 return coordinator;
645}
646
647613function EditableFileSurface({
648614 environmentId,
649615 cwd,
apps/web/src/components/files/useFileSaveCoordinator.test.tsx created+174
......@@ -0,0 +1,174 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import { AsyncResult } from "effect/unstable/reactivity";
3import { act, StrictMode } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7const { writeFile, confirmFile } = vi.hoisted(() => ({
8 writeFile: vi.fn(),
9 confirmFile: vi.fn(),
10}));
11vi.mock("~/state/projects", () => ({ projectEnvironment: { writeFile: {} } }));
12vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => writeFile }));
13vi.mock("./projectFilesQueryState", () => ({ confirmProjectFileQueryData: confirmFile }));
14
15import { setMarkdownTaskChecked } from "./filePreviewMode";
16import { useFileSaveCoordinator } from "./useFileSaveCoordinator";
17
18const environmentId = EnvironmentId.make("save-lifecycle-audit");
19const onPendingChange = vi.fn();
20const defaultProps = {
21 environmentId,
22 cwd: "/workspace",
23 relativePath: "file.txt",
24 onPendingChange,
25};
26let renderer: ReactTestRenderer | null;
27
28function ChangeSource(_props: { onChange: (contents: string) => void }) {
29 return null;
30}
31
32function FileSurface(props: Parameters<typeof useFileSaveCoordinator>[0]) {
33 const coordinator = useFileSaveCoordinator(props);
34 return <ChangeSource onChange={(contents) => coordinator.change(contents)} />;
35}
36
37function mount(props = defaultProps) {
38 act(() => {
39 renderer = create(
40 <StrictMode>
41 <FileSurface {...props} />
42 </StrictMode>,
43 );
44 });
45}
46
47function changeHandler(): (contents: string) => void {
48 return renderer!.root.findByType(ChangeSource).props.onChange;
49}
50
51beforeEach(() => {
52 renderer = null;
53 vi.useFakeTimers();
54 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
55 writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined));
56 confirmFile.mockReset();
57 onPendingChange.mockReset();
58});
59
60afterEach(async () => {
61 await act(async () => renderer?.unmount());
62 vi.useRealTimers();
63 vi.unstubAllGlobals();
64});
65
66describe("file-save React lifecycle", () => {
67 it("persists editor model changes after StrictMode setup replay", async () => {
68 mount();
69 changeHandler()("AUDIT7907NATIVE\n");
70 expect(onPendingChange).toHaveBeenCalledWith("file.txt", true);
71 await vi.advanceTimersByTimeAsync(500);
72 expect(writeFile).toHaveBeenCalledExactlyOnceWith({
73 environmentId,
74 input: { cwd: "/workspace", relativePath: "file.txt", contents: "AUDIT7907NATIVE\n" },
75 });
76 expect(confirmFile).toHaveBeenCalledExactlyOnceWith(
77 environmentId,
78 "/workspace",
79 "file.txt",
80 "AUDIT7907NATIVE\n",
81 );
82 expect(onPendingChange).toHaveBeenLastCalledWith("file.txt", false);
83 });
84
85 it("persists rendered Markdown task changes after StrictMode setup replay", async () => {
86 mount({ ...defaultProps, relativePath: "README.md" });
87 const nextContents = setMarkdownTaskChecked("- [ ] task\n", 2, true);
88 changeHandler()(nextContents);
89 await vi.advanceTimersByTimeAsync(500);
90 expect(writeFile).toHaveBeenCalledExactlyOnceWith({
91 environmentId,
92 input: { cwd: "/workspace", relativePath: "README.md", contents: "- [x] task\n" },
93 });
94 });
95
96 it("keeps the debounce across rerenders of the same file", async () => {
97 mount();
98 changeHandler()("first");
99 await vi.advanceTimersByTimeAsync(300);
100 act(() =>
101 renderer!.update(
102 <StrictMode>
103 <FileSurface {...defaultProps} />
104 </StrictMode>,
105 ),
106 );
107 changeHandler()("latest");
108 await vi.advanceTimersByTimeAsync(499);
109 expect(writeFile).not.toHaveBeenCalled();
110 await vi.advanceTimersByTimeAsync(1);
111 expect(writeFile).toHaveBeenCalledTimes(1);
112 expect(writeFile.mock.calls[0]![0].input.contents).toBe("latest");
113 });
114
115 it("flushes on unmount and ignores a retired editor callback", async () => {
116 mount();
117 const retiredChange = changeHandler();
118 retiredChange("pending edit");
119 await act(async () => renderer!.unmount());
120 renderer = null;
121 retiredChange("stale editor contents");
122 await vi.runAllTimersAsync();
123 expect(writeFile).toHaveBeenCalledTimes(1);
124 expect(writeFile.mock.calls[0]![0].input.contents).toBe("pending edit");
125 });
126
127 it.each([
128 { relativePath: "other.txt" },
129 { cwd: "/other-workspace" },
130 { environmentId: EnvironmentId.make("other-environment") },
131 ])("retires callbacks when the file identity changes: %j", async (change) => {
132 mount();
133 const retiredChange = changeHandler();
134 retiredChange("old file edit");
135 const nextProps = { ...defaultProps, ...change };
136 act(() =>
137 renderer!.update(
138 <StrictMode>
139 <FileSurface {...nextProps} />
140 </StrictMode>,
141 ),
142 );
143 retiredChange("stale editor contents");
144 changeHandler()("new file edit");
145 await vi.runAllTimersAsync();
146 expect(writeFile.mock.calls.map(([request]) => request)).toEqual([
147 {
148 environmentId,
149 input: { cwd: "/workspace", relativePath: "file.txt", contents: "old file edit" },
150 },
151 {
152 environmentId: nextProps.environmentId,
153 input: {
154 cwd: nextProps.cwd,
155 relativePath: nextProps.relativePath,
156 contents: "new file edit",
157 },
158 },
159 ]);
160 });
161
162 it("does not reactivate a retired callback when the same file mounts again", async () => {
163 mount();
164 const retiredChange = changeHandler();
165 await act(async () => renderer!.unmount());
166 renderer = null;
167 mount();
168 retiredChange("stale contents");
169 changeHandler()("current contents");
170 await vi.runAllTimersAsync();
171 expect(writeFile).toHaveBeenCalledTimes(1);
172 expect(writeFile.mock.calls[0]![0].input.contents).toBe("current contents");
173 });
174});
apps/web/src/components/files/useFileSaveCoordinator.ts created+56
......@@ -0,0 +1,56 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { createRef, useEffect, useMemo } from "react";
3
4import { projectEnvironment } from "~/state/projects";
5import { useAtomCommand } from "~/state/use-atom-command";
6
7import { FileSaveCoordinator } from "./fileSaveCoordinator";
8import { confirmProjectFileQueryData } from "./projectFilesQueryState";
9
10const FILE_SAVE_DEBOUNCE_MS = 500;
11
12interface FileSaveOptions {
13 environmentId: EnvironmentId;
14 cwd: string;
15 relativePath: string;
16 onPendingChange: (relativePath: string, pending: boolean) => void;
17}
18
19export function useFileSaveCoordinator({
20 environmentId,
21 cwd,
22 relativePath,
23 onPendingChange,
24}: FileSaveOptions): Pick<FileSaveCoordinator, "change"> {
25 const writeFile = useAtomCommand(projectEnvironment.writeFile);
26 const session = useMemo(() => {
27 const coordinatorRef = createRef<FileSaveCoordinator>();
28 return {
29 change: (contents: string) => coordinatorRef.current?.change(contents),
30 setup: () => {
31 const coordinator = new FileSaveCoordinator({
32 debounceMs: FILE_SAVE_DEBOUNCE_MS,
33 onPendingChange: (pending) => onPendingChange(relativePath, pending),
34 persist: (nextContents) =>
35 writeFile({
36 environmentId,
37 input: { cwd, relativePath, contents: nextContents },
38 }),
39 onConfirmed: (confirmedContents) => {
40 confirmProjectFileQueryData(environmentId, cwd, relativePath, confirmedContents);
41 },
42 });
43 coordinatorRef.current = coordinator;
44 return () => {
45 coordinatorRef.current = null;
46 coordinator.dispose();
47 };
48 },
49 };
50 }, [cwd, environmentId, onPendingChange, relativePath, writeFile]);
51
52 // StrictMode replays effect setup. Retired file sessions stay inert, while the
53 // replay gets a fresh coordinator instead of reusing a disposed one.
54 useEffect(session.setup, [session]);
55 return session;
56}