diff --git a/.changeset/react-pages-array-isarray-limb.md b/.changeset/react-pages-array-isarray-limb.md new file mode 100644 index 0000000000..f41d959157 --- /dev/null +++ b/.changeset/react-pages-array-isarray-limb.md @@ -0,0 +1,30 @@ +--- +"@objectstack/docs": patch +--- + +docs(react-pages): delete the unreachable `Array.isArray(result)` limb from the live-data sample + +`ObjectStackAdapter.find()` cannot resolve to an array, so the `Array.isArray(result)` +arm the live-data sample carried could never be taken. Re-derived against objectui at +the sha this repo pins (`9602dc82`) and again at objectui `origin/main`, which agree +line for line: + +- `find()` returns from five points — `{ data: [], total: 0 }` for a resource already + memoized as missing, `{ data: [], total: 0 }` for a fresh 404 that is not an + `enable`-block denial, two `normalizeQueryResult(...)` calls (the `$expand`/`$search` + raw-GET path and the client-SDK path), and `return existing`, which hands back a + promise produced by that same set. +- Both branches of `normalizeQueryResult()` return an object literal with exactly + `data`, `total`, `page`, `pageSize`, `hasMore`. The first branch is the one that + makes the limb dead: it tests `Array.isArray(result)` on the *transport* response and + **wraps** a bare array into that envelope. The array case is folded before any caller + sees it. + +The sample now reads `result.data` directly, and a new paragraph under it states the +envelope contract so the reason survives the next edit. The two `kind:'react'` pages in +`examples/app-showcase` carrying the same dead limb — `crm-workbench` and +`renewals-pipeline` — were repaired in the same edit, with the derivation recorded in the +comment that already explains the neighbouring `.records` trap. + +Behaviour-preserving: `.data` was read first and always won. What goes is a shape the +producer cannot emit, sitting in the page a customer — and a coding agent — copies from. diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index b610455b37..fa380d000c 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -176,8 +176,7 @@ function Page() { $filter: ['status', '!=', 'paid'], $top: 200, }); - const records = result?.data ?? (Array.isArray(result) ? result : []); - if (alive) setRows(records); + if (alive) setRows(result.data); })(); return () => { alive = false; }; }, [adapter]); @@ -186,6 +185,11 @@ function Page() { } ``` +`find()` always resolves to the same envelope — a `QueryResult` carrying the rows +under `data`. A backend that answers with a bare array is folded into that envelope +before your code sees it, so `result.data` is the only row shape a page is ever handed: +read it directly, with no fallback for a shape the adapter cannot produce. + The `$` prefixes are load-bearing. An unprefixed `top:` or a `filters:` key is not a query option — it is silently dropped, and the query runs as if you had not written diff --git a/examples/app-showcase/src/ui/pages/crm-workbench.page.ts b/examples/app-showcase/src/ui/pages/crm-workbench.page.ts index 4084eba3dd..f005c79694 100644 --- a/examples/app-showcase/src/ui/pages/crm-workbench.page.ts +++ b/examples/app-showcase/src/ui/pages/crm-workbench.page.ts @@ -37,7 +37,10 @@ function Page() { // silently stuck at 0 even though the ListView beside them showed the same // rows. Read .data: it is the only row shape QueryResult declares, so a // tolerant '.data || .records' alias would render correctly while - // teaching a spelling the producer cannot emit. + // teaching a spelling the producer cannot emit. There is no array + // shape to test for either: every find() path resolves to that same + // object envelope, and normalizeQueryResult WRAPS a bare array + // response into it, so an 'Array.isArray(all)' limb can never be taken. // // The cap is $top, not 'limit': QueryParams declares only $-prefixed keys // and the adapter copies only those, so a bare 'limit' is dropped without @@ -48,7 +51,7 @@ function Page() { // "Active" stays a per-row verdict over the 200 rows actually fetched; // an exact one would need its own filtered count query. const all = await adapter.find('showcase_project', { $top: 200 }); - const rows = Array.isArray(all) ? all : (all && all.data) || []; + const rows = all?.data ?? []; const total = typeof (all && all.total) === 'number' ? all.total : rows.length; setStats({ total, active: rows.filter((r) => r.status === 'active').length }); } catch (e) { console.warn('[CRM Workbench] failed to refresh stats', e); } diff --git a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts index 2814e166ba..6c7686ce6f 100644 --- a/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts +++ b/examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts @@ -75,14 +75,16 @@ function Page() { // applied, is the server's real count over the same $filter rather than // the page length. Counting data.length under a cap is how a KPI starts // under-reporting in silence; count 'total' and the cap stays a fetch - // bound instead of a lie about the business. + // bound instead of a lie about the business. That envelope is the ONLY + // shape find() resolves to -- normalizeQueryResult WRAPS a bare array + // response into it -- so there is no array form to test for either. React.useEffect(() => { let alive = true; (async () => { if (!adapter || !sel) { setRelated({ projects: 0, invoices: 0, openInvoices: 0, capped: false }); return; } const pr = await adapter.find('showcase_project', { $filter: ['account', '=', sel], $top: 500 }); const iv = await adapter.find('showcase_invoice', { $filter: ['account', '=', sel], $top: 500 }); - const rows = (res) => (Array.isArray(res) ? res : (res && res.data) || []); + const rows = (res) => res?.data ?? []; const count = (res, list) => (typeof (res && res.total) === 'number' ? res.total : list.length); const projects = rows(pr); const invoices = rows(iv);