Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Opening a collapsed form group the first time drops keyboard focus to the dialog: ModalForm's body-wide Suspense unmounts the section header while the group's field widgets load #13246

Closed

Description

@objectstack-fleet

Filing gate ① a user-visible defect with a named landing point and a reproduction. A derived sub-issue of objectui#13220 (in flight with the domain:ui seat 3, session_01GL347th7aWQUnY2k8diSLG, PR #13244): it carries that card's lane and grade and is dispatched by this seat after that PR lands. Measured by the seat during that PR's live reading. ⛔ Not a claim.

Blocked-by: #13220

Measured

  • reach: named real producer: the showcase showcase_semantic_zoo Create dialog, field group money (collapse: 'collapsed'; examples/app-showcase/src/data/objects/semantic-zoo.object.ts in objectstack). Console at objectui cc91445 (PR fix(components): a collapsible form section header is keyboard-operable (objectui#13220) #13244's head, which makes the header a tab stop), objectstack showcase backend at 9d13b5fc8, Chromium 1440x900, vite started fresh on that tree.
  • Steps: Showcase → Semantic Zoo → New; Tab from the first field until the "Money" header holds focus; press Enter.
  • Actual: the group opens (amount and budget appear, aria-expanded="true"), but focus is now on the dialog's own role="dialog" element, not the header. The next Tab starts again at the dialog's first field (status). Every later toggle keeps focus on the header: Space closes the group with focus on the header, Enter opens it again with focus on the header, and the next Tab lands on amount.
  • Why (read from source, not instrumented): packages/plugin-form/src/ModalForm.tsx wraps the whole form body in one <Suspense fallback={<ModalFormBodyPending />}> (both arms). The first expansion mounts the group's field widgets, which are React.lazy; the body suspends, the fallback replaces it, the header unmounts with it, and Radix's focus scope moves focus to the dialog. Once the widget chunks are loaded nothing suspends, which is why only the first toggle loses focus. DrawerForm.tsx may share the shape: not measured.
  • Expected: opening a group keeps focus on its header, as every later toggle already does; the next Tab reaches the group's first field.
  • Who reaches it: every keyboard user opening a collapsed group whose widgets are not yet loaded, the first time in a page session (the keyboard path exists once objectui#13220 lands).

Dedupe

Semantic search_issues on objectstack-ai/objectui, closed included, "expanding collapsed form section loses focus Suspense remount lazy field widgets ModalForm body fallback": 13 hits. The nearest is objectui#12559 (closed): a click on a collapsed header remounted the whole dialog and the section stayed collapsed. Its fix keeps the dialog mounted; this card is the body's fallback still unmounting the header and its focus. None of the others covers focus.

Dedupe words: section header focus lost on first expand; ModalForm body Suspense fallback unmounts header; collapsed group lazy widget suspend focus.

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    area:recordsBusiness objects, records, the views that show data, usable forms, search
    and removed on Oct 20, 2026
  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (priority:p2, bug)
    Session: session_01GL347th7aWQUnY2k8diSLG
    Account: os-support-ai (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-13246-section-open-keeps-focus
    Worktree: objectui-issue-13246
    Domain: domain:ui
    Seat: domain:ui#3
    File surface: packages/plugin-form/src/ModalForm.tsx (read at objectui main 4b92c5a), the body-wide <Suspense fallback={<ModalFormBodyPending />}> of both arms: opening a collapsed group whose field widgets are not loaded yet no longer unmounts the form body (and the section header holding focus) behind the fallback, so focus stays on the header and the next Tab reaches the group's first field. packages/plugin-form/src/DrawerForm.tsx if measured to share the shape. If the dev measures that the boundary belongs in the form renderer (packages/components/src/renderers/form/form.tsx, around the field rows), that file instead, with the reason in the report. Tests beside the form(s), including the objectui#12559 pins (modalSectionToggleSuspense-12559) kept green. One .changeset/13246-section-open-keeps-focus.md (patch for each package touched). ⛔ Not SectionDivider's keyboard handling (objectui#13220, landed as 4b92c5a). ⛔ Nothing under packages/components/src/ui/**. ⛔ No new export or prop. Stop on breach; explain in the report.
    Container & model: S, mode:subagent, model: opus (default tier; dispatch-gates --tier --repo objectstack-ai/objectui over this surface reads no path-derived mandate)
    Clause-②: no
    Responsibility: the risk is produced by objectui's own ModalForm, whose single body-wide Suspense swaps the whole form for its fallback while a newly opened group's lazy widgets load, unmounting the focused header. The platform path that covers it: none; later toggles keep focus only because the chunks are cached. Who reaches it: every keyboard user opening a collapsed group for the first time in a page session (measured on the showcase Semantic Zoo money group).
    Thread-read: none
    Serial constraints cleared: open objectui PRs touching ModalForm.tsx or DrawerForm.tsx: none (the ten open PRs' file lists were read at this claim). objectui#13220 (PR #13244) landed as 4b92c5a and closed; its Blocked-by on this card is met, so the card leaves pm:blocked in this claim's label write.

    Why Clause-②: no: a form keeps its own body mounted while widgets load. No export, prop, schema key or accept set of a published package moves.

    Claimed at: 2026-10-20T12:35Z


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 13246,
    "status": "done",
    "branch": "claude/issue-13246-section-open-keeps-focus",
    "pr": "#13258",
    "session": "session_01GL347th7aWQUnY2k8diSLG (subagent run: the parent seat's id)",
    "premise_still_valid": true,
    "summary": "ModalForm and DrawerForm now open and close a form section as a React transition (startTransition around the toggle's setCollapsedSections, through one local setSectionCollapsed per form, used by both section pushes of each form). Measured on unfixed main e70c21d: the toggle was an ordinary update, the newly drawn React.lazy widget suspended, and the nearest boundary that already showed content hid the drawn body with display none (hidden, not unmounted: the card's mechanism holds with that correction); in the modal, focus went to the body and then Radix put it on the role=dialog element, and the next Tab reached the dialog's first field, which is the seat's live reading. DrawerForm was measured to share the shape, worse: with no boundary of its own its toggle hid everything under the host's fallback. With the transition the drawn form stays on screen until the rows can be drawn, so the header keeps focus and the next Tab reaches the group's first field; the per-field-boundary route was not taken because the widget is the single child of FormControl's Slot (id and aria-* would need a forwarding wrapper on every widget) and it would change every form's first draw (rows one by one, initial-field focus could settle on the second field). PM mechanism assumption 3 does not hold for one line: the first objectui#12559 case asserted the body placeholder mid-load, which is exactly what hid the focused header; that line now asserts the body stays drawn, the rest of the case and the other three cases are unchanged, and a new case pins the dialog's body boundary on an ordinary update (the body's first draw).",
    "tests": "New packages/plugin-form/src/tests/sectionOpenKeepsFocus-13246.test.tsx: per host (modal, drawer) the first opening by Enter with the currency widget's chunk held keeps the header rendered and focused mid-load with no host fallback, and after release the group is open, focus is still the header, and user.tab() reaches the widget; a control per host (chunk loaded: Space closes, Enter reopens, focus stays). happy-dom lacks the focus fixup rule, so the file restores it with a MutationObserver (emulateFocusFixup); the rule was measured in headless Chromium /opt/pw-browsers/chromium: a focused element whose ancestor gets display none keeps focus synchronously, is on BODY after two frames, and stays on BODY when un-hidden. On unfixed main e70c21d: Tests 2 failed | 2 passed (4), both failures at 'expect(isRendered(header)).toBe(true)'; a probe on main read modal mid-load rendered=false active=BODY bodyPending=true, after load active=DIV role=dialog, next Tab=title input; drawer mid-load rendered=false hostFallback=true; ObjectForm simple (probe only) mid-load hostFallback=true, after load active=BODY. On the branch: sectionOpenKeepsFocus-13246 + modalSectionToggleSuspense-12559 Tests 9 passed (9). Package: pnpm exec vitest run packages/plugin-form/ at 583c45f: Test Files 198 passed (198), Tests 2372 passed | 1 skipped (2373), VERDICT command-exit 0. Ablation at head 583c45f via objectstack scripts/ablation-replace.mjs (src imported relatively, no dist involved; each restore proven: blob == HEAD, git diff HEAD empty): ModalForm startTransition removed (anchor 1 to 0, blob ce00451ec85e to 4a12350dff14) gives Tests 2 failed | 7 passed: the modal first-opening case and the 12559 header-click case; DrawerForm startTransition removed (blob fb3da1284569 to 8edce357c17c) gives Tests 1 failed | 3 passed: the drawer first-opening case. Coverage proof of the new 12559 case: ModalForm body Suspense removed gives Tests 1 failed | 4 passed, only the new first-draw case; the first attempt at this one was refused by the tool before running anything (replacement text already inside the anchor), re-run with a distinct replacement. The two transition ablations were also run at 9712ee2 with the same reds.",
    "gates": "Local, head 583c45f (git diff HEAD empty), exit codes captured before any pipe: pnpm turbo run build --filter='@object-ui/plugin-form^...' --concurrency=2 via os-verify-lock: VERDICT command-exit 0 (11 tasks successful, 6 cached). pnpm --filter @object-ui/plugin-form type-check: exit 0, the script echoed 'tsc --noEmit && tsc -p tsconfig.test.json' (an earlier run on 9712ee2 failed TS2322 in the new test's host mount, fixed in 583c45f). pnpm --filter @object-ui/plugin-form lint: exit 0, 0 errors (1241 warnings package-wide; the new test carries two no-explicit-any warnings, the sibling 12559 file's pattern). pnpm exec vitest run packages/plugin-form/: exit 0 (198 files). node scripts/check-changeset-presence.mjs: exit 0 (1 changeset declared for 4 source files of 1 released package). pnpm check:new-line-citations: exit 0, 'VERDICT new-cross-file-line-citations: 0 new citation(s)'. pnpm check:control-bytes: exit 0. pnpm check:changeset-claims: exit 0. pnpm check:pending-changeset-literals: exit 0. node scripts/check-changeset-no-major.mjs: exit 0. No --ran reconciliation: objectui has no dispatch-gates.mjs; the list is the dispatch's hand-derived one. CI, PR #13258 check-runs on 583c45f as read at report time (total_count 35, all 35 listed): Changeset Fixed Group Check success; label success; Test (coverage), Test (coverage shard matrix), dependabot skipped; in_progress: Test (shard 1/16 to 16/16), Test (dist pins), Type Check, Lint, Build & E2E, Build Docs, Bundle Analysis, Repo Gates, Line Citation Gate, Changeset Declaration, Changeset Bump Policy, Changeset Overwrite Report, Changeset Claim Re-read, Governed Surface Queue Guard, Live E2E (informational). Not waited on.",
    "files_changed": "packages/plugin-form/src/ModalForm.tsx (+21 -3); packages/plugin-form/src/DrawerForm.tsx (+14 -3); packages/plugin-form/src/tests/modalSectionToggleSuspense-12559.test.tsx (+31 -2); packages/plugin-form/src/tests/sectionOpenKeepsFocus-13246.test.tsx (+212, new); .changeset/13246-section-open-keeps-focus.md (+11, new; @object-ui/plugin-form patch). Total 5 files, +289 -8, against base e70c21d.",
    "line_budget": "n/a: no skills/** or ratcheted ledger file in the diff",
    "deviations": "(1) PM mechanism assumption 3 ('its pins must stay green'): one assertion of the first objectui#12559 case was changed, from 'the body placeholder is drawn mid-load' to 'the body stays drawn mid-load', because that placeholder replacing the body is what hid the focused header; no change that keeps focus can keep it. One case was added to the same file so the dialog's body boundary stays pinned (the toggle no longer reaches it). (2) Route: transition, not the suggested per-field boundary; both were offered, reasons in summary and PR body. (3) The landing point stayed ModalForm.tsx and DrawerForm.tsx; form.tsx, fieldGroups.ts and ObjectForm.tsx were not edited (file surface). (4) The harness's attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer; objectui AGENTS.md (model-free trailer pair) and the os-dev contract (session-URL footer) were followed instead. (5) Two temporary probe test files were created in the worktree to read where focus lands on main and on ObjectForm, then deleted; never committed. (6) Worktree /home/user/objectui-issue-13246 removed after the PR opened (node_modules first, no --force).",
    "mcp_calls": "0",
    "api_writes": "2 relay strokes plus 3 git pushes (git push is not a REST write). (a) pr_create: POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204, request fw-20261020T130517Z-5fb887, run 39427446757 success), then the relay sent POST /repos/objectstack-ai/objectui/pulls (draft, #13258) and POST /repos//issues/13258/assignees (os-support-ai); read-back 7598 bytes sent, 7598 stored, identical; assignee listed. No labels. (b) This os-dev-report comment via post-stamped.mjs on #13246: POST /repos//issues/13246/comments.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none (承接者:无) · noted in the PR's Acceptance notes, not filed: ObjectForm's full-page form (formType simple) has the same toggle path; a unit probe on this branch read the host fallback replacing the page mid-load and focus ending on BODY. No reach: no app under objectstack examples/ declares editMode (zero hits for 'editMode' under examples/, control hit: semantic-zoo collapse 'collapsed'), which is what routes the console to RecordFormPage. Class fix if ever wanted: one startTransition in resolveSectionCollapse (fieldGroups.ts), which every arm's toggle goes through. Dedupe words: full-page form section toggle focus lost; ObjectForm collapsed section route fallback; resolveSectionCollapse transition.",
    "carrier: none (承接者:无) · noted in the PR's Acceptance notes, not filed: read from source only, not measured: a visibleWhen that reveals a field whose widget is not loaded yet is still an ordinary update, so it would still hide the dialog body behind ModalFormBodyPending (and the input being typed in with it)."
    ],
    "browser_reading": "Not owed and not run. Where to see it: showcase object showcase_semantic_zoo, field group 'money' (collapse: 'collapsed'), Create dialog, in a fresh page session so the currency chunk is not cached. Tab to the header ([role=dialog] form [role=button][aria-expanded] with text 'Money'), press Enter: focus should stay on the header, the group opens when its fields can be drawn, and the next Tab reaches 'amount'."
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #13258 comment 6291784045 · domain:ui seat 3, session_01GL347th7aWQUnY2k8diSLG · 2026-10-20T13:19Z · head 583c45f · live first-opening reading in the record · landing through the merge queue.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:ui seat 3, session_01GL347th7aWQUnY2k8diSLG · 2026-10-20T13:32Z

    reading result
    PR #13258 MERGED through the merge queue, merge commit ff128e3 on main
    queue branch gh-readonly-queue/main/pr-13258-* absent (git ls-remote: 0 refs)
    origin/main ff128e3 startTransition(() => setCollapsedSections in ModalForm.tsx (1) and DrawerForm.tsx (1)

    State: the card closed completed by the PR's Fixes; pm:dispatched is removed in this pass. The live first-opening reading (focus stays on "Money" and the next Tab reaches amount at the head; focus on the dialog at the parent) is in the ACCEPT, 6291784045.


    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:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions