social components inlay.at
atproto components sdui
100
fork

Configure Feed

Select the types of activity you want to include in your feed.

add resolveImports, fix bug

+182 -16
+5 -1
packages/@inlay/render/src/index.ts
··· 49 49 export type RenderOptions = { 50 50 resolver: Resolver; 51 51 maxDepth?: number; 52 + resolveImports?: (imports: DidString[], nsid: string) => DidString[]; 52 53 }; 53 54 54 55 /** ··· 179 180 throw Error("Component depth limit exceeded"); 180 181 } 181 182 183 + const effectiveImports = options.resolveImports 184 + ? options.resolveImports(ctx.imports, type) 185 + : ctx.imports; 182 186 const { component, componentUri } = await resolveType( 183 187 type, 184 - ctx.imports, 188 + effectiveImports, 185 189 resolver 186 190 ); 187 191 return await renderComponent(
+10 -12
packages/@inlay/render/src/validate.ts
··· 69 69 unknown 70 70 >; 71 71 } else if (component.view) { 72 - // Synthesize validation from view entries (viewPrimitive + viewRecord) 72 + // Synthesize validation from view entries. 73 + // viewRecord → validate the view prop is an AT URI string. 74 + // viewPrimitive → declare the prop exists (required) as unknown. 75 + // The primitive type is metadata for editor toolbox matching, not a 76 + // runtime constraint — a component accepting strings can also receive 77 + // elements, arrays, etc. via JSX children. 73 78 const { prop: viewProp, accepts } = component.view; 74 79 const primitives = accepts.filter((v) => viewPrimitiveSchema.isTypeOf(v)); 75 80 const records = accepts.filter((v) => viewRecordSchema.isTypeOf(v)); ··· 80 85 { type: string; formats: Set<string> } 81 86 >(); 82 87 83 - // viewPrimitive entries define typed props 84 - for (const vp of primitives) { 85 - const existing = propEntries.get(viewProp); 86 - if (existing) { 87 - if (vp.format) existing.formats.add(vp.format); 88 - } else { 89 - const formats = new Set<string>(); 90 - if (vp.format) formats.add(vp.format); 91 - propEntries.set(viewProp, { type: vp.type, formats }); 92 - } 93 - } 88 + // viewPrimitive entries are NOT added to synthesized validation. 89 + // The primitive type is metadata for editor toolbox matching; runtime 90 + // validation of the view prop only applies when viewRecord constrains 91 + // it to an AT URI string. 94 92 95 93 // viewRecord entries imply a string prop (at-uri or did format) 96 94 for (const vr of records) {
+167 -3
packages/@inlay/render/test/render.test.ts
··· 1017 1017 }); 1018 1018 1019 1019 // ============================================================================ 1020 + // 3b. resolveImports — caller-controlled import filtering 1021 + // ============================================================================ 1022 + // 1023 + // The resolveImports option lets the host rewrite the import list before 1024 + // type resolution. It receives the component's imports and the NSID being 1025 + // resolved, and returns the DID list that should actually be searched. 1026 + 1027 + describe("resolveImports", () => { 1028 + const FANCY_DID = "did:plc:fancy"; 1029 + const CASUAL_DID = "did:plc:casual"; 1030 + 1031 + const fancyGreeting: ComponentRecord = { 1032 + $type: "at.inlay.component", 1033 + body: { 1034 + $type: "at.inlay.component#bodyTemplate", 1035 + node: serializeTree($(Text, { value: "Greetings, friend!" })), 1036 + }, 1037 + imports: [HOST_DID], 1038 + }; 1039 + 1040 + const casualGreeting: ComponentRecord = { 1041 + $type: "at.inlay.component", 1042 + body: { 1043 + $type: "at.inlay.component#bodyTemplate", 1044 + node: serializeTree($(Text, { value: "Hi" })), 1045 + }, 1046 + imports: [HOST_DID], 1047 + }; 1048 + 1049 + it("filters imports per NSID", async () => { 1050 + // Root imports both fancy and casual, but resolveImports drops fancy 1051 + // for Greeting — so casual wins even though fancy is listed first. 1052 + const rootComponent: ComponentRecord = { 1053 + $type: "at.inlay.component", 1054 + body: { 1055 + $type: "at.inlay.component#bodyTemplate", 1056 + node: serializeTree($(Greeting, {})), 1057 + }, 1058 + imports: [FANCY_DID, CASUAL_DID], 1059 + }; 1060 + 1061 + const records = { 1062 + ...HOST_RECORDS, 1063 + [`at://${FANCY_DID}/at.inlay.component/${Greeting}`]: fancyGreeting, 1064 + [`at://${CASUAL_DID}/at.inlay.component/${Greeting}`]: casualGreeting, 1065 + }; 1066 + 1067 + const { options, log } = testResolver(records); 1068 + options.resolveImports = (imports, nsid) => { 1069 + if (nsid === Greeting) { 1070 + return imports.filter((d) => d !== FANCY_DID) as DidString[]; 1071 + } 1072 + return imports; 1073 + }; 1074 + 1075 + const output = await renderToCompletion( 1076 + $(Root, {}), 1077 + options, 1078 + createContext(rootComponent, `at://${APP_DID}/at.inlay.component/${Root}`) 1079 + ); 1080 + 1081 + assert.deepEqual(output, h("span", { value: "Hi" })); 1082 + // Only casual DID is fetched for Greeting — fancy was filtered out 1083 + assertLog(log, [ 1084 + `lexicon ${Root}`, 1085 + `fetch at://${CASUAL_DID}/at.inlay.component/${Greeting}`, 1086 + `lexicon ${Greeting}`, 1087 + `fetch at://${HOST_DID}/at.inlay.component/${Text}`, 1088 + `lexicon ${Text}`, 1089 + ]); 1090 + }); 1091 + 1092 + it("can reroute a nested type without affecting its parent", async () => { 1093 + // Root's template renders Greeting, whose template renders Text. 1094 + // resolveImports only redirects Text to a different DID — Greeting 1095 + // still resolves normally through casual, but Text picks up the 1096 + // alternate DID's implementation (a bold primitive instead of span). 1097 + const ALT_DID = "did:plc:alt"; 1098 + const Bold = "test.host.Bold" as const; 1099 + 1100 + const altText: ComponentRecord = { 1101 + $type: "at.inlay.component", 1102 + body: { 1103 + $type: "at.inlay.component#bodyTemplate", 1104 + node: serializeTree($(Bold, { value: "Hi" })), 1105 + }, 1106 + imports: [ALT_DID], 1107 + }; 1108 + 1109 + const rootComponent: ComponentRecord = { 1110 + $type: "at.inlay.component", 1111 + body: { 1112 + $type: "at.inlay.component#bodyTemplate", 1113 + node: serializeTree($(Greeting, {})), 1114 + }, 1115 + imports: [CASUAL_DID], 1116 + }; 1117 + 1118 + const { options } = testResolver({ 1119 + ...HOST_RECORDS, 1120 + [`at://${CASUAL_DID}/at.inlay.component/${Greeting}`]: casualGreeting, 1121 + // Alt DID provides a different Text that wraps in Bold 1122 + [`at://${ALT_DID}/at.inlay.component/${Text}`]: altText, 1123 + [`at://${ALT_DID}/at.inlay.component/${Bold}`]: { 1124 + $type: "at.inlay.component", 1125 + }, 1126 + }); 1127 + 1128 + options.resolveImports = (imports, nsid) => { 1129 + // Only redirect Text resolution to alt DID 1130 + if (nsid === Text) return [ALT_DID] as DidString[]; 1131 + return imports; 1132 + }; 1133 + 1134 + const output = await renderToCompletion( 1135 + $(Root, {}), 1136 + options, 1137 + createContext( 1138 + rootComponent, 1139 + `at://${APP_DID}/at.inlay.component/${Root}` 1140 + ), 1141 + { [Bold]: async (el) => h("b", { ...el.props, key: el.key }) } 1142 + ); 1143 + 1144 + // Greeting resolved normally (casual → "Hi"), but its inner Text 1145 + // was rerouted to alt DID which wraps in Bold instead of span. 1146 + assert.deepEqual(output, h("b", { value: "Hi" })); 1147 + }); 1148 + 1149 + it("defaults to unmodified imports when not provided", async () => { 1150 + // Same setup as "first DID wins" — fancy is first and wins. 1151 + const rootComponent: ComponentRecord = { 1152 + $type: "at.inlay.component", 1153 + body: { 1154 + $type: "at.inlay.component#bodyTemplate", 1155 + node: serializeTree($(Greeting, {})), 1156 + }, 1157 + imports: [FANCY_DID, CASUAL_DID], 1158 + }; 1159 + 1160 + const { options } = testResolver({ 1161 + ...HOST_RECORDS, 1162 + [`at://${FANCY_DID}/at.inlay.component/${Greeting}`]: fancyGreeting, 1163 + [`at://${CASUAL_DID}/at.inlay.component/${Greeting}`]: casualGreeting, 1164 + }); 1165 + 1166 + // No resolveImports set — default behavior 1167 + const output = await renderToCompletion( 1168 + $(Root, {}), 1169 + options, 1170 + createContext(rootComponent, `at://${APP_DID}/at.inlay.component/${Root}`) 1171 + ); 1172 + 1173 + assert.deepEqual(output, h("span", { value: "Greetings, friend!" })); 1174 + }); 1175 + }); 1176 + 1177 + // ============================================================================ 1020 1178 // 4. Import isolation — each component has its own package.json 1021 1179 // ============================================================================ 1022 1180 // ··· 2736 2894 assertError(bad, "Input/uri must be a string", ` at ${Card}`); 2737 2895 }); 2738 2896 2739 - it("synthesizes validation from viewPrimitive", async () => { 2897 + it("viewPrimitive does not synthesize runtime validation", async () => { 2898 + // viewPrimitive describes what data types a component can display 2899 + // (for editor toolbox matching) but should NOT constrain prop types 2900 + // at runtime — a component accepting strings can also receive other 2901 + // values via JSX children or programmatic usage. 2740 2902 const timestampComponent: ComponentRecord = { 2741 2903 $type: "at.inlay.component", 2742 2904 body: { ··· 2763 2925 `at://${APP_DID}/at.inlay.component/${Timestamp}` 2764 2926 ); 2765 2927 2928 + // String value works 2766 2929 const ok = await renderToCompletion( 2767 2930 $(Timestamp, { value: "2026-01-01T00:00:00Z" }), 2768 2931 options, ··· 2770 2933 ); 2771 2934 assert.deepEqual(ok, h("span", {})); 2772 2935 2773 - const bad = await renderToCompletion( 2936 + // Non-string value also passes (no synthesized validation) 2937 + const alsoOk = await renderToCompletion( 2774 2938 $(Timestamp, { value: 123 as unknown as string }), 2775 2939 options, 2776 2940 ctx 2777 2941 ); 2778 - assertError(bad, "Input/value must be a string", ` at ${Timestamp}`); 2942 + assert.deepEqual(alsoOk, h("span", {})); 2779 2943 }); 2780 2944 2781 2945 it("validates collection constraints from viewRecord", async () => {