diff --git a/packages/svelte/src/compiler/phases/1-parse/state/element.js b/packages/svelte/src/compiler/phases/1-parse/state/element.js index c112cf6789..806dcebff6 100644 --- a/packages/svelte/src/compiler/phases/1-parse/state/element.js +++ b/packages/svelte/src/compiler/phases/1-parse/state/element.js @@ -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 */ diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 042e88fa2f..1bc23db8ed 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -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, diff --git a/packages/svelte/src/compiler/phases/2-analyze/visitors/SveltePortal.js b/packages/svelte/src/compiler/phases/2-analyze/visitors/SveltePortal.js new file mode 100644 index 0000000000..8c65d2d013 --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/visitors/SveltePortal.js @@ -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(); +} diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index ccbdcea4cc..1e72f32fb9 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -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, diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/SveltePortal.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SveltePortal.js new file mode 100644 index 0000000000..c232608781 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/SveltePortal.js @@ -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))); + } +} diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index 982b75e12f..cbe9b116cc 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -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 }; /** diff --git a/packages/svelte/src/compiler/phases/3-transform/server/visitors/SveltePortal.js b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SveltePortal.js new file mode 100644 index 0000000000..864589be93 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/server/visitors/SveltePortal.js @@ -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))); + } +} diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 97a25df4a7..40da4072ff 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -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; diff --git a/packages/svelte/src/internal/client/dom/blocks/svelte-portal.js b/packages/svelte/src/internal/client/dom/blocks/svelte-portal.js new file mode 100644 index 0000000000..385aa4c90b --- /dev/null +++ b/packages/svelte/src/internal/client/dom/blocks/svelte-portal.js @@ -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)); + } +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index f22c33babc..b95acb8a6c 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -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'; diff --git a/packages/svelte/src/internal/client/reactivity/effects.js b/packages/svelte/src/internal/client/reactivity/effects.js index bf890627f7..c956745684 100644 --- a/packages/svelte/src/internal/client/reactivity/effects.js +++ b/packages/svelte/src/internal/client/reactivity/effects.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 diff --git a/packages/svelte/src/internal/server/index.js b/packages/svelte/src/internal/server/index.js index b8371b7e00..fbc0862fc8 100644 --- a/packages/svelte/src/internal/server/index.js +++ b/packages/svelte/src/internal/server/index.js @@ -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 diff --git a/packages/svelte/src/internal/server/types.d.ts b/packages/svelte/src/internal/server/types.d.ts index e6c235147b..a43815c3f2 100644 --- a/packages/svelte/src/internal/server/types.d.ts +++ b/packages/svelte/src/internal/server/types.d.ts @@ -14,6 +14,7 @@ export interface Component { export interface Payload { out: string; css: Set<{ hash: string; code: string }>; + portals: Map; head: { title: string; out: string;