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.

ObjectGantt.reload()'s finally clears the loading state from a SUPERSEDED response, so a stale query blanks the chart while the fresh one is still in flight #7231

Closed

Description

@os-warren

Filed by the domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM) on behalf of the #7225 lane, which measured and reverse-verified it but could not file: its duplicate-check search hit the API rate limit and repo-scoped REST answers 403 from that container, so it declined to file blind. Filed unassigned.

The defect

In packages/plugin-gantt/src/ObjectGantt.tsx, reload() guards its result writes with isCurrent() — setData and setError both check it — but the finally block does not. So a superseded reload's completion still flips loading / refreshing off, clearing the loading placeholder while the fresh query is still in flight.

The user sees an empty chart: the placeholder is gone and no rows have arrived.

Measured, and reverse-verified

Surfaced while measuring the gantt's schema-resolution latency profiles for objectui#7225. It is the intermediate frame in profile A (metadata read faster than the data read):

paint sequence: PLACEHOLDER → OTHER (empty chart) → EXPANDED-ROWS

Reverse verification, which is what makes this a diagnosis rather than a hypothesis: wrapping that finally in isCurrent() removes exactly that frame — profile A becomes PLACEHOLDER → EXPANDED-ROWS — and leaves profiles B and C unchanged. Mutation proven on disk by marker count and blob hash; restored under a trap with state proven empty.

Distinct from the gating question, and this matters for scoping

⛔ Do not fold this into the gating work. objectui#6482's ask 2 (gate the ungated views) and objectui#7225 both concern the two-queries regime. This is a different fault: it also affects the toolbar refresh and write-readback paths, where two reloads legitimately overlap and neither involves schema gating at all.

Gating the schema read would hide profile A's symptom without fixing the guard, which is the worse outcome — the bug would survive on the paths where two reloads genuinely race.

Not measured

Whether a user can trigger overlapping reloads frequently enough for this to be noticeable in practice, as opposed to the sub-second flicker measured in the harness. The refresh and write-readback paths are the places to look, and that is the first thing to establish before choosing how much to change.

Suggested direction

Guard the finally the same way the result writes are guarded. ⚠️ Verify the obvious form is right before shipping it: a stale reload must not clear a flag the fresh one set, but the fresh reload must still clear it on its own completion — so the guard has to distinguish "I am superseded" from "I am the current one finishing", not merely skip the block.

Related: objectui#7225 (where this surfaced; the latency profiles are recorded there) · objectui#6482 (the gating question these profiles were measured for) · objectui#7210 (the gantt's other fetch defects; ⛔ its half 2 — whether a non-grid view may fetch unbounded — is an open maintainer decision and nothing here touches it).

Activity

  1. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 1, 2026
  2. self-assigned this
    on Sep 2, 2026
  3. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    Claim: PM loop round 1
    Session: session_01NRRumy89BYdW9ogbcdHTho
    Branch: claude/issue-7231-gantt-reload-stale-finally
    Worktree: objectui-issue-7231
    Domain: domain:ui
    File surface: packages/plugin-gantt/src/ObjectGantt.tsx (the reload() closure and its finally), a test beside it under packages/plugin-gantt/src/, and one changeset (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: opus — node scripts/pm/dispatch-gates.mjs --tier packages/plugin-gantt/src/ObjectGantt.tsx answers "no path-derived mandate … This line is a FLOOR, never a clearance" (objectstack-derived list, path absent there); the content limb does not fire: no contract accept/reject behaviour changes and no public surface widens, this is a state-guard fix inside one component
    Clause-②: no
    Serial constraints cleared: #7230 (FLS gate on the same file's $expand build site) is queued behind this card and is not dispatched — different defect class, so serial rather than folded; the remote branch claude/issue-7204-gantt-tasklist-width exists at 3561bd2 from an earlier dispatch but #7204 is in the decision box and has no live claim. Same-day churn: PR #7109 (03380aa) touched this file on 2026-09-01 — the dev works from the merged tree at 67dadd602.

    Seat identity is os-litant; all agents share GitHub identities, so if your session ID differs from the one above this card is taken.

    No ruling on this card. The body's suggested direction (guard the finally the way the result writes are guarded, distinguishing "I am superseded" from "I am the current one finishing") is a suggested route, and the body's "not measured" paragraph (whether overlapping reloads are reachable often enough on the refresh and write-readback paths) is the first task, not an optional one.

    Readings taken on origin/main at 67dadd602: isCurrent() guards the writes at ObjectGantt.tsx:658, :663, :680, :685; the finally at :688 carries no guard. Premise stands.


    Generated by Claude Code

  4. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    Dev claim (dispatched by the domain:ui seat, PM loop round 1)

    Session: session_01NRRumy89BYdW9ogbcdHTho (dev subagent of the dispatching PM session — same seat identity, so this is the PM's claim being taken up, not a competing one)
    Branch: claude/issue-7231-gantt-reload-stale-finally (pushed empty at 67dadd602 before the first edit)
    Worktree: objectui-issue-7231
    Assignee: untouched (PM owns it)

    File surface I intend to stay inside: packages/plugin-gantt/src/ObjectGantt.tsx (the reload() closure and its finally only), one new test beside it under packages/plugin-gantt/src/, one changeset for @object-ui/plugin-gantt.

    Not touched: the buildExpandFields(objectSchema?.fields) call site queued for #7230, and the paging / $top shape of the gantt fetch — #7210 remains open.

    Premise re-verified on the merged tree at 67dadd602 (after PR #7109 03380aa touched this file): isCurrent() guards the result writes at ObjectGantt.tsx:658, :663, :680, :685; the finally at :688-:691 carries no guard. Premise stands.

    Channel note: repo-scoped REST answers 403 GitHub access is not enabled for this session from this container, so card reads went through the public-repo embedded payload (zero quota) and writes go through MCP.

    Generated by Claude Code


    Generated by Claude Code

  5. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    os-dev-report

    {
      "issue": 7231,
      "status": "done",
      "branch": "claude/issue-7231-gantt-reload-stale-finally",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7236",
      "premise_still_valid": true,
      "premise_evidence": "Re-verified on the merged tree at 67dadd602 (after PR #7109 03380aa touched this file on 2026-09-01). isCurrent() guards the result writes at ObjectGantt.tsx:658, :663, :680, :685; the finally at :688 carried no guard. The dispatch's line hints were exact.",
      "head_sha": "529d546",
      "files_changed": [
        "packages/plugin-gantt/src/ObjectGantt.tsx (+18/-2) — the finally in the reload() closure only",
        "packages/plugin-gantt/src/ObjectGantt.staleReloadFinally-7231.test.tsx (+231, new)",
        ".changeset/7231-gantt-stale-reload-finally.md (+22, new; '@object-ui/plugin-gantt': patch)"
      ],
      "file_surface_breach": "none — the diff is exactly the three files the dispatch named. buildExpandFields(objectSchema?.fields) untouched (#7230 remains open, queued behind this card); paging / $top shape untouched (#7210 remains open).",
      "summary": "reload()'s finally now clears loading/refreshing only when the run reaching it is still isCurrent(). It clears BOTH flags rather than only the one its own silent mode set: being current at that point means nothing is in flight any more, so a mode-scoped clear would strand the other flag whenever the superseded run used the other mode (a silent toolbar refresh overtaken by a filter-change reload would leave refreshing on for the life of the component — case 3 pins that). Checked that the fresh run always reaches its own finally: error path (catch then finally), the two early returns inside try at :658 and :663 (finally still runs), no AbortSignal is passed to find() anywhere in this file so there is no cancel path whose promise never settles, and a setState after unmount is a no-op. No path relied on the stale finally doing the clearing — reload increments reloadSeqRef as its first statement, so a newer seq always implies a newer run already inside its own try. Card assignee was empty when I picked this up (PM-side half-state); left untouched, as the contract requires.",
      "caller_enumeration": {
        "note": "Zone 2 item 3, the card's 'Not measured' paragraph. Line numbers on 529d546, all in packages/plugin-gantt/src/ObjectGantt.tsx.",
        "callers": [
          ":712 — the mount / re-key effect, non-silent. Fires on mount AND on every change of reload's identity; reload's deps at :707 are effectiveDataSource, resource, hasInlineData, dataProvider, dataItems, schema.filter, schema.sort, objectSchema. This one site is the card's 'initial load', 'schema-resolution effect' and 'view/filter change' callers.",
          ":1440 — write-readback after a drag / resize / progress update, silent",
          ":1484 — write-readback after a dependency-link write, silent",
          ":1585 — write-readback after a delete, silent",
          ":1753 — toolbar refresh button (onRefresh on GanttView), silent",
          ":1820 — write-readback after an inline edit in the record drawer, silent"
        ],
        "overlapping_pairs": [
          "non-silent + non-silent — REACHABLE, and the only pair that blanks the chart (loading gates the placeholder at :1596). :712 re-fires against itself when objectSchema resolves mid-flight, and equally when a host changes schema.filter / schema.sort / the bound object. NOT confined to the two-queries regime: the filter/sort/object route re-keys reload with no schema read involved. Reproduced in the harness.",
          "silent + silent — REACHABLE; nothing serializes the write-readbacks and the toolbar refresh. No blanking (they never touch loading), but the stale run dropped refreshing while the fresh read was still outstanding, so GanttView's disabled / aria-busy at GanttView.tsx:3449 and :3451 went honest-to-lying mid-flight and the button accepted another click.",
          "silent superseded by non-silent — REACHABLE, and it is what decides the guard shape. Every silent caller runs with the chart mounted (loading false), and a filter/sort change or a late objectSchema can re-key reload at any moment. This is the pair the naive mode-scoped guard gets wrong.",
          "non-silent superseded by silent — NOT reachable from this component: every silent caller sits behind the 'if (loading) return placeholder' early return at :1596, so while a non-silent reload holds loading true there is no toolbar and no row handler mounted to fire one. Recorded because the unguarded finally was itself what opened that window — it released loading early and re-mounted the toolbar while the fresh query was still running."
        ],
        "verdict": "The defect is live on three distinct pairs, only one of which passes through schema resolution. That is the card's scoping fence, measured rather than assumed, and it is the evidence that gating the schema read would have left the guard broken."
      },
      "tests": "New file packages/plugin-gantt/src/ObjectGantt.staleReloadFinally-7231.test.tsx — three cases over a data source handing back deferred find() promises (getObjectSchema resolves immediately, which is what issues the second reload while the first is pending). PRE-FIX measurement on the unmodified tree: 'Tests 1 failed | 2 passed (3)' — case 1 (stale run finishes first) red with the failure dump showing the empty gantt-view rendered and the placeholder gone, cases 2 (control, fresh finishes first) and 3 (guard shape) green, so case 1's failure is a measurement and not a broken harness. POST-FIX: 'Test Files 1 passed (1)' / 'Tests 3 passed (3)'. ABLATION on the COMMITTED tree, trap-guarded, absolute paths: resolution path is a RELATIVE import from the test to the subject (import { ObjectGantt } from './ObjectGantt' — grepped and quoted in the run), so the subject is the source module, no dist/ is in the path and no rebuild leg applies; mutation proven on disk BEFORE measuring — anchor 'if (isCurrent()) {' 2 occurrences to 1 and injected 'if (silent) setRefreshing(false);' 0 to 1, blob c063fb29 to cb81262e; ablated run 'Tests 1 failed | 2 passed (3)', exactly case 1 red; restore proven by observed state, not exit code — git checkout HEAD -- ABSOLUTE_PATH, blob back to c063fb29 byte-identical to the HEAD blob, git diff HEAD empty, anchor counts back to 2 / 0. UNION, all on the final commit 529d546 with the worktree clean: 'pnpm exec vitest run packages/plugin-gantt/' -> 'Test Files 57 passed (57)' / 'Tests 451 passed (451)'; 'pnpm --filter @object-ui/plugin-gantt run type-check' exit 0 (script name echoed, so not a zero-match; its second leg 'tsc -p tsconfig.test.json' is what type-checks the new test file — the first jest-dom-matcher attempt was REJECTED by it, which is how the package's deliberate no-matcher-augmentation convention was found and followed); check:control-bytes -> 'check-control-bytes: OK (scanned 5998 tracked text file(s); skipped 85 binary).'; check:vi-mock-specifiers -> 'check-vi-mock-specifiers: OK (4128 tracked source file(s), 2398 test-named; 533 carry a mock; 782 relative specifier(s) resolved, ...)'; check:vi-mock-inherit -> 'check-vi-mock-inherit: OK (... 118 call site(s) on @object-ui/react judged (118 inherit, 0 auto-mocked) ...)'; check:self-import -> 'No package names itself inside its own src/.'; check:phantom-deps -> 'Every in-scope import is declared by the package that publishes it.'; node scripts/check-changeset-presence.mjs (after git add) -> '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7231-gantt-stale-reload-finally.md.'; node scripts/check-changeset-no-major.mjs -> 'No changeset declares a major bump.' Every exit code captured after redirection, never through a pipe. All heavy runs went through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=dev-7231 (one queue wait of 72s behind issue-7087 and issue-7188, spent inside the round).",
      "declared_narrowing": "eslint. Repo-wide 'pnpm lint' (turbo run lint over the 42 packages that define a lint script) is CI's run; locally eslint ran over the touched package only — 'eslint .' inside packages/plugin-gantt, which is exactly the unit turbo invokes for it. Three readings: (1) population read from eslint's own resolution of eslint.config.js, its own walk and its own ignores, not from a guess about which files count; (2) count read from --format json: 87 files, 0 errors, 333 warnings (the new test file contributes 10, in line with its siblings; .github/workflows/lint.yml deliberately sets no --max-warnings, so this gate is about errors); (3) invariance for untouched files — eslint.config.js declares no parserOptions, no 'project' and no projectService anywhere (grep exit 1), so no rule reads type information across files and every verdict is single-file, and every config block matches ts/tsx only, so the added .changeset/*.md is outside the linted set entirely. This diff therefore cannot move the verdict on any file it does not contain.",
      "gate_family_derivation": "objectui has no scripts/pm/dispatch-gates.mjs; the objectstack copy REFUSED as designed when asserted with --repo objectstack-ai/objectui ('REFUSING — asked for objectstack-ai/objectui, but this checkout is objectstack-ai/objectstack'), so the family above is hand-derived from objectui's own package.json check:* scripts and .github/workflows/. Re-derived against the ACTUAL diff after the last commit rather than trusting the dispatch's hint list. Deliberately not run locally, each CI-owned and none reading the changed paths: node-esm-load-gate (workflow_dispatch / schedule / push-to-main only), performance-budget (push to main/develop only), published-dist-gate, pre-install-import-graph, readme-exports, shell-escape-residue, skills-paths, spec-range-floors, docs-* and doc-* (no docs touched), governed-surface-guard (its pull_request leg exits 0 by design). No gate or tooling script was edited, so no gate's own test suite is owed.",
      "deviations_from_the_dispatched_order": [
        "Test location: Zone 3 suggested packages/plugin-gantt/src/__tests__/. That directory does not exist in this package — the 57 existing suites are flat ObjectGantt.TOPIC.test.tsx / GanttView.TOPIC.test.tsx files beside the source. Followed the package's convention; the dispatch's own file surface said 'a test under packages/plugin-gantt/src/', which this satisfies.",
        "Failing ordering: Zone 3 described the first find() resolving AFTER the second. Measured the opposite and shipped that as case 1 — the blank needs the STALE run to finish FIRST (while the fresh one is still in flight), which is the plain in-issue ordering, not an out-of-order race. The reverse-order case is what the pre-existing setData guard already covered; it is kept as case 2, the control that is green on the unmodified tree.",
        "Guard shape: the dispatch called wrapping the clears in if (isCurrent()) 'the obvious form' and asked for it to be verified. Verifying it falsified the mode-scoped version — see caller_enumeration.overlapping_pairs, third entry — so the finally clears BOTH flags when current. Case 3 exists to pin that and would be red under the mode-scoped form.",
        "Channel: repo-scoped REST answers 403 'GitHub access is not enabled for this session' from this container (probed once on GET /repos/objectstack-ai/objectui/issues/7231). Card body and comments were read through the public-repo embedded payload (zero quota); the PR body readback used the rendered PR page (zero quota, 49/49 line probes present, footer intact). Writes went through MCP."
      ],
      "mcp_calls": "5 — add_issue_comment (claim), create_pull_request, search_issues (one targeted duplicate check, non-zero result so no control word needed), issue_write (the finding below), add_issue_comment (this report). No list/search sweeps.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #7237 [finding]: ObjectGantt rebuilds the chart on every non-silent reload — :712 is always non-silent and re-fires on any change of reload's identity (schema.filter / schema.sort / bound object), and :1596's 'if (loading) return placeholder' unmounts GanttView entirely, losing scroll, collapsed groups and in-progress inline edits. The silent mode that exists to avoid exactly this is used only by the write-readback and toolbar paths. Filed as an observation, not a bug: how often a host changes those props at runtime is NOT measured (the in-component QuickFilterBar is client-side and does not refetch), and the right shape is a design call. Unassigned, 'finding' label only, no pm:queue. Duplicate check: one targeted search_issues, 11 results, nearest neighbours #7232 (the schema effect's never-settling exits) and #7210 (the double fetch) are both different faults. This finding is NOT created or worsened by this PR — the teardown happens with a single reload in flight."
      ]
    }

    Generated by Claude Code

  6. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    ACCEPT — PR #7236, verified against GitHub; landing follows the CI floor

    domain:ui execution seat, session session_01NRRumy89BYdW9ogbcdHTho (os-litant), reviewer of record.

    Checklist: draft ✓ · base main ✓ · first line Fixes #7231, and no other card number beside a closing keyword (#7230 / #7210 / #7225 / #6482 are all written as "remains open") ✓ · changed files = exactly the claimed surface (ObjectGantt.tsx +18/−2 inside the reload() finally, one new test beside it, one changeset @object-ui/plugin-gantt: patch; the package is published) ✓ · no content/docs/releases/ edit ✓ · governed predicate check-governed-merges.mjs --test on the three paths → NOT governed (exit 0) ✓ · report comment on the card with the literal os-dev-report first line ✓ · out-of-scope finding #7237 exists, finding label only ✓.

    Spot readings (mine, on the fetched branch head 529d546): the diff is the guard the report describes and nothing else — clear both flags only when isCurrent(). The test reaches the subject through a relative import, so the ablation needed no dist leg; its three cases are the defect (stale run finishes first), the out-of-order control, and the silent-superseded-by-non-silent guard-shape pin. Pre-fix red and the ablation were both reported as 1 failed | 2 passed, so the red is a measurement, not a collapsed suite.

    Deviations, all accepted: the test sits flat beside the source (the package has no __tests__/ directory — my Zone-3 path was wrong); the failing ordering is the plain "stale finishes first", not the reverse I sketched; the guard clears both flags, which the caller enumeration shows is the only honest shape — my "obvious form" was falsified by measurement, as the order invited. The caller enumeration also settles the card's "not measured" paragraph: three overlapping pairs are reachable, only one through schema resolution, which is the evidence the gating work would not have fixed this.

    CI at review time: 25 of 30 checks green on 529d546; Type Check and the four Test (shard n/4) still running. Landing: ready + auto-merge (squash) once every check on that head is green — flip point armed; the enqueue is confirmed positively by the queue ref, the landing by content with a live control.


    Generated by Claude Code

  7. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    Landed — PR #7236 merged as 92c65aab7 (squash, 1 parent); pm:dispatched stripped with the close

    domain:ui execution seat, session session_01NRRumy89BYdW9ogbcdHTho (os-litant). Landing verified by content on origin/main, not by the PR head sha: the guard's comment line is present in packages/plugin-gantt/src/ObjectGantt.tsx (1 hit; control isCurrent() = 5 lines, up from 4), ObjectGantt.staleReloadFinally-7231.test.tsx and .changeset/7231-gantt-stale-reload-finally.md are in the tree. Enqueue was confirmed positively at 01:47Z (queue ref pr-7236-67dadd602…, build 92c65aab) and main moved onto that build at 02:01Z. Lane inventory reconciled: this is the only card the merge closed (#7230, #7210, #7225, #6482 remain open as the PR body said).

    Follow-ups this landing unblocks in the hot-file queue: #7230 and #7232 on ObjectGantt.tsx (both still queued; dispatch resumes after the usage window resets).


    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

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