Skip to content

Commit 63d1a7c

Browse files
docs(react-pages,skills,showcase): teach the data-invalidation nonce in a react page's live-data effect, opt the two showcase react pages in (#20395) (#21045)
Fixes #20395 Clause-②: no — docs, skill prose, two strings in a generator and two example pages; no protocol key moves. The objectstack half of ruling A on objectui#10887 member 3 (ruling `5864668610`, maintainer 「同意」 2026-09-28). The objectui half landed in PR objectui#10973 (merge `deca847a8`): `ReactKindPage` injects `useDataInvalidation` into the `kind: 'react'` author scope. This repo's pinned console (`.objectui-sha` = `db11afd4967cd9d39381c5e21dc2deec9d706204`, 78 commits ahead of that merge, behind 0 — the seat's REST compare at unlock) carries it, so the six sites that still taught or ran the pre-hook pattern are brought to it. ## The hook, read at the pinned sha Read with `git show db11afd49:PATH` in the objectui checkout after `git fetch --depth=1 origin db11afd49…`, not at objectui HEAD: - `packages/components/src/renderers/layout/react-page.tsx` — the scope memo spreads `buildComponentScope(adapter)` and adds `useAdapter`, `useDataInvalidation`, `data`, `variables`, `page`; the memo still depends on `[schema, adapter]` only, so the scope identity, and with it the page's own state, is unchanged by the new member. - `packages/react/src/data-invalidation.ts:106` — `export function useDataInvalidation(objectName?: string, recordId?: string): number`. The nonce is a NUMBER: `useState(0)`, incremented once per bus change that `dataChangeMatches` (same `objectName`, or a change reported on `'*'`; a record-scoped change also stales a list reader that passed no `recordId`). With no `objectName` the hook subscribes to nothing and returns 0 forever. Its docblock prescribes the use this PR teaches: put the nonce in the fetch effect's dependency array; the refetch happens in place, no remount. - The bus is fed by `useMutationInvalidationBridge(dataSource)`, mounted once in `packages/app-shell/src/console/AppContent.tsx:449`, so every create/update/delete through the console's data source reaches the readers. `ListView` (`renderers/basic/data-list.tsx:137`), `ObjectView`, `elements.tsx` and `record-picker.tsx` read it at that sha. Every sentence the six files now carry about the hook is true of that sha. ## What changed (7 files, +33 / -15) 1. `content/docs/ui/react-pages.mdx` — the scope table gains the `useDataInvalidation` row; the Live data example reads `const changed = useDataInvalidation('showcase_invoice')` and names `changed` in the effect's dependencies; one paragraph after the block says why: a page action elsewhere writes the object, the bus reports it, the nonce moves, the effect re-runs in place — no remount, the page's state survives. The in-page link `#live-data` resolves (`check:doc-anchors` green; github-slugger). 2. `skills/objectstack-ui/rules/pages.md` (Tier H) — one bullet in the react scope list, in the file's voice. Paid for by deleting the blockquote's two sentences that restated the bullet above them (the contract covers the curated core set; runtime-injected blocks read props from the registry; `record:*` rejected) — the bullet still says all three. No other line re-wrapped. 3. `packages/spec/scripts/build-react-blocks-contract.ts` — the `## Injected scope` line gains `useDataInvalidation`; the Live-data note gains the nonce sentence. `skills/objectstack-ui/references/react-blocks.md` regenerated through `pnpm --filter @objectstack/spec gen:react-blocks`, never hand-edited: exactly those two lines moved (the note paragraph and the scope line), 115 → 115 lines; `check:react-blocks` green. `references/_index.md` untouched (not this generator's output). 4. `examples/app-showcase/src/ui/pages/crm-workbench.page.ts` — `const projectChanged = useDataInvalidation('showcase_project')`; the KPI effect's dependencies `[refreshStats, reloadKey]` → `[refreshStats, reloadKey, projectChanged]`, plus a two-line comment. 5. `examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts` — two nonces, `showcase_project` and `showcase_invoice` (the two objects the rollup reads; the hook takes one object, and a reader on `'*'` matches only wildcard changes); dependencies `[adapter, sel, reload]` → `[adapter, sel, reload, projectChanged, invoiceChanged]`; the comment's "reload bumps re-run it" now reads "reload bumps and bus writes re-run it". 6. `examples/app-showcase/test/react-page-adapter-query-contract.test.ts` — NOT in the claim's file surface; a bounded in-place fix, see Deviations. ## What a page action now does on the two showcase pages The example app is rendered by the pinned console, so the new dependency is a runtime behaviour change there: - CRM Workbench: any write to `showcase_project` the bus reports — a save in the page's own ObjectForm, a row action on its ListView, a record action from another block or page — moves `projectChanged`, and the KPI strip (Total / Active) re-reads in place. Before, it re-read only when the page's own `afterSave` bumped `reloadKey`, so a write from anywhere else left the strip stale until PageView's remount (which PR objectui#10777 removes). - Renewals Pipeline: any reported write to `showcase_project` or `showcase_invoice` (an invoice marked paid from the Invoices ListView beside it, say) re-runs the Account 360 rollup — Projects / Invoices / Open AR — in place, with `sel` and the drawer state kept. Before, only the drawer's own save (`reload`) did. - Kept on purpose, outside this card: `reloadKey` / `reload` stay in the dependency lists because they also key the ListView and ObjectForm remounts those pages do (`key={reloadKey}`). After this change a save in the page's own form runs the effect twice — the key bump and the bus event — one redundant read, same answer. Retiring the key-remount pattern is a separate decision (Acceptance notes). ## `skills/**` readings Lines and tokens; tokens are the ratchet's `ceil(utf8 bytes / 4)`, read from `check-skills-token-ratchet` at `2f2fa11d7` (before) and `c1770a387` (after). | surface | lines before → after | tokens before → after | ceiling | | --- | --- | --- | --- | | `skills/objectstack-ui/rules/pages.md` (touched, authored) | 453 → 453 | 5681 → 5676 (headroom 11 → 16) | 5692, not moved | | `skills/objectstack-ui/references/react-blocks.md` (touched, generator-owned: measured, not ratcheted) | 115 → 115 | 3340 → 3409 | — | | whole package `skills/objectstack-ui/**` (11 files) | 2231 → 2231 | 30770 → 30834 | — | | whole catalog `skills/**`, ratcheted (authored) total | — | 143505 → 143500 of 154938 | — | | whole catalog `skills/**`, bundle total (shipped tree) | — | 154058 → 154122 | — | | all `skills/*/SKILL.md` summed | 4395 → 4395 | — | — | Net: the authored skill prose shrinks by 5 tokens; the +69 in the bundle is the generator's output. No ceiling moved, no re-wrap bought a line. ## Changeset None; `skip-changeset` is the seat's to apply. `skills/**` and `content/docs/**` appear in no package's `files[]` (every `package.json` under `packages/` and `examples/` walked: zero name `skills`, `content/docs` or a parent path); `@objectstack/example-showcase` is `private: true`; `packages/spec/scripts/` is outside `@objectstack/spec`'s `files[]` (`dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json`). No `private: false` package is touched. ## Tests and gates (head `c1770a387`) Heavy runs went through `scripts/pm/os-verify-lock.sh` (slot `issue-20395`; one 540 s queue-timeout behind an objectui full build, read as NOT MEASURED and resumed on the same slot). - `pnpm --filter @objectstack/spec build` — exit 0 (held 72 s). - `pnpm turbo run build --filter="@objectstack/example-showcase^..." --concurrency=2` — 60/60 tasks, exit 0 (held 318 s); then `pnpm turbo run build --filter="!@objectstack/docs" --concurrency=2` — 72/72, 60 cached, exit 0 (held 42 s), so the whole-workspace gates could measure. - Showcase, under the lock: `typecheck` (`tsc --noEmit`) exit 0; `validate` (`objectstack validate`) exit 0 with advisory warnings only, all pre-existing and none on the two pages; `vitest run --maxWorkers=2` at `2ecc5d7ff`, before the harness fix: 28 files passed, 1 failed — `react-page-adapter-query-contract.test.ts` × 4, `ReferenceError: projectChanged is not defined`; at `c1770a387`, clean tree: `Test Files 29 passed (29)`, `Tests 385 passed (385)` (held 46 s). - Dispatch gates: the 89 commands of `dispatch-gates --commands` at `2f2fa11d7` re-derived on this branch with `--repo objectstack-ai/objectstack` — identical list. `--ran` at `c1770a387` derives 94 families (the test file adds `check:engine-double-contract`, `check:query-options-erasure`, `check:type-check-coverage`, `check:type-check-debt`, `check:where-matcher`, all run green): `94 derived famil(ies) accounted for — 94 run, 0 NOT-MEASURED (a DERIVED zero)`. Also run, outside the derivation: `pnpm --filter @objectstack/spec run check:generated` exit 0 and `check:skill-docs` exit 0. Eight gates first answered exit 3 (prerequisite: no `dist/`) and were re-run green after the builds — `docs-transcript-drift`, `dts-closure`, `dual-build-cjs-loads`, `lean-entry-closure`, `sourcemap-no-sources-content`, spec `check:skill-examples` (259 prose examples type-check), lint `check:doc-formula-expressions` and `check:doc-security-posture`; spec `check:docs` was re-run after the spec build and that run is the one cited. - By name, each exit 0: `check-skills-token-ratchet` and its `--self-test`, `check:skill-refs`, `check:skill-docs`, `check:react-blocks`, `check:doc-authoring`, `check-doc-frontmatter`, `check-docs-section-name`, `check-doc-route-spelling --advisory`, `check:doc-anchors`, `check:skill-frame-sync`, `check:skill-identifier-liveness`, `check:role-word`, `check:pm-governed-merges`, `check-adr-0087-registration --base origin/main`, `check:react-page-adapter-contract`, `check:page-declaration-shape`, `check:examples-live-imports`, `check:nul-bytes`. - Governed: `check-governed-merges --branch`: GOVERNED, landing tier H via `skills/**` × 2; 48 changed lines, under the 5000 threshold. Draft, four-piece; no seat readies, queues or arms auto-merge on it. - Not run locally: `pnpm lint` (repo-wide eslint, CI's run); the showcase Playwright smoke (`test:smoke`, a browser lane); and no browser run of the two pages — the runtime behaviour above is derived from the pinned-sha readings, and objectui's pin (`react-page-invalidation.test.tsx` in PR objectui#10973) is the one that drives the real renderer with a page in the taught shape. ## Deviations - File surface +1: `examples/app-showcase/test/react-page-adapter-query-contract.test.ts` (6 insertions, 3 deletions). The test lifts the renewals page's `React.useEffect(...)` call verbatim out of `source` and evaluates it through `new Function('React', 'adapter', 'sel', 'reload', 'setRelated', …)`; the dependency list now names `projectChanged` and `invoiceChanged`, so the lifted call threw four times. The harness gains the two parameters, bound to `0`, and a three-line comment. Bounded in-place fix, four conditions: ① the same change this card makes — the effect's dependency list is what the card edits, and the harness enumerates exactly that list; ② mechanical, the shape pinned by the existing `new Function` signature; ③ the file's last touches on `origin/main` are 2026-08-24 (`eb10fcfd4`) and 2026-08-21 (`6cca75c3a`) and no claim on it is visible from this worktree — the seat's open-PR file-list read at unlock covered the six named files, not this one, so the seat confirms; ④ inside the showcase `pnpm test` family already in this PR's verification set, no new verification surface. The claim's file surface wants this path appended in the same round (the seat's write). - The first commit's co-author trailer carried a model name and the pre-push hook (`check:commit-card-trailers`) refused it before anything left the worktree; amended to the model-free pair and pushed. Nothing else deviated. ## Acceptance notes (observed, not filed) - `skills/objectstack-ui/rules/pages.md`'s Master/detail example and `crm-workbench.page.ts` key the ListView on a counter (`key={reload}` / `key={reloadKey}`) to refresh it after a save. At the pinned sha `ListView` reads the bus itself (`data-list.tsx:137`), so that remount is redundant for the refresh; it still works and the runtime honours it, so this is a boundary note and not a card — it is also the pattern `data-invalidation.ts`'s header says the bus "deliberately replaces". Retiring it changes what the example teaches. carrier: this lane's seat, when it chooses; noted, not filed. - Same file, the `kind:'react'` paragraph: "`os build` does NOT lint react source" stands a few lines under "`os validate` rejects them here (`react-block-needs-record-context`)". Two different commands, and `os validate` does read react `source` (that rule and `page-source-className-tailwind`), so the sentence holds for `os build` only as far as it goes; not re-worded here (not this card, and the file has 16 tokens of headroom). carrier: none; noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg)_ ## 维护者速读(草稿) **改了什么。** objectui 已把 `useDataInvalidation`(数据失效总线的读端,返回一个数字 nonce)注入 `kind: react` 页面的作者作用域(objectui PR 10973,已在本仓钉住的控制台 `db11afd49` 内)。本 PR 是 objectstack 这一半:react-pages 文档的作用域表与 Live data 示例、`objectstack-ui` skill 的 react 作用域规则(一条)、生成器 `build-react-blocks-contract.ts` 的两处字符串及其产物 `react-blocks.md`(经脚本重新生成)、以及 showcase 的两个 react 页面(`crm-workbench`、`renewals-pipeline`)在 `useAdapter` 读数据的 effect 依赖里加入 nonce。附带一处测试 harness 的机械修正(见 Deviations)。 **为什么改。** 裁决 A(批次 #233 第 2 项,2026-09-28「同意」):PageView 在页面动作后的整页 remount 将被移除(objectui 10777),所有读数据的块都改走失效总线;react 页面自己的 `useAdapter` 读取若不接总线,会成为唯一在页面动作后留下陈旧数据的面。文档与 skill 教的示例若不带 nonce,AI 照抄出来的页面默认就是陈旧的。 **风险与代价(含回滚)。** 纯文档 / skill / 示例改动,无协议键变动(`Clause-②: no`),不发布任何 npm 包(skills、content/docs 不在任何包的 `files[]`,showcase 为 private)。skill 棘轮:`pages.md` 5681 → 5676 tokens(上限 5692 不动),新增那条规则靠删掉同文件里重复的两句付账;生成文件 +69 tokens 不受棘轮约束。运行时影响仅限 showcase 示例应用的两个页面:页面内自己保存后 effect 会跑两次(key 自增 + 总线事件),多一次读、结果不变。回滚:revert 本 PR 即可,无迁移。 **席位意见。** (留空,席位定稿) **你要做的。** 这是 Tier H(`skills/**`)四件套草稿 PR:需要 `os-zhuang` 或 `hotlong` 的 APPROVED review,然后由 domain:skills 席位落地。 _(本节由席位在 PR 创建后补贴:dev 报告 5924107460 申报的遗漏。)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6fc22b7 commit 63d1a7c

7 files changed

Lines changed: 33 additions & 15 deletions

File tree

‎content/docs/ui/react-pages.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ builds for it:
8686
| The data blocks | One wrapper component per **public, non-container** registered block. |
8787
| `Block` | Escape hatch — render any registered component by type. |
8888
| `useAdapter` | The live data source: `find` / `findOne` / `create` / `update`. |
89+
| `useDataInvalidation` | The data-invalidation bus reader: `useDataInvalidation('object')` returns a number that moves when a write to that object is reported on the bus. Name it in the dependencies of the effect that reads through `useAdapter` — see [Live data](#live-data). |
8990
| `data`, `variables`, `page` | The page's data, its `variables` map, and its own schema. |
9091

9192
**On the `react` tier only.** A `kind:'html'` page's source is parsed and never evaluated,
@@ -175,6 +176,7 @@ options are OData-shaped — `$filter`, `$top`, `$skip`, `$select`, `$orderby`,
175176
```jsx
176177
function Page() {
177178
const adapter = useAdapter();
179+
const changed = useDataInvalidation('showcase_invoice');
178180
const [rows, setRows] = React.useState([]);
179181

180182
React.useEffect(() => {
@@ -187,12 +189,17 @@ function Page() {
187189
if (alive) setRows(result.data);
188190
})();
189191
return () => { alive = false; };
190-
}, [adapter]);
192+
}, [adapter, changed]);
191193

192194
return <ul>{rows.map((r) => <li key={r.id}>{r.name}</li>)}</ul>;
193195
}
194196
```
195197

198+
`changed` is in the dependency array so the page's own read follows the data: when a page
199+
action elsewhere writes `showcase_invoice`, the write is reported on the data-invalidation
200+
bus (the same bus `<ListView>` refreshes from), the nonce moves, and the effect runs again
201+
in place — the page is not remounted, so its own state survives.
202+
196203
`find()` always resolves to the same envelope — a `QueryResult` carrying the rows
197204
under `data`. A backend that answers with a bare array is folded into that envelope
198205
before your code sees it, so `result.data` is the only row shape a page is ever handed:

‎examples/app-showcase/src/ui/pages/crm-workbench.page.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export const CrmWorkbenchPage = definePage({
2323
source: `
2424
function Page() {
2525
const adapter = useAdapter();
26+
// The data-invalidation bus nonce for showcase_project: a page action elsewhere
27+
// that writes a project moves it, and the KPI effect below re-reads in place.
28+
const projectChanged = useDataInvalidation('showcase_project');
2629
const [selected, setSelected] = React.useState(null);
2730
const [mode, setMode] = React.useState('edit');
2831
const [reloadKey, setReloadKey] = React.useState(0);
@@ -56,7 +59,7 @@ function Page() {
5659
setStats({ total, active: rows.filter((r) => r.status === 'active').length });
5760
} catch (e) { console.warn('[CRM Workbench] failed to refresh stats', e); }
5861
}, [adapter]);
59-
React.useEffect(() => { refreshStats(); }, [refreshStats, reloadKey]);
62+
React.useEffect(() => { refreshStats(); }, [refreshStats, reloadKey, projectChanged]);
6063
6164
const openNew = () => { setSelected(null); setMode('create'); };
6265
const onRowClick = (rec) => { setSelected(rec); setMode('edit'); };

‎examples/app-showcase/src/ui/pages/renewals-pipeline.page.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,19 @@ export const RenewalsPipelinePage = definePage({
5454
source: `
5555
function Page() {
5656
const adapter = useAdapter();
57+
// The data-invalidation bus nonces for the two objects the rollup reads: a page
58+
// action elsewhere that writes a project or an invoice moves one, and the effect
59+
// below re-reads in place.
60+
const projectChanged = useDataInvalidation('showcase_project');
61+
const invoiceChanged = useDataInvalidation('showcase_invoice');
5762
const [sel, setSel] = React.useState(null);
5863
const [editing, setEditing] = React.useState(false);
5964
const [reload, setReload] = React.useState(0);
6065
const [stage, setStage] = React.useState('active');
6166
const [related, setRelated] = React.useState({ projects: 0, invoices: 0, openInvoices: 0, capped: false });
6267
6368
// Hand-rolled rollup: the imperative counterpart of the framework blocks
64-
// below. You own the queries, loading, and refresh (reload bumps re-run it) --
69+
// below. You own the queries, loading, and refresh (reload bumps and bus writes re-run it) --
6570
// and, because you own them, you own the two adapter contracts the blocks hide:
6671
//
6772
// 1. QUERY OPTIONS ARE $-PREFIXED. QueryParams declares $select, $filter,
@@ -99,7 +104,7 @@ function Page() {
99104
});
100105
})();
101106
return () => { alive = false; };
102-
}, [adapter, sel, reload]);
107+
}, [adapter, sel, reload, projectChanged, invoiceChanged]);
103108
104109
const Stat = ({ label, value, accent }) => (
105110
<div style={{ background: 'hsl(var(--muted))', border: '1px solid hsl(var(--border))', borderRadius: 'var(--radius)', padding: 12 }}>

‎examples/app-showcase/test/react-page-adapter-query-contract.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,14 @@ async function runRollup(
116116
let settle!: (v: Rollup) => void;
117117
const done = new Promise<Rollup>((res) => { settle = res; });
118118
const React = { useEffect: (cb: () => unknown) => { cb(); } };
119+
// The effect's dependency list names the two data-invalidation bus nonces the page
120+
// reads through the injected `useDataInvalidation`; here they are plain numbers that
121+
// never move, so the lifted call evaluates and the body runs exactly once.
119122
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
120-
const run = new Function('React', 'adapter', 'sel', 'reload', 'setRelated', effectSource) as (
121-
React: unknown, adapter: unknown, sel: unknown, reload: unknown, setRelated: (v: Rollup) => void,
123+
const run = new Function('React', 'adapter', 'sel', 'reload', 'projectChanged', 'invoiceChanged', 'setRelated', effectSource) as (
124+
React: unknown, adapter: unknown, sel: unknown, reload: unknown, projectChanged: unknown, invoiceChanged: unknown, setRelated: (v: Rollup) => void,
122125
) => void;
123-
run(React, adapter, sel, 0, settle);
126+
run(React, adapter, sel, 0, 0, 0, settle);
124127
return done;
125128
}
126129

‎packages/spec/scripts/build-react-blocks-contract.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ const blocks = REACT_BLOCKS.map((b) => {
136136
// states its provenance in the frontmatter `description` it emits instead.
137137
const contract = {
138138
adr: 'ADR-0081',
139-
note: "Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. These blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update. STYLING (ADR-0065) — a page's source is runtime metadata, so the console's build-time Tailwind NEVER scans it: utility classNAMES silently produce no CSS. Do NOT use Tailwind className in page source. (a) Layout/chrome: inline style={} with hsl(var(--token)) theme colors — e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))', border:'1px solid hsl(var(--border))', and px/flex for layout. (b) Overlays: render <ObjectForm formType='drawer'|'modal' open onOpenChange> (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.",
139+
note: "Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. These blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update; const changed = useDataInvalidation('obj') is the data-invalidation bus nonce (a number that moves when a write to obj is reported) — name it in the deps of the effect that reads through useAdapter, so that read re-runs in place with no remount. STYLING (ADR-0065) — a page's source is runtime metadata, so the console's build-time Tailwind NEVER scans it: utility classNAMES silently produce no CSS. Do NOT use Tailwind className in page source. (a) Layout/chrome: inline style={} with hsl(var(--token)) theme colors — e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))', border:'1px solid hsl(var(--border))', and px/flex for layout. (b) Overlays: render <ObjectForm formType='drawer'|'modal' open onOpenChange> (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.",
140140
};
141141

142142
// markdown
@@ -169,7 +169,7 @@ for (const b of blocks) {
169169
}
170170
L.push('## Injected scope (closure variables, reference directly — not props)');
171171
L.push('');
172-
L.push('`React` · `useAdapter` · `data` · `variables` · `page`. Kanban/calendar/gantt/timeline/map of an object = `<ListView navigation={…} />` with the matching visualization, or `<Block type="object-kanban" …/>`.');
172+
L.push('`React` · `useAdapter` · `useDataInvalidation` · `data` · `variables` · `page`. Kanban/calendar/gantt/timeline/map of an object = `<ListView navigation={…} />` with the matching visualization, or `<Block type="object-kanban" …/>`.');
173173
L.push('');
174174
emit(OUT_MD, L.join('\n'));
175175

‎skills/objectstack-ui/references/react-blocks.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: Props each injected component accepts in kind:'react' page source (
77

88
# React-tier component contract (ADR-0081)
99

10-
Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. These blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update. STYLING (ADR-0065) — a page's source is runtime metadata, so the console's build-time Tailwind NEVER scans it: utility classNAMES silently produce no CSS. Do NOT use Tailwind className in page source. (a) Layout/chrome: inline style={} with hsl(var(--token)) theme colors — e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))', border:'1px solid hsl(var(--border))', and px/flex for layout. (b) Overlays: render <ObjectForm formType='drawer'|'modal' open onOpenChange> (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.
10+
Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. These blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update; const changed = useDataInvalidation('obj') is the data-invalidation bus nonce (a number that moves when a write to obj is reported) — name it in the deps of the effect that reads through useAdapter, so that read re-runs in place with no remount. STYLING (ADR-0065) — a page's source is runtime metadata, so the console's build-time Tailwind NEVER scans it: utility classNAMES silently produce no CSS. Do NOT use Tailwind className in page source. (a) Layout/chrome: inline style={} with hsl(var(--token)) theme colors — e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))', border:'1px solid hsl(var(--border))', and px/flex for layout. (b) Overlays: render <ObjectForm formType='drawer'|'modal' open onOpenChange> (a pre-styled Sheet/Dialog) — never hand-roll a fixed inset-0 backdrop.
1111

1212
**kind**: `data` = declarative config (from the spec schema — the authoritative source) · `binding` = connects the block to data · `controlled` = drive from React state · `callback` = a React function the block calls.
1313

@@ -112,4 +112,4 @@ Escape hatch — render any registered component by type. <Block type="object-ka
112112

113113
## Injected scope (closure variables, reference directly — not props)
114114

115-
`React` · `useAdapter` · `data` · `variables` · `page`. Kanban/calendar/gantt/timeline/map of an object = `<ListView navigation={…} />` with the matching visualization, or `<Block type="object-kanban" …/>`.
115+
`React` · `useAdapter` · `useDataInvalidation` · `data` · `variables` · `page`. Kanban/calendar/gantt/timeline/map of an object = `<ListView navigation={…} />` with the matching visualization, or `<Block type="object-kanban" …/>`.

‎skills/objectstack-ui/rules/pages.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ The source is real React executed at render by the runtime. The injected scope a
169169

170170
- `React` — hooks (`React.useState`, `React.useEffect`, …)
171171
- `useAdapter()` — live data: `adapter.find('obj', {…})` / `.findOne` / `.create` / `.update`
172+
- `useDataInvalidation('obj')` — the data-invalidation bus nonce (a number): name it in the
173+
deps of the effect that reads through `useAdapter`, so a page action elsewhere that writes
174+
`obj` re-runs that read in place, with no remount
172175
- the public **data blocks as PascalCase components** — `<ObjectForm>`, `<ListView>`,
173176
`<ObjectMetric>`, `<ObjectChart>`, `<ObjectKanban>`, … The scope is built at
174177
runtime from the public block registry (every non-container public block gets a
@@ -200,10 +203,7 @@ why it silently does nothing.
200203
> It is the authoritative answer to "what props does `<ObjectForm>`/`<ListView>`/…
201204
> take?" — author against it, not from memory. The `data` props are sourced from the platform's spec schemas (FormView,
202205
> ListView, Chart, …) — the same protocol the server validates;
203-
> `binding`/`controlled`/`callback` are the React overlay. The contract covers
204-
> the **curated core set**; runtime-injected blocks outside it (`<ObjectMetric>`,
205-
> `<ObjectKanban>`, …) read their props from the block registry at render time —
206-
> except the `record:*` family, which is rejected on this surface (above).
206+
> `binding`/`controlled`/`callback` are the React overlay.
207207
> (Maintainers: regenerate with `pnpm --filter @objectstack/spec gen:react-blocks`.)
208208
209209
Master/detail (click a row → edit it → save refreshes the list):

0 commit comments

Comments
 (0)