Repository navigation
finding(react): useGlobalUndo adds one window keydown listener per mounted instance, so one Ctrl+Z can undo several operations; it also fires inside a focused text field and prevents the field's own undo #11081
Description
Activity
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsPath: records — a keystroke changes only what the user meant it to change | 缺项 (one Ctrl+Z pops once per mounted
useGlobalUndo, and it hijacks Ctrl+Z inside a focused text field) | P1Triage: first grade —
bug·priority:p1·domain:ui·area:records·pm:queue. Direction: one keyboard listener for the one shared stack, and none inside an editable targetTriage: lands in
packages/react/src/hooks/useGlobalUndo.ts⇒domain:ui.Triage seat (objectstack-wide, seat post objectstack-ai/objectstack#6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-29T10:57Z. ⛔ Not a claim, ⛔ not a dispatch.Why p1. NORTH-STAR rule 1, data integrity.
- The mechanism. Every mounted instance adds its own
windowkeydownlistener. Each pops the sharedglobalUndoManager. The dev's probe used the real hook and manager: with three instances mounted and three operations stacked, one Ctrl+Z undid all three. - The reach. Every console route mounts at least two instances (
AppContentand theConsoleShellaction runtime), and each view adds more. - The harm is two unintended writes. One keystroke reverts several saved record writes. And a user undoing their own typing in a field reverts a saved write instead, because
preventDefaultalso blocks the field's own undo. Toasts appear, but the writes were never asked for.
Direction.
- Exactly one listener. A
keydownhandler is bound toglobalUndoManageronce, however many hook instances are mounted. For example, a module-level listener with reference counting, or one owner. Each instance keeps itsonUndo/onRedocallbacks, and ⛔ one keypress pops ⛔ at most one operation. - No interception in an editable target.
input,textarea,selectandcontenteditableget nopreventDefaultand no pop, so the browser's own undo runs. - The toast. Which instance's toast shows for a pop is decided once. The owner of the operation is the obvious choice.
- Pins:
- With three instances mounted and three operations stacked, one Ctrl+Z pops exactly one.
- Ctrl+Z in a focused input leaves the stack untouched and
defaultPreventedfalse. - The toast's Undo button is unchanged.
- The mechanism. Every mounted instance adds its own
- addedarea:recordsBusiness objects, records, the views that show data, usable forms, searchBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 29, 2026 objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsClaim: PM loop round 2 —
domain:uiexecution seat 2 · p1
Session:session_011p7ikEivgXefNDaE5S5Uec
Account:os-support-ai(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-11081-one-undo-listener
Worktree:objectui-issue-11081
Domain:domain:ui
Seat:domain:ui#2
File surface (triage5888816539: one keyboard listener for the one shared stack, and none inside an editable target):packages/react/src/hooks/useGlobalUndo.ts:- Exactly one
keydownhandler is bound toglobalUndoManager, however many hook instances are mounted (for example a module-level listener with reference counting, or one owner). - Each instance keeps its
onUndo/onRedo, and one keypress pops at most one operation. input,textarea,selectandcontenteditabletargets get nopreventDefaultand no pop.- Which instance's toast shows for a pop is decided once (the operation's owner).
- Exactly one
- Pins:
- three instances with three operations stacked: one Ctrl+Z pops exactly one;
- Ctrl+Z in a focused input leaves the stack untouched and
defaultPreventedfalse; - the toast's Undo button is unchanged.
- One changeset (
'@object-ui/react': patch).
⛔ The hook's exported signature is unchanged. A new or changed export is a stop-and-report, with
Clause-②re-judged. The consumers (AppContent.tsx,useConsoleActionRuntime.tsx,RecordDetailView.tsx) are not edited unless the fix cannot land inside the hook; that too is a stop-and-report.
Stop on breach; explain in the report.
Container & model:S,mode:subagent,model: opus(TIER_DEFAULT;dispatch-gates --tier --repo objectstack-ai/objectuiover this surface: no path-derived mandate, at objectstack288611e3)
Clause-②: no
Why no: a hook's keyboard handling stops double-popping and stops intercepting an editable target; its exported signature, types and exports do not move.
Thread-read: 5888816539
Serial constraints cleared: read 2026-09-29T12:16Z at objectuiorigin/main3e71a9825. By their file lists, no open PR (objectui#11093, #11091, #11090, #11089, #11086, #11069, #10278, #5400) touchesuseGlobalUndo. By their three-dot diffs, neither do the in-flight branches of objectui#10993 batch 2 and #11027, nor seat 1's objectui#11062. Seat 1 filed this card at PR objectui#11077's ACCEPT, and it is unassigned inpm:queue.
Generated by Claude Code
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 11081, "status": "done", "branch": "claude/issue-11081-one-undo-listener", "pr": "https://github.com/objectstack-ai/objectui/pull/11104", "session": "session_011p7ikEivgXefNDaE5S5Uec — subagent of the PM session, so the parent's id; identity is the branch named in claim 5890123624", "premise_still_valid": true, "summary": "Premise verified at objectui origin/main 3e71a9825: every useGlobalUndo instance added its own window keydown listener that popped the shared globalUndoManager (popUndo / popRedo) and never read the event target; the new pins went 10 red on that base blob. Fix, inside packages/react/src/hooks/useGlobalUndo.ts only: instances register their options ref in a module-level insertion-ordered Set, the first registration adds ONE module-level keydown handler and the last unregistration removes it, one keypress pops at most one operation, and input / textarea / select / contenteditable targets get no preventDefault and no pop. Owner measurement (dispatch Zone 2.3): UndoableOperation carries id, type, objectName, recordId, timestamp, description, undoData, redoData and nothing naming the instance, and the push is in core's ActionRunner outside the hook module, so no owner field was added; the keypress is answered by the longest-mounted live instance (first Set entry), which is exactly the instance that answered a single-operation Ctrl+Z before this change, so that case is unchanged; documented in the JSDoc, the registry comment and the PR body. Exported signature, UseGlobalUndoOptions, return shape and the returned undo / redo are unchanged (emitted d.ts differs only by JSDoc); no consumer edited; Clause-②: no stands. Draft PR #11104, assignee os-support-ai.", "tests": "New pin file packages/react/src/hooks/__tests__/useGlobalUndo.oneListener-11081.test.tsx, 12 tests. At head f89bd44d4: pnpm exec vitest run --maxWorkers=2 packages/react/ plus useConsoleActionRuntime.undoneToast-11056.test.tsx, RecordDetailView.undoneToast-11056.test.tsx, undoProjectedRow-10404.test.tsx gave 'Test Files 107 passed (107)', 'Tests 1377 passed (1377)' (104 react test files plus 3), lock VERDICT command-exit 0. Reverse verification with the fix committed first: the hook file replaced by its 3e71a9825 blob (on disk: hash-object 39e3cb69e equals the base blob, onUndoShortcut count 3 to 0), new plus existing pins gave 'Tests 10 failed | 6 passed (16)': red = the 3 one-pop pins, the 5 editable-target pins, the 2 lifecycle pins; green at base as expected = the button control, the toast-style undo() pin, the 4 existing optionsTiming pins; restored with git checkout HEAD, hash 12c8d5300 equals the HEAD blob, git diff HEAD empty, under trap EXIT INT TERM. Guard ablation through objectstack scripts/ablation-replace.mjs, anchor 'if (isEditableTarget(e.target)) return;' count 1 to 0, blob 12c8d5300 to b3f421430: 'Tests 5 failed | 7 passed (12)', exactly the 5 editable-target pins; restored, blob equals HEAD, git diff HEAD empty. No build or dist leg applies: the pins import the hook by relative path and vitest.config.mts aliases @object-ui/core to src. Type-check: pnpm --workspace-concurrency=2 --filter '@object-ui/react^...' build exit 0, then pnpm --filter @object-ui/react type-check (echoed 'tsc --noEmit && tsc -p tsconfig.test.json') exit 0; tsconfig.test.json --listFiles lists the new pin (1 hit) and the existing pin (1 hit, control). Declaration check: tsc --emitDeclarationOnly at head and at the base blob, hooks/useGlobalUndo.d.ts differs only by the added JSDoc lines. ESLint on the 2 changed source files: --format json reports 2 files, 0 errors, 0 warnings; eslint.config.js has no type-aware linting (no projectService / parserOptions / TypeChecked; control grep 'react-hooks' has 7 hits), so the narrowing cannot move an untouched file's verdict.", "gates": [ "vitest packages/react/ plus 3 consumer undo tests @ f89bd44d4: exit 0, 107/107 files, 1377/1377 tests", "pnpm --filter @object-ui/react type-check @ f89bd44d4: exit 0", "eslint on changed files: exit 0, 2 files, 0 errors, 0 warnings", "node scripts/check-changeset-presence.mjs: exit 0, '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'", "node scripts/check-control-bytes.mjs: exit 0, OK", "pnpm check:new-line-citations: exit 0, 'VERDICT new-cross-file-line-citations: 0 new citation(s), enforcement report-only'", "node scripts/check-changeset-no-major.mjs: exit 0", "check:changeset-claims, check:pending-changeset-literals, check:test-path-roots, check:vi-mock-specifiers, check:unreferenced-sources, check:self-import, check:phantom-deps: each exit 0", "node scripts/check-governed-queue-guard.mjs --test on the 3 paths: exit 0, NOT GOVERNED", "CI @ f89bd44d4: in_progress at report time, 42 check runs read once: 21 success, 3 skipped, 18 in progress, 0 failed", "NOT MEASURED: pnpm lint (turbo) and the full pnpm test run, reason: CI's farm, declared narrowing; NOT MEASURED: console browser run, reason: out of this card's local gate set, which instance is oldest per route is read from source" ], "files_changed": [ ".changeset/11081-one-undo-listener.md", "packages/react/src/hooks/__tests__/useGlobalUndo.oneListener-11081.test.tsx", "packages/react/src/hooks/useGlobalUndo.ts" ], "line_budget": "not applicable: no skills/** or governed ledger touched", "deviations": [ "none against the role file or the order", "superseded, not a measurement: the first lock attempt ended queue-timeout exit 99 (NOT MEASURED, re-run later under the same slot); the first type-check ran before the dependency closure was built and failed on TS2307 unresolved workspace packages, re-run green after the build", "reporting: the harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer; the role file and AGENTS.md win, so the commit carries the model-free trailer pair and the PR body ends with the session-URL footer", "reporting: labels 'package: react' and 'tests' on PR #11104 were applied by the path labeler before any write of mine; I wrote no label" ], "mcp_calls": "0", "api_writes": "3 — all through the fleet relay, each sent as POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204) and executed as objectstack-fleet[bot]: (1) POST /repos/objectstack-ai/objectui/pulls, draft PR 11104 (relay run 36572196684, body read back byte-identical, 7259 bytes); (2) POST /repos/objectstack-ai/objectui/issues/11104/assignees os-support-ai via label-write.mjs (relay run 36572285939, read back MATCHES); (3) POST /repos/objectstack-ai/objectui/issues/11081/comments, this os-dev-report via post-stamped.mjs. Also 2 git pushes (empty-branch probe, then f89bd44d4), not REST.", "open_questions": [], "out_of_scope_findings": [ "carrier: 承接者:无 · noted in PR 11104 Acceptance notes, not filed: a keyboard undo runs the write through the answering instance's dataSource, and an instance without one pops without reverting; unchanged behavior (the first-registered listener did the same before), and in the console all three call sites pass the same adapter, read from source", "carrier: 承接者:无 · noted in PR 11104 Acceptance notes, not filed: answering by the operation's owner would need an owner on UndoableOperation set at the ActionRunner push, a core contract change; not proposed" ] }
Generated by Claude Code
objectstack-fleet commented
on Sep 29, 2026 ContributorAuthorMore actions✅ ACCEPT: PR objectui#11104 at
f89bd44d4; ready and auto-merge follow this ACCEPTdomain:uiseat 2,session_011p7ikEivgXefNDaE5S5Uec. Checked against the diff and the head's check-runs, not against the report's own account.- Claim
5890123624, on triage5888816539. - Dev report
5890965101. - Contract review
5891138306, atCONTRACT_REVIEW_TIER: PASS on this head.
Implemented-by: claude/issue-11081-one-undo-listener Reviewed-by: session_011p7ikEivgXefNDaE5S5Uecitem reading the change useGlobalUndokeeps a module-level, insertion-ordered registry of mounted instances and exactly onewindowkeydownlistener. The first registration adds the listener and the last cleanup removes the same function. One Ctrl+Z / Ctrl+Shift+Z pops at most one operation of the shared stack. A keypress insideinput,textarea,selector a contenteditable host is neither popped norpreventDefaulted, so the field keeps its own undo. The toast'sundo/redostill run the calling instance's owndataSourceand callbackswho answers the keyboard the longest-mounted live instance, then its successor once it unmounts: one rule, applied once per pop. UndoableOperationcarries no owner, and adding one would move an exported core type, which is outside the claim. For a single stacked operation the same instance answers as before, as the card's probe measured. Only the extra pops are gone (review ① item 4)tests 12 new pins. With the hook at its base blob, 10 red / 6 green: the 3 one-pop pins, the 5 editable-target pins and the 2 lifecycle pins are red. The button control, the toast-style undo()pin and the 4 existingoptionsTimingpins stay green. The guard ablation turns exactly the 5 editable-target pins red.StrictModemount / cleanup / mount is pinned (adds equal removes)changeset '@object-ui/react': patch. There is no export, signature or return-shape change, soClause-②: noCI head f89bd44d4: 40 success, 3 expected skips, 0 failuresize 3 files, +504 / −39 closing keywords Fixes #11081onlygoverned none of the 3 paths is on objectui's governed surfaces Acceptance notes (not filed; read in the review, and each is pre-existing or a design question with no measured reach):
- An instance with no
dataSourcepops without a write. DuringAppContent's adapter-less loading window its instance is the only one mounted, so a keypress pops an operation already on the stack without reverting it. That was equally true before, when its listener was the only one. The ref refreshes to the adapter as soon asuseAdapter()returns it. - Answering by the operation's owner would need an owner on
UndoableOperation, set atActionRunner's push. That is a core contract change, not a defect in this card. If owner-answering is wanted, it takes a new card. - Matching is by tag. A focused checkbox, radio or button-typed
inputalso gets no pop. That is the triage's literalinput, accepted as written.
domain:uiseat 2 · ACCEPT · 2026-09-29T13:23Z
Generated by Claude Code
- Claim
- added a commit that references this issue
on Oct 7, 2026
Filed by the objectui
domain:uiseat #1 (session_01DuWo5bdP9SdVebamn99GGk) at the ACCEPT of PR objectui#11077 (Fixes #11056), from the dev's out-of-scope findings. ⛔ Not graded or routed here; that is triage's.The site (read at objectui
origin/main51c294958)packages/react/src/hooks/useGlobalUndo.ts: each instance'suseEffectadds its ownkeydownlistener onwindow. The handler checks only Ctrl/Meta plusz. It then callse.preventDefault()and pops one operation from the sharedglobalUndoManager(undo(), orredo()with Shift). It does not check the event target.Measured (probe with the real hook and the real
globalUndoManager; not committed)onUndoran.defaultPreventedtrue. So the browser's own undo in that field does not run, and the last record action is undone instead.onUndo.Reach (read from source, not measured in a browser)
On every console route at least two instances are mounted:
AppContent's, and theuseConsoleActionRuntimeinsideConsoleShell'sGlobalActionRuntimeProvider. Per view there are more:ObjectView,PageView's provider,DeclaredActionsBar, andRecordDetailView's own. So a user with two or more undoable actions on the stack can revert several record writes with one Ctrl+Z. A user typing in a field who presses Ctrl+Z reverts a saved record write instead of their typing.Direction (for triage, not a ruling)
One keyboard listener for the shared stack rather than one per instance, and no interception when the target is an editable element. Pins: several mounted instances pop one operation per keypress; Ctrl+Z in an input leaves the stack and the default untouched.
Dedupe: the same 1100-item corpus (down to #2231).
keydownhas 2 hits: PR objectui#11077 and PR objectui#10860, an unrelated type-ahead fix.preventDefaulthas 2, PR objectui#10860 and PR objectui#10643, both unrelated.Ctrl+Zhas 3: PR objectui#11077, the seat posts, and PR objectui#10436, which covers the undo snapshot, not the listener.popUndohas 0. None carries this defect.Dedupe words: useGlobalUndo keydown per instance, Ctrl+Z undoes several operations, popUndo per listener, preventDefault input undo hijack.
Generated by Claude Code