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
21 changes: 21 additions & 0 deletions .changeset/one-schemanode-one-label-vocabulary-4580.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/types': minor
'@object-ui/core': minor
'@object-ui/react': minor
'@object-ui/components': minor
'@object-ui/plugin-dashboard': minor
---

One `SchemaNode`, and one label vocabulary — the union wins, and labels resolve where the locale lives

Two packages published a type called `SchemaNode` and they were not the same type. `@object-ui/core` hand-declared `interface SchemaNode { type: string; … [key: string]: any }`; `@object-ui/types` exported `type SchemaNode = BaseSchema | string | number | boolean | null | undefined`, whose own doc comment names `'Plain string'` a valid node. Both were exported under one name from packages the same consumers import together, so which declaration a call site got depended on which package it happened to import from — #4548's canary measured 19 of 35 errors as exactly that collision. Core's declaration is now a re-export of types', so there is one declaration left to disagree with. Core's entry surface is unchanged: `dist/index.d.ts` is byte-identical across the change.

Reconciling it exposed a real defect rather than a mechanical narrowing, which is why the first attempt was withdrawn instead of forced. The spec bridges write `spec.label` — the spec's `I18nLabel`, an INLINE locale map like `{ en: 'Owner', 'zh-CN': '负责人' }` — into `node.label`, and `BaseSchema.label` declared `string`. Under core's old index signature that assignment was invisibly `any`; under one honest `SchemaNode` it is a type error. `BaseSchema.label` and `.description` therefore now accept `string | I18nLabel`, and the two bridge assignments compile with their expressions untouched.

Resolution happens at READ time, in the renderer, against the display locale — not at the bridge. Resolving at the bridge was measured unimplementable: it is a plain class method that cannot call a hook, `BridgeContext` declares no locale, and `updateContext()` has zero callers, so a bridge-resolved label would freeze one audience's language into the node tree with no re-translation channel. React's own invalidation re-translates for free at the read site.

The widening turned every blind `schema.label`-as-string read into a named compiler error, and that inventory is the audit: it named four sites repo-wide, all one class — the label reaching a React child position, where a map does not render as `[object Object]` but THROWS `Objects are not valid as a React child`, failing the whole subtree. Three are `@object-ui/components` renderers (`filter-builder`, `sidebar-group`, `dropdown-menu`), which now resolve with the spec's own `resolveI18nLabel` against `useDisplayLocale()`. The fourth is `plugin-dashboard`'s `DashboardGridLayout` heading, which resolves with `pickLocalized` against the active UI language — matching the widget-title resolution already in that same component rather than putting two resolvers and two disagreeing locale channels in one render; the two resolvers are limb-for-limb twins with a parity test pinning them.

One interface now carries both label vocabularies two properties apart — `label`/`description` are the spec's INLINE map, `ariaLabel` is the KEYED bundle reference — and each accepts the other's shape vacuously. That confusability is objectui#4167's known hazard, inherent to the spec's `I18nLabel` design; both shapes are named with cross-referenced doc comments stating which resolver owns which slot, and a pin asserts the two unions do not collapse into each other.

Finally, the spec bridges declare their return type as `BaseSchema` instead of the union. Both bridges end in a single `return node` on an object literal, so the union described nothing real while forcing a narrowing at every read — 272 mechanical errors across five suites in the first round. That change is a type annotation only; the emitted JavaScript is byte-identical.
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* 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.
*/

/**
* Renderers that read `schema.label` resolve the INLINE locale map (objectui#4580).
*
* `BaseSchema.label` and `.description` accept `string | I18nLabel` since
* #4580's revised Q1 ruling (comment 5284973826) — `I18nLabel` being the spec's
* INLINE locale MAP (`string | Record<string, string>` in `@objectstack/spec`
* 17.0.0-rc.6), the shape a spec producer already writes: `bridgeListView`
* assigns `node.label = spec.label` at `list-view.ts:180`.
*
* Resolution happens at READ time, here, against the display locale — NOT at
* the spec bridge. PR #4603 measured the bridge unable to do it: it is a plain
* class method that cannot call a hook, `BridgeContext` declares no locale, and
* `updateContext()` has zero callers, so a bridge-resolved label would freeze
* one audience's language into the node tree (the spec's own resolver doc
* records that defect class as objectstack#6761).
*
* ## The site class, and why these three are ONE case
*
* The widening turned every blind `schema.label`-as-string read into a named
* TS2322 — that compiler inventory IS the audit, and it named exactly four
* sites repo-wide, all of them the SAME class: the label reaching a React child
* position. Three are in this package; the fourth is
* `plugin-dashboard/src/DashboardGridLayout.tsx` and is pinned in that package.
*
* ## Red-first — measured BEFORE the fix, verbatim
*
* Each of the three renderers below was invoked with
* `label: { en: 'Owner', 'zh-CN': '负责人' }` against the unfixed source. All
* three THREW, with byte-identical messages:
*
* ```
* Objects are not valid as a React child (found: object with keys {en, zh-CN}).
* If you meant to render a collection of children, use an array instead.
* ```
*
* Not `[object Object]` — a throw. A text node is one of the positions React
* refuses outright rather than stringifying, so the pre-fix harm is the whole
* subtree failing to render, not a cosmetic mis-render.
*
* ⚠️ **`dropdown-menu`'s harm is invisible unless the menu is OPEN.** Radix
* mounts `DropdownMenuContent` lazily, so the first probe of that renderer
* returned an EMPTY container and no throw — a case that would have shipped
* looking green while proving nothing. `defaultOpen: true` is what makes the
* label reachable, and it is load-bearing in the case below for that reason.
*
* ## Why these invoke the registered renderer DIRECTLY
*
* Same reason PR #4603's toggle case does: `SchemaRenderer` injects its own
* props around a renderer, and a test driven through it can be green in both
* directions. `ComponentRegistry.get(name)` returns the component the registry
* actually renders (`React.createElement`, `SchemaRenderer.tsx:621`) — which is
* also why calling `useDisplayLocale()` inside these renderers is legal.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
import { ComponentRegistry } from '@object-ui/core';
// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';

/** The inline locale map an author writes; `zh-CN` exists so a switch is observable. */
const INLINE_MAP = { en: 'Owner', 'zh-CN': '负责人' } as const;

/**
* Render a registered renderer directly, with the display locale pinned.
*
* `useDisplayLocale()` is `tenantLocale || uiLanguage || 'en'`, so driving
* `LocalizationProvider` sets it deterministically rather than depending on the
* ambient react-i18next instance.
*/
function renderDirect(
name: string,
schema: Record<string, unknown>,
tenantLocale?: string,
) {
const C = ComponentRegistry.get(name) as React.ComponentType<any>;
return render(
<I18nProvider persistLanguage={false}>
<LocalizationProvider value={{ locale: tenantLocale }}>
<C schema={schema} />
</LocalizationProvider>
</I18nProvider>,
);
}

describe('label read sites resolve the inline locale map (objectui#4580)', () => {
/* ── filter-builder ──────────────────────────────────────────────────── */

describe('filter-builder', () => {
it('resolves the map for the display locale', () => {
renderDirect('filter-builder', { type: 'filter-builder', label: INLINE_MAP, fields: [] }, 'zh-CN');
expect(screen.getByText('负责人')).toBeInTheDocument();
});

it('resolves the same map differently for a different locale', () => {
renderDirect('filter-builder', { type: 'filter-builder', label: INLINE_MAP, fields: [] }, 'en');
expect(screen.getByText('Owner')).toBeInTheDocument();
});

it('passes a plain string through unchanged', () => {
renderDirect('filter-builder', { type: 'filter-builder', label: 'Filters', fields: [] }, 'zh-CN');
expect(screen.getByText('Filters')).toBeInTheDocument();
});
});

/* ── sidebar-group ───────────────────────────────────────────────────── */

describe('sidebar-group', () => {
it('resolves the map for the display locale', () => {
renderDirect('sidebar-group', { type: 'sidebar-group', label: INLINE_MAP }, 'zh-CN');
expect(screen.getByText('负责人')).toBeInTheDocument();
});

it('passes a plain string through unchanged', () => {
renderDirect('sidebar-group', { type: 'sidebar-group', label: 'Reports' }, 'zh-CN');
expect(screen.getByText('Reports')).toBeInTheDocument();
});
});

/* ── dropdown-menu ───────────────────────────────────────────────────── */

describe('dropdown-menu', () => {
// `defaultOpen` is load-bearing: Radix mounts the content lazily, so without
// it this case renders an empty container and proves nothing (measured).
it('resolves the map for the display locale', () => {
renderDirect(
'dropdown-menu',
{ type: 'dropdown-menu', label: INLINE_MAP, items: [], defaultOpen: true },
'zh-CN',
);
expect(screen.getByText('负责人')).toBeInTheDocument();
});

it('passes a plain string through unchanged', () => {
renderDirect(
'dropdown-menu',
{ type: 'dropdown-menu', label: 'Actions', items: [], defaultOpen: true },
'zh-CN',
);
expect(screen.getByText('Actions')).toBeInTheDocument();
});
});

/* ── The resolver's documented fallback ──────────────────────────────── */

/**
* The spec's `resolveI18nLabel` documents its fallback order as exact match →
* base/region (`zh-CN` ↔ `zh`) → last resort (any remaining entry), returning
* `undefined` when nothing matched. These pin the two limbs past "exact",
* because a resolver that only ever hits the exact limb is indistinguishable
* from a lookup that ignores the locale entirely.
*/
describe("the spec resolver's documented fallback", () => {
it('falls back from a region tag to its base language', () => {
// Author wrote only `zh`; viewer is `zh-CN`.
renderDirect(
'filter-builder',
{ type: 'filter-builder', label: { en: 'Owner', zh: '负责人' }, fields: [] },
'zh-CN',
);
expect(screen.getByText('负责人')).toBeInTheDocument();
});

it('falls back to a remaining entry when no limb matches', () => {
// Author wrote only `ja-JP`; viewer is `fr` — the doc's last-resort limb.
renderDirect(
'filter-builder',
{ type: 'filter-builder', label: { 'ja-JP': '所有者' }, fields: [] },
'fr',
);
expect(screen.getByText('所有者')).toBeInTheDocument();
});
});
});
16 changes: 14 additions & 2 deletions packages/components/src/renderers/complex/filter-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,20 @@

import { ComponentRegistry } from '@object-ui/core';
import type { FilterBuilderSchema, FilterGroup } from '@object-ui/types';
import { useDisplayLocale } from '@object-ui/i18n';
// Aliased on import, following PR #4169's convention (and `AppSchemaRenderer`'s
// use of it): this repo has its OWN `resolveKeyedI18nLabel` over a DIFFERENT
// vocabulary, and neither resolver accepts the other's shape. `schema.label` is
// the spec's INLINE locale map — see `BaseSchema.label` (objectui#4580).
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
import { FilterBuilder } from '../../custom/filter-builder';

ComponentRegistry.register('filter-builder',
ComponentRegistry.register('filter-builder',
({ schema, className, onChange, ...props }: { schema: FilterBuilderSchema; className?: string; onChange?: (event: any) => void; [key: string]: any }) => {
// Read-time resolution against the display locale (objectui#4580 revised
// Q1-A). `BaseSchema.label` accepts `string | I18nLabel`; rendering the map
// straight into a text node THREW "Objects are not valid as a React child".
const locale = useDisplayLocale();
const handleChange = (value: any) => {
if (onChange) {
onChange({
Expand All @@ -26,7 +36,9 @@ ComponentRegistry.register('filter-builder',
return (
<div className={schema.wrapperClass || ''}>
{schema.label && (
<label className="text-sm font-medium mb-2 block">{schema.label}</label>
<label className="text-sm font-medium mb-2 block">
{resolveInlineI18nLabel(schema.label, locale)}
</label>
)}
<FilterBuilder
fields={(schema.fields || []) as any}
Expand Down
33 changes: 25 additions & 8 deletions packages/components/src/renderers/navigation/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import { ComponentRegistry } from '@object-ui/core';
// `SidebarSchema` for them would assert `type: 'sidebar'` on a node whose type
// is `'sidebar-header'` (objectui#4353).
import type { SidebarSchema, BaseSchema } from '@object-ui/types';
import { useDisplayLocale } from '@object-ui/i18n';
// Aliased on import, following PR #4169's convention: this repo has its OWN
// `resolveKeyedI18nLabel` over a DIFFERENT vocabulary, and neither resolver
// accepts the other's shape. `schema.label` is the spec's INLINE locale map —
// see `BaseSchema.label` (objectui#4580).
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
import { renderChildren } from '../../lib/utils';
import {
SidebarProvider,
Expand Down Expand Up @@ -103,14 +109,25 @@ ComponentRegistry.register('sidebar-content',
);

ComponentRegistry.register('sidebar-group',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
<SidebarGroup {...props}>
{schema.label && <SidebarGroupLabel>{schema.label}</SidebarGroupLabel>}
<SidebarGroupContent>
{renderChildren(schema.body)}
</SidebarGroupContent>
</SidebarGroup>
),
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => {
// Read-time resolution against the display locale (objectui#4580 revised
// Q1-A). `BaseSchema.label` accepts `string | I18nLabel`; rendering the map
// straight into a text node THREW "Objects are not valid as a React child".
// The body became a block only to host this hook — the registry renders its
// entries with `React.createElement` (`SchemaRenderer.tsx:621`), so hooks
// are legal here, as `elements.tsx`'s own `useDisplayLocale()` already relies on.
const locale = useDisplayLocale();
return (
<SidebarGroup {...props}>
{schema.label && (
<SidebarGroupLabel>{resolveInlineI18nLabel(schema.label, locale)}</SidebarGroupLabel>
)}
<SidebarGroupContent>
{renderChildren(schema.body)}
</SidebarGroupContent>
</SidebarGroup>
);
},
{
namespace: 'ui',
label: 'Sidebar Group',
Expand Down
40 changes: 28 additions & 12 deletions packages/components/src/renderers/overlay/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@

import { ComponentRegistry } from '@object-ui/core';
import type { DropdownMenuSchema } from '@object-ui/types';
import { useDisplayLocale } from '@object-ui/i18n';
// Aliased on import, following PR #4169's convention: this repo has its OWN
// `resolveKeyedI18nLabel` over a DIFFERENT vocabulary, and neither resolver
// accepts the other's shape. `schema.label` is the spec's INLINE locale map —
// see `BaseSchema.label` (objectui#4580).
import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui';
import {
DropdownMenu,
DropdownMenuTrigger,
Expand Down Expand Up @@ -52,18 +58,28 @@ const renderMenuItems = (items: any[]) => {
};

ComponentRegistry.register('dropdown-menu',
({ schema, className, ...props }: { schema: DropdownMenuSchema; className?: string; [key: string]: any }) => (
<DropdownMenu modal={schema.modal} defaultOpen={schema.defaultOpen} {...props}>
<DropdownMenuTrigger asChild>
{renderChildren(schema.trigger)}
</DropdownMenuTrigger>
<DropdownMenuContent align={schema.align} side={schema.side} className={className}>
{schema.label && <DropdownMenuLabel>{schema.label}</DropdownMenuLabel>}
{schema.label && <DropdownMenuSeparator />}
{renderMenuItems(schema.items)}
</DropdownMenuContent>
</DropdownMenu>
),
({ schema, className, ...props }: { schema: DropdownMenuSchema; className?: string; [key: string]: any }) => {
// Read-time resolution against the display locale (objectui#4580 revised
// Q1-A). `BaseSchema.label` accepts `string | I18nLabel`; rendering the map
// straight into a text node THREW "Objects are not valid as a React child"
// — observable only with the menu OPEN, since Radix mounts
// `DropdownMenuContent` lazily. The body became a block only to host this hook.
const locale = useDisplayLocale();
return (
<DropdownMenu modal={schema.modal} defaultOpen={schema.defaultOpen} {...props}>
<DropdownMenuTrigger asChild>
{renderChildren(schema.trigger)}
</DropdownMenuTrigger>
<DropdownMenuContent align={schema.align} side={schema.side} className={className}>
{schema.label && (
<DropdownMenuLabel>{resolveInlineI18nLabel(schema.label, locale)}</DropdownMenuLabel>
)}
{schema.label && <DropdownMenuSeparator />}
{renderMenuItems(schema.items)}
</DropdownMenuContent>
</DropdownMenu>
);
},
{
namespace: 'ui',
label: 'Dropdown Menu',
Expand Down
Loading
Loading