From 2d399542357a3852d67532469ed011976c195d87 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 2 Jul 2026 16:15:01 +0200 Subject: [PATCH] tweaks/fixes/tests --- .../src/internal/client/dom/blocks/portal.js | 131 +++++++++++++----- .../svelte/src/internal/server/renderer.js | 12 +- .../async-portal-key-changing/_config.js | 69 +++++++++ .../async-portal-key-changing/main.svelte | 31 +++++ .../samples/async-portal-toggle/_config.js | 69 +++++++++ .../samples/async-portal-toggle/main.svelte | 35 +++++ .../samples/portal-dom/_config.js | 1 + .../samples/portal-key-changing/_config.js | 1 + .../samples/portal-multiple/_config.js | 5 + .../samples/portal-multiple/main.svelte | 15 ++ .../samples/portal-origin-first/_config.js | 1 + .../samples/portal-origin-first/main.svelte | 4 +- .../samples/portal-toggle/_config.js | 37 +++++ .../samples/portal-toggle/main.svelte | 17 +++ .../runtime-runes/samples/portal/_config.js | 1 + .../runtime-runes/samples/portal/main.svelte | 4 +- 16 files changed, 385 insertions(+), 48 deletions(-) create mode 100644 packages/svelte/tests/runtime-runes/samples/async-portal-key-changing/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-portal-key-changing/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/async-portal-toggle/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-portal-toggle/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/portal-multiple/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/portal-multiple/main.svelte create mode 100644 packages/svelte/tests/runtime-runes/samples/portal-toggle/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/portal-toggle/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 88bc820861..4d97f0cab1 100644 --- a/packages/svelte/src/internal/client/dom/blocks/portal.js +++ b/packages/svelte/src/internal/client/dom/blocks/portal.js @@ -2,6 +2,7 @@ /** @import { Batch } from '../../reactivity/batch.js' */ import { DESTROYED, DESTROYING } from '#client/constants'; import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js'; +import { capture } from '../../reactivity/async.js'; import { current_batch } from '../../reactivity/batch.js'; import { block, @@ -12,7 +13,7 @@ import { render_effect } from '../../reactivity/effects.js'; import { set, source } from '../../reactivity/sources.js'; -import { active_effect, get, untrack } from '../../runtime.js'; +import { active_effect, get, set_active_effect, untrack } from '../../runtime.js'; import { hydrate_next, hydrate_node, @@ -21,16 +22,33 @@ import { set_hydrating } from '../hydration.js'; import { create_text, get_next_sibling, should_defer_append } from '../operations.js'; +import { queue_micro_task } from '../task.js'; /** - * @typedef {{ anchor: TemplateNode, hydrate: boolean }} Outlet - * @typedef {{ key: any, anchor: TemplateNode, effect: Effect, fragment: DocumentFragment | null }} PortalBranch - * @typedef {{ anchor: TemplateNode, hydrate: boolean, client_only: boolean, outlet?: Outlet }} PortalTarget + * @typedef {{ anchor: TemplateNode }} Outlet + * @typedef {{ outlets: Source>, pending: Set<(outlet: Outlet) => void> }} OutletEntry + * @typedef {{ key: any, effect: Effect, fragment: DocumentFragment | null }} PortalBranch + * @typedef {{ anchor: TemplateNode, outlet?: Outlet }} PortalTarget */ -/** @type {Map>>} */ +/** @type {Map} */ const outlet_map = new Map(); +/** + * @param {any} key + * @returns {OutletEntry} + */ +function get_outlet_entry(key) { + let entry = outlet_map.get(key); + + if (entry === undefined) { + entry = { outlets: source([]), pending: new Set() }; + outlet_map.set(key, entry); + } + + return entry; +} + /** * @param {TemplateNode} node * @param {() => any} get_id @@ -44,20 +62,24 @@ export function portal_outlet(node, get_id) { } /** @type {Outlet} */ - var outlet = { anchor, hydrate: hydrating }; + var outlet = { anchor }; render_effect(() => { const id = get_id(); if (id == null) return; - const outlets = outlet_map.get(id) ?? source([]); + const entry = get_outlet_entry(id); + const outlets = entry.outlets; - outlet_map.set(id, outlets); set( outlets, untrack(() => [...get(outlets), outlet]) ); + for (const render of entry.pending) { + render(outlet); + } + return () => { set( outlets, @@ -96,6 +118,8 @@ export function portal(get_target, content) { let offscreen = new Map(); /** @type {Map>} */ let pending = new Map(); + /** @type {{ entry: OutletEntry, render: (outlet: Outlet) => void } | null} */ + let unrendered = null; /** @param {Map} portals */ function destroy_portals(portals) { @@ -106,6 +130,33 @@ export function portal(get_target, content) { portals.clear(); } + function clear_unrendered() { + if (unrendered === null) return; + + const { entry, render } = unrendered; + entry.pending.delete(render); + unrendered = null; + } + + /** @param {OutletEntry} entry */ + function set_unrendered(entry) { + /** @type {(outlet: Outlet) => void} */ + const render = (outlet) => { + const prev_context = capture(); + portal_context(false); + + try { + const portal = create_portal(outlet, { anchor: outlet.anchor, outlet }, false); + onscreen.set(outlet, portal); + } finally { + prev_context(false); + } + }; + + entry.pending.add(render); + unrendered = { entry, render }; + } + /** * @param {any} key * @param {PortalTarget} target @@ -129,21 +180,23 @@ export function portal(get_target, content) { previous_hydrating = true; set_hydrating(false); } - } else if (target.hydrate) { - // An outlet can be discovered before the matching portal block runs. Preserve - // the global hydration cursor while hydrating from the outlet's own anchor. - previous_hydrating = hydrating; - previous_hydrate_node = hydrate_node; - set_hydrating(true); - set_hydrate_node((anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor)))); - } else if (target.client_only && hydrating) { - previous_hydrating = true; - set_hydrating(false); + } else if (hydrating) { + if (target.outlet !== undefined) { + // An outlet was discovered before this matching portal block. Preserve + // the global hydration cursor while hydrating from the outlet's own anchor. + previous_hydrating = true; + previous_hydrate_node = hydrate_node; + set_hydrate_node((anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor)))); + } else { + // This is a DOM portal, they are not SSR'd, so temporarily disable hydration to avoid claiming the wrong nodes. + previous_hydrating = true; + set_hydrating(false); + } } + /** @type {PortalBranch} */ const portal = { key, - anchor: target.anchor, effect: branch(() => { content(anchor); return () => { @@ -159,19 +212,15 @@ export function portal(get_target, content) { }; if (previous_hydrate_node !== null) { - portal.anchor = hydrate_node; target.anchor = hydrate_node; - if (target.outlet !== undefined) { - // Future portal instances for this outlet must insert after the hydrated content, - // not after the original outlet marker. - target.outlet.anchor = hydrate_node; - target.outlet.hydrate = false; - } + // Future portal instances for this outlet must insert after the hydrated content, + // not after the original outlet marker. + /** @type {Outlet} */ (target.outlet).anchor = hydrate_node; set_hydrate_node(previous_hydrate_node); } - if (previous_hydrating || target.hydrate) { - set_hydrating(previous_hydrating); + if (previous_hydrating) { + set_hydrating(true); } return portal; @@ -229,7 +278,6 @@ export function portal(get_target, content) { /** @type {TemplateNode} */ (portal.fragment?.lastChild).remove(); target.anchor.before(/** @type {DocumentFragment} */ (portal.fragment)); portal.fragment = null; - portal.anchor = target.anchor; offscreen.delete(key); onscreen.set(key, portal); } @@ -265,8 +313,12 @@ export function portal(get_target, content) { /** @type {Effect} */ let effect; + /** @type {ReturnType} */ + let portal_context; + block(() => { effect = /** @type {Effect} */ (active_effect); + portal_context = capture(); const target = get_target(); /** @type {Map} */ @@ -280,16 +332,18 @@ export function portal(get_target, content) { target.appendChild((anchor = document.createTextNode(''))); } - targets.set(target, { anchor, hydrate: false, client_only: true }); + targets.set(target, { anchor }); } else if (target != null) { - const outlets_source = outlet_map.get(target) ?? source([]); - outlet_map.set(target, outlets_source); + const entry = get_outlet_entry(target); + const outlets_source = entry.outlets; + const outlets = get(outlets_source); + + // We are adding pending portals to the entry, so that outlets can render them when they are discovered. + set_unrendered(entry); - for (const outlet of get(outlets_source)) { + for (const outlet of outlets) { targets.set(outlet, { anchor: outlet.anchor, - hydrate: outlet.hydrate, - client_only: false, outlet }); } @@ -305,11 +359,10 @@ export function portal(get_target, content) { let portal = onscreen.get(key) ?? offscreen.get(key); if (portal !== undefined) { - portal.anchor = target.anchor; if (defer) batch.unskip_effect(portal.effect); } else { - portal = create_portal(key, target, defer && !target.hydrate); - (defer && !target.hydrate ? offscreen : onscreen).set(key, portal); + portal = create_portal(key, target, defer && !(hydrating && target.outlet !== undefined)); + (portal.fragment !== null ? offscreen : onscreen).set(key, portal); } } @@ -339,6 +392,8 @@ export function portal(get_target, content) { } return () => { + clear_unrendered(); + if (/** @type {Effect} */ (effect).f & DESTROYING) { destroy_portals(onscreen); destroy_portals(offscreen); diff --git a/packages/svelte/src/internal/server/renderer.js b/packages/svelte/src/internal/server/renderer.js index f1182c8b0c..921342be36 100644 --- a/packages/svelte/src/internal/server/renderer.js +++ b/packages/svelte/src/internal/server/renderer.js @@ -427,7 +427,7 @@ export class Renderer { this.push(BLOCK_OPEN); const portal = this.global.portals.get(key) ?? { id: undefined, renderers: [] }; - portal.id = ``; + portal.id ??= ``; this.global.portals.set(key, portal); this.push(portal.id); this.push(BLOCK_CLOSE); @@ -448,7 +448,7 @@ export class Renderer { const tmp_payload = new Renderer(this.global, this); content(tmp_payload); tmp_payload.push(EMPTY_COMMENT); - portal.renderers.push(tmp_payload); + portal.renderers.unshift(tmp_payload); } /** @@ -675,8 +675,8 @@ export class Renderer { if (item instanceof Renderer) { const portal_content = item.#collect_content(); item.#out.length = 0; - content.body = content.body.replace(portal.id, portal.id + portal_content.body); - content.head = content.head.replace(portal.id, portal.id + portal_content.body); + content.body = content.body.replaceAll(portal.id, portal.id + portal_content.body); + content.head = content.head.replaceAll(portal.id, portal.id + portal_content.body); } } } @@ -713,8 +713,8 @@ export class Renderer { for (const item of portal.renderers) { if (item instanceof Renderer) { const portal_content = await item.#collect_content_async(); - content.body = content.body.replace(portal.id, portal.id + portal_content.body); - content.head = content.head.replace(portal.id, portal.id + portal_content.body); + content.body = content.body.replaceAll(portal.id, portal.id + portal_content.body); + content.head = content.head.replaceAll(portal.id, portal.id + portal_content.body); } } } diff --git a/packages/svelte/tests/runtime-runes/samples/async-portal-key-changing/_config.js b/packages/svelte/tests/runtime-runes/samples/async-portal-key-changing/_config.js new file mode 100644 index 0000000000..190d284880 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-portal-key-changing/_config.js @@ -0,0 +1,69 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [togglePortalKey, toggleOutletKey, shift, pop] = target.querySelectorAll('button'); + + togglePortalKey.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ab' + ); + + toggleOutletKey.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ab' + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' bb hi' + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ba' + ); + + togglePortalKey.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ba' + ); + + toggleOutletKey.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ba' + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' bb hi' + ); + + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ab' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-portal-key-changing/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-portal-key-changing/main.svelte new file mode 100644 index 0000000000..303ebeabc8 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-portal-key-changing/main.svelte @@ -0,0 +1,31 @@ + + + + + + + +{await push(portalKey + outletKey)} + +{#portal portalKey} + hi +{/portal} + +{@portal outletKey} diff --git a/packages/svelte/tests/runtime-runes/samples/async-portal-toggle/_config.js b/packages/svelte/tests/runtime-runes/samples/async-portal-toggle/_config.js new file mode 100644 index 0000000000..52c3ef599e --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-portal-toggle/_config.js @@ -0,0 +1,69 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +export default test({ + async test({ assert, target }) { + await tick(); + const [togglePortalKey, toggleOutletKey, shift, pop] = target.querySelectorAll('button'); + + togglePortalKey.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' aa hi' + ); + + toggleOutletKey.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' aa hi' + ); + + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ba' + ); + + shift.click(); + await tick(); + shift.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' bb' + ); + + togglePortalKey.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' bb' + ); + + toggleOutletKey.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' bb' + ); + + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' ba' + ); + + pop.click(); + await tick(); + pop.click(); + await tick(); + assert.htmlEqual( + target.innerHTML, + ' aa hi' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-portal-toggle/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-portal-toggle/main.svelte new file mode 100644 index 0000000000..a6031b6f2d --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-portal-toggle/main.svelte @@ -0,0 +1,35 @@ + + + + + + + +{await push(portalKey + outletKey)} + +{#if portalKey === 'a'} + {#portal portalKey} + hi + {/portal} +{/if} + +{#if outletKey === 'a'} + {@portal outletKey} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/portal-dom/_config.js b/packages/svelte/tests/runtime-runes/samples/portal-dom/_config.js index 3aadf571d4..ae7405d3d4 100644 --- a/packages/svelte/tests/runtime-runes/samples/portal-dom/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/portal-dom/_config.js @@ -1,6 +1,7 @@ import { test } from '../../test'; export default test({ + solo: true, mode: ['client'], html: `

portaled

` }); diff --git a/packages/svelte/tests/runtime-runes/samples/portal-key-changing/_config.js b/packages/svelte/tests/runtime-runes/samples/portal-key-changing/_config.js index 578de014ce..daaad4024c 100644 --- a/packages/svelte/tests/runtime-runes/samples/portal-key-changing/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/portal-key-changing/_config.js @@ -2,6 +2,7 @@ import { flushSync } from 'svelte'; import { test } from '../../test'; export default test({ + solo: true, html: ' ', test({ assert, target }) { const [togglePortalKey, toggleOutletKey] = target.querySelectorAll('button'); diff --git a/packages/svelte/tests/runtime-runes/samples/portal-multiple/_config.js b/packages/svelte/tests/runtime-runes/samples/portal-multiple/_config.js new file mode 100644 index 0000000000..c3c65d3596 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/portal-multiple/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + html: `abc abc` +}); diff --git a/packages/svelte/tests/runtime-runes/samples/portal-multiple/main.svelte b/packages/svelte/tests/runtime-runes/samples/portal-multiple/main.svelte new file mode 100644 index 0000000000..39b9d1e322 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/portal-multiple/main.svelte @@ -0,0 +1,15 @@ +{#portal 'multiple'} + a +{/portal} + +{@portal 'multiple'} + +{#portal 'multiple'} + b +{/portal} + +{@portal 'multiple'} + +{#portal 'multiple'} + c +{/portal} 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 index 39665f0fb2..89885cf59a 100644 --- a/packages/svelte/tests/runtime-runes/samples/portal-origin-first/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/portal-origin-first/_config.js @@ -1,5 +1,6 @@ import { test } from '../../test'; export default test({ + solo: true, 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 index a4f8d8fc2d..caa77fe1bb 100644 --- a/packages/svelte/tests/runtime-runes/samples/portal-origin-first/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/portal-origin-first/main.svelte @@ -1,7 +1,7 @@ -{#portal 'example'} +{#portal 'origin-first'}

portaled

{/portal}
- {@portal 'example'} + {@portal 'origin-first'}
diff --git a/packages/svelte/tests/runtime-runes/samples/portal-toggle/_config.js b/packages/svelte/tests/runtime-runes/samples/portal-toggle/_config.js new file mode 100644 index 0000000000..c744340e58 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/portal-toggle/_config.js @@ -0,0 +1,37 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + html: ' hi', + test({ assert, target }) { + const [togglePortalKey, toggleOutletKey] = target.querySelectorAll('button'); + + togglePortalKey.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ' ' + ); + + toggleOutletKey.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ' ' + ); + + toggleOutletKey.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ' ' + ); + + togglePortalKey.click(); + flushSync(); + assert.htmlEqual( + target.innerHTML, + ' hi' + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/portal-toggle/main.svelte b/packages/svelte/tests/runtime-runes/samples/portal-toggle/main.svelte new file mode 100644 index 0000000000..453745b149 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/portal-toggle/main.svelte @@ -0,0 +1,17 @@ + + + + + +{#if portalKey === 'a'} + {#portal portalKey} + hi + {/portal} +{/if} + +{#if outletKey === 'a'} + {@portal outletKey} +{/if} diff --git a/packages/svelte/tests/runtime-runes/samples/portal/_config.js b/packages/svelte/tests/runtime-runes/samples/portal/_config.js index 39665f0fb2..89885cf59a 100644 --- a/packages/svelte/tests/runtime-runes/samples/portal/_config.js +++ b/packages/svelte/tests/runtime-runes/samples/portal/_config.js @@ -1,5 +1,6 @@ import { test } from '../../test'; export default test({ + solo: true, html: `

portaled

` }); diff --git a/packages/svelte/tests/runtime-runes/samples/portal/main.svelte b/packages/svelte/tests/runtime-runes/samples/portal/main.svelte index 05db742bc5..ce362604c9 100644 --- a/packages/svelte/tests/runtime-runes/samples/portal/main.svelte +++ b/packages/svelte/tests/runtime-runes/samples/portal/main.svelte @@ -1,7 +1,7 @@
- {@portal 'example'} + {@portal 'portal'}
-{#portal 'example'} +{#portal 'portal'}

portaled

{/portal}