social components inlay.at
atproto components sdui
86
fork

Configure Feed

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

rethrow errors directly

+74 -92
+12 -13
packages/@inlay/render/README.md
··· 87 87 if (Array.isArray(node)) return <>{await Promise.all(node.map(n => renderNode(n, ctx)))}</>; 88 88 89 89 if (isValidElement(node)) { 90 - const { resolved, node: out, context: outCtx } = await render( 91 - node, ctx, { resolver } 92 - ); 93 - if (resolved && isValidElement(out)) { 94 - const Primitive = primitives[out.type]; 95 - if (!Primitive) return <>{`<!-- unknown: ${out.type} -->`}</>; 96 - return Primitive({ props: out.props ?? {}, ctx: outCtx }); 90 + const result = await render(node, ctx, { resolver }); 91 + if (result.node === null) { 92 + // Primitive: host renders using element type + result.props 93 + const Primitive = primitives[node.type]; 94 + if (!Primitive) return <>{`<!-- unknown: ${node.type} -->`}</>; 95 + return Primitive({ props: result.props, ctx: result.context }); 97 96 } 98 - return renderNode(out, outCtx); 97 + return renderNode(result.node, result.context); 99 98 } 100 99 return <>{String(node)}</>; 101 100 } ··· 146 145 147 146 ## How it works 148 147 149 - `render()` resolves one element at a time. It returns `resolved: true` when the element is a primitive (the host handles it) or `resolved: false` when the element expanded into a subtree that needs more passes. The walk loop drives this to completion. 148 + `render()` resolves one element at a time. It returns `node: null` when the element is a primitive (the host renders it using `element.type` + `result.props`) or a non-null node tree when the element expanded into a subtree that needs more passes. The walk loop drives this to completion. 150 149 151 - 1. **Binding resolution** — `at.inlay.Binding` elements in props are resolved against the current scope. 150 + 1. **Binding resolution** — `at.inlay.Binding` elements in props are resolved against the current scope. The concrete values are available on `result.props`. 152 151 2. **Type lookup** — the element's NSID is looked up across the import stack. First pack that exports the type wins. 153 152 3. **Component rendering** — depends on the component's body: 154 - - **No body** — a primitive; returned as-is. 153 + - **No body** — a primitive; returns `node: null` with resolved props. 155 154 - **Template** — element tree expanded with prop bindings. 156 155 - **External** — XRPC call to the component's service. Children are passed as slots and restored from the response. 157 - 4. **Error handling** — errors become `at.inlay.Throw` elements. `MissingError` propagates for `at.inlay.Maybe` to catch. 156 + 4. **Error handling** — errors throw with `componentStack` stamped on the error. `MissingError` propagates for `at.inlay.Maybe` to catch. 158 157 159 158 ## API 160 159 ··· 173 172 174 173 - **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }` 175 174 - **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope?, stack? }` 176 - - **`RenderResult`** — `{ resolved, node, context, cache? }` 175 + - **`RenderResult`** — `{ node, context, props, cache? }` 177 176 - **`RenderOptions`** — `{ resolver, maxDepth? }` 178 177 - **`ComponentRecord`** — re-exported from generated lexicon defs 179 178 - **`CachePolicy`** — re-exported from generated lexicon defs
+12 -18
packages/@inlay/render/src/index.ts
··· 76 76 }; 77 77 78 78 export type RenderResult = { 79 - resolved: boolean; 80 79 node: unknown; 81 80 context: RenderContext; 81 + props: Record<string, unknown>; 82 82 cache?: CachePolicy; 83 83 }; 84 84 ··· 91 91 export class MissingError extends Error { 92 92 kind = "missing" as const; 93 93 path: string[]; 94 + componentStack: string[] = []; 94 95 constructor(path: string[]) { 95 96 super(`Missing: ${path.join(".")}`); 96 97 this.path = path; ··· 162 163 throw new MissingError(path as string[]); 163 164 } 164 165 return { 165 - resolved: true, 166 - node: $(type, { ...props, key: element.key }), 166 + node: null, 167 167 context: { 168 168 imports: ctx.imports, 169 169 scope: ctx.scope, 170 170 stack: ctx.stack, 171 171 }, 172 + props, 172 173 }; 173 174 } 174 175 ··· 193 194 ctx 194 195 ); 195 196 } catch (e) { 196 - if (e instanceof MissingError) throw e; 197 - const err = e as Error; 198 - const errProps: Record<string, unknown> = { message: err.message }; 199 - if (errorStack && errorStack.length > 0) { 200 - errProps.stack = errorStack.map((nsid) => ` at ${nsid}`).join("\n"); 197 + if (e != null && typeof e === "object") { 198 + (e as Record<string, unknown>).componentStack = errorStack ?? []; 201 199 } 202 - return { 203 - resolved: true, 204 - node: $("at.inlay.Throw", errProps as Record<string, string>), 205 - context: { imports: ctx.imports }, 206 - }; 200 + throw e; 207 201 } 208 202 } 209 203 ··· 279 273 280 274 resolvedProps = await validateProps(type, resolvedProps, component, resolver); 281 275 282 - // Primitive: no body, return element with resolved props 276 + // Primitive: no body, host renders directly 283 277 if (!component.body) { 284 278 return { 285 - resolved: true, 286 - node: $(type, { ...resolvedProps, key: element.key }), 279 + node: null, 287 280 context: { 288 281 imports: component.imports?.length ? component.imports : ctx.imports, 289 282 depth, 290 283 scope: ctx.scope, 291 284 stack: ctx.stack, 292 285 }, 286 + props: resolvedProps, 293 287 }; 294 288 } 295 289 ··· 425 419 426 420 const node = resolveBindings(tree, scopeResolver(scope)); 427 421 return { 428 - resolved: false, 429 422 node, 430 423 context: { 431 424 imports: component.imports ?? [], ··· 433 426 scope, 434 427 stack, 435 428 }, 429 + props, 436 430 cache, 437 431 }; 438 432 } ··· 558 552 }); 559 553 560 554 return { 561 - resolved: false, 562 555 node, 563 556 context: { 564 557 imports: component.imports ?? [], 565 558 depth: depth + 1, 566 559 stack, 567 560 }, 561 + props, 568 562 cache: response.cache, 569 563 }; 570 564 }
+45 -57
packages/@inlay/render/test/render.test.ts
··· 188 188 }), 189 189 [Throw]: async (el) => { 190 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 + throw new Error(p.message as string); 194 192 }, 195 193 [Maybe]: async (el, walk) => { 196 194 const p = (el.props ?? {}) as Record<string, unknown>; ··· 228 226 try { 229 227 return await walkNode(node, options, ctx, primitives); 230 228 } 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 }); 229 + const err = e as Error & { componentStack?: string[] }; 230 + const stack = err.componentStack 231 + ?.map((nsid) => ` at ${nsid}`) 232 + .join("\n"); 233 + return h("error", { message: err.message, stack }); 234 234 } 235 235 } 236 236 ··· 245 245 } else if (Array.isArray(node)) { 246 246 return Promise.all(node.map((n) => walkNode(n, options, ctx, primitives))); 247 247 } else if (isValidElement(node)) { 248 - const { 249 - resolved, 250 - node: out, 251 - context: outCtx, 252 - } = await render(node, ctx, options); 253 - if (resolved && isValidElement(out)) { 254 - const walk = (n: unknown) => walkNode(n, options, outCtx, primitives); 255 - return await primitives[out.type](out, walk, outCtx); 248 + const el = node as Element; 249 + const result = await render(el, ctx, options); 250 + if (result.node === null) { 251 + // Primitive: host renders using element type + resolved props 252 + const primitiveEl = $(el.type, { ...result.props, key: el.key }); 253 + const walk = (n: unknown) => 254 + walkNode(n, options, result.context, primitives); 255 + return await primitives[el.type](primitiveEl, walk, result.context); 256 256 } 257 - return walkNode(out, options, outCtx, primitives); 257 + return walkNode(result.node, options, result.context, primitives); 258 258 } 259 259 const obj = node as Record<string, unknown>; 260 260 const entries = Object.entries(obj); ··· 422 422 assert.equal(actual.tag, "error"); 423 423 assert.equal(actual.attrs.message, message); 424 424 assert.equal(actual.attrs.stack, stack); 425 + } 426 + 427 + function assertMissing(actual: unknown, path: string[]) { 428 + assert.ok(actual instanceof Output, "expected an Output node"); 429 + assert.equal(actual.tag, "error"); 430 + assert.equal(actual.attrs.message, `Missing: ${path.join(".")}`); 425 431 } 426 432 427 433 // ============================================================================ ··· 3289 3295 assert.deepEqual(output, h("span", { value: "Hello" })); 3290 3296 }); 3291 3297 3292 - it("absent binding throws MissingError", async () => { 3293 - // No props match the binding path → MissingError 3298 + it("absent binding renders as error", async () => { 3299 + // No props match the binding path → MissingError → error output 3294 3300 const cardComponent: ComponentRecord = { 3295 3301 $type: "at.inlay.component", 3296 3302 type: Card, ··· 3304 3310 }; 3305 3311 3306 3312 const { options } = world(); 3307 - await assert.rejects( 3308 - renderToCompletion($(Card, {}), options, createContext(cardComponent)), 3309 - (err: unknown) => { 3310 - if (!(err instanceof MissingError)) throw err; 3311 - assert.deepEqual(err.path, ["props", "text"]); 3312 - return true; 3313 - } 3313 + const output = await renderToCompletion( 3314 + $(Card, {}), 3315 + options, 3316 + createContext(cardComponent) 3314 3317 ); 3318 + assertMissing(output, ["props", "text"]); 3315 3319 }); 3316 3320 3317 - it("absent nested path throws MissingError", async () => { 3321 + it("absent nested path renders as error", async () => { 3318 3322 // Binding for ["reply", "parent", "uri"] — a deep path into 3319 3323 // optional atproto record fields. 3320 3324 const cardComponent: ComponentRecord = { ··· 3332 3336 }; 3333 3337 3334 3338 const { options } = world(); 3335 - await assert.rejects( 3336 - renderToCompletion($(Card, {}), options, createContext(cardComponent)), 3337 - (err: unknown) => { 3338 - if (!(err instanceof MissingError)) throw err; 3339 - assert.deepEqual(err.path, ["record", "reply", "parent", "uri"]); 3340 - return true; 3341 - } 3339 + const output = await renderToCompletion( 3340 + $(Card, {}), 3341 + options, 3342 + createContext(cardComponent) 3342 3343 ); 3344 + assertMissing(output, ["record", "reply", "parent", "uri"]); 3343 3345 }); 3344 3346 3345 - it("absent field throws even when record exists", async () => { 3347 + it("absent field renders as error even when record exists", async () => { 3346 3348 // Record has "title" but not "reply.parent.uri". Having a record 3347 3349 // doesn't guarantee all bindings resolve. 3348 3350 const cardComponent: ComponentRecord = { ··· 3378 3380 ["at://did:plc:alice/app.bsky.feed.post/123"]: { title: "Hello" }, 3379 3381 }); 3380 3382 3381 - await assert.rejects( 3382 - renderToCompletion( 3383 - $(Card, { uri: "at://did:plc:alice/app.bsky.feed.post/123" }), 3384 - options, 3385 - createContext(cardComponent) 3386 - ), 3387 - (err: unknown) => { 3388 - if (!(err instanceof MissingError)) throw err; 3389 - assert.deepEqual(err.path, ["record", "reply", "parent", "uri"]); 3390 - return true; 3391 - } 3383 + const output = await renderToCompletion( 3384 + $(Card, { uri: "at://did:plc:alice/app.bsky.feed.post/123" }), 3385 + options, 3386 + createContext(cardComponent) 3392 3387 ); 3388 + assertMissing(output, ["record", "reply", "parent", "uri"]); 3393 3389 }); 3394 3390 3395 3391 it("Maybe nukes entire subtree when a deep binding is missing", async () => { ··· 3599 3595 }), 3600 3596 }); 3601 3597 3602 - await assert.rejects( 3603 - renderToCompletion( 3604 - $(PostCard, { uri: "at://did:plc:alice/app.bsky.feed.post/123" }), 3605 - options, 3606 - createContext(postCardComponent) 3607 - ), 3608 - (err: unknown) => { 3609 - if (!(err instanceof MissingError)) { 3610 - throw err; 3611 - } 3612 - assert.deepEqual(err.path, ["record", "reply", "parent", "uri"]); 3613 - return true; 3614 - } 3598 + const output = await renderToCompletion( 3599 + $(PostCard, { uri: "at://did:plc:alice/app.bsky.feed.post/123" }), 3600 + options, 3601 + createContext(postCardComponent) 3615 3602 ); 3603 + assertMissing(output, ["record", "reply", "parent", "uri"]); 3616 3604 }); 3617 3605 3618 3606 it("Maybe catches missing slot binding through external component", async () => {
+5 -4
proto/src/render.tsx
··· 59 59 return <div class="error">{msg}</div>; 60 60 } 61 61 62 - if (isValidElement(result.node)) { 63 - const el = result.node as Element; 64 - const Builtin = componentMap[el.type]; 62 + // Primitive or builtin: node is null, host renders directly 63 + if (result.node === null) { 64 + const Builtin = componentMap[element.type]; 65 65 if (Builtin) { 66 - return Builtin({ ctx: result.context, props: el.props ?? {} }); 66 + return Builtin({ ctx: result.context, props: result.props }); 67 67 } 68 + return <div class="error">Unknown primitive: {element.type}</div>; 68 69 } 69 70 70 71 const tag = element.type.toLowerCase().replaceAll(".", "-");