diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js index 849c878e9c..c43448f066 100644 --- a/.claude/workflows/docs-accuracy-audit.js +++ b/.claude/workflows/docs-accuracy-audit.js @@ -209,6 +209,7 @@ const ALL_HANDWRITTEN = [ "content/docs/ui/index.mdx", "content/docs/ui/pages.mdx", "content/docs/ui/public-data-collection.mdx", + "content/docs/ui/react-pages.mdx", "content/docs/ui/setup-app.mdx", "content/docs/ui/translations.mdx", "content/docs/ui/views.mdx", diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index d4f688de6e..3643f6e4e1 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -4,6 +4,7 @@ "index", "apps", "pages", + "react-pages", "views", "actions", "dashboards", diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 30d5a16faa..ae7f2ee7ba 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -63,7 +63,9 @@ const homePage = { | `type` | `enum` | optional | Page type (see below; default `'record'`) | | `object` | `string` | optional | Associated object (for `record` type) | | `template` | `string` | optional | Layout template name (default: `'default'`) | +| `kind` | `enum` | optional | Page override mode (default `'full'`). `full` \| `slotted` = structured authoring; `html` = author-written constrained JSX/HTML compiled (parsed, never executed) to the tree (ADR-0080; the legacy value `'jsx'` is a deprecated alias); `react` = real-React source executed at render by the runtime (ADR-0081) — it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via `OS_PAGE_REACT=off`. See [Authoring modes](#authoring-modes) | | `regions` | `PageRegion[]` | optional | Layout regions with components (default `[]` — `list` pages render via `interfaceConfig`, and an empty `record`/`home`/`app` page falls back to the synthesized default layout) | +| `source` | `string` | optional | Page source text — required (and authoritative over `regions`) when `kind` is `'html'` or `'react'`. For `kind: 'html'` it is constrained JSX/HTML compiled to the tree by `@objectstack/sdui-parser` at save time (parse, never execute). For `kind: 'react'` it is real React/JSX executed at render by `@object-ui/react-runtime` (trusted tier). See [React Pages](/docs/ui/react-pages) | | `variables` | `PageVariable[]` | optional | Local state variables | | `isDefault` | `boolean` | optional | Is default page for its type | | `assignedProfiles` | `string[]` | optional | Profiles that can access this page | @@ -80,6 +82,42 @@ const homePage = { Earlier roadmap types (`dashboard`, `form`, `record_detail`, `record_review`, `overview`, `blank`) were **removed from the schema** because they never shipped a renderer (ADR-0049 enforce-or-remove); only the five types above are valid. +## Authoring modes [#authoring-modes] + +A page's `kind` selects **how the body is authored**. Three modes ship, and the rest of +this page describes the first one: + +| `kind` | You write | Executed? | Reach for it when | +| :--- | :--- | :--- | :--- | +| `full` (default) / `slotted` | Structured `regions` / `slots` of components — *the rest of this page* | — | Record, home and app layouts assembled from the component catalogue | +| `html` | A `source` string of constrained JSX: registered components plus safe native HTML | **No** — parsed into the same component tree at save time by `@objectstack/sdui-parser` | Free-form layout, landing pages and composed dashboards, including author- or AI-generated ones you have not reviewed | +| `react` | A `source` string of real React — hooks, handlers, arbitrary JS | **Yes** — in the app's own React tree, no sandbox | Interactive business UIs — master/detail, wizards, state-driven filters — written by authors you trust | + +The two source tiers set `source` instead of `regions`. **`source` is authoritative over +`regions`**: when both are present the source wins, and `regions` holds at most a derived +cache of it. A page whose `kind` is `html`, `react` or `jsx` with no non-empty `source` +is rejected by the schema rather than rendering empty. + +`'jsx'` is a deprecated alias for `'html'`, still accepted and converted at load time. + +Because `kind: 'react'` executes author JavaScript, it is gated by a host capability that +defaults **ON** and is disabled server-side per deployment with `OS_PAGE_REACT=off`. The +`html` tier is unaffected by that switch — it is never executed. + + +Neither source tier is styled with Tailwind utility classes. A page's `source` is +*runtime metadata*, and the console's build-time Tailwind scans only the console's own +source — so a utility class name in page source silently produces no CSS, with no error +(ADR-0065). Style an `html` page with its components' structured props and a JSON `style` +object; style a `react` page with inline `style={{ … }}` and `hsl(var(--token))` theme +colors. + + +Both tiers are checked at author time — block props, field bindings and source syntax — +by `os validate`, `os lint` and `os build` alike. See +[React Pages](/docs/ui/react-pages) for the full authoring guide and +[Validating metadata](/docs/deployment/validating-metadata) for the rules. + ## Regions Regions define layout zones on the page. Each region contains components. @@ -256,3 +294,5 @@ const accountRecordPage = { - [View Metadata](/docs/ui/views) — List views and form views for record display - [Dashboard Metadata](/docs/ui/dashboards) — Analytics-focused page layout - [App Metadata](/docs/ui/apps) — Organize pages into applications +- [React Pages](/docs/ui/react-pages) — The `html` and `react` source-authoring tiers in full +- [Validating metadata](/docs/deployment/validating-metadata) — Every author-time rule a page is held to diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx new file mode 100644 index 0000000000..7f60c43fe7 --- /dev/null +++ b/content/docs/ui/react-pages.mdx @@ -0,0 +1,414 @@ +--- +title: React Pages +description: Author a page body as real React (kind:'react') or as constrained JSX that is parsed and never executed (kind:'html') — the two source-authoring tiers, and how to choose +--- + +# React Pages + +Most pages are a **schema tree**: `regions[].components[]` of JSON nodes, described in +[Page Metadata](/docs/ui/pages). Two page kinds let you write the body as a **source +string** instead, for layouts and interactions the fixed schema cannot express. + +| `kind` | You write | Executed? | Compiled by | Author trust | +| :--- | :--- | :--- | :--- | :--- | +| `'html'` | Constrained JSX — registered components plus safe native HTML | **No** — parsed into the SDUI tree at save time | `@objectstack/sdui-parser` | Untrusted authors OK | +| `'react'` | Real React — hooks, handlers, arbitrary JS | **Yes** — in the app's own React tree | `@object-ui/react-runtime` | First-party only | + +Both set `source` and leave `regions` unused. `source` is **authoritative over +`regions`** on both tiers, and a page with either `kind` and no non-empty `source` is +rejected by the schema rather than rendering empty. `'jsx'` is a deprecated alias for +`'html'` that is still accepted and converted at load. + +## Choosing between `react` and `html` + +The line between them is a **trust boundary**, not a convenience preference. + +A `kind:'react'` page's source is transpiled and handed to `new Function(...)` with an +injected scope. There is **no sandbox**: the code runs in the application's own React +tree with everything that reach implies. Use it only for source you would accept as a +pull request. + +A `kind:'html'` page's source is **parsed into a schema tree and never executed**. Only +tags in the public block manifest are accepted, props are checked against each block's +declared inputs, and an unknown tag is a hard error at save time. That is the tier for +author- or AI-generated pages you have not reviewed. + + +Reach for `'html'` by default. Reach for `'react'` when the page genuinely needs +behaviour the schema tree cannot express — local state, computed lists, one block's +event wiring another block's props — and when you trust whoever wrote it. + + +The two tiers also differ in what they give you for layout, in opposite directions: + +- **`html`** injects the layout containers. You compose with `` and ``, and style with a JSON `style` object. +- **`react`** deliberately does **not** inject layout containers — you have real React, + so you compose with ordinary `
` and inline `style={{ … }}`. + +## The security gate + +Because the react tier runs author JavaScript, it is gated by a host capability named +`react-pages`, which defaults **ON** — the platform's assumption is that page authors +are reviewed and draft-gated. + +A deployment that does not trust its page authors turns the tier off **server-side**: + +```bash +OS_PAGE_REACT=off objectstack start +``` + +The server then injects the console's capability-disable flag, and every `kind:'react'` +page renders an explanatory notice instead of executing. `off`, `0`, `false`, `no` and +`disabled` are all accepted spellings. `kind:'html'` pages are unaffected — they were +never executed in the first place, which is the whole point of the split. + +## What is in scope + +Nothing is imported. A react page's source is evaluated with a closure scope the runtime +builds for it: + +| In scope | What it is | +| :--- | :--- | +| `React` | The host's React — call hooks through it, e.g. `React.useState`. | +| The data blocks | One wrapper component per **public, non-container** registered block. | +| `Block` | Escape hatch — render any registered component by type. | +| `useAdapter` | The live data source: `find` / `findOne` / `create` / `update`. | +| `data`, `variables`, `page` | The page's data, its `variables` map, and its own schema. | + +Blocks are referenced by the **PascalCase form of their registered type**: `object-form` +→ ``, `list-view` → ``, `object-chart` → ``. + +The per-block prop lists are **generated** from the spec's block index, so they cannot +drift out of step with the runtime. Treat the generated file as the authority and read it +before you write props: + +> **[React-tier component contract](https://github.com/objectstack-ai/objectstack/blob/main/skills/objectstack-ui/references/react-blocks.md)** +> — `skills/objectstack-ui/references/react-blocks.md`, generated from `REACT_BLOCKS` in +> `@objectstack/spec/ui`. Every prop is tagged `data` (declarative config from the +> block's spec schema), `binding` (connects the block to data), `controlled` (drive it +> from React state), or `callback` (a function the block calls). + +This page deliberately does not restate those tables. A hand-copied prop table is a new +place for the contract to rot. + +## Blocks take flat props + +An injected block folds its JSX props into the block's schema, so you write flat props +rather than a nested `schema` object: + +```jsx + +``` + +Function props are passed through as real callbacks — that is how one block drives +another: + +```jsx + setSelected(record.id)} /> +``` + +One collision is worth knowing. `type` is the SDUI envelope's component discriminator +**and** a legitimate prop name on some blocks — a chart's family, for instance. The +discriminator wins the `type` slot and your value is preserved beside it as `specType` +for the block to read, so `` works as written. + +### `Block` — the escape hatch + +Any registered component, including ones outside the curated contract: + +```jsx + +``` + +A kanban, calendar, gantt, timeline or map of an object is also reachable without the +escape hatch — `` selects the visualization directly. + +`Block` is **not** a way back to the `record:*` family; see +[below](#record-blocks-not-in-react). + +## Live data + +`useAdapter()` returns the same data source the rest of the app queries through. Query +options are OData-shaped — `$filter`, `$top`, `$skip`, `$select`, `$orderby`, `$search`: + +```jsx +function Page() { + const adapter = useAdapter(); + const [rows, setRows] = React.useState([]); + + React.useEffect(() => { + let alive = true; + (async () => { + const result = await adapter.find('showcase_invoice', { + $filter: ['status', '!=', 'paid'], + $top: 200, + }); + const records = Array.isArray(result) ? result : (result && result.records) || []; + if (alive) setRows(records); + })(); + return () => { alive = false; }; + }, [adapter]); + + return
    {rows.map((r) =>
  • {r.name}
  • )}
; +} +``` + + +The `$` prefixes are load-bearing. 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. + + +`$filter` takes an ObjectQL filter array: `['field', 'op', value]`, with `and` / `or` +compounds spelled `['and', [...], [...]]`. + +## Styling — a page's source is metadata, not source code [#styling] + + +**Do not write Tailwind utility classes in page source.** A page's `source` is *runtime +metadata*. The console's Tailwind is JIT-compiled at **build** time by scanning the +console's own `src` — it never scans your page. There is no safelist. So a utility class +name in page source produces CSS only if that exact class happens to appear somewhere in +the console's own source, and otherwise **produces nothing, with no error anywhere**. + + +This is the single most expensive mistake on this tier, because the failure mode is a +page that renders — correct structure, correct data, no styling — and reports nothing. +It is recorded as an amendment to ADR-0080 under +[ADR-0065](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0065-sdui-styling-model.md): +a modal's `bg-black/50` backdrop rendered fully transparent in production. + +Style a react page two ways instead: + +**1. Layout and chrome — inline `style={{ … }}` with theme tokens.** Colors come from the +active theme as `hsl(var(--token))`, so the page follows light/dark and any theme the +deployment installs: + +```jsx +
+ … +
+``` + +Common tokens: `--background`, `--foreground`, `--card`, `--muted`, +`--muted-foreground`, `--border`, `--primary`, `--primary-foreground`, `--destructive`, +and the spacing/radius tokens `--space-*` and `--radius`. + +**2. Overlays — let a block render them.** Never hand-roll a `position: fixed; inset: 0` +backdrop; render the form in its built-in Sheet or Dialog, which arrives already styled: + +```jsx + { if (!o) setEditing(false); }} +/> +``` + +Omit any pixel width — an author cannot know the client viewport, so the renderer derives +the size. + +Data blocks (``, ``, ``) bring their own compiled +styling; you only style the layout around them. + +## Accepted source shapes + +The page renders the source's **default export**. The runtime inserts an implicit +`export default` when the source *starts with* JSX, a `function` declaration, `()`, or +`class`: + +```jsx +function Page() { return

hi

; } // ✅ implicit default export +

hi

// ✅ +() =>

hi

// ✅ + +const Page = () =>

hi

; // ❌ exports nothing +``` + +The `const Page = …` form does **not** get the implicit export — end the source with +`export default Page;`. Getting this wrong does not render blank silently: the runtime +throws with a message naming the fix. + +## When something throws + +Transpile errors, evaluation errors and errors thrown during render all surface in a +**React page error** panel carrying the message. The error is held until the source or +its data changes, so it neither flickers nor escapes into the generic renderer error. + +Referencing an identifier that is not in scope is the common case, and reads as +`ReferenceError: is not defined` — usually a layout container (there are none on +this tier — use HTML) or a block outside the public registry (use ``). + +## Page state + +A react page keeps its own `React.useState` across re-renders and across lazily loaded +plugin chunks. The parent record on a react page is not a framework concept — it is +ordinary React state that you pass to blocks as props. + +Three things reset that state, all intentional: a change to `source`, a change to the +page's data or variables, and a **new adapter identity**. The last one is a constraint on +the host, not on you: recompiling the page is the only way a new adapter reaches the +blocks inside it, so a host that constructs an adapter inline on every render resets +every react page on every render. Hosts should provide the adapter from state or a +module constant. + +## `record:*` blocks are not in this tier [#record-blocks-not-in-react] + +``, ``, ``, `` and the +rest of the `record:*` family are **record-page composition blocks**. Each one reads its +record from the shared record context a `type:'record'` page mounts once, and they are +coupled through it — one fetch, one inline-edit draft, one save bar. + +A `kind:'react'` page mounts no such context. Those blocks therefore render empty here +however you bind them, so they are withdrawn from the tier and using one is an **error**, +by tag and through `` alike: + +``` + ✗ Author-time rules failed (1 issue) + • page "showcase_renewals_pipeline" › : renders "record:highlights", which reads its record from the record context a record page mounts — a kind:'react' page never mounts one, so the block renders empty no matter how it is bound (its objectName/recordId are not read by the renderer). + On a react page bind the record yourself: , or read the record with useAdapter().findOne and lay the strip out in JSX. + rule: react-block-needs-record-context at pages[27].source +``` + +The error names the replacement for the block you reached for, so read the hint rather +than a table here. In general: on a react page the parent record is React state, so bind +it with a block that reads its **own** props — `` +for a field panel, `', '=', parentId]}>` +for a related list — or read the record with `useAdapter().findOne` and lay it out in JSX. +To use the family itself, author the page as `type:'record'` instead. + +## How you check your work + +Every `kind:'react'` page is parsed and checked at author time. `os validate`, +`os lint` and `os build` all run the same rule set, so what one accepts the others do +too: + +```bash +objectstack validate +``` + +``` +◆ Validate +──────────────────────────────────────── + → Loading configuration... + → Validating against ObjectStack Protocol... + → Running author-time rules (41)... + → Checking capability providers (#3366)... + → Checking package docs (ADR-0046)... + + ✓ Validation passed (1523ms) +``` + +A **missing required binding** fails the build: + +``` + ✗ Author-time rules failed (1 issue) + • page "showcase_renewals_pipeline" › : is missing the required prop "objectName". + Pass objectName={…}. See the react-tier component contract. + rule: react-prop-missing-required at pages[27].source +``` + +A **near-miss prop name** is a warning — validation still passes, because the contract's +data props are a curated subset and arbitrary unknown props are deliberately not flagged: + +``` + ⚠ page "showcase_renewals_pipeline" › : has prop "onSucces" — did you mean "onSuccess"? +``` + +Field-bearing props are resolved against the object each block names, so a column, form +field or filter naming a field the object does not have is reported too — and a bad +**filter** position is an error rather than a warning, because the predicate can never +match and the list comes back indistinguishable from "there is no data". See +[Validating metadata](/docs/deployment/validating-metadata) §10 and §10b for the full +rule set. + +## A complete page + +A master/detail console: a filtered list on the left drives a summary, a chart and a +related list on the right, with edits in a drawer. Every binding is an ordinary prop — +there is no record context involved. + +{/* os:check */} +```typescript +import { definePage } from '@objectstack/spec/ui'; + +export const RenewalsConsolePage = definePage({ + name: 'renewals_console', + label: 'Renewals Console', + type: 'home', + kind: 'react', + source: ` +function Page() { + const [sel, setSel] = React.useState(null); + const [editing, setEditing] = React.useState(false); + + return ( +
+
+ { setSel(record.id); setEditing(false); }} + /> +
+ +
+ {!sel ? ( +
+ Select an account. +
+ ) : ( + + + + + + {editing ? ( + { if (!o) setEditing(false); }} + onSuccess={() => setEditing(false)} + onCancel={() => setEditing(false)} /> + ) : null} + + )} +
+
+ ); +}`, +}); +``` + +``'s axes name the **result columns** of its `aggregate`, not fields on the +object: an inline aggregate returns rows keyed by the raw field names — `status` (its +`groupBy`) and `total` (its `field`) — which is what `xAxis.field` and `yAxis[].field` +bind to above. `os validate` checks both halves. + +## Related + +- [Page Metadata](/docs/ui/pages) — the structured tiers, the Page Properties table, and where `kind` and `source` fit +- [Validating metadata](/docs/deployment/validating-metadata) — every author-time rule, including §10 and §10b for this surface +- [Layout DSL](/docs/protocol/objectui/layout-dsl) — the structured mode's regions and 12-column grid