From 10bd29a2587b55aa954bf6e40b53d7614404ff4c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 29 Jun 2026 21:36:21 +0200 Subject: [PATCH] support origin before outlet --- .../src/internal/client/dom/blocks/portal.js | 132 ++++++++++++------ .../svelte/src/internal/server/renderer.js | 12 +- .../samples/portal-origin-first/_config.js | 5 + .../samples/portal-origin-first/main.svelte | 13 ++ 4 files changed, 114 insertions(+), 48 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/portal-origin-first/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/portal-origin-first/main.svelte diff --git a/packages/svelte/src/internal/client/dom/blocks/portal.js b/packages/svelte/src/internal/client/dom/blocks/portal.js index 05db321937..25eb3dc5ca 100644 --- a/packages/svelte/src/internal/client/dom/blocks/portal.js +++ b/packages/svelte/src/internal/client/dom/blocks/portal.js @@ -1,12 +1,54 @@ -/** @import { EffectNodes, TemplateNode } from '#client' */ +/** @import { Effect, EffectNodes, TemplateNode } from '#client' */ import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js'; import { PortalKey } from '../../../shared/portal.js'; import { block, remove_effect_dom, render_effect } from '../../reactivity/effects.js'; +import { active_effect, set_active_effect } from '../../runtime.js'; import { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js'; import { get_next_sibling } from '../operations.js'; +/** + * @typedef {{ anchor: TemplateNode | undefined, pending: PendingPortal[] }} Portal + * @typedef {{ owner: Effect, content: (anchor: TemplateNode) => void }} PendingPortal + */ + +/** @type {Map} */ const portals = new Map(); +/** + * @param {Portal} portal + * @param {(anchor: TemplateNode) => void} content + * @returns {void} + */ +function render_portal(portal, content) { + let previous_hydrating = false; + let previous_hydrate_node = null; + + let anchor = /** @type {TemplateNode} */ (portal.anchor); + + if (hydrating) { + previous_hydrating = true; + previous_hydrate_node = hydrate_node; + set_hydrate_node((anchor = /** @type {TemplateNode} */ (get_next_sibling(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. + const nodes = /** @type {EffectNodes} */ (effect.nodes); + remove_effect_dom(nodes.start, /** @type {TemplateNode} */ (nodes.end)); + }; + }); + + if (previous_hydrating) { + portal.anchor = hydrate_node; // so that next portal block starts from the correct node + set_hydrate_node(/** @type {TemplateNode} */ (previous_hydrate_node)); + } +} + /** * @param {TemplateNode} node * @param {any | (() => any)} id @@ -18,7 +60,22 @@ export function portal_outlet(node, id) { render_effect(() => { id = get_id(); - portals.set(id, { anchor }); + + const portal = portals.get(id) ?? { anchor: undefined, pending: [] }; + portal.anchor = anchor; + portals.set(id, portal); + + for (const pending of portal.pending) { + const previous_effect = active_effect; + set_active_effect(pending.owner); + + try { + render_portal(portal, pending.content); + } finally { + set_active_effect(previous_effect); + } + } + portal.pending.length = 0; return () => { portals.delete(id); @@ -44,7 +101,7 @@ export function portal_outlet(node, id) { /** * @param {any} target * @param {(anchor: TemplateNode) => void} content - * @returns {void} + * @returns {void | (() => void)} */ export function portal(target, content) { if (target == null) return; @@ -56,60 +113,53 @@ export function portal(target, content) { ); } - const portal = portals.get(target); - if (!is_dom_node && !portal) { - // TODO can we lift this restriction? - throw new Error( - 'TODO error code: No portal found for given target. Make sure portal target exists before referencing it' - ); - } - - let previous_hydrating = false; - let previous_hydrate_node = null; - /** @type {TemplateNode} */ var anchor; if (is_dom_node) { + let previous_hydrating = false; + // Our rendering logic always prepends elements to the anchor. To not confuse users, // adjust the anchor such that the content is portaled _into_ the target. anchor = /** @type {TemplateNode} */ (/** @type {Element} */ (target).firstChild); if (!anchor) { target.appendChild((anchor = document.createTextNode(''))); } - } else { - anchor = portal.anchor; - } - // TODO handle multiple targeting the same portal - if (hydrating) { - previous_hydrating = true; - if (is_dom_node) { + if (hydrating) { + previous_hydrating = true; // These are not SSR'd, so temporarily disable hydration to properly insert them set_hydrating(false); - } else { - 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. - const nodes = /** @type {EffectNodes} */ (effect.nodes); - remove_effect_dom(nodes.start, /** @type {TemplateNode} */ (nodes.end)); - }; - }); + 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. + const nodes = /** @type {EffectNodes} */ (effect.nodes); + remove_effect_dom(nodes.start, /** @type {TemplateNode} */ (nodes.end)); + }; + }); - if (previous_hydrating) { - if (is_dom_node) { + if (previous_hydrating) { set_hydrating(true); - } else { - portal.anchor = hydrate_node; // so that next head block starts from the correct node - set_hydrate_node(/** @type {TemplateNode} */ (previous_hydrate_node)); } + } else { + const portal = portals.get(target) ?? { anchor: undefined, pending: [] }; + portals.set(target, portal); + + if (portal.anchor === undefined) { + const pending = { owner: /** @type {Effect} */ (active_effect), content }; + portal.pending.push(pending); + + return () => { + const index = portal.pending.indexOf(pending); + if (index !== -1) portal.pending.splice(index, 1); + }; + } + + render_portal(portal, content); } } diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index b7f898ec3c..f26808e596 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -427,7 +427,9 @@ export class Renderer { this.push(BLOCK_OPEN); const id = ``; this.push(id); - this.global.portals.set(id, []); + if (!this.global.portals.has(id)) { + this.global.portals.set(id, []); + } this.push(BLOCK_CLOSE); } @@ -447,12 +449,8 @@ export class Renderer { } const id = ``; - const portal = this.global.portals.get(id); - if (!portal) - // TODO loosen this restriction? - throw new Error( - 'TODO error code: No portal found for given target. Make sure portal target exists before referencing it' - ); + const portal = this.global.portals.get(id) ?? []; + this.global.portals.set(id, portal); const tmp_payload = new Renderer(this.global, this); content(tmp_payload); diff --git a/packages/svelte/tests/runtime-runes/samples/portal-origin-first/_config.js b/packages/svelte/tests/runtime-runes/samples/portal-origin-first/_config.js new file mode 100644 index 0000000000..39665f0fb2 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/portal-origin-first/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: `

portaled

` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/portal-origin-first/main.svelte b/packages/svelte/tests/runtime-runes/samples/portal-origin-first/main.svelte new file mode 100644 index 0000000000..2c132581ec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/portal-origin-first/main.svelte @@ -0,0 +1,13 @@ + + +{#portal key} +

portaled

+{/portal} + +
+ {@portal key} +