social components inlay.at
atproto components sdui
86
fork

Configure Feed

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

throw Missing lazily

+226 -2
+20 -2
packages/@inlay/render/src/index.ts
··· 201 201 } 202 202 } 203 203 204 - /** Build a resolve callback that looks up paths in a scope, throwing MissingError on null. */ 204 + /** Build a resolve callback that looks up paths in a scope. Returns a Missing element on null. */ 205 205 function scopeResolver( 206 206 scope: Record<string, unknown> 207 207 ): (path: string[]) => unknown { 208 208 return (path) => { 209 209 const value = resolvePath(scope, path); 210 - if (value == null) throw new MissingError(path); 210 + if (value == null) return $("at.inlay.Missing", { path }); 211 211 return value; 212 212 }; 213 + } 214 + 215 + /** Walk resolved props and throw MissingError for any lingering Missing elements. */ 216 + function throwIfMissingInProps(props: Record<string, unknown>): void { 217 + walkTree(props, (obj, walk) => { 218 + if (isValidElement(obj)) { 219 + if ((obj as Element).type === "at.inlay.Missing") { 220 + const path = ((obj as Element).props as Record<string, unknown>)?.path; 221 + throw new MissingError( 222 + Array.isArray(path) ? (path as string[]) : ["?"] 223 + ); 224 + } 225 + return obj; // opaque 226 + } 227 + for (const v of Object.values(obj)) walk(v); 228 + return obj; 229 + }); 213 230 } 214 231 215 232 export function resolvePath(obj: unknown, path: string[]): unknown { ··· 275 292 276 293 // Primitive: no body, host renders directly 277 294 if (!component.body) { 295 + throwIfMissingInProps(resolvedProps); 278 296 return { 279 297 node: null, 280 298 context: {
+206
packages/@inlay/render/test/render.test.ts
··· 3740 3740 assert.deepEqual(output, h("span", { value: "caught" })); 3741 3741 }); 3742 3742 3743 + it("Maybe catches bare Binding child that resolves to null", async () => { 3744 + // SafeWrapper template: wraps children in Maybe. 3745 + // 3746 + // <Stack> 3747 + // <Text value="header" /> 3748 + // <Maybe fallback={<Text value="fallback" />}> 3749 + // {children} 3750 + // </Maybe> 3751 + // </Stack> 3752 + // 3753 + // Called WITHOUT children — props.children is null. 3754 + // Maybe should catch the missing binding and render "fallback". 3755 + // Result: header + "fallback". 3756 + const SafeWrapper = "test.app.SafeWrapper" as const; 3757 + const safeWrapperComponent: ComponentRecord = { 3758 + $type: "at.inlay.component", 3759 + type: SafeWrapper, 3760 + body: { 3761 + $type: "at.inlay.component#bodyTemplate", 3762 + node: serializeTree( 3763 + $(Stack, { 3764 + children: [ 3765 + $(Text, { value: "header" }), 3766 + $(Maybe, { 3767 + fallback: $(Text, { value: "fallback" }), 3768 + children: $(Binding, { path: ["props", "children"] }), 3769 + }), 3770 + ], 3771 + }) 3772 + ), 3773 + }, 3774 + imports: [HOST_PACK_URI], 3775 + }; 3776 + 3777 + const cardComponent: ComponentRecord = { 3778 + $type: "at.inlay.component", 3779 + type: Card, 3780 + body: { 3781 + $type: "at.inlay.component#bodyTemplate", 3782 + node: serializeTree($(SafeWrapper, {})), 3783 + }, 3784 + imports: [ 3785 + HOST_PACK_URI, 3786 + "at://did:plc:test/at.inlay.pack/safe", 3787 + ] as AtUriString[], 3788 + }; 3789 + 3790 + const { options } = world({ 3791 + ["at://did:plc:test/at.inlay.component/safe"]: safeWrapperComponent, 3792 + ["at://did:plc:test/at.inlay.pack/safe"]: { 3793 + $type: "at.inlay.pack", 3794 + name: "safe", 3795 + exports: [ 3796 + { 3797 + type: SafeWrapper, 3798 + component: "at://did:plc:test/at.inlay.component/safe", 3799 + }, 3800 + ], 3801 + }, 3802 + }); 3803 + 3804 + const output = await renderToCompletion( 3805 + $(Card, {}), 3806 + options, 3807 + createContext(cardComponent) 3808 + ); 3809 + assert.deepEqual( 3810 + output, 3811 + h("div", { 3812 + children: [ 3813 + h("span", { value: "header" }), 3814 + h("span", { value: "fallback" }), 3815 + ], 3816 + }) 3817 + ); 3818 + }); 3819 + 3820 + it("missing binding in nested object prop throws at primitive boundary", async () => { 3821 + // <Link uri={record.reply.parent.uri} /> 3822 + // reply is absent → Missing element in uri prop → throws at primitive 3823 + const cardComponent: ComponentRecord = { 3824 + $type: "at.inlay.component", 3825 + type: Card, 3826 + body: { 3827 + $type: "at.inlay.component#bodyTemplate", 3828 + node: serializeTree( 3829 + $(Link, { 3830 + uri: $(Binding, { path: ["record", "reply", "parent", "uri"] }), 3831 + }) 3832 + ), 3833 + }, 3834 + imports: [HOST_PACK_URI], 3835 + view: { 3836 + $type: "at.inlay.component#view", 3837 + prop: "uri", 3838 + accepts: [ 3839 + { 3840 + $type: "at.inlay.component#viewRecord", 3841 + collection: "app.bsky.feed.post", 3842 + }, 3843 + ], 3844 + }, 3845 + }; 3846 + 3847 + const { options } = world({ 3848 + ["at://did:plc:alice/app.bsky.feed.post/123"]: { title: "Hello" }, 3849 + }); 3850 + 3851 + const output = await renderToCompletion( 3852 + $(Card, { uri: "at://did:plc:alice/app.bsky.feed.post/123" }), 3853 + options, 3854 + createContext(cardComponent) 3855 + ); 3856 + assertMissing(output, ["record", "reply", "parent", "uri"]); 3857 + }); 3858 + 3859 + it("missing binding in nested object prop caught by Maybe", async () => { 3860 + // <Maybe fallback={<Text value="nope" />}> 3861 + // <Link uri={record.reply.parent.uri} /> <- missing 3862 + // </Maybe> 3863 + const cardComponent: ComponentRecord = { 3864 + $type: "at.inlay.component", 3865 + type: Card, 3866 + body: { 3867 + $type: "at.inlay.component#bodyTemplate", 3868 + node: serializeTree( 3869 + $(Maybe, { 3870 + fallback: $(Text, { value: "nope" }), 3871 + children: [ 3872 + $(Link, { 3873 + uri: $(Binding, { 3874 + path: ["record", "reply", "parent", "uri"], 3875 + }), 3876 + }), 3877 + ], 3878 + }) 3879 + ), 3880 + }, 3881 + imports: [HOST_PACK_URI], 3882 + view: { 3883 + $type: "at.inlay.component#view", 3884 + prop: "uri", 3885 + accepts: [ 3886 + { 3887 + $type: "at.inlay.component#viewRecord", 3888 + collection: "app.bsky.feed.post", 3889 + }, 3890 + ], 3891 + }, 3892 + }; 3893 + 3894 + const { options } = world({ 3895 + ["at://did:plc:alice/app.bsky.feed.post/123"]: { title: "Hello" }, 3896 + }); 3897 + 3898 + const output = await renderToCompletion( 3899 + $(Card, { uri: "at://did:plc:alice/app.bsky.feed.post/123" }), 3900 + options, 3901 + createContext(cardComponent) 3902 + ); 3903 + assert.deepEqual(output, h("span", { value: "nope" })); 3904 + }); 3905 + 3906 + it("missing binding inside nested object prop detected at primitive", async () => { 3907 + // <Text value={{ nested: record.reply.parent.uri }} /> 3908 + // Missing element ends up nested inside an object prop value 3909 + const cardComponent: ComponentRecord = { 3910 + $type: "at.inlay.component", 3911 + type: Card, 3912 + body: { 3913 + $type: "at.inlay.component#bodyTemplate", 3914 + node: serializeTree( 3915 + $(Text, { 3916 + value: { 3917 + nested: $(Binding, { 3918 + path: ["record", "reply", "parent", "uri"], 3919 + }), 3920 + }, 3921 + }) 3922 + ), 3923 + }, 3924 + imports: [HOST_PACK_URI], 3925 + view: { 3926 + $type: "at.inlay.component#view", 3927 + prop: "uri", 3928 + accepts: [ 3929 + { 3930 + $type: "at.inlay.component#viewRecord", 3931 + collection: "app.bsky.feed.post", 3932 + }, 3933 + ], 3934 + }, 3935 + }; 3936 + 3937 + const { options } = world({ 3938 + ["at://did:plc:alice/app.bsky.feed.post/123"]: { title: "Hello" }, 3939 + }); 3940 + 3941 + const output = await renderToCompletion( 3942 + $(Card, { uri: "at://did:plc:alice/app.bsky.feed.post/123" }), 3943 + options, 3944 + createContext(cardComponent) 3945 + ); 3946 + assertMissing(output, ["record", "reply", "parent", "uri"]); 3947 + }); 3948 + 3743 3949 it("component's internal Maybe catches before outer Maybe", async () => { 3744 3950 // SafeWrapper template: wraps children in its own Maybe. 3745 3951 //