Skip to content

Commit 8030656

Browse files
fix: rendering islands inside other islands (#1295)
1 parent a1d266f commit 8030656

File tree

5 files changed

+155
-16
lines changed

5 files changed

+155
-16
lines changed

src/server/render.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { renderToString } from "preact-render-to-string";
2-
import { ComponentChildren, ComponentType, Fragment, h, options } from "preact";
2+
import {
3+
Component,
4+
ComponentChildren,
5+
ComponentType,
6+
Fragment,
7+
h,
8+
Options as PreactOptions,
9+
options as preactOptions,
10+
VNode,
11+
} from "preact";
312
import {
413
AppModule,
514
ErrorPage,
@@ -20,6 +29,16 @@ import { assetHashingHook } from "../runtime/utils.ts";
2029
import { htmlEscapeJsonString } from "./htmlescape.ts";
2130
import { serialize } from "./serializer.ts";
2231

32+
// These hooks are long stable, but when we originally added them we
33+
// weren't sure if they should be public.
34+
export interface AdvancedPreactOptions extends PreactOptions {
35+
/** Attach a hook that is invoked after a tree was mounted or was updated. */
36+
__c?(vnode: VNode, commitQueue: Component[]): void;
37+
/** Attach a hook that is invoked before a vnode has rendered. */
38+
__r?(vnode: VNode): void;
39+
}
40+
const options = preactOptions as AdvancedPreactOptions;
41+
2342
export interface RenderOptions<Data> {
2443
route: Route<Data> | UnknownPage | ErrorPage;
2544
islands: Island[];
@@ -450,18 +469,51 @@ function wrapWithMarker(vnode: ComponentChildren, markerText: string) {
450469
const ISLANDS: Island[] = [];
451470
const ENCOUNTERED_ISLANDS: Set<Island> = new Set([]);
452471
let ISLAND_PROPS: unknown[] = [];
472+
473+
// Keep track of which component rendered which vnode. This allows us
474+
// to detect when an island is rendered within another instead of being
475+
// passed as children.
476+
let ownerStack: VNode[] = [];
477+
const islandOwners = new Map<VNode, VNode>();
478+
453479
const originalHook = options.vnode;
454480
let ignoreNext = false;
455481
options.vnode = (vnode) => {
456482
assetHashingHook(vnode);
457483
const originalType = vnode.type as ComponentType<unknown>;
484+
485+
// Use a labelled statement that allows ous to break out of it
486+
// whilst still continuing execution. We still want to call previous
487+
// `options.vnode` hooks if there were any, otherwise we'd break
488+
// the change for other plugins hooking into Preact.
489+
patchIslands:
458490
if (typeof vnode.type === "function") {
459491
const island = ISLANDS.find((island) => island.component === originalType);
460492
if (island) {
493+
const hasOwners = ownerStack.length > 0;
494+
if (hasOwners) {
495+
const prevOwner = ownerStack[ownerStack.length - 1];
496+
islandOwners.set(vnode, prevOwner);
497+
}
498+
499+
// Check if we already patched this component
461500
if (ignoreNext) {
462501
ignoreNext = false;
463-
return;
502+
break patchIslands;
503+
}
504+
505+
// Check if an island is rendered inside another island, not just
506+
// passed as a child. Example:
507+
// function Island() {}
508+
// return <OtherIsland />
509+
// }
510+
if (hasOwners) {
511+
const prevOwner = ownerStack[ownerStack.length - 1];
512+
if (islandOwners.has(prevOwner)) {
513+
break patchIslands;
514+
}
464515
}
516+
465517
ENCOUNTERED_ISLANDS.add(island);
466518
vnode.type = (props) => {
467519
ignoreNext = true;
@@ -488,3 +540,30 @@ options.vnode = (vnode) => {
488540
}
489541
if (originalHook) originalHook(vnode);
490542
};
543+
544+
// Keep track of owners
545+
const oldDiffed = options.diffed;
546+
const oldRender = options.__r;
547+
const oldCommit = options.__c;
548+
options.__r = (vnode) => {
549+
if (
550+
typeof vnode.type === "function" &&
551+
vnode.type !== Fragment
552+
) {
553+
ownerStack.push(vnode);
554+
}
555+
oldRender?.(vnode);
556+
};
557+
options.diffed = (vnode) => {
558+
if (typeof vnode.type === "function") {
559+
if (vnode.type !== Fragment) {
560+
ownerStack.pop();
561+
}
562+
}
563+
oldDiffed?.(vnode);
564+
};
565+
options.__c = (vnode, queue) => {
566+
oldCommit?.(vnode, queue);
567+
ownerStack = [];
568+
islandOwners.clear();
569+
};

tests/fixture_island_nesting/fresh.gen.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,33 @@
44

55
import * as $0 from "./routes/index.tsx";
66
import * as $1 from "./routes/island_in_island.tsx";
7-
import * as $2 from "./routes/island_jsx_child.tsx";
8-
import * as $3 from "./routes/island_jsx_children.tsx";
9-
import * as $4 from "./routes/island_jsx_island_jsx.tsx";
10-
import * as $5 from "./routes/island_jsx_text.tsx";
11-
import * as $6 from "./routes/island_nested_props.tsx";
12-
import * as $7 from "./routes/island_siblings.tsx";
7+
import * as $2 from "./routes/island_in_island_definition.tsx";
8+
import * as $3 from "./routes/island_jsx_child.tsx";
9+
import * as $4 from "./routes/island_jsx_children.tsx";
10+
import * as $5 from "./routes/island_jsx_island_jsx.tsx";
11+
import * as $6 from "./routes/island_jsx_text.tsx";
12+
import * as $7 from "./routes/island_nested_props.tsx";
13+
import * as $8 from "./routes/island_siblings.tsx";
1314
import * as $$0 from "./islands/Island.tsx";
14-
import * as $$1 from "./islands/IslandWithProps.tsx";
15+
import * as $$1 from "./islands/IslandInsideIsland.tsx";
16+
import * as $$2 from "./islands/IslandWithProps.tsx";
1517

1618
const manifest = {
1719
routes: {
1820
"./routes/index.tsx": $0,
1921
"./routes/island_in_island.tsx": $1,
20-
"./routes/island_jsx_child.tsx": $2,
21-
"./routes/island_jsx_children.tsx": $3,
22-
"./routes/island_jsx_island_jsx.tsx": $4,
23-
"./routes/island_jsx_text.tsx": $5,
24-
"./routes/island_nested_props.tsx": $6,
25-
"./routes/island_siblings.tsx": $7,
22+
"./routes/island_in_island_definition.tsx": $2,
23+
"./routes/island_jsx_child.tsx": $3,
24+
"./routes/island_jsx_children.tsx": $4,
25+
"./routes/island_jsx_island_jsx.tsx": $5,
26+
"./routes/island_jsx_text.tsx": $6,
27+
"./routes/island_nested_props.tsx": $7,
28+
"./routes/island_siblings.tsx": $8,
2629
},
2730
islands: {
2831
"./islands/Island.tsx": $$0,
29-
"./islands/IslandWithProps.tsx": $$1,
32+
"./islands/IslandInsideIsland.tsx": $$1,
33+
"./islands/IslandWithProps.tsx": $$2,
3034
},
3135
baseUrl: import.meta.url,
3236
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ComponentChildren } from "preact";
2+
import Island from "./Island.tsx";
3+
4+
export default function IslandInsideIsland(
5+
props: { children?: ComponentChildren },
6+
) {
7+
return (
8+
<div class="island">
9+
<Island>
10+
{props.children}
11+
</Island>
12+
</div>
13+
);
14+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import IslandInsideIsland from "../islands/IslandInsideIsland.tsx";
2+
3+
export default function Home() {
4+
return (
5+
<div id="page">
6+
<IslandInsideIsland>
7+
<p>it works</p>
8+
</IslandInsideIsland>
9+
</div>
10+
);
11+
}

tests/islands_test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,37 @@ Deno.test({
344344
sanitizeResources: false,
345345
});
346346

347+
Deno.test({
348+
name: "render island inside island definition",
349+
350+
async fn(_t) {
351+
await withPageName(
352+
"./tests/fixture_island_nesting/main.ts",
353+
async (page, address) => {
354+
await page.goto(`${address}/island_in_island_definition`, {
355+
waitUntil: "networkidle2",
356+
});
357+
await page.waitForSelector(".island");
358+
359+
await delay(100);
360+
const text = await page.$eval(
361+
".island .island p",
362+
(el) => el.textContent,
363+
);
364+
assertEquals(text, "it works");
365+
366+
// Check that there is no duplicated content which could happen
367+
// when islands aren't initialized correctly
368+
const pageText = await page.$eval("#page", (el) => el.textContent);
369+
assertEquals(pageText, "it works");
370+
},
371+
);
372+
},
373+
374+
sanitizeOps: false,
375+
sanitizeResources: false,
376+
});
377+
347378
Deno.test({
348379
name:
349380
"render island with JSX children that render another island with JSX children",

0 commit comments

Comments
 (0)