diff --git a/.changeset/7450-text-bare-key-resolution-pin.md b/.changeset/7450-text-bare-key-resolution-pin.md new file mode 100644 index 0000000000..a616d841b6 --- /dev/null +++ b/.changeset/7450-text-bare-key-resolution-pin.md @@ -0,0 +1,6 @@ +--- +--- + +Pin that the bare `text` component key resolves to `ui:text`, and that +`element:text`'s `skipFallback: true` is the single line that decides it +(objectui#7450). Test only; no package is released by this change. diff --git a/packages/components/src/__tests__/text-bare-key-resolution-7450.test.tsx b/packages/components/src/__tests__/text-bare-key-resolution-7450.test.tsx new file mode 100644 index 0000000000..f970d489a0 --- /dev/null +++ b/packages/components/src/__tests__/text-bare-key-resolution-7450.test.tsx @@ -0,0 +1,131 @@ +/** + * 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. + * + * The bare `text` key belongs to `ui:text`, and ONE FLAG is why (objectui#7450). + * + * ## The hazard, measured + * + * Two production registrations claim the short name `text`: + * + * `renderers/basic/text.tsx` register('text', …, { namespace: 'ui' }) + * `renderers/basic/elements.tsx` register('text', …, { namespace: 'element', + * skipFallback: true }) + * + * `Registry.register()` claims the bare key for a namespaced registration + * `if (meta?.namespace && !meta?.skipFallback)`, and its own comment says "the + * last registration wins for non-namespaced lookups". `renderers/basic/index.ts` + * imports `./text` BEFORE `./elements`, so `element:text` is the LATER + * registration and load order alone would hand it the bare key. + * `skipFallback: true` is the single line that stops it. + * + * Deleting that one line is silent in every layer that would normally catch it: + * `Registry.ts` only `console.warn`s on such an overwrite, no type changes, and + * the corpus authors the bare `text` spelling (not `ui:text`), so every + * `variant: 'h1'`…`'h6'` / `'overline'` node in it would start rendering through + * the FOUR-value `element:text` renderer — which has no entry for those values + * and falls through to a `
`. A heading that stops being a heading, with no
+ * diagnostic anywhere.
+ *
+ * Nothing pinned that resolution before this file.
+ *
+ * ## Every fact here is DERIVED, and the mechanism has a live control
+ *
+ * The load-order half is read out of `renderers/basic/index.ts` at run time
+ * rather than restated, so re-ordering those imports moves this pin with it.
+ *
+ * The mechanism half runs on a FRESH `Registry` instance, never the shared
+ * singleton: two registrations in the same order as production, once with the
+ * flag and once without. Without a control, "the bare key is `ui:text`" also
+ * passes if the fallback path stopped working altogether, or if `element:text`
+ * had quietly stopped registering — both of which would leave the assertion
+ * green while the thing it describes was gone.
+ */
+import path from 'node:path';
+import fs from 'node:fs';
+import { describe, it, expect } from 'vitest';
+import { ComponentRegistry, Registry } from '@object-ui/core';
+import { renderComponent } from './test-utils';
+// Module scope, not a hook: the cold transform is billed to the import phase,
+// which has no test/hook timeout (AGENTS.md, objectui#3010).
+import '../renderers';
+
+const BASIC_INDEX = fs.readFileSync(
+ path.resolve(import.meta.dirname, '../renderers/basic/index.ts'),
+ 'utf8',
+);
+
+describe('objectui#7450 — the bare `text` key resolves to `ui:text`', () => {
+ it('both claimants are registered (reachability before any identity claim)', () => {
+ // Without this, every assertion below is satisfiable by a registration that
+ // simply is not there any more.
+ expect(ComponentRegistry.getConfig('ui:text'), 'ui:text is not registered').toBeDefined();
+ expect(
+ ComponentRegistry.getConfig('element:text'),
+ 'element:text is not registered',
+ ).toBeDefined();
+ });
+
+ it('`text` resolves to `ui:text`, by component identity and not only by name', () => {
+ const bare = ComponentRegistry.getConfig('text');
+ const ui = ComponentRegistry.getConfig('ui:text');
+ const element = ComponentRegistry.getConfig('element:text');
+
+ expect(bare?.type).toBe('ui:text');
+ expect(bare?.component).toBe(ui?.component);
+ expect(bare?.component).not.toBe(element?.component);
+ });
+
+ it('load order alone would hand the bare key to `element:text`', () => {
+ // Derived from the barrel, not restated: `./text` registers `ui:text` and
+ // `./elements` registers `element:text`, so the LATER import is the later
+ // registration.
+ const uiAt = BASIC_INDEX.indexOf("import './text';");
+ const elementAt = BASIC_INDEX.indexOf("import './elements';");
+
+ expect(uiAt, "renderers/basic/index.ts no longer imports './text'").toBeGreaterThan(-1);
+ expect(
+ elementAt,
+ "renderers/basic/index.ts no longer imports './elements'",
+ ).toBeGreaterThan(-1);
+ expect(elementAt).toBeGreaterThan(uiAt);
+ });
+
+ it('`skipFallback: true` on `element:text` is what overrides that order', () => {
+ expect((ComponentRegistry.getConfig('element:text') as { skipFallback?: boolean })
+ ?.skipFallback).toBe(true);
+ expect((ComponentRegistry.getConfig('ui:text') as { skipFallback?: boolean })
+ ?.skipFallback).toBeFalsy();
+ });
+
+ it('LIVE CONTROL — the same pair without the flag hands the bare key over', () => {
+ // A fresh registry, so nothing here touches the shared singleton the rest of
+ // the suite reads. Production order: the `ui` namespace first, `element`
+ // second.
+ const First = () => null;
+ const Second = () => null;
+
+ const guarded = new Registry `.
+ const { container } = renderComponent({ type: 'text', variant: 'h1', content: 'Title' } as never);
+
+ expect(container.firstElementChild?.tagName.toLowerCase()).toBe('h1');
+ });
+});
`; `element:text`
+ // has no `h1` in its four-value vocabulary and falls through to a `