Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/console-zod-jitless.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/console': patch
---

The Console document sets zod's `jitless` before any module runs, so zod no longer probes for `eval` at boot (objectui#13511). Under a content-security-policy that refuses `eval` (the ObjectStack server's policy whenever `kind: 'react'` pages are off), that probe was refused and reported once per load; nothing broke, but each load filed a false violation report. zod now uses its interpreter on every deployment, whatever the policy.

Clause-②: no
Metadata-protocol: none
15 changes: 15 additions & 0 deletions .changeset/react-pages-default-off.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/core': minor
'@object-ui/components': minor
'@object-ui/app-shell': minor
'@object-ui/console': minor
---

The `react-pages` capability (`kind: 'react'` pages, whose source runs in the application with no sandbox) now defaults to **off** in `@object-ui/core`. A host turns it on explicitly. This changes a published default; it is marked `minor` under this repository's version policy.

- **ObjectStack deployments.** The server decides: with `OS_PAGE_REACT_ENABLED=true` it writes the enable global (`window.__OBJECTUI_CAPABILITIES__`) into the Console document, and otherwise the disable global. A server older than that writes only the disable global, and only when the deployment set `OS_PAGE_REACT=off`, so on such a server react pages stay off until it is upgraded.
- **A host that embeds the renderer.** FROM: react pages ran unless the host called `disableCapability('react-pages')`. TO: they run only after the host calls `enableCapability('react-pages')` (or sets the enable global before the bundle runs).
- **While off**, a react page renders the "React pages are disabled on this deployment" notice, which now names `OS_PAGE_REACT_ENABLED=true` as the switch, and the Studio's new-page dialog offers only the `html` kind. `kind: 'html'` pages are unaffected.

Clause-②: no
Metadata-protocol: none
25 changes: 25 additions & 0 deletions apps/console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,31 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="manifest" href="./manifest.json" />
<title></title>
<!--
zod runs without its JIT (objectui#13511).

zod v4 decides whether it may compile parsers by calling
`new Function('')` once, inside a try/catch, the first time an object
schema is built. The document's content-security-policy refuses `eval`
unless the deployment turns the `kind:'react'` page tier on, so that probe
is refused and a browser that reports violations files one report per
load, although nothing breaks. With `jitless` set, zod skips the probe and
uses its interpreter.

⛔ It is set HERE, in a classic script ahead of the module entry, and not
with `z.config()` in `src/main.tsx`: the bundle puts zod and the schemas
built at load time into vendor chunks that the entry chunk imports, so
they run before any line of the entry's own body (measured on the
production build: the probe fired from `vendor-objectstack` with the call
at the top of main.tsx). zod reads its config from this global, created on
first load when absent (`zod/v4/core/core.js`), and every zod copy in the
page shares it. `src/__tests__/zod-jitless-13511.test.ts` runs this script
before the installed zod and holds both halves.
-->
<script>
window.__zod_globalConfig = window.__zod_globalConfig || {};
window.__zod_globalConfig.jitless = true;
</script>
<!--
Pre-React branding, and the page's ONE `GET /api/v1/runtime/config`
(objectui#5544).
Expand Down
3 changes: 2 additions & 1 deletion apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.1",
"vitest": "^4.1.10"
"vitest": "^4.1.10",
"zod": "^4.6.5"
},
"dependencies": {
"@object-ui/sdui-parser": "workspace:*"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core';
import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react';
// The page renderers (`type:'home'`), which dispatch `kind:'react'` to the
// react page tier.
Expand All @@ -48,6 +48,10 @@ import '@object-ui/plugin-list';
import { validateReactPageProps, REACT_PROP_RETIRED } from '@objectstack/lint';
import { pagesOf, previewHarnessFiles, readHarness } from './helpers/preview-page-sources';

// `kind:'react'` pages run only where the host turns the react-pages capability on
// (it defaults off); the pages this file renders need it on.
enableCapability(CAP_REACT_PAGES);

const HARNESS = 'sdui-workbench-preview.tsx';
const OBJECT = 'showcase_project';

Expand Down
89 changes: 89 additions & 0 deletions apps/console/src/__tests__/zod-jitless-13511.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* objectui#13511 — the Console document turns zod's JIT off before any module
* runs.
*
* zod v4 probes for `eval` with `new Function('')` the first time an object
* schema is built. Under the Console's content-security-policy that probe is
* refused, and reported on every load, unless the react-pages tier is on. The
* fix is one inline classic script in `apps/console/index.html`, so, following
* `bootSplash.test.ts`, these tests read that file through `?raw` and run the
* script itself rather than a copy of it.
*
* Two halves: the script is ahead of the module entry (a `z.config()` call in
* the entry runs too late, because the bundle evaluates the vendor chunks that
* build schemas before the entry's body), and running it before the installed
* zod means zod builds and parses with no `Function` call, against a control
* that the probe is seen without it. The second half is what notices a zod
* upgrade that stops reading the global.
*
* The zod half runs in a child Node process: this worker's setup has already
* loaded zod, which caches the probe's answer once per process.
*/
import indexHtml from '../../index.html?raw';
import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import path from 'node:path';

const SCRIPT_MARKER = '__zod_globalConfig';
const APP_ENTRY = '/src/main.tsx';

/** Every `<script>` open tag plus its inline body, in document order, comments removed. */
function scriptsOf(html: string) {
return [...html.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g)].map(
(match) => ({ attrs: match[1] ?? '', body: match[2] ?? '' }),
);
}

/** The jitless script exactly as it ships; exactly one, or the test fails rather than test nothing. */
function jitlessScript(): string {
const found = scriptsOf(indexHtml).filter((script) => script.body.includes(SCRIPT_MARKER));
if (found.length !== 1) throw new Error(`expected exactly 1 inline ${SCRIPT_MARKER} script in index.html, found ${found.length}`);
return found[0].body;
}

/** Counts `Function` calls while zod builds and parses one object schema, after `prelude`. */
function probe(prelude: string): { calls: string[]; parsed: unknown } {
const source = `
const calls = [];
const RealFunction = globalThis.Function;
globalThis.Function = new Proxy(RealFunction, {
construct(target, args) { calls.push(args.join(',')); return Reflect.construct(target, args); },
apply(target, self, args) { calls.push(args.join(',')); return Reflect.apply(target, self, args); },
});
globalThis.window = globalThis;
${prelude}
const { z } = await import('zod');
const schema = z.object({ name: z.string(), count: z.number().optional() });
const parsed = schema.parse({ name: 'a', count: 1 });
process.stdout.write(JSON.stringify({ calls, parsed }));
`;
const run = spawnSync(process.execPath, ['--input-type=module', '-e', source], {
cwd: path.resolve(__dirname, '..'),
encoding: 'utf8',
});
if (run.status !== 0) throw new Error(`probe exited ${run.status}\n${run.stderr}`);
return JSON.parse(run.stdout);
}

describe('apps/console/index.html — zod runs without its JIT (objectui#13511)', () => {
it('sets it in a classic script ahead of the module entry', () => {
const scripts = scriptsOf(indexHtml);
const jitless = scripts.findIndex((script) => script.body.includes(SCRIPT_MARKER));
const entry = scripts.findIndex((script) => script.attrs.includes(APP_ENTRY));
expect(jitless).toBeGreaterThanOrEqual(0);
expect(entry).toBeGreaterThan(jitless);
expect(scripts[jitless].attrs).not.toMatch(/type\s*=\s*["']?module|\bsrc\s*=|\b(async|defer)\b/);
});

it('control: without it, the installed zod calls Function when it builds an object schema', () => {
const { calls, parsed } = probe('');
expect(parsed).toEqual({ name: 'a', count: 1 });
expect(calls.length).toBeGreaterThan(0);
});

it('with it run first, the installed zod builds and parses an object schema without calling Function', () => {
const { calls, parsed } = probe(jitlessScript());
expect(parsed).toEqual({ name: 'a', count: 1 });
expect(calls).toEqual([]);
});
});
11 changes: 6 additions & 5 deletions apps/console/src/sdui-workbench-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ import React from 'react';
import { createRoot } from 'react-dom/client';
import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react';
import { I18nProvider } from '@object-ui/i18n';
// NOTE: we do NOT call enableCapability here — `react-pages` is ON by default.
// `?disable=1` simulates a server that set OS_PAGE_REACT=off (the runtime would
// inject this global); the page then renders the "disabled" notice.
if (new URLSearchParams(location.search).has('disable')) {
(window as unknown as { __OBJECTUI_CAPABILITIES_DISABLED__?: string[] }).__OBJECTUI_CAPABILITIES_DISABLED__ = ['react-pages'];
// `react-pages` is OFF by default, so this preview enables it the way a server
// with OS_PAGE_REACT_ENABLED=true does: it sets the enable global the runtime
// injects. `?disable=1` leaves it unset (a server with the tier off); the page
// then renders the "disabled" notice.
if (!new URLSearchParams(location.search).has('disable')) {
(window as unknown as { __OBJECTUI_CAPABILITIES__?: string[] }).__OBJECTUI_CAPABILITIES__ = ['react-pages'];
}

// ---- in-memory adapter (just enough for ListView + ObjectForm) ----
Expand Down
19 changes: 11 additions & 8 deletions content/docs/guide/react-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,17 @@ function Page() {
### The security gate

A react page's source is transpiled and evaluated directly in the application —
no isolation, full access to the page's React tree. The platform assumes page
authors are reviewed and draft-gated, so the host capability `react-pages`
defaults **ON**.

A deployment that does not trust its authors turns it off server-side with
`OS_PAGE_REACT=off` (or `disableCapability('react-pages')` in the host). Pages
then render an explanatory notice instead of executing. Existing `kind:'html'`
pages are unaffected.
no isolation, full access to the page's React tree. So the host capability
`react-pages` defaults **OFF**, and a deployment turns it on only when its page
authors are trusted and reviewed.

On ObjectStack the operator turns it on server-side with
`OS_PAGE_REACT_ENABLED=true`; the server then writes the enable global
(`window.__OBJECTUI_CAPABILITIES__`) into the Console document, and with the
tier off it writes the disable global instead. A host that embeds the renderer
itself calls `enableCapability('react-pages')` before rendering. While the
capability is off, a react page renders a notice that names the switch instead
of executing. `kind:'html'` pages are unaffected either way.

### What is in scope

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ import { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { PagePreview } from '../metadata-admin/previews/PagePreview';
import { pageStarterSource } from './interfaceCreate';
import { t } from '../metadata-admin/i18n';
import { CAP_REACT_PAGES, enableCapability } from '@object-ui/core';

// `kind:'react'` pages run only where the host turns the react-pages capability on
// (it defaults off); the pages this file renders need it on.
enableCapability(CAP_REACT_PAGES);

const dataSource = createEmptyDataSource();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import type { NavNode } from './navSurface';
import { t, type SupportedLocale } from '../metadata-admin/i18n';
import { isStaticPageOption } from '../metadata-admin/inspectors/nav-target';

// `kind:'react'` pages run only where the host turns the react-pages capability on
// (it defaults off); the pages this file renders need it on.
enableCapability(CAP_REACT_PAGES);

const LOCALES: SupportedLocale[] = ['en-US', 'zh-CN'];
const starterOf = (locale: SupportedLocale) => t('engine.studio.interfaces.create.pageStarter', locale);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ export const DEFAULT_PAGE_SOURCE_KIND: PageSourceKind = 'html';

/**
* The source kinds this deployment can render, in the order the dialog offers
* them. `react` is left out where the host has turned the react-pages
* capability off (`OS_PAGE_REACT=off`): the save door would accept such a page,
* them. `react` is left out unless the host has turned the react-pages
* capability on (`OS_PAGE_REACT_ENABLED=true`): the save door would accept such a page,
* but the preview beside its editor, and the running app, would only say that
* react pages are disabled here.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core';
import { SchemaRenderer, AdapterCtx, SchemaRendererContext } from '@object-ui/react';
import type { PageDocumentNode } from '@object-ui/types';
import '../renderers';

// `kind:'react'` pages run only where the host turns the react-pages capability on
// (it defaults off); the pages this file renders need it on.
enableCapability(CAP_REACT_PAGES);

/** The adapter each render of the stand-in block resolved from its context. */
const seen: unknown[] = [];
/** Whether each render's node carried a `dataSource` key. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* The `kind:'react'` page tier is off unless the host turns it on.
*
* A react page's source runs in the application with no sandbox, so
* `CAP_REACT_PAGES` defaults off in `@object-ui/core`. These cases render a
* real react page through the real `type:'home'` PageRenderer, with nothing
* but the capability changed: by default it draws the notice that names the
* server switch and never runs the source; once the host enables the
* capability (the global the ObjectStack server writes, or
* `enableCapability`), the same source runs.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import React from 'react';
import { CAP_REACT_PAGES, disableCapability, enableCapability } from '@object-ui/core';
import { SchemaRenderer, AdapterCtx } from '@object-ui/react';
import type { PageDocumentNode } from '@object-ui/types';
// Registers PageRenderer for `type:'home'`, which dispatches kind:'react'.
import '../renderers';
// The runtime chunk the page loads once enabled, imported here so its cold
// transform is not billed to a `findBy` window.
import '@object-ui/react-runtime';

const adapter = { find: async () => [] } as any;

const SCHEMA: PageDocumentNode = {
type: 'home',
kind: 'react',
name: 'gate_page',
label: 'Gate page',
source: `function Page() { return <p data-testid="ran">ran</p>; }`,
};

function Host() {
return (
<AdapterCtx.Provider value={adapter}>
<SchemaRenderer schema={SCHEMA} />
</AdapterCtx.Provider>
);
}

afterEach(() => cleanup());

describe('the react-pages capability gate', () => {
it('is closed by default: the page draws the notice naming the switch and never runs', async () => {
const { findByText, queryByTestId, container } = render(<Host />);
expect(await findByText('React pages are disabled on this deployment')).toBeTruthy();
expect(container.textContent).toContain('OS_PAGE_REACT_ENABLED=true');
expect(queryByTestId('ran')).toBeNull();
});

it('runs the same page once the host enables the capability, and closes again when it is disabled', async () => {
enableCapability(CAP_REACT_PAGES);
const first = render(<Host />);
expect((await first.findByTestId('ran')).textContent).toBe('ran');
first.unmount();

disableCapability(CAP_REACT_PAGES);
const second = render(<Host />);
expect(await second.findByText('React pages are disabled on this deployment')).toBeTruthy();
expect(second.queryByTestId('ran')).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import { SchemaRenderer, AdapterCtx } from '@object-ui/react';
// Registers PageRenderer for `type:'home'`, which dispatches kind:'react'. At
// module scope, not in a hook (object-ui/no-dynamic-import-in-test-hook).
import '../renderers';
import { CAP_REACT_PAGES, enableCapability } from '@object-ui/core';

// `kind:'react'` pages run only where the host turns the react-pages capability on
// (it defaults off); the pages this file renders need it on.
enableCapability(CAP_REACT_PAGES);

const adapter = { find: async () => [], getObjectSchema: async () => ({ name: 'x', fields: {} }) } as any;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ import type { PageDocumentNode } from '@object-ui/types';
import '@object-ui/react-runtime';
// Registers PageRenderer for `type:'home'`, which dispatches kind:'react'.
import '../renderers';
import { CAP_REACT_PAGES, enableCapability } from '@object-ui/core';

// `kind:'react'` pages run only where the host turns the react-pages capability on
// (it defaults off); the pages this file renders need it on.
enableCapability(CAP_REACT_PAGES);

/** The Live data pattern the react-pages guide teaches, nonce included. */
const SOURCE = `
Expand Down
6 changes: 5 additions & 1 deletion packages/components/src/__tests__/react-page-scope.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core';
import { SchemaRenderer, AdapterCtx, SchemaRendererContext } from '@object-ui/react';
// Registers PageRenderer for `type:'home'`, which dispatches kind:'react'.
import '../renderers';

// `kind:'react'` pages run only where the host turns the react-pages capability on
// (it defaults off); the pages this file renders need it on.
enableCapability(CAP_REACT_PAGES);

/** Props the stand-in blocks last received, for flat-prop assertions. */
const captured: { listView?: any; listViewContextAdapter?: unknown; objectForm?: any; kanban?: any } = {};

Expand Down
6 changes: 5 additions & 1 deletion packages/components/src/__tests__/react-page-state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
import { describe, it, expect, afterEach } from 'vitest';
import { render, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core';
import { SchemaRenderer, AdapterCtx } from '@object-ui/react';

const adapter = { find: async () => [], getObjectSchema: async () => ({ name: 'showcase_project', fields: {} }) } as any;
Expand All @@ -56,6 +56,10 @@ const PLAIN_SCHEMA = { type: 'home', kind: 'react', name: 'test_page', source: c
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

// `kind:'react'` pages run only where the host turns the react-pages capability on
// (it defaults off); the pages this file renders need it on.
enableCapability(CAP_REACT_PAGES);

afterEach(() => {
ComponentRegistry.unregister('object-kanban', 'plugin-kanban');
ComponentRegistry.unregister('object-kanban');
Expand Down
Loading
Loading