-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathreact-page.tsx
More file actions
179 lines (167 loc) · 7.97 KB
/
Copy pathreact-page.tsx
File metadata and controls
179 lines (167 loc) · 7.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* `kind:'react'` page renderer — the TRUSTED execution tier.
*
* Unlike `kind:'html'` (constrained JSX parsed, never executed), a react page's
* `source` is real JavaScript/JSX: hooks, event handlers, `.map`, arbitrary
* expressions. It is transpiled (Sucrase) and evaluated directly in the main
* React tree by `@object-ui/react-runtime` — NO sandbox. The platform trusts
* its (reviewed, draft-gated) page authors, so the host capability
* `CAP_REACT_PAGES` defaults ON; a deployment that does not trust its authors
* turns it OFF server-side (the runtime injects the disable global when
* `OS_PAGE_REACT=off`). The transpiler is lazy-loaded — fetched in a
* separate chunk only when a react page actually renders with the capability on.
*
* Scope injected into the source:
* - `React` — so authors can call hooks.
* - the PUBLIC data blocks — `<ObjectGrid>`, `<ObjectForm>`, charts, metrics…
* each as a prop-driven wrapper that renders via SchemaRenderer. Layout is
* left to plain HTML + Tailwind (React's strength); only the data blocks
* that can't be expressed in HTML are injected.
* - `Block` — escape hatch: `<Block type="object-grid" .../>`.
* - `useAdapter` — live data hook: query/create/update objects.
* - `data` / `variables` — page data + local variables, for convenience.
*/
import * as React from 'react';
import { ComponentRegistry, isCapabilityEnabled, CAP_REACT_PAGES } from '@object-ui/core';
import { SchemaRenderer, SchemaRendererProvider, useAdapter } from '@object-ui/react';
type RuntimeModule = typeof import('@object-ui/react-runtime');
// kebab/snake tag -> PascalCase identifier authors write in JSX.
function toPascal(tag: string): string {
return tag
.split(/[-_:]/)
.filter(Boolean)
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join('');
}
// Build the component scope from the curated PUBLIC contract. We inject the
// data/leaf blocks (non-containers) as prop-driven wrappers; layout containers
// are intentionally left out — in react mode the author composes layout with
// real HTML + Tailwind, not our schema-children renderers.
function buildComponentScope(dataSource: unknown): Record<string, React.ComponentType<any>> {
const scope: Record<string, React.ComponentType<any>> = {};
const seen = new Set<string>();
// Some data blocks read their dataSource from props (e.g. `list-view`), others
// from the SchemaRenderer context (e.g. `object-form`). We inject it as a prop
// here AND wrap the page in a SchemaRendererProvider below, so both kinds work.
for (const cfg of ComponentRegistry.getPublicConfigs() as Array<{ type: string; isContainer?: boolean }>) {
const tag = cfg.type;
if (!tag || cfg.isContainer) continue;
const name = toPascal(tag);
if (seen.has(name)) continue;
seen.add(name);
// `type` is the SDUI envelope's component discriminator, but it is ALSO a
// legitimate prop name in a block's own spec schema — `ChartConfig.type` is
// the chart family. Flattening props into the schema bag collides the two:
// spreading last let an author's `type="bar"` replace `object-chart` and
// the block stopped resolving; stamping the discriminator last silently ate
// the author's value instead. Neither is acceptable (ADR-0078), so the
// discriminator wins the `type` slot and the author's value is preserved
// beside it under `specType` for the block to read
// (objectui#2880 / framework#3729).
const Wrapper: React.FC<any> = ({ children: _children, ...props }) => {
const specType = typeof props.type === 'string' && props.type !== tag ? props.type : undefined;
return React.createElement(SchemaRenderer as any, {
schema: { dataSource, ...props, ...(specType ? { specType } : {}), type: tag },
});
};
Wrapper.displayName = name;
scope[name] = Wrapper;
}
// Escape hatch: render any registered component by type.
const Block: React.FC<{ type: string; [k: string]: unknown }> = ({ type, children: _c, ...props }) =>
React.createElement(SchemaRenderer as any, { schema: { type, dataSource, ...props } });
Block.displayName = 'Block';
scope.Block = Block;
return scope;
}
function CapabilityDisabledNotice(): React.ReactElement {
return (
<div className="m-4 rounded-md border border-amber-400/40 bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
<div className="font-semibold">React pages are disabled on this deployment</div>
<p className="mt-1 leading-relaxed">
<code>kind:'react'</code> pages execute author JavaScript directly in the
application. This deployment has turned the capability off
(<code>OS_PAGE_REACT=off</code> / <code>disableCapability('react-pages')</code>).
It is ON by default; re-enable it if your page authors are trusted.
</p>
</div>
);
}
export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => {
const source: string = typeof schema?.source === 'string' ? schema.source : '';
// The live data source for the injected data blocks (and the page's own
// `useAdapter()` calls). Same object the rest of the app renders against.
const adapter = useAdapter();
// Gate: default-closed. Off in OSS / untrusted builds. Read here so the hooks
// below stay unconditional; the disabled notice is returned after them, and
// the effect never loads the gated runtime when disabled.
const capabilityEnabled = isCapabilityEnabled(CAP_REACT_PAGES);
const [runtime, setRuntime] = React.useState<RuntimeModule | null>(null);
const [loadError, setLoadError] = React.useState<Error | null>(null);
React.useEffect(() => {
if (!capabilityEnabled) return;
let alive = true;
import('@object-ui/react-runtime')
.then((m) => alive && setRuntime(m))
.catch((e) => alive && setLoadError(e as Error));
return () => {
alive = false;
};
}, [capabilityEnabled]);
const scope = React.useMemo(
() => ({
...buildComponentScope(adapter),
// Live data access — `const adapter = useAdapter()` inside the page, then
// adapter.find('object', {...}) / .create / .update. Hooks injected as
// closure vars; the page calls them from its own component body.
useAdapter,
data: schema?.data ?? schema?.variables ?? {},
variables: schema?.variables ?? {},
page: schema ?? {},
}),
[schema, adapter],
);
// Capability gate — returned after all hooks above so hook order stays stable.
if (!capabilityEnabled) {
return <CapabilityDisabledNotice />;
}
if (loadError) {
return (
<div className="m-4 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<div className="font-semibold">Failed to load the react runtime</div>
<pre className="mt-1 whitespace-pre-wrap">{String(loadError)}</pre>
</div>
);
}
if (!source.trim()) {
return (
<div className="m-4 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
A <code>kind:'react'</code> page requires a non-empty <code>source</code>.
</div>
);
}
if (!runtime) {
return <div className="m-4 text-sm text-muted-foreground">Loading react runtime…</div>;
}
const { ReactRunner } = runtime;
return (
<SchemaRendererProvider dataSource={adapter ?? {}}>
<ReactRunner
code={source}
scope={scope}
fallback={(error) => (
<div className="m-4 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
<div className="font-semibold">React page error</div>
<pre className="mt-1 whitespace-pre-wrap">{String(error)}</pre>
</div>
)}
/>
</SchemaRendererProvider>
);
};