Skip to content

Commit 4a1d997

Browse files
fix(web): make Portal a client-only island instead of throwing during SSR
The server renders nothing for a Portal (children never evaluate, no async starts); the client gates the portal's content, effects, and tree anchor with ssrSource "client" so they render fresh in the settle flush. Both sides consume exactly one child-id slot so hydration ids after a portal stay aligned (#2876). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 43c537a commit 4a1d997

10 files changed

Lines changed: 281 additions & 19 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
Portal no longer crashes SSR — portals are client-only islands (#2876)
6+
7+
The server renders nothing for a `<Portal>`: children are never evaluated, no
8+
async starts, and nothing is serialized. Throwing (as earlier betas did) was
9+
caught by ancestor `Errored` boundaries and baked the error fallback into the
10+
streamed HTML for trees that render fine client-side.
11+
12+
Both sides advance the parent's child-id counter by exactly one slot — the
13+
client scopes the portal's internals under a dedicated owner and the server
14+
consumes the matching id — so hydration ids for siblings after a portal stay
15+
aligned.
16+
17+
On the client, the portal's content memo and effects are gated with
18+
`ssrSource: "client"`, so under hydration the children render fresh in the
19+
settle flush — no evaluation during the hydration walk, no effect-type
20+
switching (the 1.x timing hack). Async discovered inside a portal after
21+
settle forwards through already-initialized ancestor boundaries as ordinary
22+
pending status, so nothing regresses to a fallback; the portal simply attaches
23+
when its content is ready.

examples/rendering/shared/src/components/Settings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Portal } from "@solidjs/web";
33

44
const Settings = () => {
55
const [text, setText] = createSignal("Hi");
6-
const [modalOpen, setModalOpen] = createSignal(false);
6+
const [modalOpen, setModalOpen] = createSignal(true);
77
const [modalClicks, setModalClicks] = createSignal(0);
88
const id = createUniqueId();
99

packages/solid-web/server/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,21 @@ export function Dynamic<T extends ValidComponent>(props: DynamicProps<T>): JSX.E
125125
return createComponent(Comp, omit(props, "component") as ComponentProps<T>);
126126
}
127127

128-
export function Portal(props: { mount?: Node; useShadow?: boolean; children: JSX.Element }) {
129-
throw new Error("Portal is not supported on the server");
128+
/**
129+
* Portals are client-only islands: the server renders nothing for them —
130+
* `props.children` is never evaluated, no async is started, and nothing is
131+
* serialized. The client renders the content fresh once hydration settles.
132+
* Throwing here instead (as earlier betas did) is strictly worse: an ancestor
133+
* `Errored` catches it and bakes the error fallback into the streamed HTML
134+
* for a tree that renders fine client-side (#2876).
135+
*
136+
* The one thing both sides must still agree on is the parent's child-id
137+
* counter: the client Portal scopes its internals under one owner (one slot),
138+
* so consume the matching slot here or every hydration id after the portal
139+
* drifts.
140+
*/
141+
export function Portal(props: { mount?: Element; children: JSX.Element }) {
142+
const o = getOwner();
143+
if (o?.id != null) getNextChildId(o);
144+
return undefined as unknown as JSX.Element;
130145
}

packages/solid-web/src/index.ts

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import {
1515
createComponent,
1616
createMemo,
17+
createOwner,
1718
createRoot,
1819
getOwner,
1920
runWithOwner,
@@ -197,6 +198,12 @@ export const hydrate: typeof hydrateCore = (...args) => {
197198
* still participates in the parent's reactive scope and disposes when the
198199
* parent does.
199200
*
201+
* Portals are client-only islands: the server renders nothing for them, and
202+
* under hydration the children render fresh once hydration settles. Async
203+
* read inside a portal therefore starts on the client — data that should be
204+
* fetched on the server belongs above the portal (hoist the read, not the
205+
* render), and async UI inside one wants its own `<Loading>` boundary.
206+
*
200207
* @example
201208
* ```tsx
202209
* <Portal mount={document.getElementById("modal-root")!}>
@@ -206,15 +213,30 @@ export const hydrate: typeof hydrateCore = (...args) => {
206213
*
207214
* @description https://docs.solidjs.com/reference/components/portal
208215
*/
209-
export function Portal<T extends boolean = false, S extends boolean = false>(props: {
210-
mount?: Element;
211-
children: JSX.Element;
212-
}): JSX.Element {
216+
export function Portal(props: { mount?: Element; children: JSX.Element }): JSX.Element {
217+
// Everything the portal allocates (content memo, effects, anchor memo)
218+
// lives under one dedicated id scope: the server Portal renders nothing,
219+
// so without this the client primitives would advance the parent's
220+
// child-id counter and shift every hydration id after the portal. Both
221+
// sides consume exactly one slot from the parent instead — the owner
222+
// here, `getNextChildId` in the server Portal.
223+
return runWithOwner(createOwner(), () => portalImpl(props)) as unknown as JSX.Element;
224+
}
225+
226+
function portalImpl(props: { mount?: Element; children: JSX.Element }): JSX.Element {
213227
const treeMarker = document.createTextNode(""),
214228
startMarker = document.createTextNode(""),
215229
endMarker = document.createTextNode(""),
216230
mount = () => props.mount || document.body,
217-
content = createMemo(() => [startMarker, props.children] as unknown as JSX.Element);
231+
// `ssrSource: "client"`: the server renders nothing for portals, so under
232+
// hydration the children must not evaluate during the hydration walk —
233+
// the gate defers the compute to the settle flush, where it runs as a
234+
// plain fresh render. Ancestor boundaries are `_initialized` by then, so
235+
// async discovered inside the portal forwards as ordinary pending status
236+
// instead of regressing anything to a fallback (#2876).
237+
content = createMemo(() => [startMarker, props.children] as unknown as JSX.Element, {
238+
ssrSource: "client"
239+
});
218240

219241
createRenderEffect<[Element, JSX.Element, Owner | null]>(
220242
// `getOwner()` is captured in the compute-half: the effect-half runs from
@@ -247,17 +269,37 @@ export function Portal<T extends boolean = false, S extends boolean = false>(pro
247269
}
248270
};
249271
},
250-
{ schedule: true }
272+
// Also `ssrSource: "client"` (like `content` above) so the effect never
273+
// fires with empty content during the hydration window — the first run
274+
// happens post-settle with the real children, exactly like a fresh mount.
275+
{ schedule: true, ssrSource: "client" }
251276
);
252277

253-
createEffect(mount, () => {
254-
const m = untrack(mount);
255-
const ownerRoot = getDelegatedRoot(treeMarker);
256-
if (!ownerRoot || (ownerRoot as Node).contains(m)) return;
257-
registerDelegatedContainer(m, ownerRoot);
258-
return () => unregisterDelegatedContainer(m, ownerRoot);
259-
});
278+
createEffect(
279+
mount,
280+
() => {
281+
const m = untrack(mount);
282+
const ownerRoot = getDelegatedRoot(treeMarker);
283+
if (!ownerRoot || (ownerRoot as Node).contains(m)) return;
284+
registerDelegatedContainer(m, ownerRoot);
285+
return () => unregisterDelegatedContainer(m, ownerRoot);
286+
},
287+
{ ssrSource: "client" }
288+
);
260289

290+
// The anchor is client-only content too: during the hydration walk the
291+
// parent's insert only claims server nodes, so a bare `treeMarker` would be
292+
// silently dropped and break everything resolving through
293+
// `treeMarker.parentNode` (`_$host` retargeting, delegated containers).
294+
// Gate it like the rest. The memo isn't caching the (constant) marker —
295+
// it's the reactive carrier of the hydration gate: `ssrSource: "client"`
296+
// wraps the compute in a hidden gate signal, so the memo reads undefined
297+
// during the walk and flips to the marker in the settle flush, re-running
298+
// the parent's insert when fresh nodes may be placed again. A memo (not a
299+
// naked accessor) so the flip is equals-gated and resolved under this
300+
// owner, like any control-flow return.
301+
if (sharedConfig.hydrating)
302+
return createMemo(() => treeMarker, { ssrSource: "client" }) as unknown as JSX.Element;
261303
return treeMarker as unknown as JSX.Element;
262304
}
263305

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "portal-async-content",
3+
"shell": "<div _hk=1000><span>body </span><!--$--><!--/--><section></section></div><script>(self.$R=self.$R||{})[\"\"]=[];_$HY.r[\"0\"]=$R[0]=($R[1]=($R[2]=() => {\n const resolver = {\n p: 0,\n s: 0,\n f: 0\n };\n resolver.p = new Promise((resolve, reject) => {\n resolver.s = resolve;\n resolver.f = reject;\n });\n return resolver;\n})()).p;</script>",
4+
"rest": "<script>($R[3]=(resolver, data) => {\n resolver.s(data);\n resolver.p.s = 1;\n resolver.p.v = data;\n})($R[1],\"late\");</script>"
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "portal-before-siblings",
3+
"shell": "<template id=\"pl-1\"></template><p _hk=10>loading</p><!--pl-1--><script>(self.$R=self.$R||{})[\"\"]=[];_$HY.r[\"0\"]=$R[0]=($R[1]=($R[2]=() => {\n const resolver = {\n p: 0,\n s: 0,\n f: 0\n };\n resolver.p = new Promise((resolve, reject) => {\n resolver.s = resolve;\n resolver.f = reject;\n });\n return resolver;\n})()).p;_$HY.r[\"1_fr\"]=$R[3]=($R[4]=$R[2]()).p;</script>",
4+
"rest": "<template id=\"1\"><div _hk=1000><span>lead </span><!--$--><!--/--><!--$--><h4 _hk=10030>head </h4><!--/--><span>srv</span><section></section></div></template><script>($R[5]=(resolver, data) => {\n resolver.s(data);\n resolver.p.s = 1;\n resolver.p.v = data;\n})($R[1],\"srv\");$df(\"1\");function $df(e,n,o,t){if(!(n=document.getElementById(e)))return 0;if(!(o=document.getElementById(\"pl-\"+e)))return(_$HY.dq=_$HY.dq||{})[e]=1,0;for(;o&&8!==o.nodeType&&o.nodeValue!==\"pl-\"+e;)t=o.nextSibling,o.remove(),o=t;_$HY.done?o.remove():o.replaceWith(n.content),n.remove(),_$HY.fe(e),$dfd();return 1}function $dfl(e,o,n){if(!(o=document.getElementById(\"pl-\"+e)))return(_$HY.dlq=_$HY.dlq||{})[e]=1,0;if(o._$fl)return 1;for(n=o.nextSibling;n;){if(8===n.nodeType&&n.nodeValue===\"pl-\"+e){o.parentNode&&o.parentNode.insertBefore(o.content.cloneNode(!0),n),o._$fl=1,$dfd();return 1}n=n.nextSibling}return 0}function $dflj(e,i){for(i=0;i<e.length;i++)$dfl(e[i])}function $dfd(e,i){if(e=_$HY.dq){_$HY.dq=0;for(i in e)$df(i)}if(e=_$HY.dlq){_$HY.dlq=0;for(i in e)$dfl(i)}}function $dfs(e,c,d){(_$HY.sc=_$HY.sc||{})[e]=c,d&&((_$HY.sd=_$HY.sd||{})[e]=1)}function $dfg(e,g,i,k){if(!(g=_$HY.sg&&_$HY.sg[e]))return;for(i=0;i<g.length;i++)if(_$HY.sc&&_$HY.sc[g[i]]>0)return;for(i=0;i<g.length;i++)k=g[i],delete _$HY.sg[k],$df(k)}function $dfc(e){if(--_$HY.sc[e]<=0){delete _$HY.sc[e],_$HY.sg&&_$HY.sg[e]?$dfg(e):!(_$HY.sd&&_$HY.sd[e])&&$df(e);_$HY.sd&&delete _$HY.sd[e]}}function $dfj(e,i,n){for(i=0;i<e.length;i++)if(_$HY.sc&&_$HY.sc[e[i]]>0){for(n=0;n<e.length;n++)(_$HY.sg=_$HY.sg||{})[e[n]]=e;return}for(i=0;i<e.length;i++)$df(e[i])};$R[5]($R[4],!0);</script>"
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "portal-client-island",
3+
"shell": "<div _hk=0><article><span>page </span><span>clicks:<!--$-->0<!--/--> </span><!--$--><!--/--></article><section></section></div>",
4+
"rest": ""
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "portal-under-errored",
3+
"shell": "<div _hk=000><span>safe </span><!--$--><!--/--><section></section></div>",
4+
"rest": ""
5+
}

packages/solid-web/test/harness/scenarios.tsx

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
* instantiation, so the hydrate spec can drive post-hydration updates.
2222
* - Keep async delays short (5-15ms) — the specs own the settle waits.
2323
*/
24-
import { createSignal, createMemo, Show, For, Loading } from "solid-js";
24+
import { createSignal, createMemo, Show, For, Loading, Errored } from "solid-js";
25+
import { Portal } from "@solidjs/web";
2526

2627
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
2728

@@ -30,6 +31,12 @@ export type Scenario = {
3031
App: () => any;
3132
/** textContent of the container once hydration fully settles */
3233
expectedText: string;
34+
/**
35+
* Tokens the server-rendered HTML must contain. Defaults to expectedText —
36+
* override for scenarios with client-only content (e.g. Portal), where the
37+
* settled client DOM legitimately contains text the server never rendered.
38+
*/
39+
serverText?: string;
3340
/** client-side update trigger (rebound on every App instantiation) */
3441
update?: () => void;
3542
expectedTextAfterUpdate?: string;
@@ -364,6 +371,119 @@ function FalsyAndProp() {
364371
return <ValueCard value={val() && val()!.toUpperCase()} />;
365372
}
366373

374+
// ---------------------------------------------------------------------------
375+
// Portal: client-only island (#2876). Server renders nothing for the portal;
376+
// the client renders its children fresh once hydration settles. Mounting into
377+
// a host inside the container lets the harness textContent assertion cover
378+
// the portal content. The host is a signal so the `mount` prop compiles as a
379+
// getter — a bare identifier would be captured (undefined) at creation time,
380+
// before the ref assigns.
381+
//
382+
// The host <section> sits OUTSIDE the <article> that owns the click handler,
383+
// so the update's synthetic click on the portal content can only reach the
384+
// handler through `_$host` logical retargeting — which requires the portal's
385+
// tree anchor to actually connect during hydration. (A dead anchor still
386+
// mounts visible content; this is the assertion that catches it, since real
387+
// DOM bubbling from the host would never touch the <article>.)
388+
let setPortalMsg!: (v: string) => void;
389+
let clickPortalContent!: () => void;
390+
function PortalClientIsland() {
391+
const [msg, set] = createSignal("modal");
392+
const [clicks, setClicks] = createSignal(0);
393+
const [host, setHost] = createSignal<HTMLElement>();
394+
setPortalMsg = set;
395+
let b!: HTMLElement;
396+
clickPortalContent = () => b.click();
397+
return (
398+
<div>
399+
<article onClick={() => setClicks(c => c + 1)}>
400+
<span>page </span>
401+
<span>clicks:{clicks()} </span>
402+
<Portal mount={host()}>
403+
<b ref={b}>{msg()}</b>
404+
</Portal>
405+
</article>
406+
<section ref={setHost} />
407+
</div>
408+
);
409+
}
410+
411+
// ---------------------------------------------------------------------------
412+
// Portal with async children under a settled Loading boundary. The async is
413+
// discovered only after hydration settles (the portal gate defers children);
414+
// the initialized boundary must forward the pending status without regressing
415+
// the page to its fallback, and the portal content pops in when it resolves.
416+
let refreshPortalAsync!: () => void;
417+
function PortalAsyncContent() {
418+
const [version, setVersion] = createSignal(0);
419+
const [host, setHost] = createSignal<HTMLElement>();
420+
refreshPortalAsync = () => setVersion(v => v + 1);
421+
const data = createMemo(async () => {
422+
const v = version();
423+
await sleep(10);
424+
return v ? `late-${v}` : "late";
425+
});
426+
return (
427+
<Loading fallback={<p>loading</p>}>
428+
<div>
429+
<span>body </span>
430+
<Portal mount={host()}>
431+
<em>{data()}</em>
432+
</Portal>
433+
<section ref={setHost} />
434+
</div>
435+
</Loading>
436+
);
437+
}
438+
439+
// ---------------------------------------------------------------------------
440+
// Portal BEFORE id-allocating siblings. The portal's client-side primitives
441+
// allocate hydration ids that the server (which renders nothing) never did —
442+
// unless both sides advance the parent counter identically, every id after
443+
// the portal drifts: the sibling condition's <h4> _hk no longer matches and
444+
// the async memo looks up (or worse, adopts) a serialized value that belongs
445+
// to a different primitive.
446+
let refreshPortalSibling!: () => void;
447+
function PortalBeforeSiblings() {
448+
const [version, setVersion] = createSignal(0);
449+
const [host, setHost] = createSignal<HTMLElement>();
450+
refreshPortalSibling = () => setVersion(v => v + 1);
451+
const [shown] = createSignal(true);
452+
const data = createMemo(async () => {
453+
const v = version();
454+
await sleep(5);
455+
return v ? `srv-${v}` : "srv";
456+
});
457+
return (
458+
<Loading fallback={<p>loading</p>}>
459+
<div>
460+
<span>lead </span>
461+
<Portal mount={host()}>pop</Portal>
462+
{shown() && <h4>head </h4>}
463+
<span>{data()}</span>
464+
<section ref={setHost} />
465+
</div>
466+
</Loading>
467+
);
468+
}
469+
470+
// ---------------------------------------------------------------------------
471+
// Portal under Errored — the exact #2876 report shape. The old server throw
472+
// was caught by the boundary and baked the error fallback into the stream;
473+
// the no-op server Portal must render the real content around it.
474+
function PortalUnderErrored() {
475+
const [host, setHost] = createSignal<HTMLElement>();
476+
return (
477+
<Errored fallback={<p>err-fallback</p>}>
478+
<div>
479+
<span>safe </span>
480+
<Portal mount={host()}>tip</Portal>
481+
<section ref={setHost} />
482+
</div>
483+
</Errored>
484+
);
485+
}
486+
367487
export const scenarios: Scenario[] = [
368488
{
369489
name: "text-hole",
@@ -508,5 +628,44 @@ export const scenarios: Scenario[] = [
508628
update: () => setVal("hi"),
509629
expectedTextAfterUpdate: "set:HI",
510630
stableSelector: "div"
631+
},
632+
{
633+
name: "portal-client-island",
634+
App: PortalClientIsland,
635+
expectedText: "page clicks:0 modal",
636+
serverText: "page clicks:0",
637+
update: () => {
638+
setPortalMsg("MODAL");
639+
clickPortalContent();
640+
},
641+
expectedTextAfterUpdate: "page clicks:1 MODAL",
642+
stableSelector: "div, span, section"
643+
},
644+
{
645+
name: "portal-async-content",
646+
App: PortalAsyncContent,
647+
async: true,
648+
expectedText: "body late",
649+
serverText: "body",
650+
update: () => refreshPortalAsync(),
651+
expectedTextAfterUpdate: "body late-1",
652+
stableSelector: "span, section"
653+
},
654+
{
655+
name: "portal-before-siblings",
656+
App: PortalBeforeSiblings,
657+
async: true,
658+
expectedText: "lead head srvpop",
659+
serverText: "lead head srv",
660+
update: () => refreshPortalSibling(),
661+
expectedTextAfterUpdate: "lead head srv-1pop",
662+
stableSelector: "h4, section"
663+
},
664+
{
665+
name: "portal-under-errored",
666+
App: PortalUnderErrored,
667+
expectedText: "safe tip",
668+
serverText: "safe",
669+
stableSelector: "div, span, section"
511670
}
512671
];

packages/solid-web/test/server/hydration-harness.spec.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,12 @@ describe("hydration parity harness — server render", () => {
5252
const full = shell + rest;
5353

5454
// Text sanity: strip scripts, then tags. Template contents survive the
55-
// tag strip, so late-streamed fragment text is included.
55+
// tag strip, so late-streamed fragment text is included. serverText
56+
// overrides expectedText for scenarios with client-only content.
5657
const visible = full.replace(/<script[\s\S]*?<\/script>/g, "").replace(/<[^>]*>/g, "");
57-
for (const token of scenario.expectedText.split(/\s+/).filter(Boolean)) {
58+
for (const token of (scenario.serverText ?? scenario.expectedText)
59+
.split(/\s+/)
60+
.filter(Boolean)) {
5861
expect(visible).toContain(token);
5962
}
6063

0 commit comments

Comments
 (0)