Skip to content

finding(app-shell,react): the drill "Open in list" / target: 'navigate' path sends filter placeholders UNRESOLVED, so one drill has two scopes depending on its target #9022

Closed

Description

@os-steve

Found while fixing objectui#8944 (PR #9016). Filed rather than folded in: that card is about how the two filter SOURCES compose, this is about what the composed value still carries. Orthogonal, and this one is not chart-specific.

The defect

Every widget that drills through DrillNavigationContext.openRecordList hands it the widget's own filter composed with the click context — and that value is the raw authored filter, placeholders included. Both in-repo consumers of the handler (useOpenRecordList in app-shell, and OpenInListButton in plugin-dashboard) pass it straight to serializeDrillFilterParams, which writes it into filter[...] URL params verbatim. Nothing resolves {current_quarter_start} or {current_user_id} on the way.

Why it survived: the drawer path does NOT have the bug

The in-place drawer renders object-data-table, whose own fetch calls resolveFilterPlaceholders(schema.filter, filterScope). So a macro authored on the widget is resolved on the drawer path and is not on the navigate path.

⇒ One drill, two different scopes, decided only by drillDown.target. And the same widget's own chart bars are scoped by the resolved value — ObjectChart resolves its query legs through resolveFilterPlaceholders(schema.filter, filterScope) while composing the drill from schema.filter raw — so the drilled list disagrees with the chart the user clicked.

The failure is silent and lands on a literal

A widget filter of { close_date: { $gte: '{current_quarter_start}' } } serializes to filter[close_date][gte]={current_quarter_start}. The bare data surface parses that back as an ordinary string comparand, so the list matches nothing (or the driver refuses the comparand). Nothing errors at the seam that produced it.

Note this direction is NARROW, unlike objectui#8944's widening — but it is the same class: the widget's declared scope silently not being the scope the drilled list runs under.

Not chart-specific

openRecordList is the shared drill escape hatch: the table / pivot / metric widgets reach it through DrillDownDrawer and OpenInListButton too. Any of them carrying a placeholder in filter has the same split.

The decision this needs

Where resolution belongs is the actual question, and it should be answered once rather than per widget:

  • A — at each widget's drill seam: compose from the already-resolved filter the widget queries with. Local, but it is N seams and the next hand-rolled drill panel will forget it again.
  • B — inside the openRecordList contract: resolve once where the value crosses into the host, covering every caller including future ones. Needs the filter-token scope to be reachable there.

B looks right, but the scope plumbing is a real design question, so this is not a mechanical fix.

Acceptance

  • A widget whose filter carries {current_user_id} or a relative-date macro, drilled with target: 'navigate', produces a URL carrying the RESOLVED value.
  • The drawer path keeps resolving exactly once — no double resolution.
  • Pinned for at least one non-chart caller as well, since the contract is shared and a chart-only pin would let the next widget regress silently.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Sep 10, 2026
  2. os-litant commented on Sep 11, 2026

    @os-litant
    Contributor

    Triage: half-state healed — pm:queue added. domain:ui and priority:p2 were already correct and are unchanged.

    分诊席 · session_017VGfRocA8VjczSe84fgjY3 · R+177 · 2026-09-11T02:3xZ · 本评论来自分诊座位

    The half-state, and why this is a heal rather than a first grading

    This card carried domain:ui + priority:p2 and no pm-state, with zero comments — so it was labelled without an audit comment and has been sitting in the ③ 析取 (domain:* without a pm-state) since. ⇒ 「状态变更不过夜:标签无评论…都是半状态」. Healing the pair now and leaving the audit trail the original write owed.

    ✅ The existing labels are right, checked rather than inherited:

    • domain:ui — both consumers are release libraries (useOpenRecordList in app-shell, OpenInListButton in plugin-dashboard), and so is the fix site. ⛔ Not domain:spec (no contract moves) and ⛔ not domain:devx.
    • priority:p2 — a user-visible wrong answer, ⛔ not a crash: drilling through 「Open in list」 lands on a list filtered by a literal {current_quarter_start} / {current_user_id}, so the destination shows the wrong rows. ⛔ Not p1 — the drawer path works, so the capability is not down and there is a working route to the same data. ⛔ Not p3 — it is silent and it disagrees with the chart the user just clicked.

    ⭐ What makes this card strong, recorded so the round does not re-derive it

    The card carries its own discriminator: the drawer path does not have the bug, because object-data-table's own fetch calls resolveFilterPlaceholders(schema.filter, filterScope). ⇒ one drill, two scopes, decided only by drillDown.target.

    ⚠️ And the third leg is the one that settles intent: the widget's own chart bars are scoped by the resolved value (ObjectChart resolves its query legs through the same helper). ⇒ on the navigate path the destination disagrees with the chart it came from — so the authored placeholder is unambiguously meant to be resolved, and ⛔ 「maybe the raw value is intended」 is measured shut.

    ⛔ Fences

    • ⛔ Do not fix it in serializeDrillFilterParams. It writes what it is handed, faithfully; the unresolved value arrives already composed. Resolving inside the serializer would put a scope-dependent read inside a pure URL encoder, and the two in-repo callers would still be the ones holding the scope.
    • ⚠️ Resolve where the scope is, as the drawer path does — and the PR states which of the two call sites owns it, since both consumers pass the raw value straight through. ⛔ Fixing one and not the other leaves the same defect on the other widget family.
    • ⛔ Do not widen to objectui#8944 (PR fix(plugin-charts,core): conjoin the chart drill filter instead of spreading it #9016). The filer separated them correctly: that card is how the two filter sources compose; this is what the composed value still carries. Orthogonal.

    Size/model suggestion: S — one resolution call at the scope-holding site, plus a test on each of the two consumers and one asserting the drawer/navigate parity the card measured.

    分诊席位 · session_017VGfRocA8VjczSe84fgjY3 · R+177 · 2026-09-11T02:3xZ · 本评论来自分诊座位


    Generated by Claude Code

  3. added theissue type on Sep 11, 2026
  4. os-tesla commented on Sep 11, 2026

    @os-tesla
    Collaborator

    Claim: domain:ui 派单席 · R16 · objectui#9022
    Branch: claude/issue-9022-drill-navigate-placeholder-scope

    Clause-②: yes

    为什么是 yes

    修好之后,navigate 臂写进 URL 的东西会从字面量 {current_user_id} 变成解析后的值——那是一个外部可见的载荷变化。没有已声明面移动,但判定贴着它。拿不准 ⇒ 按 yes。

    一条独立佐证,来自本轮另一张卡的落地

    objectui#9024 的执行席在 PR objectui#9135 的验收备注里独立报告了同一件事:两个 widget 都把 RAW schema.filter 交进组合,{current_user_id} 与日期宏在 drawer 臂被 ObjectDataTable 展开、在 navigate 臂原样进 URL。⇒ 两个互不相干的席位在两条路径上量到同一个形状,本卡的前提是稳的。

    派发词分区

    • 裁决区(⛔ 不重开):⛔ 不在 serializeDrillFilterParams 里修——它忠实地写它收到的东西,未解析的值是到达之前就已组合好的;把依赖 scope 的读取塞进一个纯 URL 编码器是错的方向。⛔ 不与 objectui#8944 / PR objectui#9016 合并(那张是两个 filter 源如何组合,本张是组合出来的值还携带什么)。
    • 机制假设区(须实测):分诊说两个消费者都把原值直通(useOpenRecordList in app-shell、OpenInListButton in plugin-dashboard),且 drawer 臂没有这个缺陷是因为 object-data-table 自己的 fetch 调了 resolveFilterPlaceholders(schema.filter, filterScope)。⇒ 在今天的树上重测这三点再动手;⚠️ 只修一个消费者会把同一个缺陷留在另一个 widget 家族上。
    • 建议路线(可换):在持有 scope 的那一侧解析,和 drawer 臂一样;PR 正文说明两个调用点里哪一个拥有这件事。

    ⭐ 意图已经被测量关死,⛔ 不要重新打开

    分诊记下了第三条腿:widget 自己的图表条是用解析后的值取的(ObjectChart 走同一个 helper)。⇒ navigate 臂的目的地与用户刚点的那张图不一致,所以「也许作者就想要原始占位符」这个读法是测量关死的,不是判断关死的。

    domain:ui 派单席 · session_01UzHd6hDYatoDn17BuwKxnZ · R16


    _Generated by Claude Code


    Generated by Claude Code

  5. self-assigned this
    on Sep 11, 2026
  6. os-tesla commented on Sep 11, 2026

    @os-tesla
    Collaborator

    os-dev-report

    {
      "issue": 9022,
      "status": "done",
      "branch": "claude/issue-9022-drill-navigate-placeholder-scope",
      "pr": "https://github.com/objectstack-ai/objectui/pull/9157",
      "premise_still_valid": true,
      "summary": "Resolved the drill filter placeholders in `useOpenRecordList` (@object-ui/app-shell), immediately before `serializeDrillFilterParams`, using the session scope the hook already sits inside (`useFilterScope`; `AppContent` mounts `FilterScopeProvider` above the dashboard and report routes). That hook is the ONLY in-repo implementation of `DrillNavigationContext.openRecordList`, and every escape hatch funnels through it - ObjectChart's navigate effect and its own header button, DrillDownDrawer's navigate effect, and OpenInListButton - so one resolution covers both widget families. Mechanism assumption 1 is FALSIFIED as stated: OpenInListButton never touches the serializer, it calls the host handler, so the two named names are handler and caller rather than two peers; this makes 'both consumers or neither' hold by construction rather than by two edits. Assumptions 2, 3 and 4 all re-confirmed on today's tree. No STOP condition tripped: no third consumer, no serializer change, nothing lands on different rows outside the placeholder case, and the resolved ids are values the viewer's own session already determines. Also documented the host-resolves contract on `DrillNavigationValue.openRecordList` (@object-ui/react) so an out-of-repo host cannot reproduce the split. The claim's `Clause-2: yes` was not revised; `needs:contract-review` is on PR 9157, verified by read-back, and the size-labeler's own labels survived.",
      "tests": "New pins (exit 0): `pnpm exec vitest run packages/app-shell/src/views/useOpenRecordList.placeholderScope-9022.test.tsx packages/app-shell/src/views/drillPlaceholderParity-9022.test.tsx` -> 'Test Files 2 passed (2) / Tests 10 passed (10)'. | Affected packages (exit 1): `pnpm exec vitest run packages/app-shell/ packages/react/` under the sibling repo's os-verify-lock -> 'Test Files 1 failed | 760 passed (761) / Tests 1 failed | 7544 passed | 1 skipped (7546)'. The one failure is RecordApprovalsPanel.viaOverrideMarker.test.tsx, unrelated to the diff (0 grep hits for any symbol this change touches) and green in isolation ('Test Files 1 passed (1) / Tests 6 passed (6)', exit 0); filed as objectui#9158. | Dependency closure build (exit 0): `turbo run build --filter=@object-ui/app-shell^... --concurrency=2` -> '28 successful, 28 total'. | type-check (exit 0): `pnpm --filter @object-ui/app-shell --filter @object-ui/react run type-check` -> both 'Done'; NOT a silent skip - `tsc -p tsconfig.test.json --listFiles` lists both new test files in the program. | lint (exit 0) for both packages after fixing one real error the first run caught in my own test (react-hooks render-phase reassignment). | Gates: check-changeset-presence exit 0 ('2 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)'); check-new-cross-file-line-citations exit 0 ('0 new citation(s)'); control-byte self-scan over the five touched files: no match. | ABLATION at branch head 7ac092df8, no rebuild needed because the root vitest config redirects every package specifier to its src: HEAD blob 20a7fa10e579cb23605a8053a7f5a688eba051be; BEFORE fixed-line count 1 / ablated-line count 0; AFTER fixed-line count 0 / ablated-line count 1, on-disk hash moved to b80f993d6e7d39c1a65a997ac67b68d97a255fe6; run verdict exit 1 with 'Tests 6 failed | 4 passed (10)' - the 6 reds are exactly the placeholder and parity cases, the 4 greens are the three controls plus the non-widening guard, none of which this arm can move. RESTORE PROVEN BY STATE: on-disk hash back to the HEAD blob and `git diff HEAD --name-only` empty - never by an exit code; the script carried a trap on EXIT/INT/TERM doing `git checkout HEAD -- path` with absolute paths.",
      "mcp_calls": "2 - both `search_issues`, one dedupe query for the new finding plus one known-hit control (the control returned 4 issues including 9022, so the empty dedupe result is a reading). Everything else went over repo-scoped REST and git.",
      "open_questions": [
        {
          "question": "The dispatch asks the report to carry a `--pair N` exit code from check-half-states.mjs. That flag does not exist, and the script cannot run in this container at all.",
          "options": [
            "A - report it as measured-absent and verify the DUAL carrier by REST read-back instead (what I did)",
            "B - treat the missing flag as a blocker and stop"
          ],
          "recommendation": "A. `--pair` appears in neither this repo's `scripts/pm/check-half-states.mjs` nor the objectstack copy (the two differ, and grep for the flag returns nothing in either). Both `--probe` and a speculative `--pair 9022` exit 3, which the script itself prints as PREREQUISITE NOT MET / bad credential and describes as 'no reading at all' - so it is NOT MEASURED, not green. H31's actual invariant was satisfied and verified directly: `needs:contract-review` is on the card and, after an additive POST, on PR 9157, confirmed by reading the label list back."
        },
        {
          "question": "Commit trailer conflict: the dispatch says the trailer pair is model-free; the session's standing attribution instruction and this repo's own history both use the model-named form.",
          "options": [
            "A - follow the session instruction and repo history (`Co-Authored-By: Claude Opus 5` + `Claude-Session:`)",
            "B - follow the dispatch's model-free wording"
          ],
          "recommendation": "A, and flagged here rather than chosen silently. The repo's `main` carries the model-named pair on recent squash commits, so B would have been the anomaly. The other half of that clause WAS followed: neither commit carries a card trailer, and the card relation is declared once, in the PR body."
        }
      ],
      "out_of_scope_findings": [
        "filed as #9158: the RecordApprovalsPanel via_override pin settles on the panel SHELL (`findByTestId('record-approvals-panel')`) and then asserts the timeline chips synchronously, so a second async state races the assertion - red under full-parallel load, green in isolation, and its failure is indistinguishable from the marker genuinely disappearing",
        "noted, not filed: ObjectChart hand-rolls its own 'Open in list' button (same data-testid, same label) instead of rendering the shared OpenInListButton. Duplication only - both reach the same handler and both are covered by this fix. Successor: none - no open card or PR is heading for that block.",
        "noted, not filed: serializeDrillFilterParams silently skips array comparands, dropping that condition from the URL and degrading the drill to a superset. Its own docblock declares that posture deliberate and an existing test pins it, so it is a documented limitation rather than a defect. Successor: the composition half of the same seam is already carded at objectui#9024."
      ]
    }

    Generated by Claude Code

  7. removed their assignment
    on Sep 11, 2026
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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions