Skip to content

Commit c5f04e9

Browse files
committed
Merge origin/main into claude/tabbed-modal-form-state-5740c7
Sync before merging so CI validates the real merge result — main picked up a @object-ui/types refactor (#2985) and two plugin-form wizard changes while this branch was in flight.
2 parents ad17ed8 + 02aef0c commit c5f04e9

25 files changed

Lines changed: 1240 additions & 86 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@object-ui/core": minor
3+
"@object-ui/components": patch
4+
---
5+
6+
fix(sdui): a `kind:'html'` page can use lazily-registered blocks, and recovers when one registers late
7+
8+
objectui#2953 had a twin one tier over, unreported. The whitelist a
9+
`kind:'html'` page's source compiles against was built from `getAllTypes()` +
10+
`getConfig()` — both loaded-only — so any block registered via `registerLazy()`
11+
was rejected as *"not an allowed component"*.
12+
13+
The blast radius is worse than the react tier's. There, a missing block cost one
14+
identifier; here a compile diagnostic fails the **whole page**, so a single
15+
`<object-kanban>` replaced the entire page with `HTML page failed to compile (2)`.
16+
And it never recovered: `layoutElement` was memoised on `[schema, pageType]` with
17+
no registry signal, so the cached error panel outlived the plugin actually
18+
landing — permanently broken for the session.
19+
20+
`ComponentRegistry` gains three lazy-aware reads:
21+
22+
- `getKnownTypes()` — loaded registrations **plus** pending lazy stubs, deduped.
23+
The set a whitelist or manifest should be built from. `getAllTypes()` keeps its
24+
loaded-only meaning ("what can render right now") and now says so.
25+
- `getMeta(type, namespace?)` — metadata from the loaded registration, else from
26+
a pending stub. `getConfig()` stays loaded-only, since callers read
27+
`.component` off it.
28+
- `getVersion()` — monotonic counter of changes to the known set, bumped on
29+
register / unregister / registerLazy. A cache key that a type *count* cannot
30+
substitute for: one registration plus one unregistration leaves the count
31+
untouched while the set changed.
32+
33+
`getJsxManifest()` builds from those, and `PageRenderer` subscribes to the
34+
registry so a page that could not compile retries when the registry grows.
35+
36+
A stub carries no `inputs` yet, so its props surface as `unknown-prop` warnings
37+
rather than errors — the page compiles and renders, and the inner
38+
`SchemaRenderer` triggers the loader and swaps in the real block. Authoring-time
39+
prop validation is unaffected: `sdui.manifest.json` is generated with every
40+
plugin eagerly loaded, and asserts as much.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@object-ui/react": patch
3+
"@object-ui/react-runtime": minor
4+
"@object-ui/types": minor
5+
"@object-ui/components": patch
6+
---
7+
8+
fix(sdui): a react page no longer loses its state to a memo that never held, and a source that exports nothing fails loudly
9+
10+
Writing the regression guard for objectui#2954's "latent hazard" found it was
11+
already real.
12+
13+
**`evaluatedSchema` was memoised on values rebuilt every render.**
14+
`SchemaRenderer` fell back to a fresh `{}` when no `SchemaRendererProvider` sat
15+
above it, and `usePageVariables()` returned a brand-new object literal outside a
16+
`PageVariablesProvider`. Both feed the `evaluatedSchema` memo's dependency list,
17+
so for any tree without those providers the memo never hit: the schema was
18+
re-cloned and the ExpressionEvaluator re-run on every render, and children got a
19+
new schema identity every time. A `kind:'react'` page memoises its compiled
20+
source on that identity, so the page was recompiled — a new page function, a new
21+
element type — and React remounted it, silently discarding the user's `useState`.
22+
Any registry notification (every lazy plugin's first load) triggered it. Both
23+
fallbacks are now module constants.
24+
25+
**A source that exports nothing now throws instead of rendering blank.**
26+
`generateElement` inserts the implicit `export default` only when the source
27+
*starts with* JSX, a `function` declaration, `()` or `class` — so the very
28+
common `const Page = () => …` exported nothing, and the page rendered blank with
29+
no error reported anywhere. It now throws with a message naming the fix, which
30+
`ReactRunner`'s error panel surfaces. `export default null` still means "render
31+
nothing"; a default export that is not a component throws too.
32+
33+
**`PageSchema['kind']` matches `@objectstack/spec`.** It declared
34+
`'full' | 'slotted'` while the renderer had shipped `'react'` and
35+
`'html'`/`'jsx'` since ADR-0080 and read the field through a cast. The union now
36+
spells all five and the cast is gone.
37+
38+
Docs: new `content/docs/guide/react-pages.md` (choosing between the executed and
39+
parsed tiers, the capability gate, the injected scope, flat props, `Block`,
40+
`useAdapter`, source shapes, error handling) and a `@object-ui/react-runtime`
41+
README — the package had neither, while being the tier AI-authored pages target.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@object-ui/types": minor
3+
---
4+
5+
refactor(types): retire the five forks that shadowed a `@objectstack/spec` vocabulary (#2944)
6+
7+
Five declarations in `@object-ui/types` restated a spec vocabulary, four of them
8+
re-exported under **the spec's own symbol name** — so an importer could not tell
9+
which definition they had. Every one had already drifted:
10+
11+
| Declaration | Was | Spec |
12+
|---|---|---|
13+
| `ChartTypeSchema` (`zod/data-display.zod.ts`) | 7 values | **19** |
14+
| `ChartType` (`data-display.ts`) | 7 values | **19** |
15+
| `PageTypeSchema` (`zod/layout.zod.ts`) | 4 — no `list` | 5 |
16+
| `PageType` (`layout.ts`) | 10 — five the spec repudiates | 5 + local |
17+
| `ReportType` (`reports.ts`) | 3 — no `joined` | 4 |
18+
| `ActionType` (`ui-action.ts`) | 5 — no `form` | 6 |
19+
20+
All are now the spec's schema by reference, or its type re-exported/derived.
21+
22+
**This is why #2901 was filed with an inverted premise.** It read the 7-value
23+
`ChartTypeSchema` as the protocol and concluded `plugin-charts` had outgrown it
24+
with renderer-local dialect. The spec has 19; the 7-value list was this fork.
25+
26+
**Widening only for consumers.** `ActionType` gains `form` (which
27+
`ActionRunner.executeForm` already implemented, so a host app previously got a
28+
type error on working code), `ReportType` gains `joined`, `ChartType` goes 7 → 19,
29+
and `PageTypeSchema` gains `list`. Nothing was removed, so no existing value
30+
stops type-checking or validating. Verified against the whole repo: 76/76
31+
type-check tasks and 8215 tests pass.
32+
33+
**`PageType` keeps a named local extension.** `grid`/`gallery`/`kanban`/
34+
`calendar`/`timeline` are visualizations, not page kinds — `ui/page.zod.ts` says
35+
so outright — but narrowing them away is a breaking type change for anyone
36+
assigning `pageType: 'kanban'`. They are now `PageVisualizationAlias`, a
37+
sanctioned and documented local extension (issue #2231's prescription) rather
38+
than five names hidden inside a hand-written union. Removing it is the separate
39+
"visualizations are not page types" cleanup.
40+
41+
Guarded going forward: `spec-subschema-parity.test.ts` pins the two zod schemas
42+
**by reference** (a faithful copy fails, because a copy is a fork), and the new
43+
`spec-derived-unions.test.ts` covers the type aliases, which reference identity
44+
cannot reach.

content/docs/guide/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"record-edit-modes",
2424
"dashboard-filters",
2525
"slotted-pages",
26+
"react-pages",
2627
"console",
2728
"console-architecture",
2829
"flow-designer",

content/docs/guide/react-pages.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
---
2+
title: React Pages
3+
description: Author a page body as real React (kind:'react') or as constrained JSX that is parsed and never executed (kind:'html') — and how to choose between them.
4+
---
5+
6+
# React Pages
7+
8+
Most pages in ObjectUI are a **schema tree**`regions[].components[]` of JSON
9+
nodes. Two page kinds let you write the body as **source** instead, for layouts
10+
that are awkward to express as nested JSON:
11+
12+
| `kind` | Source is | Executed? | Author trust |
13+
|---|---|---|---|
14+
| `"html"` | Constrained JSX/HTML + Tailwind | **No** — parsed into a schema tree | Untrusted OK |
15+
| `"react"` | Real React (hooks, handlers, arbitrary JS) | **Yes** — in the main React tree | Trusted only |
16+
17+
Both set `source` and leave `regions` unused. `"jsx"` is a deprecated alias for
18+
`"html"` and is still accepted.
19+
20+
> Page `kind` also carries the record-page override values `"full"` (default)
21+
> and `"slotted"` — a different axis, covered in [Slotted Pages](./slotted-pages.md).
22+
23+
## Choosing between them
24+
25+
Reach for **`kind:'html'`** by default. It is parsed, whitelisted against the
26+
public block manifest, and never executed, so it is safe for AI-generated and
27+
customer-authored pages. It covers layout, blocks, and Tailwind styling.
28+
29+
Reach for **`kind:'react'`** only when you need real behaviour the schema tree
30+
cannot express — local state, computed lists, event handlers wiring one block to
31+
another, custom data fetching. It runs **without a sandbox**.
32+
33+
## `kind:'react'`
34+
35+
```json
36+
{
37+
"type": "home",
38+
"name": "project_console",
39+
"kind": "react",
40+
"source": "function Page() {\n const [selected, setSelected] = React.useState(null);\n return (\n <div className=\"grid grid-cols-2 gap-4\">\n <ListView objectName=\"showcase_project\" fields={['name', 'status']} onRowClick={(r) => setSelected(r._id)} />\n {selected && <ObjectForm objectName=\"showcase_project\" mode=\"edit\" recordId={selected} />}\n </div>\n );\n}"
41+
}
42+
```
43+
44+
Written out, that `source` is:
45+
46+
```jsx
47+
function Page() {
48+
const [selected, setSelected] = React.useState(null);
49+
return (
50+
<div className="grid grid-cols-2 gap-4">
51+
<ListView
52+
objectName="showcase_project"
53+
fields={['name', 'status']}
54+
onRowClick={(r) => setSelected(r._id)}
55+
/>
56+
{selected && <ObjectForm objectName="showcase_project" mode="edit" recordId={selected} />}
57+
</div>
58+
);
59+
}
60+
```
61+
62+
### The security gate
63+
64+
A react page's source is transpiled and evaluated directly in the application —
65+
no isolation, full access to the page's React tree. The platform assumes page
66+
authors are reviewed and draft-gated, so the host capability `react-pages`
67+
defaults **ON**.
68+
69+
A deployment that does not trust its authors turns it off server-side with
70+
`OS_PAGE_REACT=off` (or `disableCapability('react-pages')` in the host). Pages
71+
then render an explanatory notice instead of executing. Existing `kind:'html'`
72+
pages are unaffected.
73+
74+
### What is in scope
75+
76+
Nothing is imported. These identifiers are injected as closure variables:
77+
78+
| In scope | What it is |
79+
|---|---|
80+
| `React` | The host's React — call hooks with it (`React.useState`). |
81+
| The public data blocks | `<ObjectGrid>`, `<ListView>`, `<ObjectForm>`, `<ObjectKanban>`, `<ObjectChart>`, `<ObjectMetric>`, `<Markdown>`, … |
82+
| `Block` | Escape hatch for anything not injected. |
83+
| `useAdapter` | The live data source — query/create/update. |
84+
| `data`, `variables`, `page` | The page's own data, local variables, and schema. |
85+
86+
The block tags come from the curated **public contract**
87+
(`PUBLIC_BLOCKS`), converted from kebab-case to PascalCase: `object-grid`
88+
`<ObjectGrid>`, `record:details``<RecordDetails>`. Blocks registered lazily
89+
are in scope too — you never wait on a plugin chunk to reference one.
90+
91+
**Layout containers are deliberately not injected.** In react mode you compose
92+
layout with real HTML and Tailwind, which React is better at than a schema-children
93+
renderer. `<flex>`, `<grid>`, `<card>` and friends have no injected wrapper — use
94+
`<div className="flex gap-4">`.
95+
96+
### Blocks take flat props
97+
98+
An injected block folds its JSX props into the block's schema, so you write
99+
flat props rather than a nested `schema` object:
100+
101+
```jsx
102+
<ObjectGrid objectName="showcase_project" fields={['name', 'status']} pageSize={25} />
103+
```
104+
105+
Function props (`onRowClick`, `onSelect`) are passed through as real callbacks —
106+
that is how you wire one block to another.
107+
108+
One collision to know about: `type` is both the schema's component
109+
discriminator and a legitimate prop name on some blocks (a chart's family, for
110+
instance). The discriminator wins the `type` slot, and your value is preserved
111+
next to it as `specType` for the block to read.
112+
113+
### `Block` — the escape hatch
114+
115+
Any registered component, including ones outside the public contract:
116+
117+
```jsx
118+
<Block type="object-tree" objectName="showcase_category" />
119+
```
120+
121+
### Live data
122+
123+
```jsx
124+
function Page() {
125+
const adapter = useAdapter();
126+
const [rows, setRows] = React.useState([]);
127+
128+
React.useEffect(() => {
129+
adapter.find('showcase_project', { filters: [['status', '=', 'open']] }).then(setRows);
130+
}, [adapter]);
131+
132+
return <ul>{rows.map((r) => <li key={r._id}>{r.name}</li>)}</ul>;
133+
}
134+
```
135+
136+
### Source shapes
137+
138+
The page renders the source's **default export**. An implicit `export default`
139+
is added when the source *starts with* JSX, a `function` declaration, `()`, or
140+
`class`:
141+
142+
```jsx
143+
function Page() { return <p/>; } //
144+
<p>hi</p> //
145+
() => <p>hi</p> //
146+
147+
const Page = () => <p/>; // ❌ exports nothing
148+
const Page = () => <p/>;
149+
export default Page; //
150+
```
151+
152+
The `const Page = …` form does **not** get the implicit export — export it
153+
explicitly. Getting this wrong reports an error in the page error panel; it does
154+
not silently render blank.
155+
156+
### When something throws
157+
158+
Transpile errors, evaluation errors, and errors thrown while rendering all
159+
surface in a **React page error** panel with the message. The error is held
160+
until the page source or its data changes, so it does not flicker or escape to
161+
the generic renderer error.
162+
163+
Referencing an identifier that is not in scope is the common case, and reads as
164+
`ReferenceError: <Name> is not defined` — usually a layout container (not
165+
injected — use HTML) or a block outside the public contract (use `Block`).
166+
167+
### Page state
168+
169+
A react page keeps its own `useState` across re-renders and across lazy plugin
170+
loads. Two things reset it, both intentional: a change to `source`, and a change
171+
to the page's data/variables — the page is genuinely a different page then.
172+
173+
## `kind:'html'`
174+
175+
The constrained tier. Same JSX-looking syntax, but the source is **parsed**
176+
into a schema tree and rendered through the normal renderer — never executed.
177+
Only tags in the public block manifest are allowed, props are validated against
178+
each block's declared inputs, and unknown tags are a hard error at save time.
179+
180+
Use it for anything author- or AI-generated. Expressions are limited to what the
181+
schema supports (`${data.x}`), and there is no local state or event handling
182+
beyond the action system.
183+
184+
## Related
185+
186+
- [Slotted Pages](./slotted-pages.md)`kind:'full'` / `kind:'slotted'` record pages.
187+
- [Schema Rendering](./schema-rendering.md) — the schema tree the other kinds compile to.
188+
- [Component Registry](./component-registry.md) — how blocks are registered and what makes one public.

0 commit comments

Comments
 (0)