Skip to content

Commit 12cc07f

Browse files
os-warrenclaude
andcommitted
test(components): pin the bare text key to ui:text (objectui#7450)
`element:text` is the LATER registration in `renderers/basic/index.ts`, so `Registry.register()`'s bare-name fallback would hand it the short `text` key on load order alone; `skipFallback: true` is the one line that stops it and `Registry.ts` only `console.warn`s on such an overwrite. Nothing pinned the resolution, so deleting that flag re-pointed every authored `text` node at the four-value renderer silently. The load-order half is derived from the barrel at run time, and the mechanism carries a live control on a fresh `Registry` instance: the same pair without the flag hands the bare key to `element:text`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
1 parent b6d07df commit 12cc07f

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
---
3+
4+
Pin that the bare `text` component key resolves to `ui:text`, and that
5+
`element:text`'s `skipFallback: true` is the single line that decides it
6+
(objectui#7450). Test only; no package is released by this change.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* The bare `text` key belongs to `ui:text`, and ONE FLAG is why (objectui#7450).
9+
*
10+
* ## The hazard, measured
11+
*
12+
* Two production registrations claim the short name `text`:
13+
*
14+
* `renderers/basic/text.tsx` register('text', …, { namespace: 'ui' })
15+
* `renderers/basic/elements.tsx` register('text', …, { namespace: 'element',
16+
* skipFallback: true })
17+
*
18+
* `Registry.register()` claims the bare key for a namespaced registration
19+
* `if (meta?.namespace && !meta?.skipFallback)`, and its own comment says "the
20+
* last registration wins for non-namespaced lookups". `renderers/basic/index.ts`
21+
* imports `./text` BEFORE `./elements`, so `element:text` is the LATER
22+
* registration and load order alone would hand it the bare key.
23+
* `skipFallback: true` is the single line that stops it.
24+
*
25+
* Deleting that one line is silent in every layer that would normally catch it:
26+
* `Registry.ts` only `console.warn`s on such an overwrite, no type changes, and
27+
* the corpus authors the bare `text` spelling (not `ui:text`), so every
28+
* `variant: 'h1'`…`'h6'` / `'overline'` node in it would start rendering through
29+
* the FOUR-value `element:text` renderer — which has no entry for those values
30+
* and falls through to a `<p>`. A heading that stops being a heading, with no
31+
* diagnostic anywhere.
32+
*
33+
* Nothing pinned that resolution before this file.
34+
*
35+
* ## Every fact here is DERIVED, and the mechanism has a live control
36+
*
37+
* The load-order half is read out of `renderers/basic/index.ts` at run time
38+
* rather than restated, so re-ordering those imports moves this pin with it.
39+
*
40+
* The mechanism half runs on a FRESH `Registry` instance, never the shared
41+
* singleton: two registrations in the same order as production, once with the
42+
* flag and once without. Without a control, "the bare key is `ui:text`" also
43+
* passes if the fallback path stopped working altogether, or if `element:text`
44+
* had quietly stopped registering — both of which would leave the assertion
45+
* green while the thing it describes was gone.
46+
*/
47+
import path from 'node:path';
48+
import fs from 'node:fs';
49+
import { describe, it, expect } from 'vitest';
50+
import { ComponentRegistry, Registry } from '@object-ui/core';
51+
import { renderComponent } from './test-utils';
52+
// Module scope, not a hook: the cold transform is billed to the import phase,
53+
// which has no test/hook timeout (AGENTS.md, objectui#3010).
54+
import '../renderers';
55+
56+
const BASIC_INDEX = fs.readFileSync(
57+
path.resolve(import.meta.dirname, '../renderers/basic/index.ts'),
58+
'utf8',
59+
);
60+
61+
describe('objectui#7450 — the bare `text` key resolves to `ui:text`', () => {
62+
it('both claimants are registered (reachability before any identity claim)', () => {
63+
// Without this, every assertion below is satisfiable by a registration that
64+
// simply is not there any more.
65+
expect(ComponentRegistry.getConfig('ui:text'), 'ui:text is not registered').toBeDefined();
66+
expect(
67+
ComponentRegistry.getConfig('element:text'),
68+
'element:text is not registered',
69+
).toBeDefined();
70+
});
71+
72+
it('`text` resolves to `ui:text`, by component identity and not only by name', () => {
73+
const bare = ComponentRegistry.getConfig('text');
74+
const ui = ComponentRegistry.getConfig('ui:text');
75+
const element = ComponentRegistry.getConfig('element:text');
76+
77+
expect(bare?.type).toBe('ui:text');
78+
expect(bare?.component).toBe(ui?.component);
79+
expect(bare?.component).not.toBe(element?.component);
80+
});
81+
82+
it('load order alone would hand the bare key to `element:text`', () => {
83+
// Derived from the barrel, not restated: `./text` registers `ui:text` and
84+
// `./elements` registers `element:text`, so the LATER import is the later
85+
// registration.
86+
const uiAt = BASIC_INDEX.indexOf("import './text';");
87+
const elementAt = BASIC_INDEX.indexOf("import './elements';");
88+
89+
expect(uiAt, "renderers/basic/index.ts no longer imports './text'").toBeGreaterThan(-1);
90+
expect(
91+
elementAt,
92+
"renderers/basic/index.ts no longer imports './elements'",
93+
).toBeGreaterThan(-1);
94+
expect(elementAt).toBeGreaterThan(uiAt);
95+
});
96+
97+
it('`skipFallback: true` on `element:text` is what overrides that order', () => {
98+
expect((ComponentRegistry.getConfig('element:text') as { skipFallback?: boolean })
99+
?.skipFallback).toBe(true);
100+
expect((ComponentRegistry.getConfig('ui:text') as { skipFallback?: boolean })
101+
?.skipFallback).toBeFalsy();
102+
});
103+
104+
it('LIVE CONTROL — the same pair without the flag hands the bare key over', () => {
105+
// A fresh registry, so nothing here touches the shared singleton the rest of
106+
// the suite reads. Production order: the `ui` namespace first, `element`
107+
// second.
108+
const First = () => null;
109+
const Second = () => null;
110+
111+
const guarded = new Registry<unknown>();
112+
guarded.register('text', First, { namespace: 'ui' });
113+
guarded.register('text', Second, { namespace: 'element', skipFallback: true });
114+
expect(guarded.getConfig('text')?.type).toBe('ui:text');
115+
116+
const unguarded = new Registry<unknown>();
117+
unguarded.register('text', First, { namespace: 'ui' });
118+
unguarded.register('text', Second, { namespace: 'element' });
119+
// THE CONTROL FIRES: drop the flag and the later registration takes the key.
120+
expect(unguarded.getConfig('text')?.type).toBe('element:text');
121+
});
122+
123+
it('what the flag protects: an authored `text` node keeps its heading element', () => {
124+
// The corpus spells the bare name, so this is the shape the flag decides for
125+
// every authored text node. `ui:text` maps `h1` to an `<h1>`; `element:text`
126+
// has no `h1` in its four-value vocabulary and falls through to a `<p>`.
127+
const { container } = renderComponent({ type: 'text', variant: 'h1', content: 'Title' } as never);
128+
129+
expect(container.firstElementChild?.tagName.toLowerCase()).toBe('h1');
130+
});
131+
});

0 commit comments

Comments
 (0)