Skip to content
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
40 changes: 40 additions & 0 deletions .changeset/html-page-lazy-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@object-ui/core": minor
"@object-ui/components": patch
---

fix(sdui): a `kind:'html'` page can use lazily-registered blocks, and recovers when one registers late

objectui#2953 had a twin one tier over, unreported. The whitelist a
`kind:'html'` page's source compiles against was built from `getAllTypes()` +
`getConfig()` — both loaded-only — so any block registered via `registerLazy()`
was rejected as *"not an allowed component"*.

The blast radius is worse than the react tier's. There, a missing block cost one
identifier; here a compile diagnostic fails the **whole page**, so a single
`<object-kanban>` replaced the entire page with `HTML page failed to compile (2)`.
And it never recovered: `layoutElement` was memoised on `[schema, pageType]` with
no registry signal, so the cached error panel outlived the plugin actually
landing — permanently broken for the session.

`ComponentRegistry` gains three lazy-aware reads:

- `getKnownTypes()` — loaded registrations **plus** pending lazy stubs, deduped.
The set a whitelist or manifest should be built from. `getAllTypes()` keeps its
loaded-only meaning ("what can render right now") and now says so.
- `getMeta(type, namespace?)` — metadata from the loaded registration, else from
a pending stub. `getConfig()` stays loaded-only, since callers read
`.component` off it.
- `getVersion()` — monotonic counter of changes to the known set, bumped on
register / unregister / registerLazy. A cache key that a type *count* cannot
substitute for: one registration plus one unregistration leaves the count
untouched while the set changed.

`getJsxManifest()` builds from those, and `PageRenderer` subscribes to the
registry so a page that could not compile retries when the registry grows.

A stub carries no `inputs` yet, so its props surface as `unknown-prop` warnings
rather than errors — the page compiles and renders, and the inner
`SchemaRenderer` triggers the loader and swaps in the real block. Authoring-time
prop validation is unaffected: `sdui.manifest.json` is generated with every
plugin eagerly loaded, and asserts as much.
109 changes: 109 additions & 0 deletions packages/components/src/__tests__/html-page-lazy-blocks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* 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.
*
* A `kind:'html'` page can use lazily-registered blocks (objectui#2953, html tier).
*
* Same root cause as the react tier, worse blast radius. The whitelist a page's
* source is compiled against was built from `getAllTypes()` + `getConfig()` —
* both loaded-only — so a block registered via `registerLazy()` was rejected as
* "not an allowed component". And because a compile diagnostic fails the WHOLE
* page rather than the one node, `<object-kanban>` anywhere in the source
* replaced the entire page with an error panel. Load-order dependent, so the
* same page worked or didn't depending on what else had rendered first.
*
* It also never recovered: `layoutElement` was memoised on `[schema, pageType]`
* with no registry signal, so the cached error panel outlived the plugin
* actually landing.
*/

import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import React from 'react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer, AdapterCtx } from '@object-ui/react';

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

/** Registers a lazy `object-kanban` whose chunk lands only when you say so. */
function lazyKanban() {
let land!: () => void;
const gate = new Promise<void>((resolve) => {
land = resolve;
});
ComponentRegistry.registerLazy(
'object-kanban',
async () => {
await gate;
ComponentRegistry.register('object-kanban', () => <div data-testid="kanban-double" />, {
namespace: 'plugin-kanban',
});
},
{ namespace: 'plugin-kanban', category: 'view' },
);
return land;
}

function renderHtmlPage(source: string) {
return render(
<AdapterCtx.Provider value={adapter}>
<SchemaRenderer schema={{ type: 'home', kind: 'html', name: 'test_page', source }} />
</AdapterCtx.Provider>,
);
}

beforeAll(async () => {
// Registers PageRenderer for `type:'home'`, which dispatches kind:'html'.
await import('../renderers');
}, 30000);

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

describe('kind:\'html\' page with a lazily-registered block', () => {
it('compiles instead of failing the whole page, and renders the block once loaded', async () => {
const land = lazyKanban();

const { container, findByTestId } = renderHtmlPage(
`<flex><object-kanban object="showcase_project" /></flex>`,
);

// Before: 'HTML page failed to compile (2)' — `<object-kanban> is not an
// allowed component` + `is not a known component` — and no <flex> either.
expect(container.textContent).not.toContain('failed to compile');

land();
expect(await findByTestId('kanban-double')).toBeTruthy();
});

it('recovers a page that compiled before the block was registered', async () => {
// Nothing knows `object-kanban` yet, so this page genuinely cannot compile.
const { container } = renderHtmlPage(`<flex><object-kanban object="showcase_project" /></flex>`);
expect(container.textContent).toContain('failed to compile');

// The plugin registers late — a legitimate case (a plugin installed at
// runtime, or any registration that races first paint).
ComponentRegistry.register('object-kanban', () => <div data-testid="kanban-double" />, {
namespace: 'plugin-kanban',
});

// Before: the memoised error panel outlived the registration, permanently.
await waitFor(() => expect(container.querySelector('[data-testid=kanban-double]')).toBeTruthy());
expect(container.textContent).not.toContain('failed to compile');
});

it('still rejects a tag nothing has registered at all', async () => {
const { container } = renderHtmlPage(`<flex><totally-not-a-block /></flex>`);

// The negative control. Admitting lazy stubs must not turn the whitelist
// into "anything goes" — that whitelist is what keeps a parsed page from
// rendering arbitrary tags.
await waitFor(() => expect(container.textContent).toContain('failed to compile'));
expect(container.textContent).toContain('totally-not-a-block');
});
});
40 changes: 31 additions & 9 deletions packages/components/src/renderers/layout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,16 +366,26 @@ function getJsxManifest() {
// config's `.type` is always the namespaced form (Registry stores the bare
// alias pointing at the namespaced type), so getAllConfigs() alone would
// never admit the bare `<flex>` tag authors write.
const types = ComponentRegistry.getAllTypes();
if (_jsxManifest === null || _jsxManifestSig !== types.length) {
const configs = types.map((t) => {
const cfg = ComponentRegistry.getConfig(t) as
| { namespace?: string; isContainer?: boolean; inputs?: unknown }
| undefined;
return { type: t, namespace: cfg?.namespace, isContainer: cfg?.isContainer, inputs: cfg?.inputs };
//
// KNOWN types, not loaded ones. A lazily-registered block is a real member of
// this app's vocabulary; leaving it out of the whitelist rejected
// `<object-kanban>` as "not an allowed component" and — because a compile
// error fails the WHOLE page, not just that node — took the entire page down
// with it, load-order dependently (objectui#2953, html tier).
//
// A stub has no `inputs` yet, so its props come back as `unknown-prop`
// WARNINGS rather than errors: the page compiles and renders, and the inner
// SchemaRenderer triggers the loader and swaps in the real block. Authoring
// time still gets full prop validation — `sdui.manifest.json` is generated
// with every plugin eagerly loaded, and asserts as much.
const version = ComponentRegistry.getVersion();
if (_jsxManifest === null || _jsxManifestSig !== version) {
const configs = ComponentRegistry.getKnownTypes().map((t) => {
const meta = ComponentRegistry.getMeta(t);
return { type: t, namespace: meta?.namespace, isContainer: meta?.isContainer, inputs: meta?.inputs };
});
_jsxManifest = manifestFromConfigs(configs as unknown as Parameters<typeof manifestFromConfigs>[0]);
_jsxManifestSig = types.length;
_jsxManifestSig = version;
}
return _jsxManifest;
}
Expand All @@ -389,6 +399,13 @@ export const PageRenderer: React.FC<{
[key: string]: any;
}> = ({ schema, className, ...props }) => {
const pageType = schema.pageType || 'record';
// A `kind:'html'` page compiles its source against the registry, and a
// compile error fails the whole page. Without this the failure is permanent
// for the session: `layoutElement` is memoised, so a page that compiled
// before the block it needs was registered kept rendering the cached error
// panel even after the plugin landed. Mirrors SchemaRenderer's subscription.
const [registryTick, bumpRegistryTick] = React.useReducer((n: number) => n + 1, 0);
React.useEffect(() => ComponentRegistry.subscribe(bumpRegistryTick), []);
// Spec PageSchema declares `label` (required); `title` is the objectui
// spelling. Dual-read so a spec-authored page still renders its header
// (framework#1878 §3 naming-drift recheck).
Expand Down Expand Up @@ -480,7 +497,12 @@ export const PageRenderer: React.FC<{
default:
return <RecordPageLayout schema={schema} />;
}
}, [schema, pageType]);
// `registryTick` only matters to the kind:'html' branch above, which
// recompiles against the (now larger) whitelist. Every other branch just
// rebuilds the same element type with the same props, which React
// reconciles in place — no remount, no state loss (pinned by
// react-page-state.test.tsx).
}, [schema, pageType, registryTick]);

// Full-bleed: a page whose `main` region is declared `width: 'full'` fills
// the viewport with NO centered max-width cap — for dashboards / 大屏 /
Expand Down
61 changes: 59 additions & 2 deletions packages/core/src/registry/Registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ export class Registry<T = any> {
* load completes.
*/
private listeners = new Set<() => void>();
/**
* Bumped whenever the set of KNOWN types changes — a registration, an
* unregistration, or a new lazy stub. A cheap cache key for consumers that
* derive something from the whole registry (whitelists, manifests, palettes)
* and need to rebuild when it grows. Counting types is not a substitute: a
* registration paired with an unregistration leaves the count untouched.
*/
private version = 0;

/**
* Register a component with optional namespace support.
Expand Down Expand Up @@ -230,6 +238,10 @@ export class Registry<T = any> {
if (meta?.namespace && !meta?.skipFallback) {
this.lazyEntries.set(type, entry);
}
// Bump the version but do NOT notify: the set of KNOWN types grew, so
// whitelists and manifests must rebuild, but nothing new can render yet —
// waking every subscriber for each stub at boot would be pure churn.
this.version++;
}

/**
Expand Down Expand Up @@ -274,6 +286,7 @@ export class Registry<T = any> {
}

private notify() {
this.version++;
for (const listener of this.listeners) {
try {
listener();
Expand All @@ -283,6 +296,14 @@ export class Registry<T = any> {
}
}

/**
* Monotonic counter of changes to the set of known types. Rebuild anything
* derived from the whole registry when this moves — see {@link getKnownTypes}.
*/
getVersion(): number {
return this.version;
}

/**
* Get a component by type. Supports both namespaced and non-namespaced lookups.
*
Expand Down Expand Up @@ -356,14 +377,50 @@ export class Registry<T = any> {
}

/**
* Get all registered component types.
*
* Get all LOADED component types — what can be rendered right now.
*
* Pending `registerLazy` stubs are not included; use {@link getKnownTypes}
* for the question "is this a type this app knows about", which is what a
* whitelist or manifest wants (objectui#2953).
*
* @returns Array of all component type identifiers
*/
getAllTypes(): string[] {
return Array.from(this.components.keys());
}

/**
* Every type the registry can resolve — loaded registrations PLUS pending
* lazy stubs, deduped. Both the bare and namespaced keys are included, as
* they are in {@link getAllTypes}.
*
* This is the set to build a whitelist or a manifest from. Keying one off
* `getAllTypes()` instead makes it depend on which plugin chunks happen to
* have loaded: a lazily-registered block gets rejected as unknown, and
* whether it does varies by session (objectui#2953).
*/
getKnownTypes(): string[] {
return Array.from(new Set([...this.components.keys(), ...this.lazyEntries.keys()]));
}

/**
* Metadata for `type` — from the loaded registration when there is one,
* otherwise from a pending `registerLazy` stub.
*
* Use this when you need to know what a type IS (namespace, container-ness,
* declared inputs). {@link getConfig} answers a different question — "can I
* render this right now" — and is deliberately loaded-only, since callers
* read `.component` off it.
*
* A stub carries only what `registerLazy` was given, so `inputs` is usually
* absent until the chunk loads. Consumers should treat that as "not yet
* known", not as "declares no props".
*/
getMeta(type: string, namespace?: string): ComponentMeta | undefined {
const key = namespace ? `${namespace}:${type}` : type;
return this.components.get(key) ?? this.lazyEntries.get(key)?.meta;
}

/**
* Get all registered component configurations.
*
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/registry/__tests__/Registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,83 @@ describe('Registry', () => {
});
});

describe('getKnownTypes() / getMeta() / getVersion() — lazy-aware reads (objectui#2953)', () => {
const loader = () => Promise.resolve();

it('getKnownTypes() includes pending lazy stubs; getAllTypes() does not', () => {
registry.register('button', () => 'b1');
registry.registerLazy('object-kanban', loader, { namespace: 'plugin-kanban' });

// getAllTypes() answers "what can render right now" — the stub cannot.
expect(registry.getAllTypes()).not.toContain('object-kanban');
// getKnownTypes() answers "what does this app know about" — the question
// a whitelist or a manifest is actually asking. Building one off
// getAllTypes() rejected lazily-registered blocks as unknown.
expect(registry.getKnownTypes()).toContain('object-kanban');
expect(registry.getKnownTypes()).toContain('plugin-kanban:object-kanban');
expect(registry.getKnownTypes()).toContain('button');
});

it('getKnownTypes() dedupes a type that is both loaded and stubbed', () => {
registry.registerLazy('object-map', loader, { namespace: 'plugin-map' });
registry.register('object-map', () => 'm', { namespace: 'plugin-map' });

const known = registry.getKnownTypes();
expect(known.filter((t) => t === 'object-map')).toHaveLength(1);
});

it('getMeta() reads through to a stub, getConfig() stays loaded-only', () => {
registry.registerLazy('object-gantt', loader, {
namespace: 'plugin-gantt',
category: 'view',
isContainer: false,
});

expect(registry.getConfig('object-gantt')).toBeUndefined();
expect(registry.getMeta('object-gantt')).toMatchObject({
namespace: 'plugin-gantt',
category: 'view',
isContainer: false,
});
// A stub has no declared inputs until its chunk lands — "not yet known",
// which consumers must not read as "declares no props".
expect(registry.getMeta('object-gantt')?.inputs).toBeUndefined();
});

it('getMeta() prefers the loaded registration once the chunk lands', () => {
registry.registerLazy('object-chart', loader, { namespace: 'plugin-charts' });
registry.register('object-chart', () => 'c', {
namespace: 'plugin-charts',
inputs: [{ name: 'type', type: 'string' }],
});

expect(registry.getMeta('object-chart')?.inputs).toHaveLength(1);
});

it('getMeta() honours an explicit namespace', () => {
registry.registerLazy('kanban', loader, { namespace: 'view' });

expect(registry.getMeta('kanban', 'view')).toBeTruthy();
expect(registry.getMeta('kanban', 'nope')).toBeUndefined();
});

it('getVersion() moves on every change to the known set', () => {
const start = registry.getVersion();
registry.register('button', () => 'b1');
const afterRegister = registry.getVersion();
registry.registerLazy('object-map', loader, { namespace: 'plugin-map' });
const afterLazy = registry.getVersion();
registry.unregister('button');
const afterUnregister = registry.getVersion();

// Counting types cannot substitute for this: a registration paired with
// an unregistration leaves the count untouched while the set changed.
expect(afterRegister).toBeGreaterThan(start);
expect(afterLazy).toBeGreaterThan(afterRegister);
expect(afterUnregister).toBeGreaterThan(afterLazy);
});
});

describe('getAllTypes() and getAllConfigs()', () => {
it('should return all registered types including namespaced ones', () => {
registry.register('button', () => 'b1');
Expand Down
Loading