social components inlay-proto.up.railway.app/
atproto components sdui

add stacks

+254 -66
+1 -1
packages/@inlay/render/README.md
··· 173 173 ### Types 174 174 175 175 - **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }` 176 - - **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope? }` 176 + - **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope?, stack? }` 177 177 - **`RenderResult`** — `{ resolved, node, context, cache? }` 178 178 - **`RenderOptions`** — `{ resolver, maxDepth?, validate? }` 179 179 - **`ComponentRecord`** — re-exported from generated lexicon defs
+51 -19
packages/@inlay/render/src/index.ts
··· 63 63 * component boundary to prevent infinite recursion. 64 64 * - `scope`: template variable bindings. Bindings in child element props 65 65 * resolve against this at the next render boundary. 66 + * - `stack`: component NSID chain for error reporting. Most recent 67 + * component first — the "who rendered who" owner chain. 66 68 */ 67 69 export type RenderContext = { 68 70 imports: AtUriString[]; ··· 70 72 componentUri?: string; 71 73 depth?: number; 72 74 scope?: Record<string, unknown>; 75 + stack?: string[]; 73 76 }; 74 77 75 78 export type RenderResult = { ··· 115 118 options: RenderOptions 116 119 ): Promise<RenderResult> { 117 120 const { resolver } = options; 118 - const effective = slotContexts.get(element) ?? context; 119 - const depth = effective.depth ?? 0; 121 + const ctx = slotContexts.get(element) ?? context; 122 + const depth = ctx.depth ?? 0; 120 123 const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; 121 124 const validate = options.validate ?? true; 122 125 126 + let errorStack = ctx.stack; 127 + 123 128 try { 124 129 const type = element.type; 125 130 let props = (element.props ?? {}) as Record<string, unknown>; 126 131 127 132 // Resolve any Bindings left in props by the parent template 128 - if (effective.scope) { 129 - props = resolveBindings(props, scopeResolver(effective.scope)); 133 + if (ctx.scope) { 134 + props = resolveBindings(props, scopeResolver(ctx.scope)); 130 135 } 131 136 132 137 // Root render: use component directly 133 - if (effective.component) { 134 - if (type !== effective.component.type) { 138 + if (ctx.component) { 139 + if (type !== ctx.component.type) { 135 140 throw new Error( 136 - `render was given ${effective.component.type}, cannot render ${type}` 141 + `render was given ${ctx.component.type}, cannot render ${type}` 137 142 ); 138 143 } 144 + errorStack = [type, ...(ctx.stack ?? [])]; 139 145 return await renderComponent( 140 146 resolver, 141 - effective.component, 142 - effective.componentUri, 147 + ctx.component, 148 + ctx.componentUri, 143 149 element, 144 150 props, 145 - effective, 151 + ctx, 146 152 validate 147 153 ); 148 154 } ··· 160 166 return { 161 167 resolved: true, 162 168 node: $(type, { ...props, key: element.key }), 163 - context: { imports: effective.imports, scope: effective.scope }, 169 + context: { 170 + imports: ctx.imports, 171 + scope: ctx.scope, 172 + stack: ctx.stack, 173 + }, 164 174 }; 165 175 } 176 + 177 + // Past here we're rendering a component — include it in the owner stack. 178 + errorStack = [type, ...(ctx.stack ?? [])]; 166 179 167 180 if (depth >= maxDepth) { 168 181 throw Error("Component depth limit exceeded"); ··· 170 183 171 184 const { component, componentUri } = await resolveType( 172 185 type, 173 - effective.imports, 186 + ctx.imports, 174 187 resolver 175 188 ); 176 189 return await renderComponent( ··· 179 192 componentUri, 180 193 element, 181 194 props, 182 - effective, 195 + ctx, 183 196 validate 184 197 ); 185 198 } catch (e) { 186 199 if (e instanceof MissingError) throw e; 187 200 const err = e as Error; 188 201 const errProps: Record<string, unknown> = { message: err.message }; 189 - if (err.stack) errProps.stack = err.stack; 202 + if (errorStack && errorStack.length > 0) { 203 + errProps.stack = errorStack.map((nsid) => ` at ${nsid}`).join("\n"); 204 + } 190 205 return { 191 206 resolved: true, 192 207 node: $("at.inlay.Throw", errProps as Record<string, string>), 193 - context: { imports: effective.imports }, 208 + context: { imports: ctx.imports }, 194 209 }; 195 210 } 196 211 } ··· 259 274 validate: boolean 260 275 ): Promise<RenderResult> { 261 276 const depth = ctx.depth ?? 0; 277 + const stack = [element.type, ...(ctx.stack ?? [])]; 262 278 263 279 // Primitive: no body, return element with resolved props 264 280 if (!component.body) { ··· 269 285 imports: component.imports?.length ? component.imports : ctx.imports, 270 286 depth, 271 287 scope: ctx.scope, 288 + stack, 272 289 }, 273 290 }; 274 291 } ··· 287 304 resolver 288 305 ); 289 306 } 307 + 308 + const childCtx = { ...ctx, stack }; 290 309 291 310 if (component.body.$type === "at.inlay.component#bodyTemplate") { 292 - return renderTemplate(resolver, component, resolvedProps, ctx); 311 + return renderTemplate(resolver, component, resolvedProps, childCtx); 293 312 } 294 313 295 314 if (component.body.$type === "at.inlay.component#bodyExternal") { ··· 299 318 component, 300 319 componentUri, 301 320 resolvedProps, 302 - ctx 321 + childCtx 303 322 ); 304 323 } 305 324 ··· 364 383 365 384 // Replace caller-provided elements with Slots so they resolve through the 366 385 // caller's imports, not the component's — same isolation as external slots. 386 + const callerStack = ctx.stack?.slice(1); 367 387 const callerCtx: RenderContext = { 368 388 imports: ctx.imports, 369 389 depth, 370 390 scope: ctx.scope, 391 + stack: callerStack, 371 392 }; 372 393 const slottedProps = walkTree(props, (obj, walk) => { 373 394 if (isValidElement(obj)) { ··· 417 438 return { 418 439 resolved: false, 419 440 node, 420 - context: { imports: component.imports ?? [], depth: depth + 1, scope }, 441 + context: { 442 + imports: component.imports ?? [], 443 + depth: depth + 1, 444 + scope, 445 + stack: ctx.stack, 446 + }, 421 447 cache, 422 448 }; 423 449 } ··· 505 531 })) as { node: unknown; cache?: CachePolicy }; 506 532 507 533 // Restore slots — register caller context in the WeakMap 534 + const callerStack = ctx.stack?.slice(1); 508 535 const callerCtx: RenderContext = { 509 536 imports: ctx.imports, 510 537 depth, 511 538 scope: ctx.scope, 539 + stack: callerStack, 512 540 }; 513 541 514 542 const node = deserializeTree(response.node, (el) => { ··· 536 564 return { 537 565 resolved: false, 538 566 node, 539 - context: { imports: component.imports ?? [], depth: depth + 1 }, 567 + context: { 568 + imports: component.imports ?? [], 569 + depth: depth + 1, 570 + stack: ctx.stack, 571 + }, 540 572 cache: response.cache, 541 573 }; 542 574 }
+202 -46
packages/@inlay/render/test/render.test.ts
··· 187 187 children: await walk((el.props as Record<string, unknown>)?.children), 188 188 }), 189 189 [Throw]: async (el) => { 190 - throw new Error((el.props as Record<string, unknown>)?.message as string); 190 + const p = (el.props ?? {}) as Record<string, unknown>; 191 + const err = new Error(p.message as string); 192 + (err as any).inlayStack = p.stack; 193 + throw err; 191 194 }, 192 195 [Maybe]: async (el, walk) => { 193 196 const p = (el.props ?? {}) as Record<string, unknown>; ··· 222 225 extra?: Record<string, Primitive> 223 226 ): Promise<unknown> { 224 227 const primitives = extra ? { ...HOST_PRIMITIVES, ...extra } : HOST_PRIMITIVES; 225 - return walkNode(node, options, ctx, primitives); 228 + try { 229 + return await walkNode(node, options, ctx, primitives); 230 + } catch (e) { 231 + if (e instanceof MissingError) throw e; 232 + const err = e as Error & { inlayStack?: string }; 233 + return h("error", { message: err.message, stack: err.inlayStack }); 234 + } 226 235 } 227 236 228 237 async function walkNode( ··· 243 252 } = await render(node, ctx, options); 244 253 if (resolved && isValidElement(out)) { 245 254 const walk = (n: unknown) => walkNode(n, options, outCtx, primitives); 246 - try { 247 - return await primitives[out.type](out, walk, outCtx); 248 - } catch (e) { 249 - if (e instanceof MissingError) throw e; 250 - return `[Error: ${(e as Error).message}]`; 251 - } 255 + return await primitives[out.type](out, walk, outCtx); 252 256 } 253 257 return walkNode(out, options, outCtx, primitives); 254 258 } ··· 413 417 assert.deepEqual(actual, expanded); 414 418 } 415 419 416 - function assertError(actual: unknown, message: string) { 417 - assert.equal(actual, `[Error: ${message}]`); 420 + function assertError(actual: unknown, message: string, stack: string) { 421 + assert.ok(actual instanceof Output, "expected an Output node"); 422 + assert.equal(actual.tag, "error"); 423 + assert.equal(actual.attrs.message, message); 424 + assert.equal(actual.attrs.stack, stack); 418 425 } 419 426 420 427 // ============================================================================ ··· 1099 1106 options, 1100 1107 createContext(rootComponent) 1101 1108 ); 1102 - assertError(output, `No pack exports type: ${unknown}`); 1109 + assertError( 1110 + output, 1111 + `No pack exports type: ${unknown}`, 1112 + ` at ${unknown}\n at ${Root}` 1113 + ); 1103 1114 }); 1104 1115 }); 1105 1116 ··· 1310 1321 createContext(pageComponent) 1311 1322 ); 1312 1323 1313 - assertError(output, `No pack exports type: ${Card}`); 1324 + assertError( 1325 + output, 1326 + `No pack exports type: ${Card}`, 1327 + ` at ${Card}\n at ${Greeting}\n at ${Page}` 1328 + ); 1314 1329 }); 1315 1330 1316 1331 it("composed children resolve through caller, not callee", async () => { ··· 1795 1810 // 6. Error handling — Throw elements 1796 1811 // ============================================================================ 1797 1812 // 1798 - // Errors during render are caught and turned into at.inlay.Throw elements. 1813 + // Errors during render are caught and turned into at.inlay.Throw elements 1814 + // with a component stack trace showing the owner chain. 1815 + // 1799 1816 // For now, the seam where the error is displayed is controlled by the host. 1800 1817 // In the future, a Catch boundary may be added to let the user control this. 1801 1818 1802 1819 describe("error handling", () => { 1803 - it("infinite recursion is caught", async () => { 1820 + it("template chain shows owner stack", async () => { 1821 + // Page renders Card, Card renders Greeting. Greeting fails. 1822 + // The stack should read bottom-up: Greeting, Card, Page. 1823 + const pageComponent: ComponentRecord = { 1824 + $type: "at.inlay.component", 1825 + type: Page, 1826 + body: { 1827 + $type: "at.inlay.component#bodyTemplate", 1828 + node: serializeTree($(Card, {})), 1829 + }, 1830 + imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[], 1831 + }; 1832 + 1833 + const cardComponent: ComponentRecord = { 1834 + $type: "at.inlay.component", 1835 + type: Card, 1836 + body: { 1837 + $type: "at.inlay.component#bodyTemplate", 1838 + node: serializeTree($(Greeting, {})), 1839 + }, 1840 + imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[], 1841 + }; 1842 + 1843 + const greetingComponent: ComponentRecord = { 1844 + $type: "at.inlay.component", 1845 + type: Greeting, 1846 + body: { 1847 + $type: "at.inlay.component#bodyTemplate", 1848 + node: serializeTree($(Stack, {})), 1849 + }, 1850 + imports: [HOST_PACK_URI], 1851 + }; 1852 + 1853 + const { options } = testResolver({ 1854 + ...HOST_RECORDS, 1855 + ["at://did:plc:test/at.inlay.component/page"]: pageComponent, 1856 + ["at://did:plc:test/at.inlay.component/card"]: cardComponent, 1857 + ["at://did:plc:test/at.inlay.component/greet"]: greetingComponent, 1858 + ["at://did:plc:test/at.inlay.pack/app"]: { 1859 + $type: "at.inlay.pack", 1860 + name: "app", 1861 + exports: [ 1862 + { 1863 + type: Page, 1864 + component: "at://did:plc:test/at.inlay.component/page", 1865 + }, 1866 + { 1867 + type: Card, 1868 + component: "at://did:plc:test/at.inlay.component/card", 1869 + }, 1870 + { 1871 + type: Greeting, 1872 + component: "at://did:plc:test/at.inlay.component/greet", 1873 + }, 1874 + ], 1875 + }, 1876 + }); 1877 + 1878 + // Greeting's resolver fails — error should bubble with full owner chain. 1879 + const output = await renderToCompletion( 1880 + $(Page, {}), 1881 + { 1882 + ...options, 1883 + resolver: { 1884 + ...options.resolver, 1885 + fetchRecord: ((original) => async (uri: AtUriString) => { 1886 + const result = await original(uri); 1887 + if (result && (result as ComponentRecord).type === Greeting) { 1888 + // Return a component whose template references a missing type. 1889 + return { 1890 + ...result, 1891 + body: { 1892 + $type: "at.inlay.component#bodyTemplate", 1893 + node: serializeTree($("test.app.DoesNotExist", {})), 1894 + }, 1895 + }; 1896 + } 1897 + return result; 1898 + })(options.resolver.fetchRecord), 1899 + xrpc: options.resolver.xrpc, 1900 + resolveLexicon: options.resolver.resolveLexicon, 1901 + }, 1902 + }, 1903 + createContext(pageComponent) 1904 + ); 1905 + assertError( 1906 + output, 1907 + "No pack exports type: test.app.DoesNotExist", 1908 + [ 1909 + " at test.app.DoesNotExist", 1910 + " at test.app.Greeting", 1911 + " at test.app.Card", 1912 + " at test.app.Page", 1913 + ].join("\n") 1914 + ); 1915 + }); 1916 + 1917 + it("infinite recursion stack shows the repeated component", async () => { 1804 1918 const loopComponent: ComponentRecord = { 1805 1919 $type: "at.inlay.component", 1806 1920 type: Loop, ··· 1827 1941 { ...options, maxDepth: 5 }, 1828 1942 createContext(loopComponent) 1829 1943 ); 1830 - assertError(output, "Component depth limit exceeded"); 1944 + assertError( 1945 + output, 1946 + "Component depth limit exceeded", 1947 + [ 1948 + " at test.infinite.Loop", 1949 + " at test.infinite.Loop", 1950 + " at test.infinite.Loop", 1951 + " at test.infinite.Loop", 1952 + " at test.infinite.Loop", 1953 + " at test.infinite.Loop", 1954 + ].join("\n") 1955 + ); 1956 + }); 1957 + 1958 + it("xrpc error includes the external component in the stack", async () => { 1959 + const pageComponent: ComponentRecord = { 1960 + $type: "at.inlay.component", 1961 + type: Page, 1962 + body: { 1963 + $type: "at.inlay.component#bodyTemplate", 1964 + node: serializeTree($(Greeting, {})), 1965 + }, 1966 + imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[], 1967 + }; 1968 + 1969 + const greetingComponent: ComponentRecord = { 1970 + $type: "at.inlay.component", 1971 + type: Greeting, 1972 + body: { 1973 + $type: "at.inlay.component#bodyExternal", 1974 + did: SERVICE_DID, 1975 + }, 1976 + imports: [HOST_PACK_URI], 1977 + }; 1978 + 1979 + const { options } = world({ 1980 + [`xrpc:${SERVICE_DID}:${Greeting}`]: () => { 1981 + throw new Error("service unavailable"); 1982 + }, 1983 + ["at://did:plc:test/at.inlay.component/page"]: pageComponent, 1984 + ["at://did:plc:test/at.inlay.component/greet"]: greetingComponent, 1985 + ["at://did:plc:test/at.inlay.pack/app"]: { 1986 + $type: "at.inlay.pack", 1987 + name: "app", 1988 + exports: [ 1989 + { 1990 + type: Page, 1991 + component: "at://did:plc:test/at.inlay.component/page", 1992 + }, 1993 + { 1994 + type: Greeting, 1995 + component: "at://did:plc:test/at.inlay.component/greet", 1996 + }, 1997 + ], 1998 + }, 1999 + }); 2000 + 2001 + const output = await renderToCompletion( 2002 + $(Page, {}), 2003 + options, 2004 + createContext(pageComponent) 2005 + ); 2006 + assertError( 2007 + output, 2008 + "service unavailable", 2009 + [" at test.app.Greeting", " at test.app.Page"].join("\n") 2010 + ); 1831 2011 }); 1832 2012 1833 2013 it("catches resolver errors", async () => { ··· 1858 2038 errorOptions, 1859 2039 createContext(rootComponent) 1860 2040 ); 1861 - assertError(output, "network down"); 1862 - }); 1863 - 1864 - it("catches xrpc service errors", async () => { 1865 - const greetingComponent: ComponentRecord = { 1866 - $type: "at.inlay.component", 1867 - type: Greeting, 1868 - body: { 1869 - $type: "at.inlay.component#bodyExternal", 1870 - did: SERVICE_DID, 1871 - }, 1872 - imports: [HOST_PACK_URI], 1873 - }; 1874 - 1875 - const { options } = world({ 1876 - [`xrpc:${SERVICE_DID}:${Greeting}`]: () => { 1877 - throw new Error("service unavailable"); 1878 - }, 1879 - }); 1880 - 1881 - const output = await renderToCompletion( 1882 - $(Greeting, { name: "world" }), 1883 - options, 1884 - createContext(greetingComponent) 1885 - ); 1886 - assertError(output, "service unavailable"); 2041 + assertError(output, "network down", ` at ${Stack}\n at ${Root}`); 1887 2042 }); 1888 2043 1889 2044 it("unused children don't trigger errors", async () => { 1890 - // Ignore is a template that doesn't reference its children. 2045 + // Layout is a template that doesn't reference its children. 1891 2046 // Even though the child is an external that throws, no error 1892 2047 // occurs because the child is never rendered. 1893 2048 const ignoreComponent: ComponentRecord = { ··· 2746 2901 assert.deepEqual(ok, h("span", {})); 2747 2902 2748 2903 const bad = await renderToCompletion($(View, {}), opts, ctx); 2749 - assertError(bad, 'Input must have the property "uri"'); 2904 + assertError(bad, 'Input must have the property "uri"', ` at ${View}`); 2750 2905 }); 2751 2906 2752 2907 it("synthesizes validation from viewRecord", async () => { ··· 2785 2940 options, 2786 2941 ctx 2787 2942 ); 2788 - assertError(bad, "Input/uri must be a string"); 2943 + assertError(bad, "Input/uri must be a string", ` at ${Card}`); 2789 2944 }); 2790 2945 2791 2946 it("synthesizes validation from viewPrimitive", async () => { ··· 2825 2980 options, 2826 2981 ctx 2827 2982 ); 2828 - assertError(bad, "Input/value must be a string"); 2983 + assertError(bad, "Input/value must be a string", ` at ${Timestamp}`); 2829 2984 }); 2830 2985 2831 2986 it("validates collection constraints from viewRecord", async () => { ··· 2866 3021 ); 2867 3022 assertError( 2868 3023 bad, 2869 - `${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like` 3024 + `${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like`, 3025 + ` at ${Card}` 2870 3026 ); 2871 3027 }); 2872 3028 });