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
1 week 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
### Types
174
175
- **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }`
176
-
- **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope? }`
177
- **`RenderResult`** — `{ resolved, node, context, cache? }`
178
- **`RenderOptions`** — `{ resolver, maxDepth?, validate? }`
179
- **`ComponentRecord`** — re-exported from generated lexicon defs
···
173
### Types
174
175
- **`Resolver`** — `{ fetchRecord, xrpc, resolveLexicon }`
176
+
- **`RenderContext`** — `{ imports, component?, componentUri?, depth?, scope?, stack? }`
177
- **`RenderResult`** — `{ resolved, node, context, cache? }`
178
- **`RenderOptions`** — `{ resolver, maxDepth?, validate? }`
179
- **`ComponentRecord`** — re-exported from generated lexicon defs
+51
-19
packages/@inlay/render/src/index.ts
···
63
* component boundary to prevent infinite recursion.
64
* - `scope`: template variable bindings. Bindings in child element props
65
* resolve against this at the next render boundary.
0
0
66
*/
67
export type RenderContext = {
68
imports: AtUriString[];
···
70
componentUri?: string;
71
depth?: number;
72
scope?: Record<string, unknown>;
0
73
};
74
75
export type RenderResult = {
···
115
options: RenderOptions
116
): Promise<RenderResult> {
117
const { resolver } = options;
118
-
const effective = slotContexts.get(element) ?? context;
119
-
const depth = effective.depth ?? 0;
120
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
121
const validate = options.validate ?? true;
122
0
0
123
try {
124
const type = element.type;
125
let props = (element.props ?? {}) as Record<string, unknown>;
126
127
// Resolve any Bindings left in props by the parent template
128
-
if (effective.scope) {
129
-
props = resolveBindings(props, scopeResolver(effective.scope));
130
}
131
132
// Root render: use component directly
133
-
if (effective.component) {
134
-
if (type !== effective.component.type) {
135
throw new Error(
136
-
`render was given ${effective.component.type}, cannot render ${type}`
137
);
138
}
0
139
return await renderComponent(
140
resolver,
141
-
effective.component,
142
-
effective.componentUri,
143
element,
144
props,
145
-
effective,
146
validate
147
);
148
}
···
160
return {
161
resolved: true,
162
node: $(type, { ...props, key: element.key }),
163
-
context: { imports: effective.imports, scope: effective.scope },
0
0
0
0
164
};
165
}
0
0
0
166
167
if (depth >= maxDepth) {
168
throw Error("Component depth limit exceeded");
···
170
171
const { component, componentUri } = await resolveType(
172
type,
173
-
effective.imports,
174
resolver
175
);
176
return await renderComponent(
···
179
componentUri,
180
element,
181
props,
182
-
effective,
183
validate
184
);
185
} catch (e) {
186
if (e instanceof MissingError) throw e;
187
const err = e as Error;
188
const errProps: Record<string, unknown> = { message: err.message };
189
-
if (err.stack) errProps.stack = err.stack;
0
0
190
return {
191
resolved: true,
192
node: $("at.inlay.Throw", errProps as Record<string, string>),
193
-
context: { imports: effective.imports },
194
};
195
}
196
}
···
259
validate: boolean
260
): Promise<RenderResult> {
261
const depth = ctx.depth ?? 0;
0
262
263
// Primitive: no body, return element with resolved props
264
if (!component.body) {
···
269
imports: component.imports?.length ? component.imports : ctx.imports,
270
depth,
271
scope: ctx.scope,
0
272
},
273
};
274
}
···
287
resolver
288
);
289
}
0
0
290
291
if (component.body.$type === "at.inlay.component#bodyTemplate") {
292
-
return renderTemplate(resolver, component, resolvedProps, ctx);
293
}
294
295
if (component.body.$type === "at.inlay.component#bodyExternal") {
···
299
component,
300
componentUri,
301
resolvedProps,
302
-
ctx
303
);
304
}
305
···
364
365
// Replace caller-provided elements with Slots so they resolve through the
366
// caller's imports, not the component's — same isolation as external slots.
0
367
const callerCtx: RenderContext = {
368
imports: ctx.imports,
369
depth,
370
scope: ctx.scope,
0
371
};
372
const slottedProps = walkTree(props, (obj, walk) => {
373
if (isValidElement(obj)) {
···
417
return {
418
resolved: false,
419
node,
420
-
context: { imports: component.imports ?? [], depth: depth + 1, scope },
0
0
0
0
0
421
cache,
422
};
423
}
···
505
})) as { node: unknown; cache?: CachePolicy };
506
507
// Restore slots — register caller context in the WeakMap
0
508
const callerCtx: RenderContext = {
509
imports: ctx.imports,
510
depth,
511
scope: ctx.scope,
0
512
};
513
514
const node = deserializeTree(response.node, (el) => {
···
536
return {
537
resolved: false,
538
node,
539
-
context: { imports: component.imports ?? [], depth: depth + 1 },
0
0
0
0
540
cache: response.cache,
541
};
542
}
···
63
* component boundary to prevent infinite recursion.
64
* - `scope`: template variable bindings. Bindings in child element props
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.
68
*/
69
export type RenderContext = {
70
imports: AtUriString[];
···
72
componentUri?: string;
73
depth?: number;
74
scope?: Record<string, unknown>;
75
+
stack?: string[];
76
};
77
78
export type RenderResult = {
···
118
options: RenderOptions
119
): Promise<RenderResult> {
120
const { resolver } = options;
121
+
const ctx = slotContexts.get(element) ?? context;
122
+
const depth = ctx.depth ?? 0;
123
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
124
const validate = options.validate ?? true;
125
126
+
let errorStack = ctx.stack;
127
+
128
try {
129
const type = element.type;
130
let props = (element.props ?? {}) as Record<string, unknown>;
131
132
// Resolve any Bindings left in props by the parent template
133
+
if (ctx.scope) {
134
+
props = resolveBindings(props, scopeResolver(ctx.scope));
135
}
136
137
// Root render: use component directly
138
+
if (ctx.component) {
139
+
if (type !== ctx.component.type) {
140
throw new Error(
141
+
`render was given ${ctx.component.type}, cannot render ${type}`
142
);
143
}
144
+
errorStack = [type, ...(ctx.stack ?? [])];
145
return await renderComponent(
146
resolver,
147
+
ctx.component,
148
+
ctx.componentUri,
149
element,
150
props,
151
+
ctx,
152
validate
153
);
154
}
···
166
return {
167
resolved: true,
168
node: $(type, { ...props, key: element.key }),
169
+
context: {
170
+
imports: ctx.imports,
171
+
scope: ctx.scope,
172
+
stack: ctx.stack,
173
+
},
174
};
175
}
176
+
177
+
// Past here we're rendering a component — include it in the owner stack.
178
+
errorStack = [type, ...(ctx.stack ?? [])];
179
180
if (depth >= maxDepth) {
181
throw Error("Component depth limit exceeded");
···
183
184
const { component, componentUri } = await resolveType(
185
type,
186
+
ctx.imports,
187
resolver
188
);
189
return await renderComponent(
···
192
componentUri,
193
element,
194
props,
195
+
ctx,
196
validate
197
);
198
} catch (e) {
199
if (e instanceof MissingError) throw e;
200
const err = e as Error;
201
const errProps: Record<string, unknown> = { message: err.message };
202
+
if (errorStack && errorStack.length > 0) {
203
+
errProps.stack = errorStack.map((nsid) => ` at ${nsid}`).join("\n");
204
+
}
205
return {
206
resolved: true,
207
node: $("at.inlay.Throw", errProps as Record<string, string>),
208
+
context: { imports: ctx.imports },
209
};
210
}
211
}
···
274
validate: boolean
275
): Promise<RenderResult> {
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) {
···
285
imports: component.imports?.length ? component.imports : ctx.imports,
286
depth,
287
scope: ctx.scope,
288
+
stack,
289
},
290
};
291
}
···
304
resolver
305
);
306
}
307
+
308
+
const childCtx = { ...ctx, stack };
309
310
if (component.body.$type === "at.inlay.component#bodyTemplate") {
311
+
return renderTemplate(resolver, component, resolvedProps, childCtx);
312
}
313
314
if (component.body.$type === "at.inlay.component#bodyExternal") {
···
318
component,
319
componentUri,
320
resolvedProps,
321
+
childCtx
322
);
323
}
324
···
383
384
// Replace caller-provided elements with Slots so they resolve through the
385
// caller's imports, not the component's — same isolation as external slots.
386
+
const callerStack = ctx.stack?.slice(1);
387
const callerCtx: RenderContext = {
388
imports: ctx.imports,
389
depth,
390
scope: ctx.scope,
391
+
stack: callerStack,
392
};
393
const slottedProps = walkTree(props, (obj, walk) => {
394
if (isValidElement(obj)) {
···
438
return {
439
resolved: false,
440
node,
441
+
context: {
442
+
imports: component.imports ?? [],
443
+
depth: depth + 1,
444
+
scope,
445
+
stack: ctx.stack,
446
+
},
447
cache,
448
};
449
}
···
531
})) as { node: unknown; cache?: CachePolicy };
532
533
// Restore slots — register caller context in the WeakMap
534
+
const callerStack = ctx.stack?.slice(1);
535
const callerCtx: RenderContext = {
536
imports: ctx.imports,
537
depth,
538
scope: ctx.scope,
539
+
stack: callerStack,
540
};
541
542
const node = deserializeTree(response.node, (el) => {
···
564
return {
565
resolved: false,
566
node,
567
+
context: {
568
+
imports: component.imports ?? [],
569
+
depth: depth + 1,
570
+
stack: ctx.stack,
571
+
},
572
cache: response.cache,
573
};
574
}
+202
-46
packages/@inlay/render/test/render.test.ts
···
187
children: await walk((el.props as Record<string, unknown>)?.children),
188
}),
189
[Throw]: async (el) => {
190
-
throw new Error((el.props as Record<string, unknown>)?.message as string);
0
0
0
191
},
192
[Maybe]: async (el, walk) => {
193
const p = (el.props ?? {}) as Record<string, unknown>;
···
222
extra?: Record<string, Primitive>
223
): Promise<unknown> {
224
const primitives = extra ? { ...HOST_PRIMITIVES, ...extra } : HOST_PRIMITIVES;
225
-
return walkNode(node, options, ctx, primitives);
0
0
0
0
0
0
226
}
227
228
async function walkNode(
···
243
} = await render(node, ctx, options);
244
if (resolved && isValidElement(out)) {
245
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
-
}
252
}
253
return walkNode(out, options, outCtx, primitives);
254
}
···
413
assert.deepEqual(actual, expanded);
414
}
415
416
-
function assertError(actual: unknown, message: string) {
417
-
assert.equal(actual, `[Error: ${message}]`);
0
0
0
418
}
419
420
// ============================================================================
···
1099
options,
1100
createContext(rootComponent)
1101
);
1102
-
assertError(output, `No pack exports type: ${unknown}`);
0
0
0
0
1103
});
1104
});
1105
···
1310
createContext(pageComponent)
1311
);
1312
1313
-
assertError(output, `No pack exports type: ${Card}`);
0
0
0
0
1314
});
1315
1316
it("composed children resolve through caller, not callee", async () => {
···
1795
// 6. Error handling — Throw elements
1796
// ============================================================================
1797
//
1798
-
// Errors during render are caught and turned into at.inlay.Throw elements.
0
0
1799
// For now, the seam where the error is displayed is controlled by the host.
1800
// In the future, a Catch boundary may be added to let the user control this.
1801
1802
describe("error handling", () => {
1803
-
it("infinite recursion is caught", async () => {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1804
const loopComponent: ComponentRecord = {
1805
$type: "at.inlay.component",
1806
type: Loop,
···
1827
{ ...options, maxDepth: 5 },
1828
createContext(loopComponent)
1829
);
1830
-
assertError(output, "Component depth limit exceeded");
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1831
});
1832
1833
it("catches resolver errors", async () => {
···
1858
errorOptions,
1859
createContext(rootComponent)
1860
);
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");
1887
});
1888
1889
it("unused children don't trigger errors", async () => {
1890
-
// Ignore is a template that doesn't reference its children.
1891
// Even though the child is an external that throws, no error
1892
// occurs because the child is never rendered.
1893
const ignoreComponent: ComponentRecord = {
···
2746
assert.deepEqual(ok, h("span", {}));
2747
2748
const bad = await renderToCompletion($(View, {}), opts, ctx);
2749
-
assertError(bad, 'Input must have the property "uri"');
2750
});
2751
2752
it("synthesizes validation from viewRecord", async () => {
···
2785
options,
2786
ctx
2787
);
2788
-
assertError(bad, "Input/uri must be a string");
2789
});
2790
2791
it("synthesizes validation from viewPrimitive", async () => {
···
2825
options,
2826
ctx
2827
);
2828
-
assertError(bad, "Input/value must be a string");
2829
});
2830
2831
it("validates collection constraints from viewRecord", async () => {
···
2866
);
2867
assertError(
2868
bad,
2869
-
`${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like`
0
2870
);
2871
});
2872
});
···
187
children: await walk((el.props as Record<string, unknown>)?.children),
188
}),
189
[Throw]: async (el) => {
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;
194
},
195
[Maybe]: async (el, walk) => {
196
const p = (el.props ?? {}) as Record<string, unknown>;
···
225
extra?: Record<string, Primitive>
226
): Promise<unknown> {
227
const primitives = extra ? { ...HOST_PRIMITIVES, ...extra } : HOST_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
+
}
235
}
236
237
async function walkNode(
···
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);
0
0
0
0
0
256
}
257
return walkNode(out, options, outCtx, primitives);
258
}
···
417
assert.deepEqual(actual, expanded);
418
}
419
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);
425
}
426
427
// ============================================================================
···
1106
options,
1107
createContext(rootComponent)
1108
);
1109
+
assertError(
1110
+
output,
1111
+
`No pack exports type: ${unknown}`,
1112
+
` at ${unknown}\n at ${Root}`
1113
+
);
1114
});
1115
});
1116
···
1321
createContext(pageComponent)
1322
);
1323
1324
+
assertError(
1325
+
output,
1326
+
`No pack exports type: ${Card}`,
1327
+
` at ${Card}\n at ${Greeting}\n at ${Page}`
1328
+
);
1329
});
1330
1331
it("composed children resolve through caller, not callee", async () => {
···
1810
// 6. Error handling — Throw elements
1811
// ============================================================================
1812
//
1813
+
// Errors during render are caught and turned into at.inlay.Throw elements
1814
+
// with a component stack trace showing the owner chain.
1815
+
//
1816
// For now, the seam where the error is displayed is controlled by the host.
1817
// In the future, a Catch boundary may be added to let the user control this.
1818
1819
describe("error handling", () => {
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 () => {
1918
const loopComponent: ComponentRecord = {
1919
$type: "at.inlay.component",
1920
type: Loop,
···
1941
{ ...options, maxDepth: 5 },
1942
createContext(loopComponent)
1943
);
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
+
);
2011
});
2012
2013
it("catches resolver errors", async () => {
···
2038
errorOptions,
2039
createContext(rootComponent)
2040
);
2041
+
assertError(output, "network down", ` at ${Stack}\n at ${Root}`);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
2042
});
2043
2044
it("unused children don't trigger errors", async () => {
2045
+
// Layout is a template that doesn't reference its children.
2046
// Even though the child is an external that throws, no error
2047
// occurs because the child is never rendered.
2048
const ignoreComponent: ComponentRecord = {
···
2901
assert.deepEqual(ok, h("span", {}));
2902
2903
const bad = await renderToCompletion($(View, {}), opts, ctx);
2904
+
assertError(bad, 'Input must have the property "uri"', ` at ${View}`);
2905
});
2906
2907
it("synthesizes validation from viewRecord", async () => {
···
2940
options,
2941
ctx
2942
);
2943
+
assertError(bad, "Input/uri must be a string", ` at ${Card}`);
2944
});
2945
2946
it("synthesizes validation from viewPrimitive", async () => {
···
2980
options,
2981
ctx
2982
);
2983
+
assertError(bad, "Input/value must be a string", ` at ${Timestamp}`);
2984
});
2985
2986
it("validates collection constraints from viewRecord", async () => {
···
3021
);
3022
assertError(
3023
bad,
3024
+
`${Card}: uri expects app.bsky.feed.post, got app.bsky.feed.like`,
3025
+
` at ${Card}`
3026
);
3027
});
3028
});