Skip to content

Commit 6328ff4

Browse files
authored
fix(portal-gun): Add support for portals with block elements inside inline elements (#258)
1 parent 293de42 commit 6328ff4

File tree

6 files changed

+72
-4
lines changed

6 files changed

+72
-4
lines changed

.changeset/block-element-portal.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@inox-tools/portal-gun": patch
3+
---
4+
5+
Fixed portals placed inside inline elements (like `<p>` tags) when the portal content contains block elements (like `<div>`).
6+
7+
Previously, using a custom element tag for portal entries caused HTML parsers to treat the portal as an inline element, leading to malformed HTML when block elements were sent through the portal. Now portal entries use a `<div>` placeholder with a data attribute, which correctly handles the block-in-inline parsing behavior.
8+
9+
Note: When a block element inside a portal causes it to be moved out of an inline element, the inline element will be split, leaving empty tags (e.g., `<p></p><p></p>`). This is standard HTML parsing behavior and matches how MDX handles components alone in their lines. If this is undesired, consider using a plugin or a separate middleware to remove consecutive empty `<p>` tags.
10+
11+
Fixes #257

packages/portal-gun/src/runtime/middleware.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,25 @@ import {
1313

1414
const processor = rehype();
1515

16+
// Must be a standard block element to handle the problem with block-in-inline elements.
17+
// See https://github.com/Fryuni/inox-tools/issues/257
18+
const PLACEHOLDER_ELEMENT_TAG = 'div';
19+
1620
export const onRequest: MiddlewareHandler = async (_, next) => {
1721
const response = await next();
1822
if (response.headers.get('content-type')?.includes('text/html') !== true) {
1923
return response;
2024
}
2125

2226
const body = await response.text();
23-
const tree = processor.parse(body);
27+
const tree = processor.parse(
28+
body
29+
.replaceAll(
30+
new RegExp(`<${ENTRY_PORTAL_TAG}(?=\\s|>)`, 'g'),
31+
`<${PLACEHOLDER_ELEMENT_TAG} data-inox-tools-portal-entry`
32+
)
33+
.replaceAll(`</${ENTRY_PORTAL_TAG}>`, `</${PLACEHOLDER_ELEMENT_TAG}>`)
34+
);
2435

2536
const portalContents = new Map<string, hast.ElementContent[][]>();
2637
const landingPortals: Array<{
@@ -83,11 +94,14 @@ export const onRequest: MiddlewareHandler = async (_, next) => {
8394
switch (node.tagName) {
8495
case EXIT_PORTAL_TAG:
8596
return portalOut(node, parents);
86-
case ENTRY_PORTAL_TAG:
87-
return portalIn(node, parents);
8897
case 'body':
8998
case 'head':
9099
break;
100+
case PLACEHOLDER_ELEMENT_TAG:
101+
if (node.properties.dataInoxToolsPortalEntry !== undefined) {
102+
return portalIn(node, parents);
103+
}
104+
// fallthrough: treat non-portal divs as normal elements
91105
default: {
92106
const id = node.properties.id;
93107
if (typeof id === 'string') {

packages/portal-gun/tests/common.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { expect, test } from 'vitest';
22

3-
export const defineCommonTests = (loadPath: (path: string) => Promise<string>) => {
3+
export const defineCommonTests = (loadPathRaw: (path: string) => Promise<string | null>) => {
4+
const loadPath = async (path: string) => {
5+
const result = await loadPathRaw(path);
6+
if (result === null) throw new Error(`Could not load path: ${path}`);
7+
return result;
8+
};
9+
410
test('elements are sent across Astro Components', async () => {
511
const html = await loadPath('header-footer');
612

@@ -139,6 +145,26 @@ export const defineCommonTests = (loadPath: (path: string) => Promise<string>) =
139145
</footer>
140146
</body>
141147
</html>
148+
`);
149+
});
150+
151+
test('portals work with block elements while inside inline elements', async () => {
152+
const html = await loadPath('inside-inline-element');
153+
154+
expect(html).toEqualIgnoringWhitespace(`
155+
<!doctype html>
156+
<html>
157+
<head>
158+
<title>Index</title>
159+
</head>
160+
<body>
161+
<p>Hello </p><p></p>
162+
<main id="content">
163+
<p>Original</p>
164+
<div>WORLD</div>
165+
</main>
166+
</body>
167+
</html>
142168
`);
143169
});
144170
};

packages/portal-gun/tests/fixture/basic/astro.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ import portalGun from '@inox-tools/portal-gun';
44
import preact from '@astrojs/preact';
55

66
export default defineConfig({
7+
compressHTML: false,
78
integrations: [portalGun(), preact()],
89
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<portal-gate to="end:#content"><div>WORLD</div></portal-gate>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
import Portal from './_comp/Portal.astro';
3+
---
4+
5+
<html>
6+
<head>
7+
<title>Index</title>
8+
</head>
9+
<body>
10+
<p>Hello <Portal /></p>
11+
<main id="content">
12+
<p>Original</p>
13+
</main>
14+
</body>
15+
</html>

0 commit comments

Comments
 (0)