Skip to content

Commit 3e110d6

Browse files
fix(studio): an Interfaces edit whose save was refused counts as unsaved, so leaving or reloading asks first (#29268)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb8BXXt1h8wKMh989eNnDojJ)_ Fixes #29113 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio「界面」里,报表、仪表盘、页面等条目的自动保存被服务端拒绝(如 422)后,这次修改现在算作「未保存」:刷新或关闭标签页、点顶栏的其它板块或首页、在左侧栏打开另一个条目、点「新建」,都会先弹出「有未保存的修改,确定离开吗?」。对象条目的列表视图修改被拒时同样处理。 - **为什么改**:以前被拒的修改仍显示在屏幕上,但所有离开确认都看不到它,刷新或切换后修改直接丢失、没有任何提示。 - **风险与回滚**:只在「最后一次保存被拒、且修改仍在」时才多问一次;保存成功、或打开另一个条目后自动清除,没改过的条目从不提示。没有新的元数据键或选项。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx` 的 `refusedUnsent` 与它上报的 `onDirtyChange` effect,以及 `doSave` 里三处 `setLeafRefusedFor`;`StudioDesignSurface.refusedLeafUnsaved-29113.test.tsx`。 - **两分钟验证**:Studio 打开 showcase 的「Hours by Status」报表,点「自定义」后改一下描述,并让这次保存返回 422(浏览器开发者工具拦截该 PUT);出现红色拒绝提示后刷新页面,浏览器会弹出离开确认。 - **需要你做的**:无,席位落地。 ## What changes - **The Interfaces pillar reports a refused leaf edit as unsaved.** `InterfacesPillar` mirrored only `navDirty` through `onDirtyChange`, so the surface's `beforeunload`, its header leave guard and the shell seam (`useRegisterUnsavedEdits`) never saw a leaf edit whose save the server refused. Such an edit stays in the buffer, and `useDraftAutoSave` sends it again only after the next edit. The pillar now keeps `leafRefusedFor`, the leaf key of the last refused leaf save, set on each of `doSave`'s three failure exits (the refused save itself, plus the customize door's refusal and its no-version answer) and cleared by a save that lands or by a buffer a load installs. `refusedUnsent` is true while the open leaf is dirty, its own buffer is loaded (`draftLoaded`), and that key is the open leaf's. The same reading covers an `object` leaf's list-view buffer, whose `failure` is already kept by `useObjectListViewDraft` (`dirty && failure.during === 'save'`). The mirror now reports `navDirty || refusedUnsent`. - **The rail and New ask before another leaf replaces a refused edit.** `pickSurface` and `openCreate` asked only for a held edit (`heldUnsent`). They now ask for `heldUnsent || refusedUnsent`, the same way the list-view switcher already asks for a refused view edit. - **`heldEdit` keeps its meaning.** The surface's held-clause state, which the Publish refusal toast reads, is untouched. A refused edit is unsent but not held, so it does not block Publish. ## Tests - `StudioDesignSurface.refusedLeafUnsaved-29113.test.tsx` (new). The report cases render the real `StudioDesignSurface` on the real route shape, with a stub report canvas and a client double that answers a draft save with a 422 while asked to. - A refused report save makes a reload ask (`beforeunload` is prevented) and the Data pillar link ask. The next edit's save lands, and then neither asks. - The rail asks before another report replaces the refused edit. Re-picking the open report asks nothing. OK opens the other report, and the guard is clear. - CONTROL: an untouched report, and an edit whose save lands, never ask (reload, rail, pillar link). - An `object` leaf's list-view edit refused with a 422 makes the pillar report dirty, and a later save that lands reports clean (real list-view panel, `onDirtyChange` read directly). ## Acceptance notes - Leaf editors measured: the report, dashboard and page canvases (and every other leaf with a registered preview and no studio canvas) save through the one leaf buffer (`draft`, `doSave`). An `object` leaf's list view saves through its own buffer (`useObjectListViewDraft`), and nav edits through the app buffer (`appDraft`), which `navDirty` already covered. `refusedUnsent` covers the first two, so one dirty report covers all three buffers. - A transport failure (no HTTP answer) leaves the same edit unsent, so it counts too. The reading is "the last save did not land", not "the answer was 422". - **Deviation from the claim's surface ("InterfacesPillar and its dirty mirror only"), declared:** besides the mirror, the rail's `pickSurface` and the New dialog's `openCreate` now ask on a refused edit too (they asked only on a held one). Both live inside `InterfacesPillar`; no other file or component changed. Without them, opening another item in the rail still dropped the refused edit silently, which the dispatch scope ("beforeunload, the header guard and the rail treat it as unsaved") covers. ## Verification Measured on `d6b4e8a1` (base `origin/main` `10bfd70e`). Head `d45ce04c` merges `origin/main` `74a19dd8`, which does not touch `StudioDesignSurface.tsx`; the delta against main is still exactly the three files above. On `d45ce04c` the new file and the merged-in `StudioDesignSurface.automationsReadOnly-11124.test.tsx` were rerun (2 files, 15/15), the derived gate list is unchanged (34), and `check-sdui-manifest`, `check-changeset-no-major` and `check-empty-changeset` were rerun (exit 0). <details><summary>New tests, the base run, and the ablation</summary> - `vitest run StudioDesignSurface.refusedLeafUnsaved-29113.test.tsx`: 4/4 passed. - Base leg (the file at `10bfd70e` written over the source, restored from `HEAD` by trap, blob back to `30a4c402` = HEAD): 3 failed, 1 passed. The three are the report reload/pillar-link case, the rail case and the list-view case. The control passes. - Ablation with `scripts/ablation-replace.mjs`, replacing `onDirtyChangeRef.current?.(navDirty || refusedUnsent);` with `onDirtyChangeRef.current?.(navDirty);` (anchor 1 to 0, blob `30a4c402` to `592451e1`): 2 failed (the report reload/pillar-link case and the list-view case), 2 passed (the rail case, which reads `leafUnsent` directly, and the control). Restored: blob equals HEAD and `git diff HEAD` is empty. </details> <details><summary>Real browser: showcase Studio, a report save forced to 422</summary> `examples/app-showcase` (`objectstack dev --seed-admin --fresh`) with the Console's Vite dev server on app-shell source, Playwright (Chromium). Opened Interfaces, then report `showcase_hours_by_status`, pressed Customize, and typed in Description. The probe answered the report's draft PUT (`/api/v1/meta/report/showcase_hours_by_status?mode=draft&package=com.example.showcase.custom`) with a 422 while asked to. - Before (base `StudioDesignSurface.tsx` served): the red refusal strip showed, a dispatched `beforeunload` was not prevented, and the Data pillar link left the pillar with no confirm. The edit was dropped silently. - After (this head): the strip showed, `beforeunload` was prevented, and the Data link raised the native confirm "You have unsaved changes. Leave this page anyway?" (dismissed, so the pillar stayed). The next edit's PUT was let through and landed: the strip cleared and `beforeunload` was no longer prevented. Untouched, nothing asked. </details> <details><summary>Sweep, type-check and gates</summary> - Neighbour sweep under the verify lock: every app-shell test file that names `InterfacesPillar` or the `/interfaces` route and reads a confirm, `beforeunload`, `onDirtyChange`, a refusal or a save (73 files, two batches): 73 passed, 528 tests passed. - `turbo run type-check --filter=@object-ui/app-shell`: 65/65 tasks, exit 0. - `dispatch-gates --commands`: 34 derived families, all exit 0, including `check-sdui-manifest` and `pnpm --dir ui exec vitest run scripts/` (169 files passed, 2 skipped). `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED. `check-closing-keyword-parity`, `check-changeset-no-major` and `check-adr-0087-registration` were also run against this body. - `pnpm check:new-line-citations` (ui): 0 new citations. - Narrowing: the whole app-shell vitest suite is CI's Test UI; it was not run locally. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSfsyajgxceM7R1qi6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c93af7 commit 3e110d6

3 files changed

Lines changed: 442 additions & 9 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(studio): an Interfaces edit whose save the server refused counts as unsaved, so leaving or reloading asks first
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
When the server refused a report's, dashboard's or page's autosave (a 422, say), the edit stayed on screen but Studio's leave guards did not see it: a reload, a pillar or Home link, or opening another item in the Interfaces rail dropped it with no question. The Interfaces pillar now reports such an edit as unsaved work beside its navigation edits, so the browser's "leave site?" prompt, the Studio header and the shell's link guard ask, and the rail and the New dialog ask before another item replaces it. The same holds for an object item's list-view edit whose save was refused. An edit whose save lands, or the load of another item, clears it; an item you have not edited never asks.

0 commit comments

Comments
 (0)