tangled
alpha
login
or
join now
danabra.mov
/
inlay
42
fork
atom
social components
inlay-proto.up.railway.app/
atproto
components
sdui
42
fork
atom
overview
issues
pulls
pipelines
add stacks
danabra.mov
5 days ago
ba6c4e09
c8b1222d
+254
-66
3 changed files
expand all
collapse all
unified
split
packages
@inlay
render
README.md
src
index.ts
test
render.test.ts
+1
-1
packages/@inlay/render/README.md
···
173
173
### Types
174
174
175
175
- **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }`
176
176
-
- **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope? }`
176
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
66
+
* - `stack`: component NSID chain for error reporting. Most recent
67
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
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
118
-
const effective = slotContexts.get(element) ?? context;
119
119
-
const depth = effective.depth ?? 0;
121
121
+
const ctx = slotContexts.get(element) ?? context;
122
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
126
+
let errorStack = ctx.stack;
127
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
128
-
if (effective.scope) {
129
129
-
props = resolveBindings(props, scopeResolver(effective.scope));
133
133
+
if (ctx.scope) {
134
134
+
props = resolveBindings(props, scopeResolver(ctx.scope));
130
135
}
131
136
132
137
// Root render: use component directly
133
133
-
if (effective.component) {
134
134
-
if (type !== effective.component.type) {
138
138
+
if (ctx.component) {
139
139
+
if (type !== ctx.component.type) {
135
140
throw new Error(
136
136
-
`render was given ${effective.component.type}, cannot render ${type}`
141
141
+
`render was given ${ctx.component.type}, cannot render ${type}`
137
142
);
138
143
}
144
144
+
errorStack = [type, ...(ctx.stack ?? [])];
139
145
return await renderComponent(
140
146
resolver,
141
141
-
effective.component,
142
142
-
effective.componentUri,
147
147
+
ctx.component,
148
148
+
ctx.componentUri,
143
149
element,
144
150
props,
145
145
-
effective,
151
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
163
-
context: { imports: effective.imports, scope: effective.scope },
169
169
+
context: {
170
170
+
imports: ctx.imports,
171
171
+
scope: ctx.scope,
172
172
+
stack: ctx.stack,
173
173
+
},
164
174
};
165
175
}
176
176
+
177
177
+
// Past here we're rendering a component — include it in the owner stack.
178
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
173
-
effective.imports,
186
186
+
ctx.imports,
174
187
resolver
175
188
);
176
189
return await renderComponent(
···
179
192
componentUri,
180
193
element,
181
194
props,
182
182
-
effective,
195
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
189
-
if (err.stack) errProps.stack = err.stack;
202
202
+
if (errorStack && errorStack.length > 0) {
203
203
+
errProps.stack = errorStack.map((nsid) => ` at ${nsid}`).join("\n");
204
204
+
}
190
205
return {
191
206
resolved: true,
192
207
node: $("at.inlay.Throw", errProps as Record<string, string>),
193
193
-
context: { imports: effective.imports },
208
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
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
288
+
stack,
272
289
},
273
290
};
274
291
}
···
287
304
resolver
288
305
);
289
306
}
307
307
+
308
308
+
const childCtx = { ...ctx, stack };
290
309
291
310
if (component.body.$type === "at.inlay.component#bodyTemplate") {
292
292
-
return renderTemplate(resolver, component, resolvedProps, ctx);
311
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
302
-
ctx
321
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
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
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
420
-
context: { imports: component.imports ?? [], depth: depth + 1, scope },
441
441
+
context: {
442
442
+
imports: component.imports ?? [],
443
443
+
depth: depth + 1,
444
444
+
scope,
445
445
+
stack: ctx.stack,
446
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
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
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
539
-
context: { imports: component.imports ?? [], depth: depth + 1 },
567
567
+
context: {
568
568
+
imports: component.imports ?? [],
569
569
+
depth: depth + 1,
570
570
+
stack: ctx.stack,
571
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
190
-
throw new Error((el.props as Record<string, unknown>)?.message as string);
190
190
+
const p = (el.props ?? {}) as Record<string, unknown>;
191
191
+
const err = new Error(p.message as string);
192
192
+
(err as any).inlayStack = p.stack;
193
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
225
-
return walkNode(node, options, ctx, primitives);
228
228
+
try {
229
229
+
return await walkNode(node, options, ctx, primitives);
230
230
+
} catch (e) {
231
231
+
if (e instanceof MissingError) throw e;
232
232
+
const err = e as Error & { inlayStack?: string };
233
233
+
return h("error", { message: err.message, stack: err.inlayStack });
234
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
246
-
try {
247
247
-
return await primitives[out.type](out, walk, outCtx);
248
248
-
} catch (e) {
249
249
-
if (e instanceof MissingError) throw e;
250
250
-
return `[Error: ${(e as Error).message}]`;
251
251
-
}
255
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
416
-
function assertError(actual: unknown, message: string) {
417
417
-
assert.equal(actual, `[Error: ${message}]`);
420
420
+
function assertError(actual: unknown, message: string, stack: string) {
421
421
+
assert.ok(actual instanceof Output, "expected an Output node");
422
422
+
assert.equal(actual.tag, "error");
423
423
+
assert.equal(actual.attrs.message, message);
424
424
+
assert.equal(actual.attrs.stack, stack);
418
425
}
419
426
420
427
// ============================================================================
···
1099
1106
options,
1100
1107
createContext(rootComponent)
1101
1108
);
1102
1102
-
assertError(output, `No pack exports type: ${unknown}`);
1109
1109
+
assertError(
1110
1110
+
output,
1111
1111
+
`No pack exports type: ${unknown}`,
1112
1112
+
` at ${unknown}\n at ${Root}`
1113
1113
+
);
1103
1114
});
1104
1115
});
1105
1116
···
1310
1321
createContext(pageComponent)
1311
1322
);
1312
1323
1313
1313
-
assertError(output, `No pack exports type: ${Card}`);
1324
1324
+
assertError(
1325
1325
+
output,
1326
1326
+
`No pack exports type: ${Card}`,
1327
1327
+
` at ${Card}\n at ${Greeting}\n at ${Page}`
1328
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
1798
-
// Errors during render are caught and turned into at.inlay.Throw elements.
1813
1813
+
// Errors during render are caught and turned into at.inlay.Throw elements
1814
1814
+
// with a component stack trace showing the owner chain.
1815
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
1803
-
it("infinite recursion is caught", async () => {
1820
1820
+
it("template chain shows owner stack", async () => {
1821
1821
+
// Page renders Card, Card renders Greeting. Greeting fails.
1822
1822
+
// The stack should read bottom-up: Greeting, Card, Page.
1823
1823
+
const pageComponent: ComponentRecord = {
1824
1824
+
$type: "at.inlay.component",
1825
1825
+
type: Page,
1826
1826
+
body: {
1827
1827
+
$type: "at.inlay.component#bodyTemplate",
1828
1828
+
node: serializeTree($(Card, {})),
1829
1829
+
},
1830
1830
+
imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[],
1831
1831
+
};
1832
1832
+
1833
1833
+
const cardComponent: ComponentRecord = {
1834
1834
+
$type: "at.inlay.component",
1835
1835
+
type: Card,
1836
1836
+
body: {
1837
1837
+
$type: "at.inlay.component#bodyTemplate",
1838
1838
+
node: serializeTree($(Greeting, {})),
1839
1839
+
},
1840
1840
+
imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[],
1841
1841
+
};
1842
1842
+
1843
1843
+
const greetingComponent: ComponentRecord = {
1844
1844
+
$type: "at.inlay.component",
1845
1845
+
type: Greeting,
1846
1846
+
body: {
1847
1847
+
$type: "at.inlay.component#bodyTemplate",
1848
1848
+
node: serializeTree($(Stack, {})),
1849
1849
+
},
1850
1850
+
imports: [HOST_PACK_URI],
1851
1851
+
};
1852
1852
+
1853
1853
+
const { options } = testResolver({
1854
1854
+
...HOST_RECORDS,
1855
1855
+
["at://did:plc:test/at.inlay.component/page"]: pageComponent,
1856
1856
+
["at://did:plc:test/at.inlay.component/card"]: cardComponent,
1857
1857
+
["at://did:plc:test/at.inlay.component/greet"]: greetingComponent,
1858
1858
+
["at://did:plc:test/at.inlay.pack/app"]: {
1859
1859
+
$type: "at.inlay.pack",
1860
1860
+
name: "app",
1861
1861
+
exports: [
1862
1862
+
{
1863
1863
+
type: Page,
1864
1864
+
component: "at://did:plc:test/at.inlay.component/page",
1865
1865
+
},
1866
1866
+
{
1867
1867
+
type: Card,
1868
1868
+
component: "at://did:plc:test/at.inlay.component/card",
1869
1869
+
},
1870
1870
+
{
1871
1871
+
type: Greeting,
1872
1872
+
component: "at://did:plc:test/at.inlay.component/greet",
1873
1873
+
},
1874
1874
+
],
1875
1875
+
},
1876
1876
+
});
1877
1877
+
1878
1878
+
// Greeting's resolver fails — error should bubble with full owner chain.
1879
1879
+
const output = await renderToCompletion(
1880
1880
+
$(Page, {}),
1881
1881
+
{
1882
1882
+
...options,
1883
1883
+
resolver: {
1884
1884
+
...options.resolver,
1885
1885
+
fetchRecord: ((original) => async (uri: AtUriString) => {
1886
1886
+
const result = await original(uri);
1887
1887
+
if (result && (result as ComponentRecord).type === Greeting) {
1888
1888
+
// Return a component whose template references a missing type.
1889
1889
+
return {
1890
1890
+
...result,
1891
1891
+
body: {
1892
1892
+
$type: "at.inlay.component#bodyTemplate",
1893
1893
+
node: serializeTree($("test.app.DoesNotExist", {})),
1894
1894
+
},
1895
1895
+
};
1896
1896
+
}
1897
1897
+
return result;
1898
1898
+
})(options.resolver.fetchRecord),
1899
1899
+
xrpc: options.resolver.xrpc,
1900
1900
+
resolveLexicon: options.resolver.resolveLexicon,
1901
1901
+
},
1902
1902
+
},
1903
1903
+
createContext(pageComponent)
1904
1904
+
);
1905
1905
+
assertError(
1906
1906
+
output,
1907
1907
+
"No pack exports type: test.app.DoesNotExist",
1908
1908
+
[
1909
1909
+
" at test.app.DoesNotExist",
1910
1910
+
" at test.app.Greeting",
1911
1911
+
" at test.app.Card",
1912
1912
+
" at test.app.Page",
1913
1913
+
].join("\n")
1914
1914
+
);
1915
1915
+
});
1916
1916
+
1917
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
1830
-
assertError(output, "Component depth limit exceeded");
1944
1944
+
assertError(
1945
1945
+
output,
1946
1946
+
"Component depth limit exceeded",
1947
1947
+
[
1948
1948
+
" at test.infinite.Loop",
1949
1949
+
" at test.infinite.Loop",
1950
1950
+
" at test.infinite.Loop",
1951
1951
+
" at test.infinite.Loop",
1952
1952
+
" at test.infinite.Loop",
1953
1953
+
" at test.infinite.Loop",
1954
1954
+
].join("\n")
1955
1955
+
);
1956
1956
+
});
1957
1957
+
1958
1958
+
it("xrpc error includes the external component in the stack", async () => {
1959
1959
+
const pageComponent: ComponentRecord = {
1960
1960
+
$type: "at.inlay.component",
1961
1961
+
type: Page,
1962
1962
+
body: {
1963
1963
+
$type: "at.inlay.component#bodyTemplate",
1964
1964
+
node: serializeTree($(Greeting, {})),
1965
1965
+
},
1966
1966
+
imports: ["at://did:plc:test/at.inlay.pack/app"] as AtUriString[],
1967
1967
+
};
1968
1968
+
1969
1969
+
const greetingComponent: ComponentRecord = {
1970
1970
+
$type: "at.inlay.component",
1971
1971
+
type: Greeting,
1972
1972
+
body: {
1973
1973
+
$type: "at.inlay.component#bodyExternal",
1974
1974
+
did: SERVICE_DID,
1975
1975
+
},
1976
1976
+
imports: [HOST_PACK_URI],
1977
1977
+
};
1978
1978
+
1979
1979
+
const { options } = world({
1980
1980
+
[`xrpc:${SERVICE_DID}:${Greeting}`]: () => {
1981
1981
+
throw new Error("service unavailable");
1982
1982
+
},
1983
1983
+
["at://did:plc:test/at.inlay.component/page"]: pageComponent,
1984
1984
+
["at://did:plc:test/at.inlay.component/greet"]: greetingComponent,
1985
1985
+
["at://did:plc:test/at.inlay.pack/app"]: {
1986
1986
+
$type: "at.inlay.pack",
1987
1987
+
name: "app",
1988
1988
+
exports: [
1989
1989
+
{
1990
1990
+
type: Page,
1991
1991
+
component: "at://did:plc:test/at.inlay.component/page",
1992
1992
+
},
1993
1993
+
{
1994
1994
+
type: Greeting,
1995
1995
+
component: "at://did:plc:test/at.inlay.component/greet",
1996
1996
+
},
1997
1997
+
],
1998
1998
+
},
1999
1999
+
});
2000
2000
+
2001
2001
+
const output = await renderToCompletion(
2002
2002
+
$(Page, {}),
2003
2003
+
options,
2004
2004
+
createContext(pageComponent)
2005
2005
+
);
2006
2006
+
assertError(
2007
2007
+
output,
2008
2008
+
"service unavailable",
2009
2009
+
[" at test.app.Greeting", " at test.app.Page"].join("\n")
2010
2010
+
);
1831
2011
});
1832
2012
1833
2013
it("catches resolver errors", async () => {
···
1858
2038
errorOptions,
1859
2039
createContext(rootComponent)
1860
2040
);
1861
1861
-
assertError(output, "network down");
1862
1862
-
});
1863
1863
-
1864
1864
-
it("catches xrpc service errors", async () => {
1865
1865
-
const greetingComponent: ComponentRecord = {
1866
1866
-
$type: "at.inlay.component",
1867
1867
-
type: Greeting,
1868
1868
-
body: {
1869
1869
-
$type: "at.inlay.component#bodyExternal",
1870
1870
-
did: SERVICE_DID,
1871
1871
-
},
1872
1872
-
imports: [HOST_PACK_URI],
1873
1873
-
};
1874
1874
-
1875
1875
-
const { options } = world({
1876
1876
-
[`xrpc:${SERVICE_DID}:${Greeting}`]: () => {
1877
1877
-
throw new Error("service unavailable");
1878
1878
-
},
1879
1879
-
});
1880
1880
-
1881
1881
-
const output = await renderToCompletion(
1882
1882
-
$(Greeting, { name: "world" }),
1883
1883
-
options,
1884
1884
-
createContext(greetingComponent)
1885
1885
-
);
1886
1886
-
assertError(output, "service unavailable");
2041
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
1890
-
// Ignore is a template that doesn't reference its children.
2045
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
2749
-
assertError(bad, 'Input must have the property "uri"');
2904
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
2788
-
assertError(bad, "Input/uri must be a string");
2943
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
2828
-
assertError(bad, "Input/value must be a string");
2983
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
2869
-
`${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like`
3024
3024
+
`${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like`,
3025
3025
+
` at ${Card}`
2870
3026
);
2871
3027
});
2872
3028
});