') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(plugin-report): register under the namespace its consumers declare by claude[bot] · Pull Request #6441 · objectstack-ai/objectui · GitHub
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
48 changes: 48 additions & 0 deletions .changeset/6416-plugin-report-namespace.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
---
'@object-ui/plugin-report': patch
'@object-ui/cli': patch
---

`@object-ui/plugin-report` now registers its three components under namespace
**`plugin-report`**, the spelling its consumers already declare (objectui#6416).

It used to register `report`, `spec-report` and `report-viewer` under namespace
`report`, while `apps/console` declared the lazy stubs for the same three short
names under `plugin-report` and the CLI's known-type whitelist shipped the
`plugin-report:*` spellings as renderable. Two things followed from the
disagreement:

- **`plugin-report:report`, `plugin-report:report-viewer` and
`plugin-report:spec-report` could never be satisfied.** `Registry.register`
clears the lazy stub for the type IT registers, and that type was
`report:report`, so those three stubs were never cleared and no component was
ever stored under them: `get('report', 'plugin-report')` returned `undefined`
and `hasLazy('report', 'plugin-report')` stayed `true` forever. A schema
authored with any of the three whitelisted keys resolved to nothing — the
gate handed authors a green light for a key the runtime could not satisfy.
- **The bare `report` key was claimed twice under two different namespaces.**
`Registry.register` and `Registry.registerLazy` share the
`meta?.namespace && !meta?.skipFallback` branch, so what bare `report`
*declared* depended on whether the plugin chunk had loaded yet — the
objectui#6353 shape.

**No authored metadata changes.** The direction was chosen by measurement:
nothing in this repository, and nothing in the sibling `objectstack` checkout,
authors a `report:*` spelling (0 hits), while the bare spellings are authored in
48 places. `type: 'report'`, `type: 'spec-report'` and `type: 'report-viewer'`
resolve exactly as before; the three unreachable `report:*` keys are retired and
the three `plugin-report:*` keys now name real components for the first time.

`packages/cli/src/utils/known-schema-types.ts` is regenerated from the
registrations, dropping `report:report`, `report:report-viewer` and
`report:spec-report`.

Two pins are the half that outlives the fix:
`packages/plugin-report/src/__tests__/report-bare-key-ownership.test.ts` replays
this package's real declared metadata and a console-shaped lazy stub into a
fresh `Registry` in **both** registration orders, checking the bare key's
declared namespace after every step, so order- and phase-independence are
properties under test rather than properties of the file the test imports.
`scripts/__tests__/report-namespace-agreement-6416.test.ts` re-derives both
sites from source and fails if the plugin, the console stubs and the generated
whitelist ever disagree again.
3 changes: 0 additions & 3 deletions packages/cli/src/utils/known-schema-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -465,9 +465,6 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
'report',
'report-designer',
'report-viewer',
'report:report',
'report:report-viewer',
'report:spec-report',
'resizable',
'responsive-grid',
'richtext',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
/**
* 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.
*/

/**
* Which namespace this package registers under, and who owns the bare
* `report` / `spec-report` / `report-viewer` keys (objectui#6416).
*
* This package used to register all three short names under namespace
* `report`, while `apps/console/src/register-plugins.ts` declared the lazy
* stubs for the same three names under `plugin-report` — the spelling every
* sibling plugin uses and the one the CLI's known-type whitelist ships. Two
* consequences followed, and both are pinned below:
*
* 1. THE BARE KEY WAS DOUBLE-CLAIMED, PHASE-DEPENDENTLY. Neither site passed
* `skipFallback`, so both also claimed the bare key (`Registry.register`
* and `Registry.registerLazy` share the `meta?.namespace &&
* !meta?.skipFallback` branch). Before the chunk loaded, bare `report`
* declared namespace `plugin-report` (from the stub); after it loaded, the
* same key declared `report` (from this package). Which answer a host got
* depended on when it asked — the objectui#6353 shape.
* 2. THE NAMESPACED KEYS WERE UNSATISFIABLE. `register()` clears the lazy
* stub for the type it is registering, and the type it registered was
* `report:report` — so `plugin-report:report` was never cleared and no
* component was ever stored under it. `get('report', 'plugin-report')`
* stayed `undefined` and `hasLazy('report', 'plugin-report')` stayed
* `true` forever, while `packages/cli/src/utils/known-schema-types.ts`
* listed all three `plugin-report:*` spellings as renderable.
*
* The fix is the one direction the measurement chartered: nothing in this
* repository, and nothing in the sibling `objectstack` checkout, ever authored
* a `report:*` spelling (0 hits), while the bare spellings are authored in 48
* places — so the package moves to `plugin-report` and the `report:*` keys are
* retired. Both claimants of each bare key now name the SAME full type, which
* is the shape all 27 other console-stub/plugin pairs in this repo already
* have, so the bare key has one owner by construction rather than by whichever
* phase the host happened to observe.
*
* WHY THE REPLAY (`in EITHER registration order`): asserting only today's
* resolved outcome cannot tell "declared" apart from "happened to be observed
* after the right step". The replay reads this package's REAL declared
* metadata back out of the registry — nothing here is a hand-copied mirror of
* `../index` — re-registers it into a fresh `Registry` alongside a
* console-shaped lazy stub, in both orders, and checks the bare key's declared
* namespace after EVERY step. Order- and phase-independence are then
* properties under test rather than properties of the file this test imports.
*
* The cross-site half — that `apps/console` and the generated CLI whitelist
* really do spell it `plugin-report` — is pinned from the repo's own
* registration derivation in
* `scripts/__tests__/report-namespace-agreement-6416.test.ts`.
*/
import { describe, it, expect, vi } from 'vitest';
import { ComponentRegistry, Registry, type ComponentMeta } from '@object-ui/core';
// Importing the package entry is what performs all three registrations, exactly
// as a host does. The renderers are compared by IDENTITY below, so these pins
// cannot be satisfied by a look-alike.
import { ReportRenderer, ReportViewer } from '../index';

/**
* The consumer-facing spelling: what `apps/console` declares for its lazy
* stubs and what the CLI whitelist ships. It is stated rather than read out of
* this package's own metadata on purpose — deriving it from the thing under
* test would make the comparison circular.
*/
const NS = 'plugin-report';

/** The three short names this package registers, and the renderer each owns. */
const REGISTRATIONS: Array<[short: string, renderer: unknown]> = [
['report', ReportRenderer],
// Spec-native alias — same dispatcher, explicit name for spec-driven hosts.
['spec-report', ReportRenderer],
['report-viewer', ReportViewer],
];

const COLLISION_WARNING = 'bare-name fallback is being overwritten';

/** Every LOADED namespaced key whose short name is `short`. */
function namespacedKeysFor(short: string): string[] {
return ComponentRegistry.getAllTypes()
.filter((t) => t.includes(':') && t.slice(t.indexOf(':') + 1) === short)
.sort();
}

/**
* The registration this package actually declared — component plus meta, read
* back from the registry rather than restated here.
*
* The lookup deliberately does NOT pass `NS`: it finds the namespaced key by
* short name and reports whatever namespace was declared. Asking for
* `getConfig(short, NS)` instead would presuppose the answer, and the replay's
* per-step namespace check below could then never fail — it would be reading a
* value it had already selected for.
*/
function declaredRegistration(short: string): { component: unknown; meta: ComponentMeta } {
const keys = namespacedKeysFor(short);
expect(
keys,
`"${short}" must have exactly one namespaced spelling for the replay to mean anything`,
).toHaveLength(1);
// `type` is the FULL type (`ns:name`); `register` re-derives it from the bare
// name + namespace, so replaying it would double the prefix.
const config = ComponentRegistry.getConfig(keys[0]);
expect(config, `nothing is registered as "${keys[0]}"`).toBeDefined();
const { type: _fullType, component, ...meta } = config!;
return { component, meta: meta as ComponentMeta };
}

/** The lazy stub `apps/console/src/register-plugins.ts` declares for `short`. */
function consoleStubMeta(): ComponentMeta {
return { namespace: NS, category: 'view' } as ComponentMeta;
}

describe('plugin-report registers under the namespace its consumers declare', () => {
it.each(REGISTRATIONS)(
'"%s" is registered under exactly one namespace, and it is plugin-report',
(short) => {
expect(
namespacedKeysFor(short),
`"${short}" must be registered as "${NS}:${short}" and nothing else — a second ` +
'namespaced spelling is a key the CLI whitelist and the console stubs cannot ' +
'both satisfy (objectui#6416)',
).toEqual([`${NS}:${short}`]);
},
);

it('no `report:*` key survives — that spelling is retired', () => {
expect(ComponentRegistry.getAllTypes().filter((t) => t.startsWith('report:'))).toEqual([]);
});

it.each(REGISTRATIONS)(
'"%s" resolves to its renderer by BOTH the bare and the namespaced key',
(short, renderer) => {
// The namespaced lookup is the one the console stub and the CLI whitelist
// name; before this fix it was `undefined` for all three.
expect(ComponentRegistry.get(short, NS)).toBe(renderer);
// The bare lookup is the only spelling anything in this repo authors.
expect(ComponentRegistry.get(short)).toBe(renderer);
},
);
});

// The pin the card is actually about. Both rows replay the SAME declared
// metadata into a fresh registry; only the sequence differs. The bare key's
// declared namespace is checked after EVERY step, so a mismatch between the
// stub and the real registration reddens here even though each step on its own
// succeeds.
const ORDERS: Array<[label: string, order: Array<'stub' | 'eager'>]> = [
['stub first — what the console does: boot stubs, then the chunk loads', ['stub', 'eager']],
['eager first — a host that imports the package before declaring stubs', ['eager', 'stub']],
];

describe.each(REGISTRATIONS)(
'bare "%s" ownership is declared, not decided by registration order',
(short, renderer) => {
it.each(ORDERS)('resolves the same way in EITHER order (%s)', (_label, order) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const fresh = new Registry<unknown>();
const { component, meta } = declaredRegistration(short);

for (const step of order) {
if (step === 'stub') {
fresh.registerLazy(short, () => Promise.resolve({}), consoleStubMeta());
} else {
fresh.register(short, component, meta);
}
// The invariant that used to break: whoever last touched the bare key
// must declare the SAME namespace, at every point in the sequence.
expect(
fresh.getMeta(short)?.namespace,
`after the "${step}" step in order [${order.join(', ')}], bare "${short}" declares a ` +
'different namespace than the step before it — the bare key is double-claimed ' +
'again (objectui#6416)',
).toBe(NS);
}

expect(
fresh.get(short),
`registering in the order [${order.join(', ')}] changed who answers bare "${short}"`,
).toBe(renderer);
// The key the CLI whitelist declares renderable must name a real
// component once the chunk has loaded, in either order.
expect(fresh.get(short, NS)).toBe(renderer);

// The registry's collision guard is the mechanism this class of fix
// uses, so its silence is part of the contract: a warning here means two
// registrations are fighting over the bare key again.
const collided = warn.mock.calls.some(
(args: unknown[]) => typeof args[0] === 'string' && args[0].includes(COLLISION_WARNING),
);
expect(collided, 'the registry warned that the bare-name fallback was overwritten').toBe(
false,
);
} finally {
warn.mockRestore();
}
});

it('the real registration clears the console stub instead of stranding it', () => {
const fresh = new Registry<unknown>();
const { component, meta } = declaredRegistration(short);
fresh.registerLazy(short, () => Promise.resolve({}), consoleStubMeta());
expect(fresh.hasLazy(short, NS)).toBe(true);

fresh.register(short, component, meta);

// Consequence 2: while the namespaces disagreed, `register()` deleted
// `report:<short>` and left `plugin-report:<short>` pending forever, so
// the whitelisted key was permanently unrenderable.
expect(
fresh.hasLazy(short, NS),
`"${NS}:${short}" is still a pending lazy stub after the module registered — the ` +
'registration is landing under a different full type (objectui#6416)',
).toBe(false);
expect(fresh.hasLazy(short)).toBe(false);
});
},
);
40 changes: 37 additions & 3 deletions packages/plugin-report/src/index.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,12 +37,46 @@ export type {
// `queryDataset`, so the client-side path had no remaining consumers.
export { mergeFilters } from './mergeFilters';

// NAMESPACE — `plugin-report`, the spelling this package's consumers declare
// (objectui#6416).
//
// These three registrations used to name namespace `report`, while
// `apps/console/src/register-plugins.ts` declared the lazy stubs for the same
// three short names under `plugin-report` and
// `packages/cli/src/utils/known-schema-types.ts` shipped the `plugin-report:*`
// spellings as renderable. Two things followed from the disagreement:
//
// * `plugin-report:report` / `:report-viewer` / `:spec-report` could never be
// satisfied. `register()` clears the lazy stub for the type IT registers,
// and that type was `report:report`, so the `plugin-report:*` stubs were
// never cleared and no component was ever stored under them —
// `get('report', 'plugin-report')` stayed undefined and
// `hasLazy('report', 'plugin-report')` stayed true forever.
// * The bare `report` key was claimed twice under two different namespaces
// (`Registry.register` and `registerLazy` share the `meta?.namespace &&
// !meta?.skipFallback` branch), so what bare `report` DECLARED depended on
// whether the chunk had loaded yet — the objectui#6353 shape.
//
// Direction chosen by measurement, not by preference: nothing in this repo or
// the sibling `objectstack` checkout authors a `report:*` spelling (0 hits),
// while the bare spellings are authored in 48 places, so the `report:*` keys
// are retired rather than the consumer-facing ones. Every sibling plugin
// already namespaces by package name.
//
// The bare keys stay claimed here ON PURPOSE and must NOT take `skipFallback`:
// after this change both claimants of each bare key — the console's lazy stub
// and the registration below — name the SAME full type, which is the shape all
// 27 other console-stub/plugin pairs in this repo have. Suppressing either
// claim would strand bare `report`, the only spelling anything authors.
// Pinned by `./__tests__/report-bare-key-ownership.test.ts` and
// `scripts/__tests__/report-namespace-agreement-6416.test.ts`.

// Register report component (dispatches dataset-bound vs legacy automatically)
ComponentRegistry.register(
'report',
ReportRenderer,
{
namespace: 'report',
namespace: 'plugin-report',
label: 'Report',
category: 'Report',
inputs: [
Expand All@@ -58,7 +92,7 @@ ComponentRegistry.register(
'spec-report',
ReportRenderer,
{
namespace: 'report',
namespace: 'plugin-report',
label: 'Spec Report',
category: 'Report',
inputs: [
Expand All@@ -73,7 +107,7 @@ ComponentRegistry.register(
'report-viewer',
ReportViewer,
{
namespace: 'report',
namespace: 'plugin-report',
label: 'Report Viewer',
category: 'Report',
inputs: [
Expand Down
Loading
Loading