social components inlay.at
atproto components sdui
86
fork

Configure Feed

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

use owner stack

+208 -32
+29 -27
packages/@inlay/render/src/index.ts
··· 274 274 validate: boolean 275 275 ): Promise<RenderResult> { 276 276 const depth = ctx.depth ?? 0; 277 - const stack = [element.type, ...(ctx.stack ?? [])]; 278 - 279 - // Primitive: no body, return element with resolved props 280 - if (!component.body) { 281 - return { 282 - resolved: true, 283 - node: $(element.type, { ...props, key: element.key }), 284 - context: { 285 - imports: component.imports?.length ? component.imports : ctx.imports, 286 - depth, 287 - scope: ctx.scope, 288 - stack, 289 - }, 290 - }; 291 - } 277 + const type = element.type; 292 278 293 - const type = element.type; 294 279 let resolvedProps = props; 295 280 if (component.view) { 296 - resolvedProps = expandBareDid(props, component.view); 281 + resolvedProps = expandBareDid(resolvedProps, component.view); 297 282 } 298 283 299 284 if (validate) { ··· 305 290 ); 306 291 } 307 292 308 - const childCtx = { ...ctx, stack }; 293 + // Primitive: no body, return element with resolved props 294 + if (!component.body) { 295 + return { 296 + resolved: true, 297 + node: $(type, { ...resolvedProps, key: element.key }), 298 + context: { 299 + imports: component.imports?.length ? component.imports : ctx.imports, 300 + depth, 301 + scope: ctx.scope, 302 + stack: ctx.stack, 303 + }, 304 + }; 305 + } 309 306 310 307 if (component.body.$type === "at.inlay.component#bodyTemplate") { 311 - return renderTemplate(resolver, component, resolvedProps, childCtx); 308 + return renderTemplate(resolver, component, type, resolvedProps, ctx); 312 309 } 313 310 314 311 if (component.body.$type === "at.inlay.component#bodyExternal") { ··· 318 315 component, 319 316 componentUri, 320 317 resolvedProps, 321 - childCtx 318 + ctx 322 319 ); 323 320 } 324 321 ··· 367 364 async function renderTemplate( 368 365 resolver: Resolver, 369 366 component: ComponentRecord, 367 + type: string, 370 368 props: Record<string, unknown>, 371 369 ctx: RenderContext 372 370 ): Promise<RenderResult> { 373 371 const body = component.body as { $type: string; node: unknown }; 374 372 const depth = ctx.depth ?? 0; 373 + // Push this component onto the owner stack — it creates child elements. 374 + const stack = [type, ...(ctx.stack ?? [])]; 375 375 376 376 // Eagerly prefetch child packs so they overlap with record fetch 377 377 const childImports = component.imports ?? []; ··· 383 383 384 384 // Replace caller-provided elements with Slots so they resolve through the 385 385 // caller's imports, not the component's — same isolation as external slots. 386 - const callerStack = ctx.stack?.slice(1); 386 + // Slot elements get the caller's context (ctx.stack, before we pushed). 387 387 const callerCtx: RenderContext = { 388 388 imports: ctx.imports, 389 389 depth, 390 390 scope: ctx.scope, 391 - stack: callerStack, 391 + stack: ctx.stack, 392 392 }; 393 393 const slottedProps = walkTree(props, (obj, walk) => { 394 394 if (isValidElement(obj)) { ··· 442 442 imports: component.imports ?? [], 443 443 depth: depth + 1, 444 444 scope, 445 - stack: ctx.stack, 445 + stack, 446 446 }, 447 447 cache, 448 448 }; ··· 504 504 ): Promise<RenderResult> { 505 505 const body = component.body as { $type: string; did: string }; 506 506 const depth = ctx.depth ?? 0; 507 + // Push this component onto the owner stack — it creates child elements. 508 + const stack = [type, ...(ctx.stack ?? [])]; 507 509 508 510 // Replace child elements with Slot references for wire transport 509 511 const refs = new Map<string, unknown>(); ··· 530 532 componentUri, 531 533 })) as { node: unknown; cache?: CachePolicy }; 532 534 533 - // Restore slots — register caller context in the WeakMap 534 - const callerStack = ctx.stack?.slice(1); 535 + // Restore slots — register caller context in the WeakMap. 536 + // Slot elements get the caller's context (ctx.stack, before we pushed). 535 537 const callerCtx: RenderContext = { 536 538 imports: ctx.imports, 537 539 depth, 538 540 scope: ctx.scope, 539 - stack: callerStack, 541 + stack: ctx.stack, 540 542 }; 541 543 542 544 const node = deserializeTree(response.node, (el) => { ··· 567 569 context: { 568 570 imports: component.imports ?? [], 569 571 depth: depth + 1, 570 - stack: ctx.stack, 572 + stack, 571 573 }, 572 574 cache: response.cache, 573 575 };
+179 -5
packages/@inlay/render/test/render.test.ts
··· 498 498 `lexicon ${Greeting}`, 499 499 `fetch ${HOST_PACK_URI}`, 500 500 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 501 + `lexicon ${Text}`, 501 502 ]); 502 503 }); 503 504 ··· 590 591 `lexicon ${Greeting}`, 591 592 `fetch ${HOST_PACK_URI}`, 592 593 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 594 + `lexicon ${Text}`, 593 595 ]); 594 596 }); 595 597 ··· 810 812 `fetch:end ${HOST_PACK_URI}`, 811 813 `fetch:end ${"at://did:plc:alice/app.bsky.feed.post/123"}`, 812 814 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 815 + `lexicon ${Text}`, 813 816 ]); 814 817 }); 815 818 ··· 862 865 `lexicon ${PostCard}`, 863 866 `fetch ${HOST_PACK_URI}`, 864 867 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 868 + `lexicon ${Text}`, 865 869 ]); 866 870 }); 867 871 ··· 928 932 `fetch:end ${HOST_PACK_URI}`, 929 933 `fetch:end ${"at://did:plc:alice/app.bsky.feed.post/123"}`, 930 934 `fetch ${"at://did:plc:host/at.inlay.component/stck"}`, 935 + `lexicon ${Stack}`, 931 936 `fetch ${"at://did:plc:host/at.inlay.component/lnk"}`, 937 + `lexicon ${Link}`, 932 938 ]); 933 939 }); 934 940 }); ··· 1027 1033 `lexicon ${Greeting}`, 1028 1034 `fetch ${HOST_PACK_URI}`, 1029 1035 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 1036 + `lexicon ${Text}`, 1030 1037 ]); 1031 1038 }); 1032 1039 ··· 1084 1091 `lexicon ${Greeting}`, 1085 1092 `fetch ${HOST_PACK_URI}`, 1086 1093 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 1094 + `lexicon ${Text}`, 1087 1095 ]); 1088 1096 }); 1089 1097 ··· 1584 1592 `xrpc procedure ${Greeting} -> ${SERVICE_DID}`, 1585 1593 `fetch ${HOST_PACK_URI}`, 1586 1594 `fetch ${"at://did:plc:host/at.inlay.component/rw"}`, 1595 + `lexicon ${Row}`, 1587 1596 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 1597 + `lexicon ${Text}`, 1598 + `lexicon ${Text}`, 1588 1599 ]); 1589 1600 }); 1590 1601 ··· 1914 1925 ); 1915 1926 }); 1916 1927 1928 + it("error stack is owner stack, not parent stack", async () => { 1929 + // Page renders <Card><Greeting /></Card>. Greeting fails. 1930 + // Stack is [Greeting, Page] — Card is not the owner of Greeting. 1931 + const pageComponent: ComponentRecord = { 1932 + $type: "at.inlay.component", 1933 + type: Page, 1934 + body: { 1935 + $type: "at.inlay.component#bodyTemplate", 1936 + node: serializeTree($(Card, { children: $(Greeting, {}) })), 1937 + }, 1938 + imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[], 1939 + }; 1940 + 1941 + const cardComponent: ComponentRecord = { 1942 + $type: "at.inlay.component", 1943 + type: Card, 1944 + body: { 1945 + $type: "at.inlay.component#bodyTemplate", 1946 + node: serializeTree( 1947 + $(Stack, { children: $(Binding, { path: ["props", "children"] }) }) 1948 + ), 1949 + }, 1950 + imports: [HOST_PACK_URI], 1951 + }; 1952 + 1953 + const greetingComponent: ComponentRecord = { 1954 + $type: "at.inlay.component", 1955 + type: Greeting, 1956 + body: { 1957 + $type: "at.inlay.component#bodyTemplate", 1958 + node: serializeTree($("test.app.DoesNotExist", {})), 1959 + }, 1960 + imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[], 1961 + }; 1962 + 1963 + const { options } = testResolver({ 1964 + ...HOST_RECORDS, 1965 + ["at://did:plc:test/at.inlay.component/page"]: pageComponent, 1966 + ["at://did:plc:test/at.inlay.component/card"]: cardComponent, 1967 + ["at://did:plc:test/at.inlay.component/greet"]: greetingComponent, 1968 + ["at://did:plc:test/at.inlay.pack/app"]: { 1969 + $type: "at.inlay.pack", 1970 + name: "app", 1971 + exports: [ 1972 + { 1973 + type: Page, 1974 + component: "at://did:plc:test/at.inlay.component/page", 1975 + }, 1976 + { 1977 + type: Card, 1978 + component: "at://did:plc:test/at.inlay.component/card", 1979 + }, 1980 + { 1981 + type: Greeting, 1982 + component: "at://did:plc:test/at.inlay.component/greet", 1983 + }, 1984 + ], 1985 + }, 1986 + }); 1987 + 1988 + const output = await renderToCompletion( 1989 + $(Page, {}), 1990 + options, 1991 + createContext(pageComponent) 1992 + ); 1993 + 1994 + assertError( 1995 + output, 1996 + "No pack exports type: test.app.DoesNotExist", 1997 + [ 1998 + " at test.app.DoesNotExist", 1999 + " at test.app.Greeting", 2000 + " at test.app.Page", 2001 + ].join("\n") 2002 + ); 2003 + }); 2004 + 1917 2005 it("infinite recursion stack shows the repeated component", async () => { 1918 2006 const loopComponent: ComponentRecord = { 1919 2007 $type: "at.inlay.component", ··· 2137 2225 `fetch:start ${"at://did:plc:host/at.inlay.component/txt"}`, 2138 2226 `fetch:end ${"at://did:plc:host/at.inlay.component/stck"}`, 2139 2227 `fetch:end ${"at://did:plc:host/at.inlay.component/txt"}`, 2228 + `lexicon ${Stack}`, 2229 + `lexicon ${Text}`, 2140 2230 ]); 2141 2231 }); 2142 2232 ··· 2205 2295 `fetch:end ${"at://did:plc:test/at.inlay.pack/child"}`, 2206 2296 `fetch:end ${"at://did:plc:alice/app.bsky.feed.post/123"}`, 2207 2297 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 2298 + `lexicon ${Text}`, 2208 2299 ]); 2209 2300 }); 2210 2301 ··· 2284 2375 `xrpc:end procedure ${Card} -> ${SERVICE_DID}`, 2285 2376 `fetch ${HOST_PACK_URI}`, 2286 2377 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 2378 + `lexicon ${Text}`, 2379 + `lexicon ${Text}`, 2287 2380 ]); 2288 2381 }); 2289 2382 }); ··· 2408 2501 `fetch:end ${HOST_PACK_URI}`, 2409 2502 `fetch:end ${postUri}`, 2410 2503 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 2504 + `lexicon ${Text}`, 2411 2505 ]); 2412 2506 2413 2507 // Request 2 — warm, no fetches ··· 2418 2512 ctx 2419 2513 ); 2420 2514 assert.deepEqual(out2, h("span", { value: "Hello" })); 2421 - assertLog(r2.log, [`lexicon ${PostCard}`]); 2515 + assertLog(r2.log, [`lexicon ${PostCard}`, `lexicon ${Text}`]); 2422 2516 2423 2517 // Firehose: post updated → cache invalidated → re-fetched → output changes 2424 2518 cache.onFirehoseUpdate({ [postUri]: { text: "Updated" } }); ··· 2429 2523 ctx 2430 2524 ); 2431 2525 assert.deepEqual(out3, h("span", { value: "Updated" })); 2432 - assertLog(r3.log, [`lexicon ${PostCard}`, `fetch ${postUri}`]); 2526 + assertLog(r3.log, [ 2527 + `lexicon ${PostCard}`, 2528 + `fetch ${postUri}`, 2529 + `lexicon ${Text}`, 2530 + ]); 2433 2531 }); 2434 2532 2435 2533 it("external xrpc call is cached across requests", async () => { ··· 2474 2572 `xrpc procedure ${Greeting} -> ${SERVICE_DID}`, 2475 2573 `fetch ${HOST_PACK_URI}`, 2476 2574 `fetch ${"at://did:plc:host/at.inlay.component/txt"}`, 2575 + `lexicon ${Text}`, 2477 2576 ]); 2478 2577 2479 2578 // Request 2 — warm, xrpc cached, no calls 2480 2579 const r2 = cache.request(); 2481 2580 const out2 = await renderToCompletion($(Greeting, {}), r2.options, ctx); 2482 2581 assert.deepEqual(out2, out1); 2483 - assertLog(r2.log, [`lexicon ${Greeting}`]); 2582 + assertLog(r2.log, [`lexicon ${Greeting}`, `lexicon ${Text}`]); 2484 2583 2485 2584 // Firehose: profile updated → xrpc cache invalidated → re-called → new greeting 2486 2585 cache.onFirehoseUpdate({ [profileUri]: { displayName: "Bob" } }); ··· 2490 2589 assertLog(r3.log, [ 2491 2590 `lexicon ${Greeting}`, 2492 2591 `xrpc procedure ${Greeting} -> ${SERVICE_DID}`, 2592 + `lexicon ${Text}`, 2493 2593 ]); 2494 2594 2495 2595 // Firehose: component record itself updated → xrpc cache invalidated → re-called ··· 2505 2605 assertLog(r4.log, [ 2506 2606 `lexicon ${Greeting}`, 2507 2607 `xrpc procedure ${Greeting} -> ${SERVICE_DID}`, 2608 + `lexicon ${Text}`, 2508 2609 ]); 2509 2610 2510 2611 // Request 5 — warm, xrpc cached, no calls 2511 2612 const r5 = cache.request(); 2512 2613 const out5 = await renderToCompletion($(Greeting, {}), r5.options, ctx); 2513 2614 assert.deepEqual(out5, out4); 2514 - assertLog(r5.log, [`lexicon ${Greeting}`]); 2615 + assertLog(r5.log, [`lexicon ${Greeting}`, `lexicon ${Text}`]); 2515 2616 }); 2516 2617 2517 2618 it("cached xrpc survives while caller children re-render", async () => { ··· 2610 2711 `fetch:end ${HOST_PACK_URI}`, 2611 2712 `fetch:start ${"at://did:plc:host/at.inlay.component/stck"}`, 2612 2713 `fetch:end ${"at://did:plc:host/at.inlay.component/stck"}`, 2714 + `lexicon ${Stack}`, 2613 2715 `fetch:start ${"at://did:plc:host/at.inlay.component/txt"}`, 2614 2716 `fetch:start ${appPackUri}`, 2615 2717 `fetch:end ${"at://did:plc:host/at.inlay.component/txt"}`, 2616 2718 `fetch:end ${appPackUri}`, 2617 2719 `fetch:start ${"at://did:plc:test/at.inlay.component/pstcrd"}`, 2720 + `lexicon ${Text}`, 2618 2721 `fetch:end ${"at://did:plc:test/at.inlay.component/pstcrd"}`, 2619 2722 `lexicon ${PostCard}`, 2620 2723 `fetch ${postUri}`, 2724 + `lexicon ${Text}`, 2621 2725 ]); 2622 2726 2623 2727 // Request 2 — warm: everything cached ··· 2628 2732 ctx 2629 2733 ); 2630 2734 assert.deepEqual(out2, expectedLayout("Hello")); 2631 - assertLog(r2.log, [`lexicon ${Layout}`, `lexicon ${PostCard}`]); 2735 + assertLog(r2.log, [ 2736 + `lexicon ${Layout}`, 2737 + `lexicon ${Stack}`, 2738 + `lexicon ${Text}`, 2739 + `lexicon ${PostCard}`, 2740 + `lexicon ${Text}`, 2741 + ]); 2632 2742 2633 2743 // Firehose: post updated → post cache invalidated, 2634 2744 // Layout xrpc tagged with layoutUri → stays cached, child re-fetches ··· 2642 2752 assert.deepEqual(out3, expectedLayout("Updated")); 2643 2753 assertLog(r3.log, [ 2644 2754 `lexicon ${Layout}`, 2755 + `lexicon ${Stack}`, 2756 + `lexicon ${Text}`, 2645 2757 `lexicon ${PostCard}`, 2646 2758 `fetch ${postUri}`, 2759 + `lexicon ${Text}`, 2647 2760 ]); 2648 2761 2649 2762 // Firehose: component record itself updated → xrpc cache invalidated → re-called ··· 2663 2776 assertLog(r4.log, [ 2664 2777 `lexicon ${Layout}`, 2665 2778 `xrpc procedure ${Layout} -> ${SERVICE_DID}`, 2779 + `lexicon ${Stack}`, 2780 + `lexicon ${Text}`, 2666 2781 `lexicon ${PostCard}`, 2782 + `lexicon ${Text}`, 2667 2783 ]); 2668 2784 }); 2669 2785 }); ··· 3023 3139 bad, 3024 3140 `${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like`, 3025 3141 ` at ${Card}` 3142 + ); 3143 + }); 3144 + 3145 + it("validates builtin (no body) props against lexicon", async () => { 3146 + const pageComponent: ComponentRecord = { 3147 + $type: "at.inlay.component", 3148 + type: Page, 3149 + body: { 3150 + $type: "at.inlay.component#bodyTemplate", 3151 + node: serializeTree($(Text, {})), 3152 + }, 3153 + imports: [HOST_PACK_URI], 3154 + }; 3155 + 3156 + const { resolver } = world({ 3157 + ["at://did:plc:test/at.inlay.component/page"]: pageComponent, 3158 + ["at://did:plc:test/at.inlay.pack/app"]: { 3159 + $type: "at.inlay.pack", 3160 + name: "app", 3161 + exports: [ 3162 + { 3163 + type: Page, 3164 + component: "at://did:plc:test/at.inlay.component/page", 3165 + }, 3166 + ], 3167 + }, 3168 + }); 3169 + 3170 + resolver.resolveLexicon = async (nsid: string) => { 3171 + if (nsid === Text) { 3172 + return { 3173 + lexicon: 1, 3174 + id: Text, 3175 + defs: { 3176 + main: { 3177 + type: "procedure", 3178 + input: { 3179 + encoding: "application/json", 3180 + schema: { 3181 + type: "object", 3182 + required: ["value"], 3183 + properties: { value: { type: "string" } }, 3184 + }, 3185 + }, 3186 + }, 3187 + }, 3188 + }; 3189 + } 3190 + return null; 3191 + }; 3192 + 3193 + const ctx = createContext(pageComponent); 3194 + const bad = await renderToCompletion($(Page, {}), { resolver }, ctx); 3195 + 3196 + assertError( 3197 + bad, 3198 + 'Input must have the property "value"', 3199 + ` at ${Text}\n at ${Page}` 3026 3200 ); 3027 3201 }); 3028 3202 });