better implementation

portals
Simon Holthausen 2 months ago
parent 63d68049e6
commit a582c05350
No known key found for this signature in database

@ -1,7 +1,6 @@
/** @import { Effect, EffectNodes, Source, TemplateNode } from '#client' */ /** @import { Effect, Source, TemplateNode } from '#client' */
/** @import { Batch } from '../../reactivity/batch.js' */ /** @import { Batch } from '../../reactivity/batch.js' */
import { DESTROYED, DESTROYING } from '#client/constants'; import { DESTROYED, DESTROYING, HEAD_EFFECT } from '#client/constants';
import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js';
import { capture } from '../../reactivity/async.js'; import { capture } from '../../reactivity/async.js';
import { current_batch } from '../../reactivity/batch.js'; import { current_batch } from '../../reactivity/batch.js';
import { import {
@ -9,31 +8,63 @@ import {
branch, branch,
destroy_effect, destroy_effect,
move_effect, move_effect,
remove_effect_dom,
render_effect render_effect
} from '../../reactivity/effects.js'; } from '../../reactivity/effects.js';
import { set, source } from '../../reactivity/sources.js'; import { set, source } from '../../reactivity/sources.js';
import { active_effect, get, set_active_effect, untrack } from '../../runtime.js'; import { active_effect, get, untrack } from '../../runtime.js';
import { import {
hydrate_next, hydrate_next,
hydrate_node, hydrate_node,
hydrating, hydrating,
set_hydrate_node, set_hydrate_node,
set_hydrating set_hydrating,
skip_nodes
} from '../hydration.js'; } from '../hydration.js';
import { create_text, get_next_sibling, should_defer_append } from '../operations.js'; import { create_text, get_next_sibling, should_defer_append } from '../operations.js';
import { queue_micro_task } from '../task.js'; import { queue_micro_task } from '../task.js';
/** /**
* @typedef {{ anchor: TemplateNode }} Outlet * A branch of a `{#portal ...}` block, rendered into a single target.
* @typedef {{ outlets: Source<Array<Outlet>>, pending: Set<(outlet: Outlet) => void> }} OutletEntry * While the branch is offscreen (its insertion is deferred until a batch
* @typedef {{ key: any, effect: Effect, fragment: DocumentFragment | null }} PortalBranch * commits), `fragment` contains its DOM, otherwise it is `null`.
* @typedef {{ anchor: TemplateNode, outlet?: Outlet }} PortalTarget * @typedef {{ effect: Effect, fragment: DocumentFragment | null }} PortalBranch
*/
/**
* Represents a `{@portal ...}` outlet.
* - `anchor` is the node portaled content is inserted before
* - `claim` is only used during hydration it is the position of the last
* server-rendered portal content that was claimed by a `{#portal ...}` block
* - `items` tracks the content of the portals currently rendered into this outlet
* (ordered by portal creation order), so that content of portals that are created
* (or committed) out of order can be inserted at the right position
* @typedef {{
* anchor: TemplateNode,
* claim: TemplateNode | null,
* items: Array<{ seq: number, branch: PortalBranch }>
* }} Outlet
*/
/**
* All known outlets (and portals waiting for outlets) for a given key.
* `pending` is only used during hydration it contains render functions of
* `{#portal ...}` blocks that were created before their outlet, so that the
* outlet can have them claim their server-rendered content upon initialisation.
* @typedef {{
* outlets: Source<Outlet[]>,
* pending: Set<(outlet: Outlet) => void>
* }} OutletEntry
*/ */
/** @type {Map<any, OutletEntry>} */ /** @type {Map<any, OutletEntry>} */
const outlet_map = new Map(); const outlet_map = new Map();
/**
* Monotonically increasing sequence number, used to keep content of multiple
* portals targeting the same outlet in creation order
*/
let portal_seq = 0;
/** /**
* @param {any} key * @param {any} key
* @returns {OutletEntry} * @returns {OutletEntry}
@ -49,6 +80,74 @@ function get_outlet_entry(key) {
return entry; return entry;
} }
/**
* Run `fn` now (during hydration, where synchronous timing is required for
* claiming server-rendered content), or in a microtask otherwise. The latter
* ensures that outlet (un)registrations - which can happen while a batch is
* being committed - do not interfere with the commit by scheduling portal
* updates (which would happen in a new batch) at the wrong moment.
* @param {() => void} fn
*/
function run_outside_batch(fn) {
if (hydrating) {
fn();
} else {
// TODO this is a hack to get around a (I think) general batch.js bug
// where setting state while flushing (render) effects can mess with
// #commit() of the earlier batch that runs afterwards, where roots
// would not be scheduled for other batches anymore because scheduling
// an effect might reach a branch that is already unclean, so scheduling
// thinks "oh we already have this root scheduled" (wrong because not in the context of that batch).
queue_micro_task(fn);
}
}
/**
* Returns the node before which the content of a portal with the given
* sequence number must be inserted, so that the contents of multiple portals
* appear in the order in which the portals were created
* @param {Outlet} outlet
* @param {number} seq
* @returns {TemplateNode}
*/
function get_insertion_anchor(outlet, seq) {
var items = outlet.items;
// prune branches that were destroyed or moved offscreen
for (var i = items.length - 1; i >= 0; i -= 1) {
var { effect, fragment } = items[i].branch;
if ((effect.f & (DESTROYED | DESTROYING)) !== 0 || effect.nodes === null || fragment !== null) {
items.splice(i, 1);
}
}
for (var item of items) {
if (item.seq > seq) {
return /** @type {TemplateNode} */ (item.branch.effect.nodes?.start);
}
}
return outlet.anchor;
}
/**
* Registers a rendered portal branch with an outlet, keeping `outlet.items` ordered
* @param {Outlet} outlet
* @param {number} seq
* @param {PortalBranch} branch
*/
function register_branch(outlet, seq, branch) {
var items = outlet.items;
var index = items.findIndex((item) => item.seq > seq);
if (index === -1) {
items.push({ seq, branch });
} else {
items.splice(index, 0, { seq, branch });
}
}
/** /**
* @param {TemplateNode} node * @param {TemplateNode} node
* @param {() => any} get_id * @param {() => any} get_id
@ -58,347 +157,435 @@ export function portal_outlet(node, get_id) {
var anchor = node; var anchor = node;
if (hydrating) { if (hydrating) {
// `node` is the `<!--[-->` comment — advance to the `<!--portal:N-->` marker.
// Server-rendered content of `{#portal ...}` blocks comes right after it
// and is claimed by the corresponding blocks during hydration
anchor = hydrate_next(); anchor = hydrate_next();
} }
/** @type {Outlet} */ /** @type {Outlet} */
var outlet = { anchor }; var outlet = { anchor, claim: hydrating ? anchor : null, items: [] };
// TODO this should be a block effect so it runs during traversal. The way it's right now
// it means that a #portal block with async work will have that async work not coordinated
// if it's instantiated through this @portal for the first time.
render_effect(() => { render_effect(() => {
const id = get_id(); const id = get_id();
if (id == null) return; if (id == null) return;
const entry = get_outlet_entry(id); const entry = get_outlet_entry(id);
const outlets = entry.outlets;
set( var registered = false;
outlets, var cancelled = false;
untrack(() => [...get(outlets), outlet])
);
for (const render of entry.pending) { const register = () => {
render(outlet); if (cancelled) return;
} registered = true;
set(
entry.outlets,
untrack(() => [...get(entry.outlets), outlet])
);
// during hydration, portals that were created before this outlet claim
// their server-rendered content now, while the hydration position is known
for (const render of entry.pending) {
render(outlet);
}
};
const unregister = () => {
cancelled = true;
if (!registered) return;
return () => {
set( set(
outlets, entry.outlets,
untrack(() => get(outlets).filter((item) => item !== outlet)) untrack(() => get(entry.outlets).filter((o) => o !== outlet))
); );
}; };
run_outside_batch(register);
return () => run_outside_batch(unregister);
}); });
if (hydrating) { if (hydrating) {
let depth = 1; // move past the (claimed) server-rendered portal contents to the
while (anchor !== null && depth > 0) { // closing `<!--]-->` comment, which becomes the outlet anchor.
// TODO we have similar logic in other places, consolidate? // Portals that appear later in the markup claim their server-rendered
anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor)); // content through `outlet.claim`.
if (anchor?.nodeType === 8) { var close = skip_nodes(false);
var comment = /** @type {Comment} */ (anchor).data;
if (comment === HYDRATION_START || comment === HYDRATION_START_ELSE) depth += 1; outlet.anchor = close;
else if (comment[0] === HYDRATION_END) depth -= 1;
} set_hydrate_node(close);
}
set_hydrate_node(anchor);
} }
} }
/** /**
* @param {() => any} get_target * @param {() => any} get_target
* @param {(anchor: TemplateNode) => void} content * @param {(anchor: TemplateNode) => void} content
* @returns {void | (() => void)} * @returns {void}
*/ */
export function portal(get_target, content) { export function portal(get_target, content) {
// Portal targets are reconciled at batch boundaries. A target can disappear in one var seq = portal_seq++;
// 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();
/** @type {{ entry: OutletEntry, render: (outlet: Outlet) => void } | null} */
let unrendered = null;
/** @param {Map<any, PortalBranch>} portals */
function destroy_portals(portals) {
for (const portal of portals.values()) {
destroy_effect(portal.effect);
}
portals.clear();
}
function clear_unrendered() {
if (unrendered === null) return;
const { entry, render } = unrendered; /**
entry.pending.delete(render); * Branches that are currently in the DOM, keyed by target
unrendered = null; * (an {@link Outlet}, or an element in case of `{#portal some_dom_node}`).
} * Since a portal renders into every outlet with a matching key,
* there can be multiple branches at any given time
* @type {Map<Outlet | Element, PortalBranch>}
*/
var onscreen = new Map();
/** @param {OutletEntry} entry */ /**
function set_unrendered(entry) { * Branches that are rendered into a `DocumentFragment` because their
/** @type {(outlet: Outlet) => void} */ * insertion is deferred until their batch commits, keyed by target
const render = (outlet) => { * @type {Map<Outlet | Element, PortalBranch>}
const prev_context = capture(); */
portal_context(false); var offscreen = new Map();
try { /**
const portal = create_portal(outlet, { anchor: outlet.anchor, outlet }, false); * The target (outlet key or element) and resolved targets each in-flight
onscreen.set(outlet, portal); * batch wants this portal to render into. An entry is only removed once
} finally { * its batch commits or is discarded
prev_context(false); * @type {Map<Batch, { target: any, targets: Set<Outlet | Element> }>}
} */
}; var batches = new Map();
entry.pending.add(render); /** @type {Effect} */
unrendered = { entry, render }; var self;
}
/** /**
* @param {any} key * Creates a branch for the given target, either directly in the DOM
* @param {PortalTarget} target * (claiming server-rendered content, if it exists) or offscreen
* @param {boolean} offscreen * @param {Outlet | Element} target
* @returns {PortalBranch} * @param {boolean} offscreen_render
*/ */
function create_portal(key, target, offscreen) { function create_branch(target, offscreen_render) {
/** @type {DocumentFragment | null} */ var outlet = target instanceof Element ? null : target;
let fragment = null;
let anchor = target.anchor; /** @type {PortalBranch} */
let previous_hydrating = false; var portal_branch = { effect: /** @type {any} */ (null), fragment: null };
let previous_hydrate_node = null;
if (hydrating && outlet !== null && outlet.claim !== null && !offscreen_render) {
if (offscreen) { // claim the server-rendered content, which sits after the previously
fragment = document.createDocumentFragment(); // claimed content (or after the `<!--portal:N-->` marker)
anchor = create_text(); var previous_hydrate_node = hydrate_node;
fragment.append(anchor); var start = /** @type {TemplateNode} */ (get_next_sibling(outlet.claim));
if (hydrating) { set_hydrate_node(start);
// Offscreen branches are new client work, there's no SSR content to claim.
previous_hydrating = true; var effect = branch(() => content(start));
set_hydrating(false);
} // the trailing `<!---->` of this portal's server-rendered chunk
} else if (hydrating) { // becomes the branch's personal anchor
if (target.outlet !== undefined) { var anchor = hydrate_node;
// An outlet was discovered before this matching portal block. Preserve
// the global hydration cursor while hydrating from the outlet's own anchor. if (effect.nodes === null) {
previous_hydrating = true; effect.nodes = { start, end: anchor, a: null, t: null };
previous_hydrate_node = hydrate_node;
set_hydrate_node((anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor))));
} else { } else {
// This is a DOM portal, they are not SSR'd, so temporarily disable hydration to avoid claiming the wrong nodes. // make sure the whole chunk (including boundary comments) belongs
previous_hydrating = true; // to the branch, so that it is moved/removed in its entirety
set_hydrating(false); effect.nodes.start = start;
effect.nodes.end = anchor;
} }
}
/** @type {PortalBranch} */ outlet.claim = anchor;
const portal = {
key,
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) {
target.anchor = hydrate_node;
// 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); set_hydrate_node(previous_hydrate_node);
}
if (previous_hydrating) { portal_branch.effect = effect;
set_hydrating(true); onscreen.set(target, portal_branch);
} register_branch(outlet, seq, portal_branch);
} else {
// render from scratch — content is created before a personal anchor,
// which is part of the branch so it travels with it
var was_hydrating = hydrating;
if (was_hydrating) set_hydrating(false);
return portal; var personal_anchor = create_text();
}
/** @returns {Set<any>} */ try {
function get_future_keys() { if (offscreen_render) {
// Pending newer batches determine whether an offscreen branch should be preserved. var fragment = document.createDocumentFragment();
// This mirrors BranchManager's ability to keep a branch alive across discarded work. fragment.append(personal_anchor);
const keys = new Set(); portal_branch.fragment = fragment;
} else if (target instanceof Element) {
target.appendChild(personal_anchor);
} else {
get_insertion_anchor(/** @type {Outlet} */ (outlet), seq).before(personal_anchor);
}
for (const targets of pending.values()) { portal_branch.effect = branch(() => content(personal_anchor));
for (const key of targets.keys()) { } finally {
keys.add(key); if (was_hydrating) set_hydrating(true);
} }
}
return keys; if (portal_branch.effect.nodes === null) {
} portal_branch.effect.nodes = {
start: personal_anchor,
end: personal_anchor,
a: null,
t: null
};
} else {
// include the personal anchor in the effect's node range
portal_branch.effect.nodes.end = personal_anchor;
}
/** @param {Batch} batch */ if (offscreen_render) {
function discard(batch) { offscreen.set(target, portal_branch);
pending.delete(batch); } else {
const future_keys = get_future_keys(); onscreen.set(target, portal_branch);
for (const [key, portal] of offscreen) { if (outlet !== null) {
if (!future_keys.has(key)) { register_branch(outlet, seq, portal_branch);
destroy_effect(portal.effect); }
offscreen.delete(key);
} }
} }
// portaled DOM lives at the outlet, outside the node range of ancestor
// effects — this flag ensures it is removed when the branch is destroyed,
// even if an ancestor was already removed from the DOM
portal_branch.effect.f |= HEAD_EFFECT;
} }
/** @param {Batch} batch */ /**
function commit(batch) { * Brings the DOM in line with the given target selection inserts wanted
if ((effect.f & DESTROYED) !== 0) return; * offscreen branches, and removes deselected onscreen branches (moving them
* offscreen if another in-flight batch still needs them)
* @param {Set<Outlet | Element>} targets
*/
function apply(targets) {
// move offscreen branches that were selected into the DOM
for (var target of targets) {
var portal_branch = offscreen.get(target);
if (portal_branch !== undefined) {
offscreen.delete(target);
onscreen.set(target, portal_branch);
const targets = pending.get(batch); var fragment = /** @type {DocumentFragment} */ (portal_branch.fragment);
if (targets === undefined) return; portal_branch.fragment = null;
for (const [b] of pending) { if (target instanceof Element) {
pending.delete(b); target.appendChild(fragment);
// keep values for newer batches } else {
if (b === batch) break; get_insertion_anchor(target, seq).before(fragment);
register_branch(target, seq, portal_branch);
}
}
} }
const future_keys = get_future_keys(); // remove onscreen branches that were deselected...
for (const [t, b] of onscreen) {
if (targets.has(t)) continue;
if (is_wanted(t)) {
// ...unless another in-flight batch still needs the branch, in
// which case it is moved offscreen rather than destroyed
var f = document.createDocumentFragment();
move_effect(b.effect, f);
b.fragment = f;
offscreen.set(t, b);
} else {
destroy_effect(b.effect);
}
// Newly selected targets were rendered into a fragment if this batch was deferred, onscreen.delete(t);
// 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) { // destroy offscreen branches that no batch needs anymore
/** @type {TemplateNode} */ (portal.fragment?.lastChild).remove(); for (const [t, b] of offscreen) {
target.anchor.before(/** @type {DocumentFragment} */ (portal.fragment)); if (!targets.has(t) && !is_wanted(t)) {
portal.fragment = null; destroy_effect(b.effect);
offscreen.delete(key); offscreen.delete(t);
onscreen.set(key, portal);
} }
} }
}
/**
* True if an in-flight batch wants this portal to render into `target`
* @param {Outlet | Element} target
*/
function is_wanted(target) {
for (const { targets } of batches.values()) {
if (targets.has(target)) return true;
}
return false;
}
for (const [key, portal] of onscreen) { /**
if (targets.has(key)) continue; * @param {Batch} batch
*/
function commit(batch) {
// if this batch was made obsolete, or the portal was destroyed, bail
if (!batches.has(batch) || (self.f & (DESTROYED | DESTROYING)) !== 0) return;
var { target } = /** @type {{ target: any, targets: Set<Outlet | Element> }} */ (
batches.get(batch)
);
onscreen.delete(key); remove(batch);
if (future_keys.has(key)) { // The outlets for this batch's key may have changed since the batch last
// A newer pending batch wants this branch again. Move it out of the DOM instead // ran (an outlet can be (un)registered by another batch, without this
// of destroying it so the later batch can commit without recreating it. // block necessarily re-running within this batch), so the target
const fragment = document.createDocumentFragment(); // selection is computed from the now-committed state
move_effect(portal.effect, fragment); /** @type {Set<Outlet | Element>} */
fragment.append(create_text()); var targets = new Set();
portal.fragment = fragment; if (target != null) {
offscreen.set(key, portal); if (target instanceof Element) {
targets.add(target);
} else { } else {
destroy_effect(portal.effect); for (var outlet of get_outlet_entry(target).outlets.v) {
targets.add(outlet);
}
} }
} }
for (const [key, portal] of offscreen) { apply(targets);
if (targets.has(key) || future_keys.has(key)) continue; }
/**
* Removes the bookkeeping of a batch that committed or was discarded,
* and of any batches that are no longer in-flight
* @param {Batch} batch
*/
function remove(batch) {
batches.delete(batch);
destroy_effect(portal.effect); for (const b of batches.keys()) {
offscreen.delete(key); if (!b.linked) batches.delete(b);
} }
} }
/** @type {Effect} */ /**
let effect; * @param {Batch} batch
*/
function discard(batch) {
if ((self.f & (DESTROYED | DESTROYING)) !== 0) return;
/** @type {ReturnType<typeof capture>} */ remove(batch);
let portal_context;
block(() => { for (const [t, b] of offscreen) {
effect = /** @type {Effect} */ (active_effect); if (!is_wanted(t)) {
portal_context = capture(); destroy_effect(b.effect);
offscreen.delete(t);
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('')));
} }
}
}
block(() => {
self = /** @type {Effect} */ (active_effect);
targets.set(target, { anchor }); var target = get_target();
} else if (target != null) { var batch = /** @type {Batch} */ (current_batch);
const entry = get_outlet_entry(target); var defer = should_defer_append();
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. /** @type {Set<Outlet | Element>} */
set_unrendered(entry); var targets = new Set();
for (const outlet of outlets) { /** @type {(() => void) | undefined} */
targets.set(outlet, { var teardown;
anchor: outlet.anchor,
outlet /** the outlet key this run targets, if any */
}); var key = target != null && !(target instanceof Element) ? target : null;
// Register dependencies on the outlets of the current key, but also on those
// of keys other in-flight batches are interested in. Otherwise, if this run
// switches to a different key, changes to the other keys' outlets would no
// longer re-run this block within those batches
/** @type {Set<any>} */
var keys = new Set();
if (key != null) keys.add(key);
for (const info of batches.values()) {
if (info.target != null && !(info.target instanceof Element)) {
keys.add(info.target);
} }
} }
const batch = /** @type {Batch} */ (current_batch); for (const k of keys) {
const defer = should_defer_append(); var outlets = get(get_outlet_entry(k).outlets);
// Ensure every target requested by this batch has a branch, but do not destroy if (k === key) {
// branches that are absent from this batch until commit/discard tells us whether for (var outlet of outlets) {
// this batch actually wins. targets.add(outlet);
for (const [key, target] of targets) { }
let portal = onscreen.get(key) ?? offscreen.get(key); }
}
if (portal !== undefined) { if (target != null) {
if (defer) batch.unskip_effect(portal.effect); if (target instanceof Element) {
} else { targets.add(target);
portal = create_portal(key, target, defer && !(hydrating && target.outlet !== undefined)); } else if (hydrating) {
(portal.fragment !== null ? offscreen : onscreen).set(key, portal); // an outlet with our key may appear later during this hydration
// pass (`{#portal ...}` before `{@portal ...}` in the markup).
// Register a callback so it can have us claim our server-rendered
// content at its position
var entry = get_outlet_entry(target);
var restore = capture();
/** @param {Outlet} o */
var render = (o) => {
if ((self.f & (DESTROYED | DESTROYING)) !== 0) return;
var previous = capture();
restore(false);
try {
if (!onscreen.has(o) && !offscreen.has(o)) {
create_branch(o, false);
}
} finally {
previous(false);
}
};
entry.pending.add(render);
teardown = () => entry.pending.delete(render);
} }
} }
pending.set(batch, targets); for (var t of targets) {
if (!onscreen.has(t) && !offscreen.has(t)) {
create_branch(t, defer);
}
}
batches.set(batch, { target, targets });
// even if this batch's changes are applied right away, we need to know
// when the batch is done, so that its bookkeeping can be removed
batch.oncommit(commit);
batch.ondiscard(discard);
if (defer) { if (defer) {
for (const [key, portal] of onscreen) { for (const [t, b] of onscreen) {
if (targets.has(key)) { if (targets.has(t)) {
batch.unskip_effect(portal.effect); batch.unskip_effect(b.effect);
} else { } else {
batch.skip_effect(portal.effect); batch.skip_effect(b.effect);
} }
} }
for (const [key, portal] of offscreen) { for (const [t, b] of offscreen) {
if (targets.has(key)) { if (targets.has(t)) {
batch.unskip_effect(portal.effect); batch.unskip_effect(b.effect);
} else { } else {
batch.skip_effect(portal.effect); batch.skip_effect(b.effect);
} }
} }
batch.oncommit(commit);
batch.ondiscard(discard);
} else { } else {
commit(batch); apply(targets);
} }
return () => { return teardown;
clear_unrendered();
if (/** @type {Effect} */ (effect).f & DESTROYING) {
destroy_portals(onscreen);
destroy_portals(offscreen);
pending.clear();
}
};
}); });
} }

Loading…
Cancel
Save