Skip to content

Commit 43b5aaf

Browse files
docs(frames): slots with args render as JSX everywhere authored code is modeled
The asyncArg docblock and the JSX-capable fixtures now show the compiled getter-wrapped form (render callbacks included — JSX-in-map); the one eager- call test remains, relabeled as deliberately pinning the wrong form's failure mode. Argless slots stay plain prop access. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8e148a8 commit 43b5aaf

4 files changed

Lines changed: 71 additions & 32 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
The `asyncArg` docs model the correct authored form for slots with args: JSX (`<props.status …/>` — the compiler wraps each prop in a getter, deferring reads to the slot border), never a call, which evaluates its args eagerly in the component body — a top-level read, an error in most cases. Argless slots remain plain prop access.

packages/solid-web/frames/src/server.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,13 @@ export type Slot<P = {}> = (props: P & { $key?: string | number }) => SolidEleme
4848
* runtime, settled type at the border, so `Slot<P>` keeps the fill's props
4949
* truthful to what its reads actually return.
5050
*
51+
* Slots render as JSX — the compiler wraps each prop in a getter so the read
52+
* defers to the slot border, where the runtime owns it. A call form
53+
* (`props.status({ … })`) evaluates its args eagerly in the component body —
54+
* a top-level read, an error in most cases.
55+
*
5156
* ```tsx
52-
* props.status({ progress: asyncArg(gen.progress), stats: asyncArg(gen.stats) })
57+
* <props.status progress={asyncArg(gen.progress)} stats={asyncArg(gen.stats)} />
5358
* ```
5459
*/
5560
export function asyncArg<T>(value: PromiseLike<T> | AsyncIterable<T>): T {

packages/solid-web/test/server/document-face-arg-tiers.spec.tsx

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,18 @@
66
// (createSlotProps); the document face (createDocumentSlotProps) predates
77
// them. This spec pins what the document face does today, empirically:
88
//
9-
// - A NOT-READY arg (a thunk/getter throwing not-ready at the unwrap, or an
10-
// eager call suspending in the component's render) is held COARSELY by the
11-
// fragment model: the server component's own <Loading> defers the section
12-
// and the retry delivers the settled value in the deferred fragment. This
13-
// is the "holding" alternative DR-2 rejected for the stream face's
14-
// granularity — but at t=0 it is functional and consistent with "markup is
15-
// the snapshot" (generator-only-model.md §10). Pinned as PASSING.
9+
// Slots with args render as JSX (`<props.status …/>` — the compiled getter,
10+
// the only correct authored form); the one call-form test below pins the
11+
// WRONG form's failure mode on purpose.
12+
//
13+
// - A NOT-READY arg (a compiled getter throwing not-ready at the unwrap, or
14+
// the wrong-form eager call suspending in the component's render) is held
15+
// COARSELY by the fragment model: the server component's own <Loading>
16+
// defers the section and the retry delivers the settled value in the
17+
// deferred fragment. This is the "holding" alternative DR-2 rejected for
18+
// the stream face's granularity — but at t=0 it is functional and
19+
// consistent with "markup is the snapshot" (generator-only-model.md §10).
20+
// Pinned as PASSING.
1621
//
1722
// - An ASYNC VALUE PASSED WHOLE (the value tier: a promise/iterable arg)
1823
// suspends at the inline read: the document face wraps it in a full
@@ -58,11 +63,18 @@ describe("document face × arg tiers (t=0)", () => {
5863
// invariance at t=0: the same authored crossing behaves identically
5964
// whether the mount is call-driven or the initial document.
6065
test("an async slot arg's inline read settles through the document's streaming (value tier)", async () => {
61-
const ServerComp = (props: any) => (
62-
<Loading fallback={<span>GENFB</span>}>
63-
<section>{props.status({ stats: wait(10).then(() => ({ tokens: 42 })) })}</section>
64-
</Loading>
65-
);
66+
const ServerComp = (props: any) => {
67+
// Hoisted so the compiled getter reads ONE promise (a `.then()` inline
68+
// in the JSX would mint a fresh promise per getter read).
69+
const stats = wait(10).then(() => ({ tokens: 42 }));
70+
return (
71+
<Loading fallback={<span>GENFB</span>}>
72+
<section>
73+
<props.status stats={stats} />
74+
</section>
75+
</Loading>
76+
);
77+
};
6678
const Inline = frameTransformDirectResult(ServerComp, { id: "dfa-value" }) as any;
6779
const html = await collect(
6880
() => (
@@ -92,11 +104,19 @@ describe("document face × arg tiers (t=0)", () => {
92104
await wait(5);
93105
yield "second";
94106
}
95-
const ServerComp = (props: any) => (
96-
<Loading fallback={<span>GENFB</span>}>
97-
<section>{props.status({ tick: ticks() })}</section>
98-
</Loading>
99-
);
107+
const ServerComp = (props: any) => {
108+
// Hoisted: the tap's whole premise is ONE cursor with two consumers —
109+
// an inline `ticks()` in the JSX would mint a generator per getter
110+
// read and quietly dissolve the thing under test.
111+
const tick = ticks();
112+
return (
113+
<Loading fallback={<span>GENFB</span>}>
114+
<section>
115+
<props.status tick={tick} />
116+
</section>
117+
</Loading>
118+
);
119+
};
100120
const Inline = frameTransformDirectResult(ServerComp, { id: "dfa-iter" }) as any;
101121
const html = await collect(
102122
() => (
@@ -114,12 +134,16 @@ describe("document face × arg tiers (t=0)", () => {
114134
expect(html).toContain("second");
115135
});
116136

117-
test("a not-ready thunk arg is held by the fragment model and delivers settled (coarse holding)", async () => {
137+
test("a not-ready getter arg is held by the fragment model and delivers settled (coarse holding)", async () => {
118138
const ServerComp = (props: any) => {
119139
const m = createMemo(() => wait(10).then(() => "READY"));
140+
// `v={m()}` compiles to a getter — the common authored form: the read
141+
// defers to the border, where it throws not-ready at the unwrap.
120142
return (
121143
<Loading fallback={<span>GENFB</span>}>
122-
<section>{props.status({ v: () => m() })}</section>
144+
<section>
145+
<props.status v={m()} />
146+
</section>
123147
</Loading>
124148
);
125149
};
@@ -141,9 +165,14 @@ describe("document face × arg tiers (t=0)", () => {
141165
expect(visible(html)).toContain("v:READY");
142166
});
143167

144-
test("a not-ready eager call arg suspends the component render and delivers settled (same coarse holding)", async () => {
168+
test("the WRONG form — an eager call arg suspends the whole component render (pinned failure mode)", async () => {
145169
const ServerComp = (props: any) => {
146170
const m = createMemo(() => wait(10).then(() => "READY"));
171+
// The call form is the incorrect authored shape: `m()` is a top-level
172+
// read, evaluated in the component body before the border. JSX can't
173+
// even express this — which is the point. Pinned so the failure mode
174+
// is a known quantity: the not-ready read suspends the whole section
175+
// (no crash, no orphan), same coarse holding as the getter case.
147176
return (
148177
<Loading fallback={<span>GENFB</span>}>
149178
<section>{props.status({ v: m() })}</section>

packages/solid-web/test/server/frame-hn.spec.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,20 @@ const STORIES: Record<string, { title: string; comments: CommentData[] }> = {
6161
// The server component a `use server` function would return: recursive
6262
// comments where each comment is a CLIENT position (props.comment) whose
6363
// children — text and replies — are SERVER content flowing as a nested
64-
// region. Text ships in html, once, by construction.
64+
// region. Text ships in html, once, by construction. Slots with args render
65+
// as JSX — render callbacks included — never as calls, which evaluate their
66+
// args eagerly in the component body (argless slots are plain prop access).
6567
registerServerFunction("getStory", async (storyId: string) => {
6668
const story = STORIES[storyId];
6769
return (props: any) => {
68-
const renderComment = (c: CommentData): any =>
69-
props.comment({
70-
cid: c.id,
71-
children: (
72-
<div class="body">
73-
<p>{c.text}</p>
74-
{c.replies.map(renderComment)}
75-
</div>
76-
)
77-
});
70+
const renderComment = (c: CommentData): any => (
71+
<props.comment cid={c.id}>
72+
<div class="body">
73+
<p>{c.text}</p>
74+
{c.replies.map(renderComment)}
75+
</div>
76+
</props.comment>
77+
);
7878
return (
7979
<article>
8080
<h1>{story.title}</h1>

0 commit comments

Comments
 (0)