Skip to content

fix(react): one Ctrl+Z pops one undo, however many useGlobalUndo instances are mounted, and a focused field keeps its own undo (objectui#11081) - #11104

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-11081-one-undo-listener
Sep 29, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-11081-one-undo-listener

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11081
Clause-②: no

What was wrong

Every mounted useGlobalUndo added its own window keydown listener, and each one popped the one shared globalUndoManager. The console mounts several instances at once, so one Ctrl+Z reverted one saved record write per mounted instance, as many as were stacked. The handler also never read the event target: Ctrl+Z typed into a text field was preventDefaulted, the field's own undo did not run, and the last saved record write was reverted instead.

What changes (packages/react/src/hooks/useGlobalUndo.ts only)

  • Exactly one listener. Each instance registers its options ref in a module-level, insertion-ordered Set. The first registration adds the one module-level keydown handler; the last unregistration removes it. The registration effect runs once per mount and is keyed on the ref, whose identity React keeps, not on a memoised undo / redo (AGENTS.md rule 10), so an instance's place in the keyboard order is its mount order.
  • One keypress pops at most one operation (popUndo, or popRedo with Shift), and exactly one instance answers it: its dataSource runs the write and its onUndo / onRedo fires.
  • No interception in an editable target. When the target is an input, textarea, select or contenteditable region (isContentEditable, which also covers a node nested inside one), the handler returns before preventDefault and before any pop, so the browser's own undo runs in the field.
  • Unchanged: the hook's signature, UseGlobalUndoOptions, the return shape, and the returned undo / redo (what a toast's Undo button calls), which still run through the instance they belong to. The hook's emitted declaration was compared at base and head: the only difference is the added JSDoc paragraph.

Which instance answers a keypress: the longest-mounted live one

Measured at origin/main 3e71a9825: an UndoableOperation carries id, type, objectName, recordId, timestamp, description, undoData and redoData, and nothing that names the instance whose runner pushed it. The push happens in @object-ui/core's ActionRunner (globalUndoManager.push(result.undo)), outside the hook module. An owner field would change an exported core type and the runner, which the claim puts out of scope. So the rule is the dispatch's fallback: the first entry of the set, which is the longest-mounted live instance. When it unmounts, the next-oldest takes over.

That is also the instance that already answered whenever a single operation was stacked. Its listener was first on window and popped the operation, and the rest found the stack empty; the card's probe measured exactly that. So a single-operation Ctrl+Z behaves as before, and only the extra pops are gone. The choice is written in the hook's JSDoc and in the block comment above the registry.

Pins: useGlobalUndo.oneListener-11081.test.tsx

Real hook, real globalUndoManager, real keydown events that bubble to window.

  • Three instances, three operations: one Ctrl+Z pops exactly one (one dataSource.delete, one onUndo, defaultPrevented true).
  • Ctrl+Shift+Z with three instances and three redoable operations pops exactly one redo.
  • The longest-mounted instance answers. After it unmounts, its successor answers. An instance mounted by a later commit does not.
  • Ctrl+Z and Ctrl+Shift+Z in a focused input, textarea, select, contenteditable region, and a node nested inside one (five cases): the stack is untouched, defaultPrevented is false, and no callback runs. A focused button still pops (the control).
  • Three instances add one keydown listener. Unmounting all of them removes that same function, and a later keypress pops nothing and is not defaultPrevented.
  • StrictMode double-mount: one pop per keypress, keydown adds and removes balance after unmount, and nothing pops afterwards.
  • A toast-style undo() on an instance that does not own the keyboard runs that instance's dataSource and onUndo, not the keyboard owner's.

Evidence (all at head f89bd44d4)

  • pnpm exec vitest run --maxWorkers=2 packages/react/ plus the three tests that drive a real consumer's undo (useConsoleActionRuntime.undoneToast-11056.test.tsx, RecordDetailView.undoneToast-11056.test.tsx, undoProjectedRow-10404.test.tsx): Test Files 107 passed (107), Tests 1377 passed (1377), that is 104 react files plus 3.
  • Reverse verification, after committing the fix: the hook file was replaced by its 3e71a9825 blob (on-disk proof: blob equal to the base blob, onUndoShortcut count 3 to 0), then the new and existing pins ran: Tests 10 failed | 6 passed (16). Red: the three one-pop pins, the five editable-target pins and the two lifecycle pins. Green at base, as expected: the button control, the toast-style undo() pin and the four existing option-timing pins. Restored with git checkout HEAD: blob equal to HEAD, git diff HEAD empty.
  • Guard ablation through ablation-replace.mjs (anchor if (isEditableTarget(e.target)) return; count 1 to 0, blob moved): exactly the five editable-target pins went red, Tests 5 failed | 7 passed (12). Restored: blob equal to HEAD.
  • pnpm --filter @object-ui/react type-check (tsc --noEmit && tsc -p tsconfig.test.json): exit 0, after building the dependency closure with pnpm --filter '@object-ui/react^...' build. The test tsconfig's --listFiles includes the new pin file.
  • check-changeset-presence, check-control-bytes, check:new-line-citations (0 new), check-changeset-no-major, check:changeset-claims, check:pending-changeset-literals, check:test-path-roots, check:vi-mock-specifiers, check:unreferenced-sources, check:self-import and check:phantom-deps all exit 0. check-governed-queue-guard --test on the three paths answers NOT GOVERNED.

Declared narrowing. pnpm lint and the full pnpm test run are CI's. ESLint ran on the two changed source files only. The population is those two files: ESLint linted both and ignored neither. The count, 2 files with 0 errors and 0 warnings, is read from --format json. eslint.config.js declares no type-aware linting (no projectService, parserOptions or TypeChecked config), so this diff cannot move the verdict on any untouched file. The app-shell AppContent.* tests that stub useGlobalUndo were not run, because they replace the hook and cannot see this change.

Not measured: a browser run of the console. Which instance is the oldest on a given route is read from source, not measured.

Acceptance notes

  • A keyboard undo runs the write through the answering instance's dataSource. An instance without one pops without reverting. That behavior is unchanged: it is what the first-registered listener did before. In the console, all three call sites pass the same adapter (read from source).
  • For the operation's owner to answer the keypress, UndoableOperation would need an owner and the ActionRunner push would have to set it. That is a core contract change, and it is not proposed here.

Changeset: .changeset/11081-one-undo-listener.md, '@object-ui/react': patch.


Generated by Claude Code

…ances are mounted, and a focused field keeps its own undo (objectui#11081)

Every mounted useGlobalUndo added its own window keydown listener and each
popped the shared globalUndoManager, so one Ctrl+Z undid one operation per
mounted instance. The listener also ignored the event target, so Ctrl+Z in a
text field was preventDefault-ed and reverted a saved write instead.

Instances now register in a module-level set; one listener, added on the
first registration and removed on the last, pops at most one operation per
keypress and answers it through the longest-mounted live instance. A keypress
whose target is an input, textarea, select or contenteditable region is left
to the field. The hook's signature and its returned undo/redo are unchanged.

Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3108.9 KB 3149.4 KB
Main entry chunk (gzip) 149.3 KB 350 KB
Entry file index-DdTmXh1B.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.88KB 6.25KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 558.81KB 133.93KB
core (index.js) 9.94KB 3.94KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 227.99KB 63.22KB
fields (index.js) 261.11KB 66.33KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.35KB 9.18KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.32KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.58KB 4.90KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.25KB 2.17KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 52.17KB 15.06KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 137.68KB 36.71KB
plugin-designer (index.js) 215.78KB 44.42KB
plugin-detail (index.js) 233.64KB 61.85KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 169.69KB 43.37KB
plugin-gantt (index.js) 171.88KB 42.61KB
plugin-grid (index.js) 228.51KB 62.64KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 115.92KB 28.65KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 31.07KB 9.15KB
plugin-tree (index.js) 11.21KB 3.89KB
plugin-view (index.js) 90.43KB 22.76KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.55KB 39.23KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 8.61KB 3.45KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.17KB 2.73KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 22.61KB 7.40KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 3.83KB 1.49KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 3.19KB 1.62KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.30KB 6.99KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f89bd44d4c01f9f10c6d40826fdfa3cc83469d83
Local-runs: none

Inputs: card #11081 (body, triage 5888816539, claim 5890123624, os-dev-report 5890965101), PR #11104 (body, its three-file list, the net diff against main), and the latest check-runs on the head. Head-side context reads only: packages/react/src/hooks/useGlobalUndo.ts, packages/core/src/actions/UndoManager.ts, the three console call sites, packages/react/src/hooks/index.ts, the root vitest.config.mts, the head commit's trailers. Nothing built, run or re-run. Binding direction: triage 5888816539.

① Derived judgments

  1. Exactly one keydown listener, reference-counted — right. keyboardInstances is a module-level insertion-ordered Set of options refs. registerKeyboardInstance adds the ref and calls window.addEventListener('keydown', onUndoShortcut) only when the size becomes 1; its cleanup deletes the ref and removes that same handler function only when the size reaches 0. Registration is useEffect(..., []) holding the useRef object, so it runs once per mount and never re-registers on a memoised identity (the old effect was keyed on [undo, redo], an AGENTS.md [WIP] Enhance every detail of the designer #10 shape now gone). Strict mode: React runs every mount effect, then every cleanup, then every mount effect again on the same ref object, so the count goes n, 0, n and the listener is added, removed and added once, in the same order. The StrictMode pin asserts adds equal removes after unmount and that a later keypress pops nothing and is not defaultPrevented; the three-instance pin asserts exactly one add and that the removed function is the added one. The last unmount removes the listener.

  2. One keypress pops at most one operation — right. onUndoShortcut reads the Set's first entry and calls runUndo or runRedo on it once; popUndo / popRedo remove exactly one operation. Pins: three instances and three stacked operations, one Ctrl+Z pops one (one dataSource.delete, one onUndo, two left, defaultPrevented true); Ctrl+Shift+Z with three redoable operations pops one redo. Triage pin 1 held.

  3. No interception in an editable target — right. isEditableTarget returns before preventDefault and before any pop for INPUT, TEXTAREA, SELECT and any element whose isContentEditable is true (which covers a node nested inside a contenteditable host). Pins: five targets (input, textarea, select, contenteditable, nested-in-contenteditable), undo and redo keys both: stack untouched, defaultPrevented false, no callback; a focused button still pops (the control). The suite runs in happy-dom (root vitest.config.mts), where a failing isContentEditable would turn the contenteditable pins red rather than vacuous, and the dev's guard ablation turned exactly those five red. Triage pin 2 held. Observation, not a finding: the match is by tag, so a focused checkbox, radio or button-typed input also gets no pop; that is the triage's literal input, accepted as written.

  4. The owner choice (brief ①) — right, and "decided once" is satisfied. Verified at the head: UndoableOperation in packages/core/src/actions/UndoManager.ts is id, type, objectName, recordId, timestamp, description, undoData, redoData; nothing names the pushing instance, and the push lives in core's ActionRunner. So the operation's owner is unreadable inside the hook, and adding it would move an exported core type, which the claim makes a stop-and-report. The dev's fallback is one deterministic rule applied once per pop: the first registry entry, the longest-mounted live instance, its successor after it unmounts. Exactly one instance's dataSource runs the write and exactly one onUndo / onRedo fires, so exactly one toast shows. Pre-change single-operation behaviour is preserved: the old per-instance listeners were added by an effect whose deps were stable, so window's listener order was mount-effect order (children before parents within a commit, earlier commits first), the first listener popped the sole operation and the rest found the stack empty; the card's probe measured exactly that ("only the first-mounted instance's onUndo ran"). The new registry order is that same mount-effect order and its first entry answers, so for one stacked operation the same instance, dataSource and toast answer as before and only the extra pops are gone. On console routes that instance is AppContent's in practice: its hook runs before the LoadingScreen early return, so it registers in the first commit, ahead of ConsoleShell's runtime and the lazy views. Pin: the longest-mounted instance answers, its successor answers once it unmounts, a later-mounted instance does not.

  5. The toast's Undo button unchanged — right. The returned undo / redo are stable useCallback wrappers around runUndo(optionsRef) / runRedo(optionsRef) for the calling instance, and the module runners read optionsRef.current so the commit-refreshed options still apply. Pin: a toast-style undo() on a non-owner instance runs that instance's dataSource and onUndo, not the keyboard owner's. The four existing optionsTiming pins are untouched. Triage pin 3 held.

  6. Public surface — no change. Every addition in useGlobalUndo.ts (OptionsRef, executeOp, runUndo, runRedo, keyboardInstances, isEditableTarget, onUndoShortcut, registerKeyboardInstance) is module-private; packages/react/src/hooks/index.ts still re-exports the module unchanged; no consumer is edited. Accept-set change, intended by the triage: Ctrl+Z / Ctrl+Shift+Z whose target is editable is now neither popped nor preventDefaulted. With no instance mounted there is no listener, as before.

  7. Check-runs on the head (the gate verdicts), read with filter=latest at 2026-09-29T13:18Z, after waiting in the foreground for the runs still in progress when this review began: 43 runs, 40 succeeded, 3 skipped, 0 failed, 0 in progress; the aggregate Test rollup that queued once the last shard landed is among the 40. Lint, Type Check, Test shards 1 to 8, Test (dist pins), Build and E2E, Changeset Declaration, Changeset Bump Policy, Changeset Claim Re-read, Changeset Fixed Group Check, Changeset Overwrite Report, Control Byte Scan, Line Citation Gate, Governed Surface Queue Guard, Inert vi.mock Specifier Check, Spec Main Shape Gate, Build Docs, Bundle Analysis, the doc and skill checks and label all succeeded; the three skipped are Test (coverage), the coverage-shard matrix and dependabot, path-conditional on this PR.

② Semver level

.changeset/11081-one-undo-listener.md declares '@object-ui/react': patch. Right: the only source change is packages/react/src/hooks/useGlobalUndo.ts in a released fixed-group package, so a changeset is owed and present (Changeset Declaration green); it is a behavioural bug fix with no export, signature, type or return-shape change, so patch is the level; no major (Changeset Bump Policy green). The changeset body cites by content and names no model.

Clause-②: no — confirmed. Read at the head: UseGlobalUndoOptions is byte-unchanged in the diff; useGlobalUndo(options: UseGlobalUndoOptions = {}) still returns { ...state, undo, redo } where undo and redo each return a Promise of void, as before (the old bodies were async callbacks; the new ones return the async module runners). Nothing new is exported, so the emitted .d.ts can differ only by the added JSDoc, as the dev reports; Type Check on the head is green.

③ Boundary flags

open_questions: [] — nothing to answer.

Deviations, each answered:

  • "none against the role file or the order" — consistent with the diff: the hook, one pin file, one changeset; the claim's file surface held and neither stop-and-report condition (new or changed export, consumer edit) was tripped.
  • the lock queue-timeout and the type-check run before the dependency build — superseded reruns, not measurements; the head's Type Check and Test shards carry the verdict.
  • attribution — the PR body ends with the session-URL footer and names no model; the head commit's trailers are Claude-Session: and the model-free Co-authored-by: pair, which is the AGENTS.md reporting rule. Answered.
  • labels package: react and tests — applied by the path labeler (label check green); no label written by the dev. Answered.

Declared narrowing — closed by the gate verdicts: pnpm lint and the full test run are Lint and Test shards 1 to 8 on the head, all green. The console browser run was not owed by this card: which instance is oldest on a route is read from source (AppContent's, see ① 4) and it is the instance that already answered a single-operation keypress.

Out-of-scope findings (carrier 承接者:无, noted in the PR's acceptance notes, not filed), each answered:

  • an instance without a dataSource pops without reverting — verified at the head: AppContent calls useGlobalUndo({ dataSource: dataSource ?? undefined }) before its !dataSource early return, so during the adapter-less loading window its instance is the only one mounted and a keypress would pop an already-stacked operation without a write. That was equally true before the change (that instance's listener was the only listener then), the ref refreshes to the adapter as soon as useAdapter() returns it, and the two other call sites receive the same adapter as a prop. Pre-existing, unchanged by this diff, correctly left unfiled by this card.
  • answering by the operation's owner needs an owner on UndoableOperation set at the ActionRunner push — a core contract change outside the claim's hook-only surface, correctly not proposed here. If the maintainer wants the owner rather than the oldest instance to answer, that is a new card, not a defect in this one.

Implemented-by: claude/issue-11081-one-undo-listener
Reviewed-by: session_011p7ikEivgXefNDaE5S5Uec

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 29, 2026 13:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 0645133 Sep 29, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11081-one-undo-listener branch September 29, 2026 13:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants