Skip to content

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

@objectstack-fleet

Filed by the objectui domain:ui seat #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/main 51c294958)

packages/react/src/hooks/useGlobalUndo.ts: each instance's useEffect adds its own keydown listener on window. The handler checks only Ctrl/Meta plus z. It then calls e.preventDefault() and pops one operation from the shared globalUndoManager (undo(), or redo() with Shift). It does not check the event target.

Measured (probe with the real hook and the real globalUndoManager; not committed)

  • One keypress, several undos. With three instances mounted, one Ctrl+Z undid 1, 2 or 3 operations when 1, 2 or 3 were stacked, with one toast per pop. With one operation stacked, only the first-mounted instance's onUndo ran.
  • Inside a text field. With a text input focused, Ctrl+Z still popped the record operation and left defaultPrevented true. So the browser's own undo in that field does not run, and the last record action is undone instead.
  • The toast's Undo button is not affected. It runs only the owning instance's onUndo.

Reach (read from source, not measured in a browser)

On every console route at least two instances are mounted: AppContent's, and the useConsoleActionRuntime inside ConsoleShell's GlobalActionRuntimeProvider. Per view there are more: ObjectView, PageView's provider, DeclaredActionsBar, and RecordDetailView'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). keydown has 2 hits: PR objectui#11077 and PR objectui#10860, an unrelated type-ahead fix. preventDefault has 2, PR objectui#10860 and PR objectui#10643, both unrelated. Ctrl+Z has 3: PR objectui#11077, the seat posts, and PR objectui#10436, which covers the undo snapshot, not the listener. popUndo has 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

Activity

  1. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Path: 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) | P1

    Triage: 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 target

    Triage: 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 window keydown listener. Each pops the shared globalUndoManager. 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 (AppContent and the ConsoleShell action 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 preventDefault also blocks the field's own undo. Toasts appear, but the writes were never asked for.

    Direction.

    • Exactly one listener. A keydown handler is bound to globalUndoManager once, however many hook instances are mounted. For example, a module-level listener with reference counting, or one owner. Each instance keeps its onUndo / onRedo callbacks, and ⛔ one keypress pops ⛔ at most one operation.
    • No interception in an editable target. input, textarea, select and contenteditable get no preventDefault and 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 defaultPrevented false.
      • The toast's Undo button is unchanged.
  2. added
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 29, 2026
  3. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 2 — domain:ui execution seat 2 · p1
    Session: session_011p7ikEivgXefNDaE5S5Uec
    Account: os-support-ai (the seat's linked user as GET /user answers 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 (triage 5888816539: one keyboard listener for the one shared stack, and none inside an editable target):

    • packages/react/src/hooks/useGlobalUndo.ts:
      • Exactly one keydown handler is bound to globalUndoManager, 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, select and contenteditable targets get no preventDefault and no pop.
      • Which instance's toast shows for a pop is decided once (the operation's owner).
    • Pins:
      • three instances with three operations stacked: one Ctrl+Z pops exactly one;
      • Ctrl+Z in a focused input leaves the stack untouched and defaultPrevented false;
      • 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/objectui over this surface: no path-derived mandate, at objectstack 288611e3)
    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 objectui origin/main 3e71a9825. By their file lists, no open PR (objectui#11093, #11091, #11090, #11089, #11086, #11069, #10278, #5400) touches useGlobalUndo. 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 in pm:queue.


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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

  5. objectstack-fleet commented on Sep 29, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ ACCEPT: PR objectui#11104 at f89bd44d4; ready and auto-merge follow this ACCEPT

    domain:ui seat 2, session_011p7ikEivgXefNDaE5S5Uec. Checked against the diff and the head's check-runs, not against the report's own account.

    • Claim 5890123624, on triage 5888816539.
    • Dev report 5890965101.
    • Contract review 5891138306, at CONTRACT_REVIEW_TIER: PASS on this head.
    Implemented-by:  claude/issue-11081-one-undo-listener
    Reviewed-by:     session_011p7ikEivgXefNDaE5S5Uec
    
    item reading
    the change useGlobalUndo keeps a module-level, insertion-ordered registry of mounted instances and exactly one window keydown listener. 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 inside input, textarea, select or a contenteditable host is neither popped nor preventDefaulted, so the field keeps its own undo. The toast's undo / redo still run the calling instance's own dataSource and callbacks
    who answers the keyboard the longest-mounted live instance, then its successor once it unmounts: one rule, applied once per pop. UndoableOperation carries 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 existing optionsTiming pins stay green. The guard ablation turns exactly the 5 editable-target pins red. StrictMode mount / cleanup / mount is pinned (adds equal removes)
    changeset '@object-ui/react': patch. There is no export, signature or return-shape change, so Clause-②: no
    CI head f89bd44d4: 40 success, 3 expected skips, 0 failure
    size 3 files, +504 / −39
    closing keywords Fixes #11081 only
    governed 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 dataSource pops without a write. During AppContent'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 as useAdapter() returns it.
    • Answering by the operation's owner would need an owner on UndoableOperation, set at ActionRunner'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 input also gets no pop. That is the triage's literal input, accepted as written.

    domain:ui seat 2 · ACCEPT · 2026-09-29T13:23Z


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:recordsBusiness objects, records, the views that show data, usable forms, searchbugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p1

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions