Skip to content

docs(ui): the react-pages live-data sample reads result.records, which is always undefined — and its Callout misstates what a dropped option does #10469

Closed

Description

@os-zhuang

Found while fixing #10288 (the same two defects in examples/app-showcase). Filing
rather than fixing: content/docs/** pulls in a different gate family than that card's
diff (check:doc-anchors, check:docs-audit-scope, check:docs-redirects,
check:published-readme-links, check:role-word, plus the {/* os:check */} verified-
sample convention), and the Callout's second half needs a wording decision, not a
mechanical edit.

1. The sample renders an empty list

content/docs/ui/react-pages.mdx, the "Live data" sample (L143-148):

const result = await adapter.find('showcase_invoice', {
  $filter: ['status', '!=', 'paid'],
  $top: 200,
});
const records = Array.isArray(result) ? result : (result && result.records) || [];

The query options are right. The result read is not. useAdapter() returns
ObjectStackAdapter, whose find() resolves to a normalized QueryResult — objectui
packages/types/src/data.ts declares data / total / page / pageSize /
hasMore / cursor, and normalizeQueryResult (objectui
packages/data-objectstack/src/index.ts) returns exactly that shape. There is no
records key. result.records is undefined on every call, so records falls to []
and the documented sample renders an empty <ul> forever, with no error.

This is not theoretical: the identical read was fixed once already in
examples/app-showcase/src/ui/pages/crm-workbench.page.ts, whose comment records the
symptom verbatim — "Reading .records here always missed, so the KPI cards silently stuck
at 0 even though the ListView beside them showed the same rows" — and was still live in
renewals-pipeline.page.ts until #10288. The doc is the third instance, and it is the
one a customer copies from.

Fix: result.data, with the array arm kept. Every objectui consumer of this contract
spells it res?.data ?? res?.records ?? … (data-list.tsx, record-picker.tsx,
ObjectRefField.tsx, record-history.tsx, …) — canonical first, the legacy spelling as
a fallback, never alone.

2. The Callout names the wrong consequence

Directly below the sample:

An unprefixed top: or a filters: key is not a query option — it is silently
dropped, and the query comes back unfiltered or with the default page size. There
is no error.

The "unfiltered" half is right. The page-size half is backwards. The GET list route has
no default page size
, so an absent top returns the ENTIRE match set — stated in
packages/client/src/index.ts ("The GET list route has no default page size, so an
absent top returns the ENTIRE match set"), pinned in packages/client/src/client.test.ts
(#6485), and measured by packages/objectql/src/protocol-unknown-query-param.test.ts's
baseline — no params returns every row (10 rows seeded, total: 10 with no params).

So the real failure mode of a dropped top is an unbounded read, not a truncated
one — the cap the author wrote never happens, and the page pulls every matching row over
the wire. That is a different thing to warn an author about, and the current wording
sends anyone debugging it looking for pagination that is not there.

Suggested wording: "…it is silently dropped. The query then runs as if you had not
written it: an unprefixed filter: comes back unfiltered, and an unprefixed top:
comes back with every matching row, because the list route has no default page size.
There is no error."

Acceptance

  • The sample reads result.data (array arm preserved) and, run against a real adapter,
    renders rows.
  • The Callout states the unbounded-read consequence, sourced from the client/protocol
    behaviour above rather than from the old wording.
  • The named docs gates stay green.

Activity

  1. added
    documentationImprovements or additions to documentation
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Aug 21, 2026
  2. added theissue type on Aug 21, 2026
  3. os-zhuang commented on Aug 21, 2026

    @os-zhuang
    ContributorAuthor

    Triage: lands in content/docs/ui/react-pages.mdx → domain:devx (content/docs/**), type Bug (the published sample renders an empty list and the Callout states the inverse of the measured consequence). Queued. Both halves are docs-only edits with the correct wording already drafted in the body; the docs gate family named in the body is the verification surface. Size/model suggestion: S, sonnet.


    Generated by Claude Code

  4. claude commented on Aug 21, 2026

    @claude
    Contributor

    Claim — PM dispatch (devx seat)

    • Session: c970724d-303c-5614-9d20-a3f92205cfad
    • Branch: claude/issue-10469-react-pages-live-data

    Premise re-verified on origin/main = 9185ff0213 (fetched 2026-08-21T10:04:48Z).
    content/docs/ui/react-pages.mdx:147 still reads:

    const records = Array.isArray(result) ? result : (result && result.records) || [];

    ⚠️ Worth stating precisely, because the line looks defensive: the Array.isArray(result) arm
    does not rescue it. find() resolves to a normalized QueryResult object, so the array arm
    never fires, result.records is undefined on every call, and records falls to []. The
    published sample renders an empty <ul> forever, with no error.

    This is the third instance of one read. It was fixed in
    examples/app-showcase/src/ui/pages/crm-workbench.page.ts — whose comment records the symptom
    verbatim, "the KPI cards silently stuck at 0 even though the ListView beside them showed the
    same rows"
    — and again in renewals-pipeline.page.ts under #10288. The doc is the one a
    customer copies from, which is why it is the worst of the three to leave.

    ⛔ content/docs/ui/react-pages.mdx only. It is not among the 19 files PR #10714 changed, so
    it is free — but that PR merged recently, so re-derive at your own merge base.

    ⚠️ The second half is a wording decision, not a mechanical edit, and the card's drafted
    wording must be verified against the route source, not adopted: the Callout claims a dropped
    query option means the query comes back "with the default page size", and the card argues the
    GET list route has no default page size at all. Confirm that in the route implementation before
    rewriting the sentence — a Callout that states the inverse of the measured behaviour is exactly
    the defect being fixed, and shipping a second wrong consequence would repeat it.


    Generated by Claude Code

  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 21, 2026
  6. claude commented on Aug 21, 2026

    @claude
    Contributor
    {
      "issue": 10469,
      "status": "done",
      "branch": "claude/issue-10469-react-pages-live-data",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10748",
      "premise_still_valid": true,
      "summary": "Both halves landed in content/docs/ui/react-pages.mdx, one file, no other path touched. Half 1: the Live-data sample now reads `result?.data ?? result?.records ?? (Array.isArray(result) ? result : [])` -- canonical first, legacy key as fallback, array arm preserved, the exact shape data-list.tsx:140, record-picker.tsx:133, ObjectRefField.tsx:59 and record-history.tsx:90 all use. QueryResult contract verified in the objectui sibling checkout at /home/user/objectstack-ai/objectui (it IS present): packages/types/src/data.ts:109 declares data/total/page/pageSize/hasMore/cursor with no `records` key, and normalizeQueryResult (packages/data-objectstack/src/index.ts:2774) returns exactly {data,total,page,pageSize,hasMore} -- it CONSUMES the envelope's `records`/`value` and re-emits under `data`. It also folds the raw-array case into that same object, which is why the old `Array.isArray(result)` arm could never fire either: both arms missed, records fell to [], empty <ul> forever. Half 2: the card's page-size claim is CORRECT and was verified at the route, not adopted -- see the open_questions-free measured answer below. Callout rewritten to name the unbounded read. Docs-only, publishes nothing, so no changeset; skip-changeset label applied additively (POST /issues/10748/labels) and READ BACK after the bots ran: ['documentation','size/xs','skip-changeset'] -- the bot labels survived, mine survived.",
      "measured_answer_callout_page_size": "CARD CONFIRMED: the GET list route has no default page size, so a dropped `top` is an UNBOUNDED read, not a truncated one. Verified at the route implementation, packages/metadata-protocol/src/protocol.ts:8469 `findData` -- the resolver the REST GET list route runs. Paging resolves as `const pageLimit = typeof options.limit === 'number' && options.limit > 0 ? options.limit : undefined;` with no default assigned on any branch, and its own comment states the consequence: \"Without a limit the full result set is returned, so its length already IS the total.\" Earlier in the same function `limit` is coerced on PRESENCE only (`if (options.limit != null) options.limit = Number(options.limit)`), so an absent top stays absent through to engine.find. Measured, not merely read: packages/objectql/src/protocol-unknown-query-param.test.ts:147 `baseline -- no params returns every row` seeds 10 rows and asserts findData({object:'showcase_task'}) resolves total: 10. Corroborated by packages/client/src/index.ts:4475 and the #6485 pin at packages/client/src/client.test.ts:1776. SECOND MEASUREMENT the card did not make: the Callout's \"There is no error\" half is ALSO correct, and for a non-obvious reason worth keeping in the doc -- the drop happens client-side. The adapter's convertQueryParams (objectui packages/data-objectstack/src/index.ts:2976) reads ONLY the $-prefixed slots and builds a fresh options object, so an unprefixed key never reaches the wire. Had it reached the server it would NOT be silent: protocol-unknown-query-param.test.ts:161 pins ?zzzz / ?pageSize / ?page_size / ?perPage as 400 INVALID_FIELD. So the warning is correctly aimed at the client layer and the wording now names the right consequence there.",
      "before_after": {
        "sample_before_L147": "      const records = Array.isArray(result) ? result : (result && result.records) || [];",
        "sample_after_L147": "      const records = result?.data ?? result?.records ?? (Array.isArray(result) ? result : []);",
        "callout_before_L157_160": "The `$` prefixes are load-bearing. An unprefixed `top:` or a `filters:` key is not a\nquery option -- it is silently dropped, and the query comes back unfiltered or with the\ndefault page size. There is no error.",
        "callout_after_L157_162": "The `$` prefixes are load-bearing. An unprefixed `top:` or a `filters:` key is not a\nquery option -- it is silently dropped, and the query runs as if you had not written\nit: a dropped `filters:` comes back unfiltered, and a dropped `top:` comes back with\n**every matching row**, because the list route has no default page size. The failure\nmode is an unbounded read, not a truncated one. There is no error."
      },
      "anchor_counts_on_disk": {
        "result.records (bare)": "1 -> 0",
        "result?.records": "0 -> 1",
        "result?.data": "0 -> 1",
        "Array.isArray(result)": "1 -> 1 (arm preserved, moved to last position)",
        "default page size": "1 -> 1, but now inside `no default page size`: 0 -> 1",
        "every matching row": "0 -> 1"
      },
      "tests": "Docs-only card: no unit suite applies; the docs gate families ARE the verification surface. Gate list derived mechanically, no paths passed: `node scripts/pm/dispatch-gates.mjs` -> exit 0, \"change set derived from git -- 1 path(s) vs merge base 9185ff021\" (committed 1, working tree 0), 13 families. UNION RE-RUN AT THE FINAL COMMIT, sha from that run: git rev-parse --short HEAD = 882bbf7733 (= PR head sha). All 13 derived families plus check:nul-bytes (unconditional on any edit) = 14 runs, 14 green, RED COUNT: 0. Exit codes captured BEFORE any pipe (`cmd > file 2>&1; e=$?` then tail), never a bare $? after a tail. Verdict lines as each gate printed them: check:nul-bytes 'check-nul-bytes: OK (scanned 6231 text file(s) ... no raw ASCII control bytes).' | check:doc-anchors '272 internal #fragment link(s) across 408 source file(s) all resolve to a real heading' | check:doc-authoring 'doc authoring guard: 389 files clean -- no bare metadata literals.' | check:role-word 'check-role-word: OK, no new occurrences of the reserved word.' | check:docs-audit-scope 'docs-accuracy-audit scope is in sync with content/docs/: 189 hand-written doc(s).' | check:docs-redirects 'check-docs-redirects: OK (apps/docs/redirects.mjs: 92 entries ...)' | check:published-readme-links '152 outbound link(s) across 60 published markdown file(s)' | check:cross-package-test-inputs 'OK: 13 package(s) read outside themselves, all declared' (+ the same script run directly) | lint check:doc-formula-expressions '22 record-scoped formula example(s) across 416 files / 1443 TS blocks judged clean' | spec check:empty-state 'all classified (1 closed, 2 open, 4 output, 9 scope)' | spec check:variant-docs '18 discriminated union(s) -- 8 governed, 10 exempt' | spec check:strictness-ledger 'strictness ledger: 61 file(s) across 5 triaged director(ies)' | spec check:liveness 'every governed-type property ... is classified'. Dependency-closure trap hit and handled: `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first died ERR_MODULE_NOT_FOUND on @objectstack/formula/dist/index.mjs; built the closure under the shared lock (`bash scripts/pm/os-verify-lock.sh -c '... --workspace-concurrency=2 --filter \"@objectstack/lint^...\" build'` -> 'VERDICT command-exit 0 - held the lock 133s - waited 220s'), then the gate passed. Script names were checked to exist in each package.json BEFORE running the filtered gates, so no zero-match --filter could exit 0 silently. No ablation was performed: this is a prose/sample docs edit with no guard to prove failable.",
      "check_skill_examples": "NOT APPLICABLE, and checked rather than assumed -- the brief asked either way. content/docs/ui/react-pages.mdx carries exactly ONE {/* os:check */} marker, at :346 (was :344 pre-edit), directly above a ```typescript fence -- the RenewalsConsolePage example. The Live-data sample is a ```jsx fence at :135-155. packages/spec/scripts/check-skill-examples.ts refuses a marker that is not directly above a ```ts / ```typescript fence (its error text: 'Found an os:check marker not directly above a ```ts / ```typescript fence'), so a jsx block is not even eligible for the gate. My diff's only hunks are @@ -147 +147 @@ and @@ -159,2 +159,4 @@ -- nowhere near the marked block, and the marker/fence pairing was re-confirmed intact after the edit. dispatch-gates did not name the gate either.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10750: packages/client-react/src/data-hooks.tsx TSDoc @examples for useQuery (:82) and usePagination (:405) both read `data?.value`, but useQuery's data is PaginatedResult<T> (packages/client/src/index.ts:310) which declares records/total/object/hasMore and no `value`. Worse than the sibling defect: ?. short-circuits only on a nullish data, so once the query resolves `.map` runs on undefined and THROWS. Labels domain:devx + documentation, unassigned, no pm:queue. NOT fixed here -- different file, different package, out of this card's one-file scope.",
        "filed as #10751: [finding] the #10288 guard examples/app-showcase/test/react-page-adapter-query-contract.test.ts sweeps only kind:'react' pages from the app-showcase page registry, so content/docs react-page samples are invisible to it -- the mechanism by which this identical read survived two code fixes and reached instance three in the doc a customer copies. Proposes extending that guard's population to the docs corpus to close the whole class rather than adding a second scanner. Labels domain:devx + finding, unassigned, no pm:queue.",
        "NOT a fourth instance (checked, reporting the negative): content/docs/api/client-sdk.mdx:659 reads `data?.records.map(...)`. That is the CLIENT SDK path, not the adapter -- findData returns {object, records, total, hasMore} and PaginatedResult declares `records`, so that read is correct and was left alone."
      ]
    }

    Generated by Claude Code

  7. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentationdomain:devxDomain lane — seat card indexed by label:pm:seat

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions