···6969 unknown
7070 >;
7171 } else if (component.view) {
7272- // Synthesize validation from view entries (viewPrimitive + viewRecord)
7272+ // Synthesize validation from view entries.
7373+ // viewRecord → validate the view prop is an AT URI string.
7474+ // viewPrimitive → declare the prop exists (required) as unknown.
7575+ // The primitive type is metadata for editor toolbox matching, not a
7676+ // runtime constraint — a component accepting strings can also receive
7777+ // elements, arrays, etc. via JSX children.
7378 const { prop: viewProp, accepts } = component.view;
7479 const primitives = accepts.filter((v) => viewPrimitiveSchema.isTypeOf(v));
7580 const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v));
···8085 { type: string; formats: Set<string> }
8186 >();
82878383- // viewPrimitive entries define typed props
8484- for (const vp of primitives) {
8585- const existing = propEntries.get(viewProp);
8686- if (existing) {
8787- if (vp.format) existing.formats.add(vp.format);
8888- } else {
8989- const formats = new Set<string>();
9090- if (vp.format) formats.add(vp.format);
9191- propEntries.set(viewProp, { type: vp.type, formats });
9292- }
9393- }
8888+ // viewPrimitive entries are NOT added to synthesized validation.
8989+ // The primitive type is metadata for editor toolbox matching; runtime
9090+ // validation of the view prop only applies when viewRecord constrains
9191+ // it to an AT URI string.
94929593 // viewRecord entries imply a string prop (at-uri or did format)
9694 for (const vr of records) {
+167-3
packages/@inlay/render/test/render.test.ts
···10171017});
1018101810191019// ============================================================================
10201020+// 3b. resolveImports — caller-controlled import filtering
10211021+// ============================================================================
10221022+//
10231023+// The resolveImports option lets the host rewrite the import list before
10241024+// type resolution. It receives the component's imports and the NSID being
10251025+// resolved, and returns the DID list that should actually be searched.
10261026+10271027+describe("resolveImports", () => {
10281028+ const FANCY_DID = "did:plc:fancy";
10291029+ const CASUAL_DID = "did:plc:casual";
10301030+10311031+ const fancyGreeting: ComponentRecord = {
10321032+ $type: "at.inlay.component",
10331033+ body: {
10341034+ $type: "at.inlay.component#bodyTemplate",
10351035+ node: serializeTree($(Text, { value: "Greetings, friend!" })),
10361036+ },
10371037+ imports: [HOST_DID],
10381038+ };
10391039+10401040+ const casualGreeting: ComponentRecord = {
10411041+ $type: "at.inlay.component",
10421042+ body: {
10431043+ $type: "at.inlay.component#bodyTemplate",
10441044+ node: serializeTree($(Text, { value: "Hi" })),
10451045+ },
10461046+ imports: [HOST_DID],
10471047+ };
10481048+10491049+ it("filters imports per NSID", async () => {
10501050+ // Root imports both fancy and casual, but resolveImports drops fancy
10511051+ // for Greeting — so casual wins even though fancy is listed first.
10521052+ const rootComponent: ComponentRecord = {
10531053+ $type: "at.inlay.component",
10541054+ body: {
10551055+ $type: "at.inlay.component#bodyTemplate",
10561056+ node: serializeTree($(Greeting, {})),
10571057+ },
10581058+ imports: [FANCY_DID, CASUAL_DID],
10591059+ };
10601060+10611061+ const records = {
10621062+ ...HOST_RECORDS,
10631063+ [`at://${FANCY_DID}/at.inlay.component/${Greeting}`]: fancyGreeting,
10641064+ [`at://${CASUAL_DID}/at.inlay.component/${Greeting}`]: casualGreeting,
10651065+ };
10661066+10671067+ const { options, log } = testResolver(records);
10681068+ options.resolveImports = (imports, nsid) => {
10691069+ if (nsid === Greeting) {
10701070+ return imports.filter((d) => d !== FANCY_DID) as DidString[];
10711071+ }
10721072+ return imports;
10731073+ };
10741074+10751075+ const output = await renderToCompletion(
10761076+ $(Root, {}),
10771077+ options,
10781078+ createContext(rootComponent, `at://${APP_DID}/at.inlay.component/${Root}`)
10791079+ );
10801080+10811081+ assert.deepEqual(output, h("span", { value: "Hi" }));
10821082+ // Only casual DID is fetched for Greeting — fancy was filtered out
10831083+ assertLog(log, [
10841084+ `lexicon ${Root}`,
10851085+ `fetch at://${CASUAL_DID}/at.inlay.component/${Greeting}`,
10861086+ `lexicon ${Greeting}`,
10871087+ `fetch at://${HOST_DID}/at.inlay.component/${Text}`,
10881088+ `lexicon ${Text}`,
10891089+ ]);
10901090+ });
10911091+10921092+ it("can reroute a nested type without affecting its parent", async () => {
10931093+ // Root's template renders Greeting, whose template renders Text.
10941094+ // resolveImports only redirects Text to a different DID — Greeting
10951095+ // still resolves normally through casual, but Text picks up the
10961096+ // alternate DID's implementation (a bold primitive instead of span).
10971097+ const ALT_DID = "did:plc:alt";
10981098+ const Bold = "test.host.Bold" as const;
10991099+11001100+ const altText: ComponentRecord = {
11011101+ $type: "at.inlay.component",
11021102+ body: {
11031103+ $type: "at.inlay.component#bodyTemplate",
11041104+ node: serializeTree($(Bold, { value: "Hi" })),
11051105+ },
11061106+ imports: [ALT_DID],
11071107+ };
11081108+11091109+ const rootComponent: ComponentRecord = {
11101110+ $type: "at.inlay.component",
11111111+ body: {
11121112+ $type: "at.inlay.component#bodyTemplate",
11131113+ node: serializeTree($(Greeting, {})),
11141114+ },
11151115+ imports: [CASUAL_DID],
11161116+ };
11171117+11181118+ const { options } = testResolver({
11191119+ ...HOST_RECORDS,
11201120+ [`at://${CASUAL_DID}/at.inlay.component/${Greeting}`]: casualGreeting,
11211121+ // Alt DID provides a different Text that wraps in Bold
11221122+ [`at://${ALT_DID}/at.inlay.component/${Text}`]: altText,
11231123+ [`at://${ALT_DID}/at.inlay.component/${Bold}`]: {
11241124+ $type: "at.inlay.component",
11251125+ },
11261126+ });
11271127+11281128+ options.resolveImports = (imports, nsid) => {
11291129+ // Only redirect Text resolution to alt DID
11301130+ if (nsid === Text) return [ALT_DID] as DidString[];
11311131+ return imports;
11321132+ };
11331133+11341134+ const output = await renderToCompletion(
11351135+ $(Root, {}),
11361136+ options,
11371137+ createContext(
11381138+ rootComponent,
11391139+ `at://${APP_DID}/at.inlay.component/${Root}`
11401140+ ),
11411141+ { [Bold]: async (el) => h("b", { ...el.props, key: el.key }) }
11421142+ );
11431143+11441144+ // Greeting resolved normally (casual → "Hi"), but its inner Text
11451145+ // was rerouted to alt DID which wraps in Bold instead of span.
11461146+ assert.deepEqual(output, h("b", { value: "Hi" }));
11471147+ });
11481148+11491149+ it("defaults to unmodified imports when not provided", async () => {
11501150+ // Same setup as "first DID wins" — fancy is first and wins.
11511151+ const rootComponent: ComponentRecord = {
11521152+ $type: "at.inlay.component",
11531153+ body: {
11541154+ $type: "at.inlay.component#bodyTemplate",
11551155+ node: serializeTree($(Greeting, {})),
11561156+ },
11571157+ imports: [FANCY_DID, CASUAL_DID],
11581158+ };
11591159+11601160+ const { options } = testResolver({
11611161+ ...HOST_RECORDS,
11621162+ [`at://${FANCY_DID}/at.inlay.component/${Greeting}`]: fancyGreeting,
11631163+ [`at://${CASUAL_DID}/at.inlay.component/${Greeting}`]: casualGreeting,
11641164+ });
11651165+11661166+ // No resolveImports set — default behavior
11671167+ const output = await renderToCompletion(
11681168+ $(Root, {}),
11691169+ options,
11701170+ createContext(rootComponent, `at://${APP_DID}/at.inlay.component/${Root}`)
11711171+ );
11721172+11731173+ assert.deepEqual(output, h("span", { value: "Greetings, friend!" }));
11741174+ });
11751175+});
11761176+11771177+// ============================================================================
10201178// 4. Import isolation — each component has its own package.json
10211179// ============================================================================
10221180//
···27362894 assertError(bad, "Input/uri must be a string", ` at ${Card}`);
27372895 });
2738289627392739- it("synthesizes validation from viewPrimitive", async () => {
28972897+ it("viewPrimitive does not synthesize runtime validation", async () => {
28982898+ // viewPrimitive describes what data types a component can display
28992899+ // (for editor toolbox matching) but should NOT constrain prop types
29002900+ // at runtime — a component accepting strings can also receive other
29012901+ // values via JSX children or programmatic usage.
27402902 const timestampComponent: ComponentRecord = {
27412903 $type: "at.inlay.component",
27422904 body: {
···27632925 `at://${APP_DID}/at.inlay.component/${Timestamp}`
27642926 );
2765292729282928+ // String value works
27662929 const ok = await renderToCompletion(
27672930 $(Timestamp, { value: "2026-01-01T00:00:00Z" }),
27682931 options,
···27702933 );
27712934 assert.deepEqual(ok, h("span", {}));
2772293527732773- const bad = await renderToCompletion(
29362936+ // Non-string value also passes (no synthesized validation)
29372937+ const alsoOk = await renderToCompletion(
27742938 $(Timestamp, { value: 123 as unknown as string }),
27752939 options,
27762940 ctx
27772941 );
27782778- assertError(bad, "Input/value must be a string", ` at ${Timestamp}`);
29422942+ assert.deepEqual(alsoOk, h("span", {}));
27792943 });
2780294427812945 it("validates collection constraints from viewRecord", async () => {