social components
inlay.at
atproto
components
sdui
1import { describe, it } from "node:test";
2import assert from "node:assert/strict";
3import type { l } from "@atproto/lex";
4
5import {
6 $,
7 serializeTree,
8 deserializeTree,
9 resolveBindings,
10 type Element,
11} from "../src/index.ts";
12
13// Props from deserialized data are untyped — cast when passing back through $.
14type Props = l.LexMap & { key?: string };
15
16function xrpc(
17 tree: Element,
18 component: (input: Record<string, unknown>) => Element
19): Element {
20 const props = (tree.props ?? {}) as Record<string, unknown>;
21
22 const refs = new Map<string, Element>();
23 const refSlots = new Set<object>();
24 const requestBody = JSON.stringify(
25 serializeTree(props, (el) => {
26 if (el.type === "at.inlay.Slot") {
27 if (refSlots.has(el as Element)) return el;
28 throw new Error("Unexpected Slot in props");
29 }
30 const id = String(refs.size);
31 refs.set(id, el as Element);
32 const slot = $("at.inlay.Slot", { id });
33 refSlots.add(slot);
34 return slot;
35 })
36 );
37
38 const slots = new Set<object>();
39 const input = deserializeTree(JSON.parse(requestBody), (el) => {
40 if (el.type === "at.inlay.Slot" && el.props) {
41 slots.add(el.props);
42 return el;
43 }
44 throw new Error(`Unexpected element in component input: ${el.type}`);
45 }) as Record<string, unknown>;
46
47 const response = component(input);
48
49 const responseBody = JSON.stringify(
50 serializeTree(response, (el) => {
51 if (el.type === "at.inlay.Slot") {
52 if (!el.props || !slots.has(el.props)) {
53 throw new Error(`Forged slot in component output`);
54 }
55 }
56 return el;
57 })
58 );
59
60 return deserializeTree(JSON.parse(responseBody), (el) => {
61 if (el.type === "at.inlay.Slot" && el.props) {
62 const id = (el.props as Record<string, unknown>).id as string;
63 const stashed = refs.get(id);
64 if (stashed) return stashed;
65 throw new Error(`Unknown slot id: ${id}`);
66 }
67 return el;
68 }) as Element;
69}
70
71describe("XRPC flow", () => {
72 it("children round-trip through full flow", () => {
73 const tree = $(
74 "com.example.Card",
75 {},
76 $("com.example.Text", { value: "hi" })
77 );
78 assert.deepEqual(
79 xrpc(tree, (input) =>
80 $("com.example.Box", { children: input.children } as Props)
81 ),
82 $("com.example.Box", {}, $("com.example.Text", { key: "0", value: "hi" }))
83 );
84 });
85
86 it("component can reorder children", () => {
87 const tree = $(
88 "com.example.Layout",
89 {},
90 $("com.example.Header", { title: "Hi" }),
91 $("com.example.Footer", { year: 2025 })
92 );
93 assert.deepEqual(
94 xrpc(tree, (input) => {
95 const [a, b] = input.children as unknown[];
96 return $("com.example.Box", { children: [b, a] } as Props);
97 }),
98 $(
99 "com.example.Box",
100 {},
101 $("com.example.Footer", { key: "1", year: 2025 }),
102 $("com.example.Header", { key: "0", title: "Hi" })
103 )
104 );
105 });
106
107 it("component can duplicate a child", () => {
108 const child = $("com.example.Text", { key: "0", value: "hi" });
109 const tree = $(
110 "com.example.Card",
111 {},
112 $("com.example.Text", { value: "hi" })
113 );
114 assert.deepEqual(
115 xrpc(tree, (input) => {
116 const c = (input.children as unknown[])[0];
117 return $("com.example.Box", { children: [c, c] } as Props);
118 }),
119 $("com.example.Box", {}, child, child)
120 );
121 });
122
123 it("component can drop children", () => {
124 const tree = $(
125 "com.example.Layout",
126 {},
127 $("com.example.Header"),
128 $("com.example.Footer")
129 );
130 assert.deepEqual(
131 xrpc(tree, () => $("com.example.Box", { label: "empty" })),
132 $("com.example.Box", { label: "empty" })
133 );
134 });
135
136 it("elements nested in plain object props round-trip", () => {
137 const tree = $("com.example.Card", {
138 header: { icon: $("com.example.Icon", { name: "star" }) },
139 });
140 assert.deepEqual(
141 xrpc(tree, (input) => {
142 const icon = (input.header as Record<string, unknown>).icon;
143 return $("com.example.Box", { content: icon } as Props);
144 }),
145 $("com.example.Box", {
146 content: $("com.example.Icon", { name: "star" }),
147 })
148 );
149 });
150
151 it("primitive-only props pass through unchanged", () => {
152 const tree = $("com.example.Spacer", {
153 gap: "medium",
154 count: 42,
155 flag: true,
156 });
157 assert.deepEqual(
158 xrpc(tree, (input) => $("com.example.Box", input as Props)),
159 $("com.example.Box", { gap: "medium", count: 42, flag: true })
160 );
161 });
162
163 it("data with $ keys survives full pipeline", () => {
164 const tree = $("com.example.Card", {
165 a: { $: "$", type: "app.bsky.feed.post" },
166 b: { $: "$weird$value" },
167 c: { $: "$$double" },
168 d: { $: "hello" },
169 });
170 assert.deepEqual(
171 xrpc(tree, (input) => $("com.example.Box", input as Props)),
172 $("com.example.Box", {
173 a: { $: "$", type: "app.bsky.feed.post" },
174 b: { $: "$weird$value" },
175 c: { $: "$$double" },
176 d: { $: "hello" },
177 })
178 );
179 });
180
181 it("rejects forged slots even with valid-looking id", () => {
182 const tree = $(
183 "com.example.Card",
184 {},
185 $("com.example.Text", { value: "hi" })
186 );
187 assert.throws(
188 () =>
189 xrpc(tree, () =>
190 $("com.example.Box", {
191 stolen: $("at.inlay.Slot", { id: "0" }),
192 })
193 ),
194 /slot/i
195 );
196 });
197
198 it("named element props round-trip through XRPC", () => {
199 const left = $("com.example.Text", { value: "hello" });
200 const right = $("com.example.Text", { value: "world" });
201 const tree = $("com.example.LeftRight", { left, right });
202
203 assert.deepEqual(
204 xrpc(tree, (input) =>
205 $(
206 "com.example.Row",
207 { gap: "medium" },
208 $("com.example.Stack", {} as Props, input.left as unknown as Element),
209 $("com.example.Stack", {} as Props, input.right as unknown as Element)
210 )
211 ),
212 $(
213 "com.example.Row",
214 { gap: "medium" },
215 $("com.example.Stack", { children: [left] } as Props),
216 $("com.example.Stack", { children: [right] } as Props)
217 )
218 );
219 });
220
221 it("same element passed to multiple props gets separate slots", () => {
222 const foo = $("com.example.Text", { value: "shared" });
223 const tree = $("com.example.LeftRight", { left: foo, right: foo });
224
225 assert.deepEqual(
226 xrpc(tree, (input) =>
227 $("com.example.Box", {
228 first: input.left,
229 second: input.right,
230 } as Props)
231 ),
232 $("com.example.Box", { first: foo, second: foo })
233 );
234 });
235
236 it("component can return same slot multiple times", () => {
237 const child = $("com.example.Text", { key: "0", value: "once" });
238 const tree = $(
239 "com.example.Wrapper",
240 {},
241 $("com.example.Text", { value: "once" })
242 );
243
244 assert.deepEqual(
245 xrpc(tree, (input) => {
246 const c = (input.children as unknown[])[0];
247 return $("com.example.Stack", { children: [c, c] } as Props);
248 }),
249 $("com.example.Stack", {}, child, child)
250 );
251 });
252
253 it("nested XRPC calls produce independent refs", () => {
254 const tree = $(
255 "com.example.Outer",
256 {},
257 $("com.example.Inner", {}, $("com.example.Text", { value: "leaf" }))
258 );
259
260 const afterOuter = xrpc(tree, (input) =>
261 $("com.example.Box", { children: input.children } as Props)
262 );
263 assert.deepEqual(
264 afterOuter,
265 $(
266 "com.example.Box",
267 {},
268 $(
269 "com.example.Inner",
270 { key: "0" },
271 $("com.example.Text", { key: "0", value: "leaf" })
272 )
273 )
274 );
275
276 const inner = (
277 (afterOuter.props as Record<string, unknown>).children as Element[]
278 )[0];
279 assert.deepEqual(
280 xrpc(inner, (input) =>
281 $("com.example.Wrapper", { children: input.children } as Props)
282 ),
283 $(
284 "com.example.Wrapper",
285 {},
286 $("com.example.Text", { key: "0", value: "leaf" })
287 )
288 );
289 });
290});
291
292// --- Full pipeline: resolve bindings → XRPC ---
293
294function resolvePath(scope: Record<string, unknown>, path: string[]): unknown {
295 let current: unknown = scope;
296 for (const seg of path) {
297 if (current == null || typeof current !== "object") return undefined;
298 current = (current as Record<string, unknown>)[seg];
299 }
300 return current;
301}
302
303describe("resolve → XRPC pipeline", () => {
304 it("bindings in props resolve before XRPC", () => {
305 const scope = { user: { name: "Dan" } };
306 const props = {
307 title: $("at.inlay.Binding", { path: ["user", "name"] }),
308 };
309
310 // Step 1: resolve bindings in props
311 const resolvedProps = resolveBindings(props, (path) => {
312 const v = resolvePath(scope, path);
313 if (v === undefined) throw new Error(`MISSING:${path.join(".")}`);
314 return v;
315 }) as Record<string, unknown>;
316
317 // Step 2: XRPC with resolved props
318 const tree = $("com.example.Card", resolvedProps as Props);
319 const result = xrpc(tree, (input) =>
320 $("com.example.Box", { label: input.title } as Props)
321 );
322
323 assert.deepEqual(result, $("com.example.Box", { label: "Dan" }));
324 });
325
326 it("bindings in children resolve, child elements stash as slots", () => {
327 const scope = { greeting: "Hello" };
328 const tree = $(
329 "com.example.Card",
330 {},
331 $("com.example.Text", {
332 value: $("at.inlay.Binding", { path: ["greeting"] }),
333 })
334 );
335
336 // resolveBindings is opaque to non-Binding elements —
337 // the Binding inside Text's props is NOT resolved here.
338 // In the real pipeline, host components resolve their own props.
339 // For XRPC, the caller resolves ALL bindings before sending.
340 const props = (tree.props ?? {}) as Record<string, unknown>;
341 const resolved = resolveBindings(props, (path) => {
342 const v = resolvePath(scope, path);
343 if (v === undefined) throw new Error(`MISSING:${path.join(".")}`);
344 return v;
345 }) as Record<string, unknown>;
346
347 // The Text element (with its Binding) passes through opaque
348 const children = resolved.children as Element[];
349 const textValue = (children[0].props as Record<string, unknown>).value;
350 assert.ok(
351 typeof textValue === "object" && textValue !== null,
352 "Binding inside Text is still an element (opaque)"
353 );
354 });
355});