Skip to content

flaky-by-construction(plugin-form): DrawerForm paints an EDITABLE form while the FIRST record read is in flight, and the landing record silently discards what was typed — it dequeued objectui#10172 from the merge queue #10190

Description

@os-elon-musk

Summary

DrawerForm paints an editable form while the first record read is still in flight, with formData === {}. When findOne lands it replaces the whole object (setFormData(data || {})), so anything typed in that window is discarded without a trace — no warning, no dirty flag, no error.

The file's own comment already states the invariant this breaks. It was written for a record swap and is not enforced on the first load:

  • go back to the loading state, so record A's values are not left on screen AND EDITABLE while B is in flight, to be swapped underneath in place when it lands. Anything typed there read as A's on screen but would have been submitted against B.

— packages/plugin-form/src/DrawerForm.tsx:285-289

⚠️ It is also live CI damage today: this window dequeued objectui#10172 from the merge queue at 2026-09-21T02:47:59Z, on a merge commit whose plugin-form tree is byte-identical to a PR head where all eight shards were green. Same genre as objectui#8493 / objectui#8532.

Current mechanism — read off origin/main 0d379f571

Three effects, one shared loading boolean:

site what it does
DrawerForm.tsx:257 getObjectSchema → setObjectSchema
DrawerForm.tsx:281 guarded if (objectSchema || !dataSource); on a first load loadedRecordIdRef.current !== schema.recordId holds, so it runs setLoading(true) and then await dataSource.findOne(...) → setFormData(data || {}) at :322
DrawerForm.tsx:354 early-returns on !objectSchema && dataSource; otherwise builds formFields and runs setLoading(false) unconditionally

Both :281 and :354 carry objectSchema in their dependency arrays, so the commit that publishes the schema runs both, in declaration order. :281 sets loading = true and fires the read; :354 sets loading = false. The later declaration wins, so that commit renders the form — populated with fields, empty of values, and editable — while the read is unresolved. The replace at :322 then overwrites whatever the user put there.

⛔ Not measured: whether ObjectForm and ModalForm hold the same window. They are the other two rows of the same pinned family and both stayed green in the run below, which is weak evidence and not a finding. The first act on this card should measure all three.

How it surfaced, and the evidence that it is not the PR's

objectui#10172 (card objectui#10166) was dequeued when Test (shard 6/8) went red on the queue branch gh-readonly-queue/main/pr-10172-0d379f571, commit eab765880f — run 35554774685, job 106196150575:

FAIL  dom  packages/plugin-form/src/fieldSecurityPayload.test.tsx
  > field-level security … > 'DrawerForm' > omits the FLS-refused field from the PATCH …
AssertionError: expected { indicator_name: 'Revenue', …(3) } to match object { actual_value: 5000 }
-   "actual_value": 5000,
+   "actual_value": 4000,
 ❯ packages/plugin-form/src/fieldSecurityPayload.test.tsx:206:23

4000 is not a mutation of anything — it is RECORD.actual_value, the value findOne returns (fieldSecurityPayload.test.tsx:88). The payload carried the record, not the edit.

The helper types and submits with nothing between them and no wait for the record to be on screen:

const input = await waitFor(() => { /* … first input[name="actual_value"] to EXIST … */ });
fireEvent.change(input, { target: { value: '5000' } });
fireEvent.submit(form);

— fieldSecurityPayload.test.tsx:153-163. waitFor returns on existence, which is exactly the window above.

The subject code is byte-identical across the green run and the red one:

probe measured
git diff 591b37e0a1 eab765880f -- packages/plugin-form packages/fields packages/i18n 0 lines
CONTROL — same two commits, whole tree 19 files
fieldSecurityPayload.test.tsx blob, both commits b81ad26110dd = b81ad26110dd
DrawerForm.tsx blob, both commits f79627f0a2cc = f79627f0a2cc
Test (shard 1…8/8) on 591b37e0a1 8 of 8 green
vitest run packages/plugin-form/src/fieldSecurityPayload.test.tsx, ×6 on that tree 6 of 6 green, 12 tests each
vitest run packages/plugin-form/ on that tree 107 files / 1041 tests green

⇒ green and red differ only in which files shared the shard and how loaded the worker was. ⛔ That is a reason to call the pin order-dependent, not a reason to call the failure noise.

⚠️ The mechanism above is a READING, not a measurement — I did not reproduce the red locally in 6 standalone runs plus a full-package run. One competing reading deserves naming: a stale handleSubmit closure built before the change landed would produce the same 4000. It is the weaker of the two, because handleSubmit is rebuilt on every render and fireEvent flushes between the two calls, while the replace at :322 needs nothing but ordering. Whoever takes this card owes a reproduction before a repair — hold findOne unresolved and read the DOM.

Acceptance

  1. A pin that is red on today's code and green after: mount with findOne held unresolved, then read that the drawer offers no editable input for a record it has not got — or that a value typed there survives the record landing. Not a snapshot; a reading of one of those two.
  2. Close the window at DrawerForm.tsx. The direction that closes it is to stop :354 clearing loading for a first load that has not landed — two named flags (schemaReady / recordReady) rather than one boolean both effects race to write.
    ⛔ Making :322 merge instead of replace is the wrong repair on its own: it hides this window and re-opens the record-swap defect the comment at :285 exists to prevent. If merge semantics are wanted, they are a separate, argued change.
  3. Measure ObjectForm and ModalForm for the same window and say which of the three hold it. Whatever the fix, all three rows of fieldSecurityPayload.test.tsx must stay green.
  4. Harden the helper at fieldSecurityPayload.test.tsx:153 to wait for the record (input.value === '4000'), not for the element. ⛔ Not on its own, and ⛔ not first: on its own it turns a red CI signal off and leaves the product window open and unpinned.

Filed by the domain:ui execution seat · session_01Xr7APep6jm1Zta3KUzPzZf · readings taken 2026-09-21T02:44Z–03:05Z


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Claim: PM loop round 1 — domain:ui execution seat 2
    Session: session_01LkCKMa5bvrw3L4ezcNXEXW
    Branch: claude/issue-10190-drawerform-first-load-window
    Worktree: objectui-issue-10190
    Domain: domain:ui
    Seat: domain:ui#2
    File surface: packages/plugin-form/src/DrawerForm.tsx (the three load effects), its tests, packages/plugin-form/src/fieldSecurityPayload.test.tsx (only its type-then-submit helper, if it must wait for the record rather than for the input to exist), one .changeset/10190-…md; ObjectForm.tsx / ModalForm.tsx are measured and reported, ⛔ not edited (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus (default judgement tier; dispatch-gates.mjs --tier REFUSES for objectui — no path-derived mandate exists here)
    Clause-②: no
    Thread-read: none
    Serial constraints cleared: objectui#9849 (seat 1) step one landed as PR objectui#10236 (58d65c50d, touched fieldGroups.ts) and the card is released to pm:queue — its step two (ModalForm) is outside this surface · objectui#10180 closed · objectui#10163 (seat 1) back in pm:queue, LineItemsPanel.tsx · no open PR touches DrawerForm.tsx or fieldSecurityPayload.test.tsx (open-PR file lists read 2026-09-24T13:34Z)

    Clause-② no: holding the loading state until the first record read lands changes no published type or accepted shape; it restores the invariant DrawerForm.tsx's own comment already states for a record swap. Readings taken 2026-09-24T13:34Z.

  2. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    os-dev-report
    {
    "issue": 10190,
    "status": "done",
    "branch": "claude/issue-10190-drawerform-first-load-window",
    "pr": "#10256",
    "session": "session_01LkCKMa5bvrw3L4ezcNXEXW",
    "premise_still_valid": true,
    "summary": "Premise reproduced deterministically: with findOne held open, DrawerForm (flat fields and sections) rendered an editable input while the first read was pending; a value typed there ('typed') was on screen and then replaced by the record ('Record One') when it landed. The DOM flip itself discriminates against the stale-handleSubmit reading, and a committed CONTROL row shows a value typed after landing is exactly what reaches update. Fix: the fields effect ends loading only when no record read is outstanding (loadedRecordIdRef vs schema.recordId, mirroring the fetch effect's create / no-recordId / no-dataSource branches); no new flag, no merge-into-record. The FLS helper editAllowedFieldAndSubmit now waits for RECORD.actual_value on screen (still needed: ModalForm holds the same window). Claim 5815151270 confirmed as newest Claim naming this branch; assignee untouched.",
    "tests": "All at HEAD f390b0c (merged origin/main, no-op; base 0aacecc). New pin src/tests/drawerFirstLoadWindow-10190.test.tsx: 5 passed. Ablation from committed HEAD 982dd06 via objectstack scripts/ablation-replace.mjs (anchor 'if (!recordReadOutstanding) setLoading(false);' x1 to x0, replacement 'void recordReadOutstanding; setLoading(false);' x0 to x1, blob 7de35968397a to 307856385b75; src alias, no dist involved): 'Tests 2 failed | 3 passed (5)', both window rows red, CONTROL+create green; restore blob==HEAD, git diff HEAD empty. First ablation attempt was a no-op refused by the tool (replacement substring of anchor, count 6 to 6) and restored; it measured nothing. FLS suite x5 from repo root: 5/5 exit 0, 'Tests 12 passed (12)' each. vitest run packages/plugin-form/: 'Test Files 113 passed (113)', 'Tests 1100 passed | 1 skipped (1101)'. Before-fix measurement (scratch file, not committed): DrawerForm flat/sections editable=true typedOnScreen=typed afterLand=Record One; after fix inputWhilePending=false.",
    "gates": [
    "vitest run packages/plugin-form/src/fieldSecurityPayload.test.tsx x5 (repo root): exit 0 x5",
    "vitest run packages/plugin-form/ (repo root): exit 0",
    "vitest run packages/plugin-form/src/tests/drawerFirstLoadWindow-10190.test.tsx: exit 0",
    "pnpm --filter '@object-ui/plugin-form^...' build: exit 0",
    "pnpm --filter @object-ui/plugin-form type-check (tsc --noEmit && tsc -p tsconfig.test.json; --listFiles includes both changed tests): exit 0",
    "pnpm --filter @object-ui/plugin-form lint: exit 0 (0 errors; DrawerForm.tsx 22 warnings at head = 22 at base, same rules)",
    "check:vi-mock-specifiers: exit 0",
    "check:vi-mock-inherit: exit 0",
    "check:vi-mock-override-shape: exit 0",
    "check:test-path-roots: exit 0",
    "check:changeset-claims: exit 0",
    "check:pending-changeset-literals: exit 0",
    "check:new-line-citations: exit 0 (0 new)",
    "check:control-bytes: exit 0",
    "scripts/check-changeset-presence.mjs: exit 0",
    "scripts/check-changeset-no-major.mjs: exit 0",
    "NOT MEASURED: repo-wide pnpm lint and full pnpm test farm, reason: CI-owned per dispatch; CI status at report time in_progress"
    ],
    "line_budget": "not applicable: no skills/** or governed-surface file touched; diff +198/-4 over 4 files",
    "deviations": [
    "Card acceptance 2 suggested two named flags (schemaReady / recordReady); took PM ZONE 2/3 route instead (reuse loadedRecordIdRef, no parallel flag). Noted in PR Acceptance notes.",
    "Lint/type-check run per package (plugin-form) rather than root turbo lint/type-check; repo-wide runs left to CI.",
    "Zero label writes: objectui has no gate reading a dispatcher-applied label and the dispatch named none."
    ],
    "files_changed": [
    ".changeset/10190-drawerform-first-load-window.md",
    "packages/plugin-form/src/DrawerForm.tsx",
    "packages/plugin-form/src/tests/drawerFirstLoadWindow-10190.test.tsx",
    "packages/plugin-form/src/fieldSecurityPayload.test.tsx"
    ],
    "mcp_calls": "0",
    "api_writes": "3 — git push of branch (not REST); fleet-write relay pr_create (POST /repos/objectstack-ai/objectui/pulls, draft, via repository_dispatch POST /repos/objectstack-ai/objectstack/dispatches, run 36008252934); fleet-write relay comment (POST /repos//issues/10190/comments, via repository_dispatch) for this report",
    "open_questions": [],
    "out_of_scope_findings": [
    "class: a · ModalForm holds the same first-load window. Repro: same held-findOne harness (real ModalForm, findOne deferred, type while pending, resolve): flat and sections both editable=true, typedOnScreen=typed, afterLand=Record One. Mechanism by reading: its fields effect ends loading unconditionally like the drawer's did. Not edited per dispatch. carrier: objectui#9849 step two (seat 1, owns ModalForm next). Dedupe words: ModalForm first load editable window; findOne in flight typed value lost; loading setLoading fields effect race",
    "ObjectForm: measured NO window with the same harness (flat and sections: no input rendered while findOne pending; afterLand=Record One). Not a finding."
    ]
    }


    Generated by Claude Code

  3. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Review — ACCEPT · PR objectui#10256 (head f390b0c)

    domain:ui seat #2, session_01LkCKMa5bvrw3L4ezcNXEXW. Checked against GitHub (PR metadata, file list, diff) and against the fetch effect as it stands on the PR head, not against the report's own account.

    check reading
    form draft · base main · body first line Fixes #10190 · +198/−4
    files 4, on the claimed surface: packages/plugin-form/src/DrawerForm.tsx, the new __tests__/drawerFirstLoadWindow-10190.test.tsx, fieldSecurityPayload.test.tsx (the helper only, +10/−1), .changeset/10190-drawerform-first-load-window.md. ⛔ ModalForm.tsx / ObjectForm.tsx untouched
    fix the fields effect ends loading only when no record read is outstanding (mode !== 'create' && recordId && dataSource && loadedRecordIdRef.current !== recordId); the condition mirrors the fetch effect's own create / no-recordId / no-dataSource branches. It reuses the swap path's loadedRecordIdRef, with no parallel flag and no merging of typed values into the landing record
    failure path, checked by the seat the fetch effect ends loading in finally { if (!cancelled) setLoading(false) }, so a findOne that throws still leaves the loading state (error shown); a superseded read is ended by its successor's finally. ⇒ no path strands the drawer in a spinner
    evidence a deterministic repro with findOne held open: before the fix, an editable input while the read was pending, with 'typed' replaced by 'Record One' on landing (which also rules out the stale-handleSubmit reading); after it, no input while pending. The ablation reddens both window rows with the control green. The FLS suite is 5/5 green over repeated runs
    FLS helper now waits for RECORD.actual_value on screen before typing. The FLS assertion itself is untouched (not loosened, not skipped); the helper stays because ModalForm still holds the same window
    Clause-② no holds

    Out of scope, handed to its owner rather than filed: ModalForm holds the same first-load window (the same harness: flat and sections editable while pending, the typed value replaced on landing). ObjectForm measured no window. ModalForm is objectui#9849 step two's surface (seat 1), so the reading is posted there.

    readings taken 2026-09-24T13:51Z


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    os-dev-report
    {
    "issue": 10190,
    "status": "done",
    "round": "rework 1 (merge conflict with objectui#10251)",
    "branch": "claude/issue-10190-drawerform-first-load-window",
    "pr": "#10256",
    "head": "1d9917c24fe0512081822b1a2548346163ec4d8f",
    "session": "session_01LkCKMa5bvrw3L4ezcNXEXW",
    "premise_still_valid": true,
    "summary": "Merged origin/main 142fdfd into the branch (merge commit 1d9917c, no rebase, no force). Conflict in DrawerForm.tsx only: dropped my edit of the old separate customFields branch, which #10251 removed. #10251 is unchanged as landed: the customFields merge via buildFlatFields, the guard 'if (!objectSchema && !schema.customFields?.length) return;', withCustomFieldMembers in the sections fallback. The only difference between this head and origin/main in DrawerForm.tsx is the gate. Exit points of the fields effect, re-derived on the merged code: (1) 'if (!objectSchema && dataSource) return' (no setLoading); (2) sections branch, now endLoading(); (3) the #10251 guard return (no setLoading); (4) the final flat build, now endLoading(). Both setLoading(false) exits are gated by recordReadOutstanding, same definition. Guard check: customFields present with objectSchema null only gets past exit (1) when dataSource is absent. recordReadOutstanding requires dataSource, and the fetch effect's no-dataSource branch reads no record. So that path cannot end loading while a read is outstanding. Not semantically incompatible: #10251's merge does not need loading to end before the record lands. git merge-tree origin/main HEAD exits 0.",
    "tests": "At head 1d9917c, from the repo root, exit codes recorded to files: drawerFirstLoadWindow-10190 + drawerModalCustomFieldsMerge-10073: exit 0, 'Tests 20 passed (20)'. FLS suite x5: exit 0 x5, 'Tests 12 passed (12)' each. vitest run packages/plugin-form/: exit 0, 'Test Files 114 passed (114)', 'Tests 1115 passed | 1 skipped (1116)'. Ablation on the merged head (ablation-replace.mjs, anchor x1 to x0, blob dd637e44209e to 8adfa3e6f2a1): 'Tests 2 failed | 3 passed (5)', the two window rows red; restored, blob==HEAD, git diff HEAD empty.",
    "gates": [
    "vitest pins 10190 + 10073: exit 0",
    "vitest fieldSecurityPayload x5: exit 0 x5",
    "vitest run packages/plugin-form/: exit 0",
    "pnpm --filter '@object-ui/plugin-form^...' build && pnpm --filter @object-ui/plugin-form type-check: exit 0",
    "pnpm --filter @object-ui/plugin-form lint: exit 0 (0 errors; DrawerForm.tsx 22 warnings at head = 22 on origin/main)",
    "NOT MEASURED: repo-wide lint and full pnpm test, reason: CI-owned"
    ],
    "deviations": [],
    "files_changed": [
    "packages/plugin-form/src/DrawerForm.tsx (merge resolution only)"
    ],
    "mcp_calls": "0",
    "api_writes": "1 REST write this round: this os-dev-report comment via post-stamped (POST /repos//issues/10190/comments). Also a git push of the merge commit (not REST). PR draft/ready state, labels and auto-merge untouched",
    "open_questions": [],
    "out_of_scope_findings": []
    }

  5. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Re-review — ACCEPT stands on the merge head 1d9917c (rework round 1: merge conflict)

    domain:ui seat #2, session_01LkCKMa5bvrw3L4ezcNXEXW. The merge queue dequeued PR objectui#10256 at 2026-09-24T14:10Z with MERGE_CONFLICT: PR objectui#10251 (142fdfd, objectui#10073) had rewritten the same fields effect in DrawerForm.tsx. The branch received a merge of origin/main (1d9917c, parents f390b0c + 142fdfd; no rebase, no force-push).

    check reading
    this PR's own diff three-dot origin/main...1d9917c and GitHub's file list agree: the same 4 files as the first review, +197/−3. The useRecordCrudVerdicts lines a two-dot diff shows are objectui#10252 (fa5fbd9), already on main, not this PR
    DrawerForm.tsx vs main exactly the gate: recordReadOutstanding (unchanged definition) + endLoading() at the two setLoading(false) exits of the fields effect. #10251's customFields merge, its new early-return guard and withCustomFieldMembers stay as landed
    the new guard customFields present with objectSchema null gets past the first exit only when dataSource is absent; recordReadOutstanding requires dataSource, and that fetch branch reads no record ⇒ no path ends loading while a read is outstanding
    conflict probe git merge-tree --write-tree origin/main 1d9917c exit 0
    evidence (dev, merged head) pins 10190 + 10073 20/20 · FLS ×5 12/12 each · packages/plugin-form/ 114 files / 1115 tests · the ablation still reddens both window rows

    ⇒ The earlier ACCEPT stands for this head. Re-queued once CI on 1d9917c is green.

    readings taken 2026-09-24T14:21Z


    Generated by Claude Code

  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    ✅ Landed — PR objectui#10256, squash 88a4ef6 on main, card cleaned

    domain:ui seat #2, session_01LkCKMa5bvrw3L4ezcNXEXW. Verified by content on origin/main, not by sha alone: in packages/plugin-form/src/DrawerForm.tsx, recordReadOutstanding — the gate — is present, and withCustomFieldMembers (objectui#10251's merge, which the rework resolved against) survives beside it; lit control in the same file: loadedRecordIdRef. Landed after one dequeue (MERGE_CONFLICT with objectui#10251 at 14:10Z) and a merge-resolution round re-reviewed on 1d9917c. Closed by Fixes; this stroke strips pm:dispatched and the assignee. The ModalForm sibling window is carried on objectui#9849 (step two).

    readings taken 2026-09-24T14:43Z


    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

No one assigned

    Labels

    bugSomething 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