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 { DESTROYED, DESTROYING } from '#client/constants';
import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js';
import { DESTROYED, DESTROYING, HEAD_EFFECT } from '#client/constants';
import { capture } from '../../reactivity/async.js';
import { current_batch } from '../../reactivity/batch.js';
import {
@ -9,31 +8,63 @@ import {
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, set_active_effect, untrack } from '../../runtime.js';
import { active_effect, get, untrack } from '../../runtime.js';
import {
hydrate_next,
hydrate_node,
hydrating,
set_hydrate_node,
set_hydrating
set_hydrating,
skip_nodes
} 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 }} Outlet
* @typedef {{ outlets: Source<Array<Outlet>>, pending: Set<(outlet: Outlet) => void> }} OutletEntry
* @typedef {{ key: any, effect: Effect, fragment: DocumentFragment | null }} PortalBranch
* @typedef {{ anchor: TemplateNode, outlet?: Outlet }} PortalTarget
* A branch of a `{#portal ...}` block, rendered into a single target.
* While the branch is offscreen (its insertion is deferred until a batch
* commits), `fragment` contains its DOM, otherwise it is `null`.
* @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>} */
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
* @returns {OutletEntry}
@ -49,6 +80,74 @@ function get_outlet_entry(key) {
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 {() => any} get_id
@ -58,347 +157,435 @@ export function portal_outlet(node, get_id) {
var anchor = node;
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();
}
/** @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(() => {
const id = get_id();
if (id == null) return;
const entry = get_outlet_entry(id);
const outlets = entry.outlets;
var registered = false;
var cancelled = false;
const register = () => {
if (cancelled) return;
registered = true;
set(
outlets,
untrack(() => [...get(outlets), outlet])
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(
outlets,
untrack(() => get(outlets).filter((item) => item !== outlet))
entry.outlets,
untrack(() => get(entry.outlets).filter((o) => o !== outlet))
);
};
run_outside_batch(register);
return () => run_outside_batch(unregister);
});
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);
// move past the (claimed) server-rendered portal contents to the
// closing `<!--]-->` comment, which becomes the outlet anchor.
// Portals that appear later in the markup claim their server-rendered
// content through `outlet.claim`.
var close = skip_nodes(false);
outlet.anchor = close;
set_hydrate_node(close);
}
}
/**
* @param {() => any} get_target
* @param {(anchor: TemplateNode) => void} content
* @returns {void | (() => void)}
* @returns {void}
*/
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();
/** @type {{ entry: OutletEntry, render: (outlet: Outlet) => void } | null} */
let unrendered = null;
var seq = portal_seq++;
/** @param {Map<any, PortalBranch>} portals */
function destroy_portals(portals) {
for (const portal of portals.values()) {
destroy_effect(portal.effect);
}
/**
* Branches that are currently in the DOM, keyed by target
* (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();
portals.clear();
}
/**
* Branches that are rendered into a `DocumentFragment` because their
* insertion is deferred until their batch commits, keyed by target
* @type {Map<Outlet | Element, PortalBranch>}
*/
var offscreen = new Map();
/**
* The target (outlet key or element) and resolved targets each in-flight
* batch wants this portal to render into. An entry is only removed once
* its batch commits or is discarded
* @type {Map<Batch, { target: any, targets: Set<Outlet | Element> }>}
*/
var batches = new Map();
/** @type {Effect} */
var self;
/**
* Creates a branch for the given target, either directly in the DOM
* (claiming server-rendered content, if it exists) or offscreen
* @param {Outlet | Element} target
* @param {boolean} offscreen_render
*/
function create_branch(target, offscreen_render) {
var outlet = target instanceof Element ? null : target;
/** @type {PortalBranch} */
var portal_branch = { effect: /** @type {any} */ (null), fragment: null };
if (hydrating && outlet !== null && outlet.claim !== null && !offscreen_render) {
// claim the server-rendered content, which sits after the previously
// claimed content (or after the `<!--portal:N-->` marker)
var previous_hydrate_node = hydrate_node;
var start = /** @type {TemplateNode} */ (get_next_sibling(outlet.claim));
function clear_unrendered() {
if (unrendered === null) return;
set_hydrate_node(start);
const { entry, render } = unrendered;
entry.pending.delete(render);
unrendered = null;
var effect = branch(() => content(start));
// the trailing `<!---->` of this portal's server-rendered chunk
// becomes the branch's personal anchor
var anchor = hydrate_node;
if (effect.nodes === null) {
effect.nodes = { start, end: anchor, a: null, t: null };
} else {
// make sure the whole chunk (including boundary comments) belongs
// to the branch, so that it is moved/removed in its entirety
effect.nodes.start = start;
effect.nodes.end = anchor;
}
/** @param {OutletEntry} entry */
function set_unrendered(entry) {
/** @type {(outlet: Outlet) => void} */
const render = (outlet) => {
const prev_context = capture();
portal_context(false);
outlet.claim = anchor;
set_hydrate_node(previous_hydrate_node);
portal_branch.effect = effect;
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);
var personal_anchor = create_text();
try {
const portal = create_portal(outlet, { anchor: outlet.anchor, outlet }, false);
onscreen.set(outlet, portal);
if (offscreen_render) {
var fragment = document.createDocumentFragment();
fragment.append(personal_anchor);
portal_branch.fragment = fragment;
} else if (target instanceof Element) {
target.appendChild(personal_anchor);
} else {
get_insertion_anchor(/** @type {Outlet} */ (outlet), seq).before(personal_anchor);
}
portal_branch.effect = branch(() => content(personal_anchor));
} finally {
prev_context(false);
if (was_hydrating) set_hydrating(true);
}
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;
}
entry.pending.add(render);
unrendered = { entry, render };
if (offscreen_render) {
offscreen.set(target, portal_branch);
} else {
onscreen.set(target, portal_branch);
if (outlet !== null) {
register_branch(outlet, seq, portal_branch);
}
}
}
// 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 {any} key
* @param {PortalTarget} target
* @param {boolean} offscreen
* @returns {PortalBranch}
* Brings the DOM in line with the given target selection inserts wanted
* offscreen branches, and removes deselected onscreen branches (moving them
* offscreen if another in-flight batch still needs them)
* @param {Set<Outlet | Element>} targets
*/
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;
if (offscreen) {
fragment = document.createDocumentFragment();
anchor = create_text();
fragment.append(anchor);
function apply(targets) {
// move offscreen branches that were selected into the DOM
for (var target of targets) {
var portal_branch = offscreen.get(target);
if (hydrating) {
// Offscreen branches are new client work, there's no SSR content to claim.
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))));
if (portal_branch !== undefined) {
offscreen.delete(target);
onscreen.set(target, portal_branch);
var fragment = /** @type {DocumentFragment} */ (portal_branch.fragment);
portal_branch.fragment = null;
if (target instanceof Element) {
target.appendChild(fragment);
} 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);
get_insertion_anchor(target, seq).before(fragment);
register_branch(target, seq, portal_branch);
}
}
}
/** @type {PortalBranch} */
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
};
// remove onscreen branches that were deselected...
for (const [t, b] of onscreen) {
if (targets.has(t)) continue;
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);
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);
}
if (previous_hydrating) {
set_hydrating(true);
onscreen.delete(t);
}
return portal;
// destroy offscreen branches that no batch needs anymore
for (const [t, b] of offscreen) {
if (!targets.has(t) && !is_wanted(t)) {
destroy_effect(b.effect);
offscreen.delete(t);
}
/** @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;
/**
* 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;
}
/** @param {Batch} batch */
function discard(batch) {
pending.delete(batch);
const future_keys = get_future_keys();
return false;
}
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 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)
);
remove(batch);
// The outlets for this batch's key may have changed since the batch last
// ran (an outlet can be (un)registered by another batch, without this
// block necessarily re-running within this batch), so the target
// selection is computed from the now-committed state
/** @type {Set<Outlet | Element>} */
var targets = new Set();
if (target != null) {
if (target instanceof Element) {
targets.add(target);
} else {
for (var outlet of get_outlet_entry(target).outlets.v) {
targets.add(outlet);
}
}
}
/** @param {Batch} batch */
function commit(batch) {
if ((effect.f & DESTROYED) !== 0) return;
apply(targets);
}
const targets = pending.get(batch);
if (targets === undefined) return;
/**
* 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);
for (const [b] of pending) {
pending.delete(b);
// keep values for newer batches
if (b === batch) break;
for (const b of batches.keys()) {
if (!b.linked) batches.delete(b);
}
}
const future_keys = get_future_keys();
/**
* @param {Batch} batch
*/
function discard(batch) {
if ((self.f & (DESTROYED | DESTROYING)) !== 0) return;
// 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);
remove(batch);
if (portal !== undefined) {
/** @type {TemplateNode} */ (portal.fragment?.lastChild).remove();
target.anchor.before(/** @type {DocumentFragment} */ (portal.fragment));
portal.fragment = null;
offscreen.delete(key);
onscreen.set(key, portal);
for (const [t, b] of offscreen) {
if (!is_wanted(t)) {
destroy_effect(b.effect);
offscreen.delete(t);
}
}
}
for (const [key, portal] of onscreen) {
if (targets.has(key)) continue;
block(() => {
self = /** @type {Effect} */ (active_effect);
onscreen.delete(key);
var target = get_target();
var batch = /** @type {Batch} */ (current_batch);
var defer = should_defer_append();
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());
/** @type {Set<Outlet | Element>} */
var targets = new Set();
portal.fragment = fragment;
offscreen.set(key, portal);
} else {
destroy_effect(portal.effect);
/** @type {(() => void) | undefined} */
var teardown;
/** 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);
}
}
for (const [key, portal] of offscreen) {
if (targets.has(key) || future_keys.has(key)) continue;
for (const k of keys) {
var outlets = get(get_outlet_entry(k).outlets);
destroy_effect(portal.effect);
offscreen.delete(key);
if (k === key) {
for (var outlet of outlets) {
targets.add(outlet);
}
}
}
/** @type {Effect} */
let effect;
/** @type {ReturnType<typeof capture>} */
let portal_context;
if (target != null) {
if (target instanceof Element) {
targets.add(target);
} else if (hydrating) {
// 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();
block(() => {
effect = /** @type {Effect} */ (active_effect);
portal_context = capture();
/** @param {Outlet} o */
var render = (o) => {
if ((self.f & (DESTROYED | DESTROYING)) !== 0) return;
const target = get_target();
/** @type {Map<any, PortalTarget>} */
const targets = new Map();
var previous = capture();
restore(false);
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 });
} else if (target != null) {
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 outlets) {
targets.set(outlet, {
anchor: outlet.anchor,
outlet
});
try {
if (!onscreen.has(o) && !offscreen.has(o)) {
create_branch(o, false);
}
} finally {
previous(false);
}
};
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);
entry.pending.add(render);
teardown = () => entry.pending.delete(render);
}
}
if (portal !== undefined) {
if (defer) batch.unskip_effect(portal.effect);
} else {
portal = create_portal(key, target, defer && !(hydrating && target.outlet !== undefined));
(portal.fragment !== null ? offscreen : onscreen).set(key, portal);
for (var t of targets) {
if (!onscreen.has(t) && !offscreen.has(t)) {
create_branch(t, defer);
}
}
pending.set(batch, targets);
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) {
for (const [key, portal] of onscreen) {
if (targets.has(key)) {
batch.unskip_effect(portal.effect);
for (const [t, b] of onscreen) {
if (targets.has(t)) {
batch.unskip_effect(b.effect);
} else {
batch.skip_effect(portal.effect);
batch.skip_effect(b.effect);
}
}
for (const [key, portal] of offscreen) {
if (targets.has(key)) {
batch.unskip_effect(portal.effect);
for (const [t, b] of offscreen) {
if (targets.has(t)) {
batch.unskip_effect(b.effect);
} else {
batch.skip_effect(portal.effect);
batch.skip_effect(b.effect);
}
}
batch.oncommit(commit);
batch.ondiscard(discard);
} else {
commit(batch);
apply(targets);
}
return () => {
clear_unrendered();
if (/** @type {Effect} */ (effect).f & DESTROYING) {
destroy_portals(onscreen);
destroy_portals(offscreen);
pending.clear();
}
};
return teardown;
});
}

Loading…
Cancel
Save