portals
Simon Holthausen 4 weeks ago
parent bbf38291fc
commit 1d40d3f237

@ -45,7 +45,8 @@ const meta_tags = new Map([
['svelte:component', 'SvelteComponent'],
['svelte:self', 'SvelteSelf'],
['svelte:fragment', 'SvelteFragment'],
['svelte:boundary', 'SvelteBoundary']
['svelte:boundary', 'SvelteBoundary'],
['svelte:portal', 'SveltePortal']
]);
/** @param {Parser} parser */

@ -62,6 +62,7 @@ import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { SveltePortal } from './visitors/SveltePortal.js';
import { TaggedTemplateExpression } from './visitors/TaggedTemplateExpression.js';
import { Text } from './visitors/Text.js';
import { TitleElement } from './visitors/TitleElement.js';
@ -175,6 +176,7 @@ const visitors = {
SvelteSelf,
SvelteWindow,
SvelteBoundary,
SveltePortal,
TaggedTemplateExpression,
Text,
TransitionDirective,

@ -0,0 +1,15 @@
/** @import { AST } from '#compiler' */
/** @import { Context } from '../types' */
import { visit_component } from './shared/component.js';
import * as e from '../../../errors.js';
import * as w from '../../../warnings.js';
import { filename } from '../../../state.js';
/**
* @param {AST.SveltePortal} node
* @param {Context} context
*/
export function SveltePortal(node, context) {
// TODO validation for attributes etc
context.next();
}

@ -49,6 +49,7 @@ import { SvelteDocument } from './visitors/SvelteDocument.js';
import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { SveltePortal } from './visitors/SveltePortal.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
@ -124,6 +125,7 @@ const visitors = {
SvelteElement,
SvelteFragment,
SvelteBoundary,
SveltePortal,
SvelteHead,
SvelteSelf,
SvelteWindow,

@ -0,0 +1,33 @@
/** @import { BlockStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types' */
import * as b from '../../../../utils/builders.js';
import { build_attribute_value } from './shared/element.js';
/**
* @param {AST.SveltePortal} node
* @param {ComponentContext} context
*/
export function SveltePortal(node, context) {
const _for = node.attributes.find((attr) => attr.type === 'Attribute' && attr.name === 'for');
const target = node.attributes.find(
(attr) => attr.type === 'Attribute' && attr.name === 'target'
);
context.state.template.push('<!>');
if (target) {
// TODO handle reactive targets? Doesn't really make sense IMHO
const value = build_attribute_value(/** @type {AST.Attribute} */ (target).value, context);
const body = /** @type {BlockStatement} */ (
context.visit(node.fragment, { ...context.state, transform: { ...context.state.transform } })
);
context.state.init.push(
b.stmt(b.call('$.portal', value.value, b.arrow([b.id('$$anchor')], body)))
);
} else {
// TODO reactive sources? Doesn't really make sense IMHO
const value = build_attribute_value(/** @type {AST.Attribute} */ (_for).value, context);
context.state.init.push(b.stmt(b.call('$.portal_outlet', context.state.node, value.value)));
}
}

@ -39,6 +39,7 @@ import { TitleElement } from './visitors/TitleElement.js';
import { UpdateExpression } from './visitors/UpdateExpression.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { SveltePortal } from './visitors/SveltePortal.js';
/** @type {Visitors} */
const global_visitors = {
@ -77,7 +78,8 @@ const template_visitors = {
SvelteHead,
SvelteSelf,
TitleElement,
SvelteBoundary
SvelteBoundary,
SveltePortal
};
/**

@ -0,0 +1,27 @@
/** @import { BlockStatement } from 'estree' */
/** @import { AST } from '#compiler' */
/** @import { ComponentContext } from '../types.js' */
import * as b from '../../../../utils/builders.js';
import { build_attribute_value } from './shared/utils.js';
/**
* @param {AST.SveltePortal} node
* @param {ComponentContext} context
*/
export function SveltePortal(node, context) {
const _for = node.attributes.find((attr) => attr.type === 'Attribute' && attr.name === 'for');
const target = node.attributes.find(
(attr) => attr.type === 'Attribute' && attr.name === 'target'
);
if (target) {
const value = build_attribute_value(/** @type {AST.Attribute} */ (target).value, context);
const body = /** @type {BlockStatement} */ (context.visit(node.fragment, context.state));
context.state.template.push(
b.stmt(b.call('$.portal', b.id('$$payload'), value, b.arrow([b.id('$$payload')], body)))
);
} else {
const value = build_attribute_value(/** @type {AST.Attribute} */ (_for).value, context);
context.state.template.push(b.stmt(b.call('$.portal_outlet', b.id('$$payload'), value)));
}
}

@ -370,6 +370,11 @@ export namespace AST {
name: 'svelte:boundary';
}
export interface SveltePortal extends BaseElement {
type: 'SveltePortal';
name: 'svelte:portal';
}
export interface SvelteHead extends BaseElement {
type: 'SvelteHead';
name: 'svelte:head';
@ -535,7 +540,8 @@ export namespace AST {
| AST.SvelteOptionsRaw
| AST.SvelteSelf
| AST.SvelteWindow
| AST.SvelteBoundary;
| AST.SvelteBoundary
| AST.SveltePortal;
export type Tag = AST.ExpressionTag | AST.HtmlTag | AST.ConstTag | AST.DebugTag | AST.RenderTag;

@ -0,0 +1,76 @@
/** @import { TemplateNode } from '#client' */
import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js';
import { block, remove_nodes, render_effect } from '../../reactivity/effects.js';
import { hydrate_node, hydrating, set_hydrate_node } from '../hydration.js';
import { get_next_sibling } from '../operations.js';
const portals = new Map();
/**
* @param {TemplateNode} node
* @param {any} id
* @returns {void}
*/
export function portal_outlet(node, id) {
var anchor = node;
render_effect(() => {
portals.set(id, { anchor });
return () => {
portals.delete(id);
// TODO what happens to rendered content, if there's still some? Remove? Error?
};
});
if (hydrating) {
let depth = 1;
while (anchor !== null && depth > 0) {
// TODO we have similar logic in other places, consolidate?
anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor));
if (anchor?.nodeType === 8) {
var comment = /** @type {Comment} */ (anchor).data;
if (comment === HYDRATION_START || comment === HYDRATION_START_ELSE) depth += 1;
else if (comment[0] === HYDRATION_END) depth -= 1;
}
}
set_hydrate_node(anchor);
}
}
/**
* @param {any} target
* @param {(anchor: TemplateNode) => void} content
* @returns {void}
*/
export function portal(target, content) {
const portal = portals.get(target);
if (!portal)
throw new Error(
'TODO error code: No portal found for given target. Make sure portal target exists before referencing it'
);
let previous_hydrate_node = null;
var anchor = portal.anchor;
if (hydrating) {
previous_hydrate_node = hydrate_node;
set_hydrate_node((anchor = /** @type {TemplateNode} */ (get_next_sibling(portal.anchor))));
}
const effect = block(() => {
content(anchor);
return () => {
// The parent block will traverse all nodes in the current context, and then state that
// child effects (like this one) don't need to traverse the nodes anymore because they
// were already removed by the parent. That's not true in this case because the nodes
// are somewhere else, so remove them "manually" here.
remove_nodes(effect);
};
});
if (hydrating) {
portal.anchor = hydrate_node; // so that next head block starts from the correct node
set_hydrate_node(/** @type {TemplateNode} */ (previous_hydrate_node));
}
}

@ -24,6 +24,7 @@ export { sanitize_slots, slot } from './dom/blocks/slot.js';
export { snippet, wrap_snippet } from './dom/blocks/snippet.js';
export { component } from './dom/blocks/svelte-component.js';
export { element } from './dom/blocks/svelte-element.js';
export { portal, portal_outlet } from './dom/blocks/svelte-portal.js';
export { head } from './dom/blocks/svelte-head.js';
export { append_styles } from './dom/css.js';
export { action } from './dom/elements/actions.js';

@ -446,18 +446,7 @@ export function destroy_effect(effect, remove_dom = true) {
var removed = false;
if ((remove_dom || (effect.f & HEAD_EFFECT) !== 0) && effect.nodes_start !== null) {
/** @type {TemplateNode | null} */
var node = effect.nodes_start;
var end = effect.nodes_end;
while (node !== null) {
/** @type {TemplateNode | null} */
var next = node === end ? null : /** @type {TemplateNode} */ (get_next_sibling(node));
node.remove();
node = next;
}
remove_nodes(effect);
removed = true;
}
@ -500,6 +489,22 @@ export function destroy_effect(effect, remove_dom = true) {
null;
}
/**
* @param {Effect} effect
*/
export function remove_nodes(effect) {
var node = /** @type {TemplateNode | null} */ (effect.nodes_start);
var end = effect.nodes_end;
while (node !== null) {
/** @type {TemplateNode | null} */
var next = node === end ? null : /** @type {TemplateNode} */ (get_next_sibling(node));
node.remove();
node = next;
}
}
/**
* Detach an effect from the effect tree, freeing up memory and
* reducing the amount of work that happens on subsequent traversals

@ -28,10 +28,11 @@ const INVALID_ATTR_NAME_CHAR_REGEX =
* @param {Payload} to_copy
* @returns {Payload}
*/
export function copy_payload({ out, css, head }) {
export function copy_payload({ out, css, head, portals }) {
return {
out,
css: new Set(css),
portals: new Map(portals),
head: {
title: head.title,
out: head.out
@ -93,7 +94,7 @@ export let on_destroy = [];
*/
export function render(component, options = {}) {
/** @type {Payload} */
const payload = { out: '', css: new Set(), head: { title: '', out: '' } };
const payload = { out: '', css: new Set(), portals: new Map(), head: { title: '', out: '' } };
const prev_on_destroy = on_destroy;
on_destroy = [];
@ -126,6 +127,16 @@ export function render(component, options = {}) {
for (const cleanup of on_destroy) cleanup();
on_destroy = prev_on_destroy;
let out = payload.out;
// Fill portals
let offset = 0;
for (const portal of payload.portals.values()) {
out =
out.slice(0, portal.idx + offset) + portal.content.join('') + out.slice(portal.idx + offset);
offset += portal.content.length;
}
let head = payload.head.out + payload.head.title;
for (const { hash, code } of payload.css) {
@ -134,8 +145,8 @@ export function render(component, options = {}) {
return {
head,
html: payload.out,
body: payload.out
html: out,
body: out
};
}
@ -151,6 +162,38 @@ export function head(payload, fn) {
head_payload.out += BLOCK_CLOSE;
}
/**
* @param {Payload} payload
* @param {any} id
* @returns {void}
*/
export function portal_outlet(payload, id) {
payload.out += BLOCK_OPEN;
payload.portals.set(id, { idx: payload.out.length, content: [] });
payload.out += BLOCK_CLOSE;
}
/**
* @param {Payload} payload
* @param {any} target
* @param {(p: Payload) => void} content
* @returns {void}
*/
export function portal(payload, target, content) {
const portal = payload.portals.get(target);
if (!portal)
throw new Error(
'TODO error code: No portal found for given target. Make sure portal target exists before referencing it'
);
/** @type {Payload} */
const tmp_payload = { ...payload, out: '' };
content(tmp_payload);
tmp_payload.out += EMPTY_COMMENT;
portal.content.push(tmp_payload.out);
payload.out += EMPTY_COMMENT;
}
/**
* @param {Payload} payload
* @param {boolean} is_html

@ -14,6 +14,7 @@ export interface Component {
export interface Payload {
out: string;
css: Set<{ hash: string; code: string }>;
portals: Map<any, { idx: number; content: string[] }>;
head: {
title: string;
out: string;

Loading…
Cancel
Save