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
62 changes: 62 additions & 0 deletions .changeset/8760-unfulfilled-chart-stubs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
'@object-ui/console': minor
'@object-ui/cli': minor
'@object-ui/types': minor
---

Retire `line-chart`, `area-chart` and `advanced-chart` — three chart component keys the
console registered as lazy stubs that `@object-ui/plugin-charts` never fulfilled
(objectui#8760).

**The defect, and why it outlived the checks.** `apps/console` registered ten chart
variants as `registerLazy` stubs pointing at `@object-ui/plugin-charts`. That package
registers eight keys; three of the ten were not among them. An unfulfilled stub does not
fail — it succeeds at being useless, in both of the places that decide whether a defect
is ever seen:

- **At render.** `SchemaRenderer`'s lazy branch re-checks `hasLazy(type)` on every pass
and returns the `Loading <type>…` placeholder. `Registry.register()` deletes a lazy
entry only for keys the loaded module actually registers, so for an unfulfilled key the
entry SURVIVES the load and every later pass takes the same branch. Measured on
`b775500af` through the real chain: `{ "type": "line-chart" }` painted
`role="status"` / `data-lazy-loading="line-chart"` / `Loading line-chart…`,
permanently. **Not** the `OBJUI-001` panel the card expected — no alert, no error, no
console warning. A skeleton that never resolves reads to a user as a slow network.
- **At authoring.** A stub is enough to put a key into `getKnownTypes()`, so
`check:doc-types` and the CLI's generated `KNOWN_SCHEMA_TYPES` snapshot both blessed
all three. `content/docs/plugins/plugin-dashboard.mdx` taught `"type": "line-chart"`
inside a `card` body, and every gate was green on it.

So the failure was strictly worse than an unknown key: an unknown key is refused loudly at
authoring time, while these passed every check, were taught by the documentation, and
failed only at render in front of a user.

**Why removal rather than implementation.** Both repairs were available and they are not
equivalent. Fulfilment would mint three new pieces of authorable surface: `line-chart` and
`area-chart` duplicate, under a second spelling, families the plugin already draws as
`{ "type": "chart", "chartType": "line" | "area" }`, and `advanced-chart` was never a
family at all — it named `AdvancedChartImpl`, an internal module. Measured demand for all
three is zero: a sweep of both repositories for authored nodes of these types returns
exactly one hit, the doc snippet corrected here, against lit controls in the same
commands (`"type": "bar-chart"` 5, `"type": "chart"` 12, `object-chart` 1 in the sibling
repo). No example app, fixture, seed document or deployment authors any of them. Under
声明即强制, a declaration with no delivery and no demand comes off rather than growing an
implementation to match it.

**Breaking, for anyone who authored a retired key.** A document with
`{ "type": "line-chart" }` used to resolve in the registry and then draw nothing; it is
now refused by name — `objectui check` reports `Unknown schema type "line-chart"`, and
`SchemaRenderer` paints the `OBJUI-001` panel instead of an endless skeleton. That is a
louder failure for the same broken document, not a new one: no document that previously
DREW is affected. Migrate to `{ "type": "chart", "chartType": "line" | "area" }`, which
`CHART_TYPE_KEYWORD_FAMILIES` resolves. Scored `minor`, not `major`, per
AGENTS.md §版本号策略.

**What moved.** The stub lists in `apps/console/src/register-plugins.ts` and
`apps/console/src/preview-gallery.tsx` (both loops, because the doc gate's key universe is
their union — retiring one alone would have changed nothing observable); the dashboard doc
snippet plus a note on how chart families are actually spelled; the regenerated
`KNOWN_SCHEMA_TYPES` snapshot (six entries, three bare and three namespaced); and the
`line-chart` leg of `node-slot-registered-arms-8499.test.ts`, whose premise this
retirement changed and which reads the stub list rather than the file so the ⛔ comment
left behind cannot satisfy it.
202 changes: 202 additions & 0 deletions apps/console/src/__tests__/unfulfilled-chart-stubs-8760.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* 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.
*/

/**
* Every chart key this app registers as a lazy stub is FULFILLED by the module
* the stub points at (objectui#8760).
*
* ## The defect
*
* `register-plugins.ts` registered ten chart variants as `registerLazy` stubs
* pointing at `@object-ui/plugin-charts`. That package registers eight keys,
* and three of the ten — `line-chart`, `area-chart`, `advanced-chart` — were
* not among them.
*
* An unfulfilled stub does not fail; it succeeds at being useless, and it does
* so in the two places that decide whether a defect is ever seen:
*
* - AT RENDER. `SchemaRenderer`'s lazy branch is re-entered on every pass:
* it checks `hasLazy(type)` and returns the `Loading <type>…` placeholder.
* `Registry.register()` deletes a lazy entry only for the keys the loaded
* module actually registers, so for an unfulfilled key the entry SURVIVES
* the load and every subsequent pass takes the same branch. MEASURED on
* `b775500af`, through this file's own chain: `{ "type": "line-chart" }`
* painted `role="status"` / `data-lazy-loading="line-chart"` /
* `Loading line-chart…`, permanently. Not OBJUI-001 — no alert, no error,
* no console warning. A skeleton that never resolves reads to a user as a
* slow network, which is why this outlived the card that first noticed it.
* - AT AUTHORING. The stub puts the key in `getKnownTypes()`, so
* `check:doc-types` and the CLI's generated `KNOWN_SCHEMA_TYPES` snapshot
* both bless it. `content/docs/plugins/plugin-dashboard.mdx` taught
* `"type": "line-chart"` inside a `card` body and every gate was green on
* it.
*
* So the failure was strictly worse than an unknown key: an unknown key is
* refused loudly at authoring time, while this one passed every check, was
* taught by the documentation, and failed only at render in front of a user.
*
* ## What this file pins, and what it deliberately does not
*
* The claim under test is the OUTCOME an author observes, never "the registry
* has N entries" or "the stub was registered" — those are the readings that let
* the defect exist for as long as it did.
*
* 1. THE INVARIANT. The stub list is read from this app's own SOURCE and each
* key is driven through the REAL loader (`ComponentRegistry.loadLazy`),
* then re-checked in the registry — which is exactly what `loadLazy`'s
* docblock tells callers to do, because it resolves "whether or not the
* loaded module actually registered the expected type". A key added to
* that list without a matching `register()` in `packages/plugin-charts`
* fails HERE, on the first render nobody has done yet.
* 2. THE RETIREMENT, as a render outcome. An authored node of a retired type
* now paints the loud OBJUI-001 refusal instead of the eternal skeleton.
* 3. THE CONTROL. `pie-chart` and `bar-chart` — fulfilled variants in the
* same sweep — are unchanged end to end: same registry identity, same
* drawn output, no alert and no placeholder. Each is asserted
* individually; neither reads zero on both sides of the change.
*
* The CLI half of (2) — `objectui check` refusing a document that authors a
* retired key, which is the "refused at authoring time" outcome on the
* PUBLISHED surface — is pinned in
* `packages/cli/src/__tests__/unfulfilled-chart-stubs-retired-8760.test.ts`.
*
* `AnyComponentSchema` is NOT part of this file's claim: it refused all three
* spellings before this change and refuses them after
* (`packages/types/src/__tests__/node-slot-registered-arms-8499.test.ts` holds
* that, and already carried `area-chart` as a firing control). Claiming it here
* would be claiming a reading this card did not move.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { render } from '@testing-library/react';

import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer } from '@object-ui/react';
// Module-scope import of the module the stubs point at, per AGENTS.md's
// flaky-test rule: every assertion below lands after a dynamic `import()`
// boundary, and paying that cost at import time puts it outside every
// test/hook timeout. The specifier is character-identical to the one
// `register-plugins.ts` hands `registerLazy`, so ESM hands the loader the very
// same module instance. It cannot mask the defect it guards against: the three
// retired keys are ones this module never registers under ANY load order.
import '@object-ui/plugin-charts';
import '../register-plugins';

const HERE = dirname(fileURLToPath(import.meta.url));
const REGISTER_PLUGINS = join(HERE, '..', 'register-plugins.ts');

/** The three spellings objectui#8760 retired. */
const RETIRED = ['line-chart', 'area-chart', 'advanced-chart'] as const;

/**
* The chart stub list, read from the source it is declared in — never a copy.
* A copy is the failure this card is about: two declarations of one set, with
* nothing comparing them.
*/
function chartStubList(): string[] {
const source = readFileSync(REGISTER_PLUGINS, 'utf8');
const marker = "() => import('@object-ui/plugin-charts')";
const at = source.indexOf(`for (const variant of [`);
const loops = [...source.matchAll(/for \(const variant of \[([^\]]*)\]\)([\s\S]{0,220}?)\}/g)];
const charts = loops.filter((m) => m[2].includes(marker));
if (charts.length !== 1) {
throw new Error(`expected exactly one chart stub loop, found ${charts.length} (at ${at})`);
}
return [...charts[0][1].matchAll(/'([^']+)'/g)].map((m) => m[1]);
}

/** Render one authored node and report only what an author could observe. */
function paint(type: string, extra: Record<string, unknown> = {}) {
const { container } = render(<SchemaRenderer schema={{ type, ...extra } as never} />);
return {
alerts: container.querySelectorAll('[role="alert"]').length,
placeholders: container.querySelectorAll('[data-lazy-loading]').length,
elements: container.querySelectorAll('*').length,
text: (container.textContent ?? '').replace(/\s+/g, ' '),
};
}

const ROWS = [
{ m: 'Jan', v: 4000 },
{ m: 'Feb', v: 3000 },
{ m: 'Mar', v: 6000 },
];

describe('objectui#8760 — every chart stub this app registers is fulfilled', () => {
it('the source read finds the list, and finds it non-empty', () => {
// Non-vacuity for every assertion below: a reader that has stopped reading
// must throw or come back empty, never yield a silently passing zero.
const list = chartStubList();
expect(list.length, 'the stub-list read went vacuous').toBeGreaterThanOrEqual(5);
expect(list, 'the reader is looking at the chart loop').toContain('pie-chart');
for (const retired of RETIRED) expect(list).not.toContain(retired);
});

it('drives each stub through the REAL loader and re-checks the registry', async () => {
const list = chartStubList();
const unfulfilled: string[] = [];
for (const key of list) {
// The call, not the name: `loadLazy` resolves whether or not the module
// registered the key, so the reading that matters is the one taken after.
await ComponentRegistry.loadLazy(key);
if (ComponentRegistry.get(key) === undefined) unfulfilled.push(key);
}
expect(
unfulfilled,
'a lazy stub resolves to nothing — the key renders `Loading …` forever',
).toEqual([]);
expect(list).toHaveLength(7);
}, 20000);
});

describe('objectui#8760 — a retired key is refused loudly instead of drawing nothing', () => {
it.each(RETIRED)('`%s` has no stub and no registration', async (type) => {
expect(ComponentRegistry.hasLazy(type)).toBe(false);
expect(ComponentRegistry.hasLazy(type, 'plugin-charts')).toBe(false);
await ComponentRegistry.loadLazy(type);
expect(ComponentRegistry.get(type)).toBeUndefined();
expect(ComponentRegistry.getKnownTypes()).not.toContain(type);
});

it.each(RETIRED)('an authored `%s` node paints OBJUI-001, not an endless skeleton', (type) => {
const shot = paint(type, { data: ROWS, xAxisKey: 'm', series: [{ dataKey: 'v' }] });
// The half that changed. Before this card BOTH numbers were the other way
// round: 0 alerts and 1 placeholder, on every render pass, forever.
expect(shot.alerts, `${type} did not refuse`).toBe(1);
expect(shot.placeholders, `${type} is still stuck on a lazy placeholder`).toBe(0);
expect(shot.text).toContain(`Unknown component type: ${type}`);
expect(shot.text).toContain('OBJUI-001');
});
});

describe('objectui#8760 — CONTROL: the fulfilled variants are unmoved, each on its own', () => {
// Two controls, verified individually rather than as a pair: each resolves to
// a DIFFERENT renderer, and each is non-zero on both sides of the change, so
// neither can be the "reads 0 either way" kind that proves nothing.
it.each([
['pie-chart', 'ChartRenderer'],
['bar-chart', 'ChartBarRenderer'],
])('`%s` still resolves to `%s`', async (type, renderer) => {
await ComponentRegistry.loadLazy(type);
const impl = ComponentRegistry.get(type);
expect(impl).toBeDefined();
expect((impl as { name?: string }).name).toBe(renderer);
expect(ComponentRegistry.getKnownTypes()).toContain(type);
});

it.each(['pie-chart', 'bar-chart'])('`%s` still draws — no alert, no placeholder', async (type) => {
await ComponentRegistry.loadLazy(type);
const shot = paint(type, { chartType: 'pie', data: ROWS, xAxisKey: 'm', dataKey: 'v', series: [{ dataKey: 'v' }] });
expect(shot.alerts, `${type} started refusing — the control moved`).toBe(0);
expect(shot.placeholders, `${type} fell back to a lazy placeholder`).toBe(0);
expect(shot.elements, `${type} drew nothing at all`).toBeGreaterThan(0);
});
});
9 changes: 8 additions & 1 deletion apps/console/src/preview-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ for (const variant of [
category: 'view',
});
}
for (const variant of ['chart', 'bar-chart', 'line-chart', 'pie-chart', 'area-chart']) {
// ⛔ `line-chart` / `area-chart` are RETIRED here too (objectui#8760): this
// list is a second `registerLazy` site for the same plugin, and the doc gate's
// key universe is the UNION of every such loop. Retiring them from
// `register-plugins.ts` alone would have left both keys blessed by
// `check:doc-types` from THIS file, so the retirement would have changed
// nothing an author can observe. Chart families are reached as
// `{ "type": "chart", "chartType": "line" | "area" }`.
for (const variant of ['chart', 'bar-chart', 'pie-chart']) {
ComponentRegistry.registerLazy(variant, () => import('@object-ui/plugin-charts'), {
namespace: 'plugin-charts',
category: 'chart',
Expand Down
23 changes: 22 additions & 1 deletion apps/console/src/register-plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,28 @@ ComponentRegistry.registerLazy('chart', () => import('@object-ui/plugin-charts')
});
// Additional chart variants registered by @object-ui/plugin-charts so the
// renderer can lazy-load when any chart type appears in a schema.
for (const variant of ['object-chart', 'bar-chart', 'pie-chart', 'donut-chart', 'radar-chart', 'scatter-chart', 'line-chart', 'area-chart', 'advanced-chart', 'chart:bar']) {
//
// ⛔ Every key in this list must be one `@object-ui/plugin-charts` ACTUALLY
// REGISTERS. A stub for a key the loaded module never registers does not fail
// — it succeeds at being useless (objectui#8760). `Registry.loadLazy` resolves
// "whether or not the loaded module actually registered the expected type",
// and the renderer's lazy branch re-checks `hasLazy` on every pass, so an
// unfulfilled stub leaves `SchemaRenderer` painting `Loading <type>…` FOREVER:
// no OBJUI-001, no error, no console warning. Meanwhile the key is in
// `getKnownTypes()`, so `check:doc-types` and the CLI's `KNOWN_SCHEMA_TYPES`
// snapshot both bless it and the documentation is free to teach it.
//
// ⛔ `line-chart`, `area-chart` and `advanced-chart` are RETIRED from this list
// (objectui#8760) — they were stubs this package never fulfilled. Authors
// reach those chart families the way the plugin actually registers them:
// `{ "type": "chart", "chartType": "line" | "area" }`, which
// `CHART_TYPE_KEYWORD_FAMILIES` resolves. `advanced-chart` was never a family
// at all — it named `AdvancedChartImpl`, an internal module.
// ⛔ Do not re-add a key here without a matching `ComponentRegistry.register()`
// in `packages/plugin-charts/src`: `unfulfilled-chart-stubs-8760.test.ts`
// drives this very list through the real loader and fails on the first key
// that resolves to nothing.
for (const variant of ['object-chart', 'bar-chart', 'pie-chart', 'donut-chart', 'radar-chart', 'scatter-chart', 'chart:bar']) {
ComponentRegistry.registerLazy(variant, () => import('@object-ui/plugin-charts'), {
namespace: 'plugin-charts',
category: 'chart',
Expand Down
22 changes: 20 additions & 2 deletions content/docs/plugins/plugin-dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ rewrites each bare-name registry entry without its `label`/`category` metadata

### Dashboard with Charts

Charts are authored as `type: "chart"` with a `chartType` — `line`, `area`,
`bar`, `pie`, `donut`, `radar` or `scatter`. `chartType` is what selects the
family; there is no per-family node key for `line` or `area`.

<Callout type="warn">
This snippet used to teach `"type": "line-chart"`. That key was registered as a
lazy stub the charts plugin never fulfilled, so a document copied from here
resolved in the registry and then sat on `Loading line-chart…` forever
(objectui#8760). The key is retired; `chartType` is the spelling that draws.
</Callout>

```json
{
"type": "dashboard",
Expand All @@ -207,8 +218,15 @@ rewrites each bare-name registry entry without its `label`/`category` metadata
"type": "card",
"title": "Sales Trend",
"body": {
"type": "line-chart",
"data": [],
"type": "chart",
"chartType": "line",
"data": [
{ "month": "Jan", "revenue": 4000 },
{ "month": "Feb", "revenue": 3000 },
{ "month": "Mar", "revenue": 6000 }
],
"xAxisKey": "month",
"series": [{ "dataKey": "revenue" }],
"height": 300
}
}
Expand Down
Loading
Loading