|
| 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