From f41950efc0f08ebafed15a66a06ca7b6a5da4c90 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:37:35 +0000 Subject: [PATCH 1/3] fix(studio): an uncommitted nav rename raises the held-edit guard Claude-Session: https://claude.ai/code/session_01L6bhgXU26UBEjd1KKHgNEx Co-authored-by: Claude --- .../metadata-admin/previews/AppNavCanvas.tsx | 36 +++++ ...DesignSurface.navRenameHeld-27464.test.tsx | 144 ++++++++++++++++++ .../studio-design/StudioDesignSurface.tsx | 9 +- ui/packages/i18n/src/namespaces/studio/en.ts | 2 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + 5 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx index 445106608ab..09d44a901ef 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/AppNavCanvas.tsx @@ -291,6 +291,14 @@ export interface AppNavCanvasProps { onPatch?: (patch: Record) => void; selection: { kind: string; id: string } | null; onSelectionChange?: (sel: { kind: string; id: string; label?: string } | null) => void; + /** + * objectstack#27464 — the label of the entry whose rename is typed but not + * yet committed (the input differs from the authored label), or `null`. A + * rename commits on blur or Enter, so a reload or a tab close before either + * drops it; a host reports it as an edit held unsent. Optional: a host with + * no departure guard leaves it out. + */ + onRenamePendingChange?: (label: string | null) => void; } export function AppNavCanvas({ @@ -299,6 +307,7 @@ export function AppNavCanvas({ onPatch, selection, onSelectionChange, + onRenamePendingChange, }: AppNavCanvasProps) { const locale = useMetadataLocale(); const targetLabel = useNavTargetLabel(); @@ -477,6 +486,7 @@ export function AppNavCanvas({ canEdit={!!onPatch} onClick={(p, lbl) => onSelectionChange?.({ kind: 'nav', id: p, label: lbl })} onRename={(lbl) => renameItem(i, lbl)} + onRenamePendingChange={onRenamePendingChange} onRemove={() => removeItem(i)} onDragStart={() => setDragIndex(i)} onDragEnd={() => setDragIndex(null)} @@ -504,6 +514,7 @@ function NavCardTree({ canEdit, onClick, onRename, + onRenamePendingChange, onRemove, onDragStart, onDragEnd, @@ -519,6 +530,7 @@ function NavCardTree({ canEdit: boolean; onClick: (path: string, label: string) => void; onRename: (label: string) => void; + onRenamePendingChange?: (label: string | null) => void; onRemove: () => void; onDragStart: () => void; onDragEnd: () => void; @@ -537,6 +549,7 @@ function NavCardTree({ canEdit={canEdit && depth === 0} onClick={() => onClick(path, navLabel(item, index, locale, targetLabel))} onRename={onRename} + onRenamePendingChange={onRenamePendingChange} onRemove={onRemove} onDragStart={onDragStart} onDragEnd={onDragEnd} @@ -580,6 +593,7 @@ function NavCard({ canEdit, onClick, onRename, + onRenamePendingChange, onRemove, onDragStart, onDragEnd, @@ -595,6 +609,7 @@ function NavCard({ canEdit: boolean; onClick: () => void; onRename: (label: string) => void; + onRenamePendingChange?: (label: string | null) => void; onRemove: () => void; onDragStart: () => void; onDragEnd: () => void; @@ -615,6 +630,27 @@ function NavCard({ if (!editing) setDraft(authored); }, [authored, editing]); + // objectstack#27464 — a typed rename that blur or Enter has not committed + // yet is reported, keyed on the label string (AGENTS.md #10). Only a card + // that has reported takes it back, so the other cards' mounts never clear it. + const pendingRename = editing && draft.trim() !== '' && draft.trim() !== authored ? label : null; + const reportRef = React.useRef(onRenamePendingChange); + React.useLayoutEffect(() => { + reportRef.current = onRenamePendingChange; + }); + const reportedRef = React.useRef(false); + React.useEffect(() => { + if (pendingRename === null && !reportedRef.current) return; + reportedRef.current = pendingRename !== null; + reportRef.current?.(pendingRename); + }, [pendingRename]); + React.useEffect( + () => () => { + if (reportedRef.current) reportRef.current?.(null); + }, + [], + ); + return (
{dropPos === 'before' && ( diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx new file mode 100644 index 00000000000..eba5fc8b5cc --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27464 — a navigation rename typed into the Interfaces nav editor + * but not committed yet (it commits on blur or Enter) is unsaved work: a reload + * or a tab close asks first, as it does for every other held Studio edit. + * + * The canvas reports the pending rename and the pillar folds it into the one + * held-edit report the surface already reads (`useHeldEditReport`), so the + * surface's single `beforeunload` guard covers it. Rendered through the REAL + * `StudioDesignSurface` on the real route shape. A bare `AppNavCanvas` (the + * `AppPreview` caller passes no report) renames as before. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +const PKG = 'com.acme.app'; +const NAV = [{ id: 'nav_qa', type: 'page', label: 'QA Dash', pageName: 'home' }]; +const HOME = { name: 'home', label: 'Home', type: 'app', regions: [{ name: 'main', components: [] }] }; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (type: string) => (type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [])), + listDrafts: vi.fn(async () => []), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(), + getDraft: vi.fn(async () => { + throw Object.assign(new Error('No pending draft'), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown) => ({ type, name, item })), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ loading: false, error: null, entries: [] }) }; +}); +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => [{ id: PKG, name: 'Acme', writable: true, namespace: 'acme' }]) }; +}); +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); +vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); +vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas'; + +window.matchMedia = ((query: string) => ({ + matches: false, media: query, onchange: null, + addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; +(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + (globalThis as { ResizeObserver?: unknown }).ResizeObserver ?? class { observe() {} unobserve() {} disconnect() {} }; + +beforeEach(() => { + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + mockClient.layered.mockImplementation(async (type: string) => { + const effective = type === 'app' ? { name: 'acme_app', label: 'Acme', navigation: NAV } : type === 'page' ? HOME : null; + return { code: null, overlay: effective, overlayScope: effective ? 'env' : null, effective, editable: true }; + }); + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => [] })) as unknown as typeof fetch); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +/** Whether a reload / tab close would be asked about. */ +function reloadAsks(): boolean { + const e = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(e); + return e.defaultPrevented; +} + +/** Opens nav editing and starts a rename of "QA Dash"; returns its input. */ +async function startRename(): Promise { + render( + + + } /> + + , + ); + await screen.findByRole('button', { name: /QA Dash/ }, { timeout: 8000 }); + fireEvent.click(screen.getByTitle(/^Edit navigation/)); + await screen.findByRole('button', { name: /Add nav item/ }, { timeout: 8000 }); + const card = screen.getAllByRole('button').find((b) => b.getAttribute('aria-pressed') !== null && b.textContent?.startsWith('QA Dash'))!; + fireEvent.doubleClick(card.querySelector('span')!); + return (await screen.findByDisplayValue('QA Dash')) as HTMLInputElement; +} + +describe('an uncommitted nav rename raises the held-edit guard (objectstack#27464)', () => { + it('typing a rename asks on reload; Escape reverts it and the guard lets go', async () => { + const input = await startRename(); + expect(reloadAsks()).toBe(false); + fireEvent.change(input, { target: { value: 'QA Dash Lost' } }); + await waitFor(() => expect(reloadAsks()).toBe(true)); + fireEvent.keyDown(input, { key: 'Escape' }); + await waitFor(() => expect(reloadAsks()).toBe(false)); + expect(mockClient.save).not.toHaveBeenCalled(); + }); + + it('typing the authored label back is no rename, and the guard lets go', async () => { + const input = await startRename(); + fireEvent.change(input, { target: { value: 'QA Dash Lost' } }); + await waitFor(() => expect(reloadAsks()).toBe(true)); + fireEvent.change(input, { target: { value: 'QA Dash' } }); + await waitFor(() => expect(reloadAsks()).toBe(false)); + }); + + it('committing hands the rename to the nav autosave; once it lands nothing is held', async () => { + const input = await startRename(); + fireEvent.change(input, { target: { value: 'QA Dash Lost' } }); + await waitFor(() => expect(reloadAsks()).toBe(true)); + fireEvent.blur(input); + await waitFor(() => expect(mockClient.save.mock.calls.some(([type]) => type === 'app')).toBe(true), { timeout: 8000 }); + const sent = mockClient.save.mock.calls.find(([type]) => type === 'app')![2] as { navigation: Array<{ label: unknown }> }; + expect(sent.navigation[0].label).toBe('QA Dash Lost'); + await waitFor(() => expect(reloadAsks()).toBe(false), { timeout: 8000 }); + }); +}); + +describe('a canvas with no report (the AppPreview caller) renames as before', () => { + it('commits on blur and needs no report', () => { + const onPatch = vi.fn(); + render(); + fireEvent.doubleClick(screen.getByText('QA Dash')); + const input = screen.getByDisplayValue('QA Dash'); + fireEvent.change(input, { target: { value: 'Renamed' } }); + fireEvent.blur(input); + expect(onPatch).toHaveBeenCalledWith({ navigation: [expect.objectContaining({ label: 'Renamed' })] }); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 112c2439faa..b47faa46181 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -4834,7 +4834,13 @@ export function InterfacesPillar({ }); // Only the open leaf's own buffer is held (objectui#11272). const leafHeld = draftLoaded ? leafIncomplete : null; - useHeldEditReport(onHeldEditChange, leafHeld?.clause ?? null); + // objectstack#27464 — a nav rename typed but not committed (blur / Enter) + // lives only in the canvas input, so it is held as the leaf's edit is: the + // same report, so the surface's one guard (beforeunload included) asks. + const [navRenamePending, setNavRenamePending] = React.useState(null); + const navRenameClause = + navRenamePending === null ? null : tFormat('engine.studio.held.navRename', locale, { item: navRenamePending }); + useHeldEditReport(onHeldEditChange, leafHeld?.clause ?? navRenameClause); // objectui#12725 — the header's draft line reads the badge's ledger. const leafDraftStatus = useStudioItemDraftStatus( current ? { type: current.type, name: current.name } : null, @@ -6103,6 +6109,7 @@ export function InterfacesPillar({ onPatch={onNavPatch} selection={navSel} onSelectionChange={(s) => setNavSel(s ? { kind: s.kind, id: s.id } : null)} + onRenamePendingChange={setNavRenamePending} /> ) : navTree.length === 0 ? (

diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index d299ff61b4f..8a0645c7fb3 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2999,6 +2999,8 @@ const STUDIO_EN: Record = { // objectui#11910 — a dashboard widget held until it is bound; `input` is the // widget inspector's own label for it (Dataset, Values (measures)). 'engine.studio.held.widgetNeedsInput': '{input} on the widget “{widget}” needs a value', + // objectstack#27464 — a navigation rename typed but not committed yet. + 'engine.studio.held.navRename': 'the rename of the navigation item “{item}” is not finished', 'engine.studio.held.line': 'Not saved yet: {clause}. Your changes are kept here and saved once it is filled in.', 'engine.studio.held.publish': 'Not published: {clause}, so that change is not saved yet. Finish it, then publish.', 'engine.studio.held.inputHint': 'Required. Changes are saved once this has a value.', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 558f5535d97..c4f6402066d 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2668,6 +2668,8 @@ const STUDIO_ZH: Record = { 'engine.studio.held.needsRuleField': '规则「{rule}」需要选择一个字段', 'engine.studio.held.needsRuleMessage': '规则「{rule}」需要一条错误消息', 'engine.studio.held.widgetNeedsInput': '组件「{widget}」的「{input}」需要填写', + // objectstack#27464 — a navigation rename typed but not committed yet. + 'engine.studio.held.navRename': '导航项「{item}」的重命名尚未完成', 'engine.studio.held.line': '尚未保存:{clause}。更改已保留在此处,补全后会自动保存。', 'engine.studio.held.publish': '未发布:{clause},该更改尚未保存。请先补全,再发布。', 'engine.studio.held.inputHint': '必填。填写后才会保存更改。', From 9e41e3b9b3d4c398b277f6db65a71f6fb030ed9f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:54:38 +0000 Subject: [PATCH 2/3] chore: changeset for the nav rename held guard Claude-Session: https://claude.ai/code/session_01L6bhgXU26UBEjd1KKHgNEx Co-authored-by: Claude --- .changeset/27464-interfaces-rename-held-guard.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/27464-interfaces-rename-held-guard.md diff --git a/.changeset/27464-interfaces-rename-held-guard.md b/.changeset/27464-interfaces-rename-held-guard.md new file mode 100644 index 00000000000..e688db34d57 --- /dev/null +++ b/.changeset/27464-interfaces-rename-held-guard.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(studio): a navigation rename typed in the Interfaces nav editor but not yet committed now asks before a reload or a tab close + +Clause-②: no +Metadata-protocol: none + +A rename in the nav editor commits on blur or Enter. Before either, the typed label lived only in the input, so a reload or a tab close dropped it with no prompt, while every other held Studio edit asked. `AppNavCanvas` gains an optional `onRenamePendingChange` report, and the Interfaces pillar folds it into the same held-edit report the other pillars use, so the surface's one `beforeunload` guard covers it and Publish names it as an edit not finished yet. Escape, or typing the authored label back, lets the guard go. From 301cf9ffb6193c7a5d64ee4e162b05be0a45d298 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:09:14 +0000 Subject: [PATCH 3/3] test: trim the nav rename held suite Claude-Session: https://claude.ai/code/session_01L6bhgXU26UBEjd1KKHgNEx Co-authored-by: Claude --- ...DesignSurface.navRenameHeld-27464.test.tsx | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx index eba5fc8b5cc..aba45c59c80 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.navRenameHeld-27464.test.tsx @@ -6,10 +6,8 @@ * or a tab close asks first, as it does for every other held Studio edit. * * The canvas reports the pending rename and the pillar folds it into the one - * held-edit report the surface already reads (`useHeldEditReport`), so the - * surface's single `beforeunload` guard covers it. Rendered through the REAL - * `StudioDesignSurface` on the real route shape. A bare `AppNavCanvas` (the - * `AppPreview` caller passes no report) renames as before. + * held-edit report the surface already reads, so its one `beforeunload` guard + * covers it. A bare `AppNavCanvas` (the `AppPreview` caller) renames as before. */ import '@testing-library/jest-dom/vitest'; @@ -55,11 +53,8 @@ vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => nul import { StudioDesignSurface } from './StudioDesignSurface'; import { AppNavCanvas } from '../metadata-admin/previews/AppNavCanvas'; -window.matchMedia = ((query: string) => ({ - matches: false, media: query, onchange: null, - addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, - dispatchEvent: () => false, -})) as unknown as typeof window.matchMedia; +const noop = () => {}; +window.matchMedia = ((media: string) => ({ matches: false, media, addEventListener: noop, removeEventListener: noop })) as unknown as typeof window.matchMedia; (globalThis as { ResizeObserver?: unknown }).ResizeObserver = (globalThis as { ResizeObserver?: unknown }).ResizeObserver ?? class { observe() {} unobserve() {} disconnect() {} }; @@ -67,14 +62,11 @@ beforeEach(() => { for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); mockClient.layered.mockImplementation(async (type: string) => { const effective = type === 'app' ? { name: 'acme_app', label: 'Acme', navigation: NAV } : type === 'page' ? HOME : null; - return { code: null, overlay: effective, overlayScope: effective ? 'env' : null, effective, editable: true }; + return { code: null, overlay: null, overlayScope: null, effective, editable: true }; }); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => [] })) as unknown as typeof fetch); }); -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); +afterEach(() => (cleanup(), vi.unstubAllGlobals())); /** Whether a reload / tab close would be asked about. */ function reloadAsks(): boolean {