tweaks/fixes/tests

portals
Simon Holthausen 2 months ago
parent e1e6de65f3
commit 2d39954235
No known key found for this signature in database

@ -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<Array<Outlet>>, pending: Set<(outlet: Outlet) => void> }} OutletEntry
* @typedef {{ key: any, effect: Effect, fragment: DocumentFragment | null }} PortalBranch
* @typedef {{ anchor: TemplateNode, outlet?: Outlet }} PortalTarget
*/
/** @type {Map<any, Source<Array<Outlet>>>} */
/** @type {Map<any, OutletEntry>} */
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<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) {
@ -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<typeof capture>} */
let portal_context;
block(() => {
effect = /** @type {Effect} */ (active_effect);
portal_context = capture();
const target = get_target();
/** @type {Map<any, PortalTarget>} */
@ -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);

@ -427,7 +427,7 @@ export class Renderer {
this.push(BLOCK_OPEN);
const portal = this.global.portals.get(key) ?? { id: undefined, renderers: [] };
portal.id = `<!--portal:${this.global.portal_id++}-->`;
portal.id ??= `<!--portal:${this.global.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);
}
}
}

@ -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,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> ab'
);
toggleOutletKey.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> ab'
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> bb hi'
);
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> ba'
);
togglePortalKey.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> ba'
);
toggleOutletKey.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> ba'
);
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> bb hi'
);
pop.click();
await tick();
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> ab'
);
}
});

@ -0,0 +1,31 @@
<script>
let portalKey = $state('a');
let outletKey = $state('b');
let queued = [];
let first = true;
function push(v) {
if (first) {
first = false;
return v;
}
return new Promise((resolve) => {
queued.push(() => resolve(v));
});
}
</script>
<button onclick={() => portalKey = portalKey === 'a' ? 'b' : 'a'}>toggle portalKey</button>
<button onclick={() => outletKey = outletKey === 'a' ? 'b' : 'a'}>toggle outletKey</button>
<button onclick={() => queued.shift()?.()}>shift</button>
<button onclick={() => queued.pop()?.()}>pop</button>
{await push(portalKey + outletKey)}
{#portal portalKey}
hi
{/portal}
{@portal outletKey}

@ -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,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> aa hi'
);
toggleOutletKey.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> aa hi'
);
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> ba'
);
shift.click();
await tick();
shift.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> bb'
);
togglePortalKey.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> bb'
);
toggleOutletKey.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> bb'
);
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> ba'
);
pop.click();
await tick();
pop.click();
await tick();
assert.htmlEqual(
target.innerHTML,
'<button>toggle portalKey</button> <button>toggle outletKey</button> <button>shift</button> <button>pop</button> aa hi'
);
}
});

@ -0,0 +1,35 @@
<script>
let portalKey = $state('a');
let outletKey = $state('a');
let queued = [];
let first = true;
function push(v) {
if (first) {
first = false;
return v;
}
return new Promise((resolve) => {
queued.push(() => resolve(v));
});
}
</script>
<button onclick={() => portalKey = portalKey === 'a' ? 'b' : 'a'}>toggle portalKey</button>
<button onclick={() => outletKey = outletKey === 'a' ? 'b' : 'a'}>toggle outletKey</button>
<button onclick={() => queued.shift()?.()}>shift</button>
<button onclick={() => queued.pop()?.()}>pop</button>
{await push(portalKey + outletKey)}
{#if portalKey === 'a'}
{#portal portalKey}
hi
{/portal}
{/if}
{#if outletKey === 'a'}
{@portal outletKey}
{/if}

@ -1,6 +1,7 @@
import { test } from '../../test';
export default test({
solo: true,
mode: ['client'],
html: `<main><p>portaled</p></main>`
});

@ -2,6 +2,7 @@ import { flushSync } from 'svelte';
import { test } from '../../test';
export default test({
solo: true,
html: '<button>toggle portalKey</button> <button>toggle outletKey</button>',
test({ assert, target }) {
const [togglePortalKey, toggleOutletKey] = target.querySelectorAll('button');

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
html: `abc abc`
});

@ -0,0 +1,15 @@
{#portal 'multiple'}
a
{/portal}
{@portal 'multiple'}
{#portal 'multiple'}
b
{/portal}
{@portal 'multiple'}
{#portal 'multiple'}
c
{/portal}

@ -1,5 +1,6 @@
import { test } from '../../test';
export default test({
solo: true,
html: `<main><p>portaled</p></main>`
});

@ -1,7 +1,7 @@
{#portal 'example'}
{#portal 'origin-first'}
<p>portaled</p>
{/portal}
<main>
{@portal 'example'}
{@portal 'origin-first'}
</main>

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

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

@ -1,5 +1,6 @@
import { test } from '../../test';
export default test({
solo: true,
html: `<main><p>portaled</p></main>`
});

@ -1,7 +1,7 @@
<main>
{@portal 'example'}
{@portal 'portal'}
</main>
{#portal 'example'}
{#portal 'portal'}
<p>portaled</p>
{/portal}

Loading…
Cancel
Save