manage multiple portals and multiple batches

portals
Simon Holthausen 1 month ago
parent e82ddd6ea9
commit e1e6de65f3
No known key found for this signature in database

@ -15,9 +15,7 @@ export function PortalBlock(node, context) {
const body = /** @type {BlockStatement} */ (
context.visit(node.fragment, { ...context.state, transform: { ...context.state.transform } })
);
const portal = b.call('$.portal', value, b.arrow([b.id('$$anchor')], body));
const portal = b.call('$.portal', b.thunk(value), b.arrow([b.id('$$anchor')], body));
context.state.init.push(
b.stmt(node.metadata.expression.has_state ? b.call('$.render_effect', b.thunk(portal)) : portal)
);
context.state.init.push(b.stmt(portal));
}

@ -1,88 +1,68 @@
/** @import { Effect, EffectNodes, TemplateNode } from '#client' */
/** @import { Effect, EffectNodes, Source, TemplateNode } from '#client' */
/** @import { Batch } from '../../reactivity/batch.js' */
import { DESTROYED, DESTROYING } from '#client/constants';
import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js';
import { block, remove_effect_dom, render_effect } from '../../reactivity/effects.js';
import { active_effect, set_active_effect } from '../../runtime.js';
import { hydrate_next, hydrate_node, hydrating, set_hydrate_node, set_hydrating } from '../hydration.js';
import { get_next_sibling } from '../operations.js';
import { current_batch } from '../../reactivity/batch.js';
import {
block,
branch,
destroy_effect,
move_effect,
remove_effect_dom,
render_effect
} from '../../reactivity/effects.js';
import { set, source } from '../../reactivity/sources.js';
import { active_effect, get, untrack } from '../../runtime.js';
import {
hydrate_next,
hydrate_node,
hydrating,
set_hydrate_node,
set_hydrating
} from '../hydration.js';
import { create_text, get_next_sibling, should_defer_append } from '../operations.js';
/**
* @typedef {{ anchor: TemplateNode | undefined, pending: PendingPortal[] }} Portal
* @typedef {{ owner: Effect, content: (anchor: TemplateNode) => void }} PendingPortal
* @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
*/
/** @type {Map<any, Portal>} */
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));
}
}
/** @type {Map<any, Source<Array<Outlet>>>} */
const outlet_map = new Map();
/**
* @param {TemplateNode} node
* @param {any | (() => any)} id
* @param {() => any} get_id
* @returns {void}
*/
export function portal_outlet(node, id) {
export function portal_outlet(node, get_id) {
var anchor = node;
const get_id = typeof id === 'function' ? id : () => id;
if (hydrating) {
anchor = hydrate_next();
}
render_effect(() => {
id = get_id();
/** @type {Outlet} */
var outlet = { anchor, hydrate: hydrating };
const portal = portals.get(id) ?? { anchor: undefined, pending: [] };
portal.anchor = anchor;
portals.set(id, portal);
render_effect(() => {
const id = get_id();
if (id == null) return;
for (const pending of portal.pending) {
const previous_effect = active_effect;
set_active_effect(pending.owner);
const outlets = outlet_map.get(id) ?? source([]);
try {
render_portal(portal, pending.content);
} finally {
set_active_effect(previous_effect);
}
}
portal.pending.length = 0;
outlet_map.set(id, outlets);
set(
outlets,
untrack(() => [...get(outlets), outlet])
);
return () => {
portals.delete(id);
// TODO what happens to rendered content, if there's still some? Remove? Error?
set(
outlets,
untrack(() => get(outlets).filter((item) => item !== outlet))
);
};
});
@ -102,61 +82,268 @@ export function portal_outlet(node, id) {
}
/**
* @param {any} target
* @param {() => any} get_target
* @param {(anchor: TemplateNode) => void} content
* @returns {void | (() => void)}
*/
export function portal(target, content) {
if (target == null) return;
export function portal(get_target, content) {
// Portal targets are reconciled at batch boundaries. A target can disappear in one
// pending batch and reappear in a later one, so effects are kept offscreen until we
// know whether they are committed or discarded (similar to BranchManager).
/** @type {Map<any, PortalBranch>} */
let onscreen = new Map();
/** @type {Map<any, PortalBranch>} */
let offscreen = new Map();
/** @type {Map<Batch, Map<any, PortalTarget>>} */
let pending = new Map();
/** @param {Map<any, PortalBranch>} portals */
function destroy_portals(portals) {
for (const portal of portals.values()) {
destroy_effect(portal.effect);
}
const is_dom_node = target instanceof Element;
/** @type {TemplateNode} */
var anchor;
if (is_dom_node) {
portals.clear();
}
/**
* @param {any} key
* @param {PortalTarget} target
* @param {boolean} offscreen
* @returns {PortalBranch}
*/
function create_portal(key, target, offscreen) {
/** @type {DocumentFragment | null} */
let fragment = null;
let anchor = target.anchor;
let previous_hydrating = false;
let previous_hydrate_node = null;
// 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('')));
}
if (offscreen) {
fragment = document.createDocumentFragment();
anchor = create_text();
fragment.append(anchor);
if (hydrating) {
if (hydrating) {
// Offscreen branches are new client work, there's no SSR content to claim.
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;
// These are not SSR'd, so temporarily disable hydration to properly insert them
set_hydrating(false);
}
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) {
set_hydrating(true);
const portal = {
key,
anchor: target.anchor,
effect: branch(() => {
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} */ (portal.effect.nodes);
remove_effect_dom(nodes.start, /** @type {TemplateNode} */ (nodes.end));
};
}),
fragment
};
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;
}
set_hydrate_node(previous_hydrate_node);
}
if (previous_hydrating || target.hydrate) {
set_hydrating(previous_hydrating);
}
return portal;
}
/** @returns {Set<any>} */
function get_future_keys() {
// Pending newer batches determine whether an offscreen branch should be preserved.
// This mirrors BranchManager's ability to keep a branch alive across discarded work.
const keys = new Set();
for (const targets of pending.values()) {
for (const key of targets.keys()) {
keys.add(key);
}
}
return keys;
}
/** @param {Batch} batch */
function discard(batch) {
pending.delete(batch);
const future_keys = get_future_keys();
for (const [key, portal] of offscreen) {
if (!future_keys.has(key)) {
destroy_effect(portal.effect);
offscreen.delete(key);
}
}
}
/** @param {Batch} batch */
function commit(batch) {
if ((effect.f & DESTROYED) !== 0) return;
const targets = pending.get(batch);
if (targets === undefined) return;
for (const [b] of pending) {
pending.delete(b);
// keep values for newer batches
if (b === batch) break;
}
const future_keys = get_future_keys();
// Newly selected targets were rendered into a fragment if this batch was deferred,
// move them into the real outlet/element now that the batch committed.
for (const [key, target] of targets) {
const portal = offscreen.get(key);
if (portal !== undefined) {
/** @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);
}
}
} 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);
};
for (const [key, portal] of onscreen) {
if (targets.has(key)) continue;
onscreen.delete(key);
if (future_keys.has(key)) {
// A newer pending batch wants this branch again. Move it out of the DOM instead
// of destroying it so the later batch can commit without recreating it.
const fragment = document.createDocumentFragment();
move_effect(portal.effect, fragment);
fragment.append(create_text());
portal.fragment = fragment;
offscreen.set(key, portal);
} else {
destroy_effect(portal.effect);
}
}
render_portal(portal, content);
for (const [key, portal] of offscreen) {
if (targets.has(key) || future_keys.has(key)) continue;
destroy_effect(portal.effect);
offscreen.delete(key);
}
}
/** @type {Effect} */
let effect;
block(() => {
effect = /** @type {Effect} */ (active_effect);
const target = get_target();
/** @type {Map<any, PortalTarget>} */
const targets = new Map();
if (target instanceof Element) {
// 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.
let anchor = /** @type {TemplateNode} */ (target.firstChild);
if (!anchor) {
target.appendChild((anchor = document.createTextNode('')));
}
targets.set(target, { anchor, hydrate: false, client_only: true });
} else if (target != null) {
const outlets_source = outlet_map.get(target) ?? source([]);
outlet_map.set(target, outlets_source);
for (const outlet of get(outlets_source)) {
targets.set(outlet, {
anchor: outlet.anchor,
hydrate: outlet.hydrate,
client_only: false,
outlet
});
}
}
const batch = /** @type {Batch} */ (current_batch);
const defer = should_defer_append();
// Ensure every target requested by this batch has a branch, but do not destroy
// branches that are absent from this batch until commit/discard tells us whether
// this batch actually wins.
for (const [key, target] of targets) {
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);
}
}
pending.set(batch, targets);
if (defer) {
for (const [key, portal] of onscreen) {
if (targets.has(key)) {
batch.unskip_effect(portal.effect);
} else {
batch.skip_effect(portal.effect);
}
}
for (const [key, portal] of offscreen) {
if (targets.has(key)) {
batch.unskip_effect(portal.effect);
} else {
batch.skip_effect(portal.effect);
}
}
batch.oncommit(commit);
batch.ondiscard(discard);
} else {
commit(batch);
}
return () => {
if (/** @type {Effect} */ (effect).f & DESTROYING) {
destroy_portals(onscreen);
destroy_portals(offscreen);
pending.clear();
}
};
});
}

@ -0,0 +1,37 @@
import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
html: '<button>toggle portalKey</button> <button>toggle outletKey</button>',
test({ assert, target }) {
const [togglePortalKey, toggleOutletKey] = target.querySelectorAll('button');
togglePortalKey.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> hi'
);
toggleOutletKey.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button>'
);
toggleOutletKey.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> hi'
);
togglePortalKey.click();
flushSync();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button>'
);
}
});

@ -0,0 +1,13 @@
<script>
let portalKey = $state('a');
let outletKey = $state('b');
</script>
<button onclick={() => portalKey = portalKey === 'a' ? 'b' : 'a'}>toggle portalKey</button>
<button onclick={() => outletKey = outletKey === 'a' ? 'b' : 'a'}>toggle outletKey</button>
{#portal portalKey}
hi
{/portal}
{@portal outletKey}
Loading…
Cancel
Save