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 { Batch } from '../../reactivity/batch.js' */
import { DESTROYED, DESTROYING } from '#client/constants'; import { DESTROYED, DESTROYING } from '#client/constants';
import { HYDRATION_END, HYDRATION_START, HYDRATION_START_ELSE } from '../../../../constants.js'; 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 { current_batch } from '../../reactivity/batch.js';
import { import {
block, block,
@ -12,7 +13,7 @@ import {
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, untrack } from '../../runtime.js'; import { active_effect, get, set_active_effect, untrack } from '../../runtime.js';
import { import {
hydrate_next, hydrate_next,
hydrate_node, hydrate_node,
@ -21,16 +22,33 @@ import {
set_hydrating set_hydrating
} 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';
/** /**
* @typedef {{ anchor: TemplateNode, hydrate: boolean }} Outlet * @typedef {{ anchor: TemplateNode }} Outlet
* @typedef {{ key: any, anchor: TemplateNode, effect: Effect, fragment: DocumentFragment | null }} PortalBranch * @typedef {{ outlets: Source<Array<Outlet>>, pending: Set<(outlet: Outlet) => void> }} OutletEntry
* @typedef {{ anchor: TemplateNode, hydrate: boolean, client_only: boolean, outlet?: Outlet }} PortalTarget * @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(); 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 {TemplateNode} node
* @param {() => any} get_id * @param {() => any} get_id
@ -44,20 +62,24 @@ export function portal_outlet(node, get_id) {
} }
/** @type {Outlet} */ /** @type {Outlet} */
var outlet = { anchor, hydrate: hydrating }; var outlet = { anchor };
render_effect(() => { render_effect(() => {
const id = get_id(); const id = get_id();
if (id == null) return; 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( set(
outlets, outlets,
untrack(() => [...get(outlets), outlet]) untrack(() => [...get(outlets), outlet])
); );
for (const render of entry.pending) {
render(outlet);
}
return () => { return () => {
set( set(
outlets, outlets,
@ -96,6 +118,8 @@ export function portal(get_target, content) {
let offscreen = new Map(); let offscreen = new Map();
/** @type {Map<Batch, Map<any, PortalTarget>>} */ /** @type {Map<Batch, Map<any, PortalTarget>>} */
let pending = new Map(); let pending = new Map();
/** @type {{ entry: OutletEntry, render: (outlet: Outlet) => void } | null} */
let unrendered = null;
/** @param {Map<any, PortalBranch>} portals */ /** @param {Map<any, PortalBranch>} portals */
function destroy_portals(portals) { function destroy_portals(portals) {
@ -106,6 +130,33 @@ export function portal(get_target, content) {
portals.clear(); 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 {any} key
* @param {PortalTarget} target * @param {PortalTarget} target
@ -129,21 +180,23 @@ export function portal(get_target, content) {
previous_hydrating = true; previous_hydrating = true;
set_hydrating(false); set_hydrating(false);
} }
} else if (target.hydrate) { } else if (hydrating) {
// An outlet can be discovered before the matching portal block runs. Preserve 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. // the global hydration cursor while hydrating from the outlet's own anchor.
previous_hydrating = hydrating; previous_hydrating = true;
previous_hydrate_node = hydrate_node; previous_hydrate_node = hydrate_node;
set_hydrating(true);
set_hydrate_node((anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor)))); set_hydrate_node((anchor = /** @type {TemplateNode} */ (get_next_sibling(anchor))));
} else if (target.client_only && hydrating) { } else {
// This is a DOM portal, they are not SSR'd, so temporarily disable hydration to avoid claiming the wrong nodes.
previous_hydrating = true; previous_hydrating = true;
set_hydrating(false); set_hydrating(false);
} }
}
/** @type {PortalBranch} */
const portal = { const portal = {
key, key,
anchor: target.anchor,
effect: branch(() => { effect: branch(() => {
content(anchor); content(anchor);
return () => { return () => {
@ -159,19 +212,15 @@ export function portal(get_target, content) {
}; };
if (previous_hydrate_node !== null) { if (previous_hydrate_node !== null) {
portal.anchor = hydrate_node;
target.anchor = hydrate_node; target.anchor = hydrate_node;
if (target.outlet !== undefined) {
// Future portal instances for this outlet must insert after the hydrated content, // Future portal instances for this outlet must insert after the hydrated content,
// not after the original outlet marker. // not after the original outlet marker.
target.outlet.anchor = hydrate_node; /** @type {Outlet} */ (target.outlet).anchor = hydrate_node;
target.outlet.hydrate = false;
}
set_hydrate_node(previous_hydrate_node); set_hydrate_node(previous_hydrate_node);
} }
if (previous_hydrating || target.hydrate) { if (previous_hydrating) {
set_hydrating(previous_hydrating); set_hydrating(true);
} }
return portal; return portal;
@ -229,7 +278,6 @@ export function portal(get_target, content) {
/** @type {TemplateNode} */ (portal.fragment?.lastChild).remove(); /** @type {TemplateNode} */ (portal.fragment?.lastChild).remove();
target.anchor.before(/** @type {DocumentFragment} */ (portal.fragment)); target.anchor.before(/** @type {DocumentFragment} */ (portal.fragment));
portal.fragment = null; portal.fragment = null;
portal.anchor = target.anchor;
offscreen.delete(key); offscreen.delete(key);
onscreen.set(key, portal); onscreen.set(key, portal);
} }
@ -265,8 +313,12 @@ export function portal(get_target, content) {
/** @type {Effect} */ /** @type {Effect} */
let effect; let effect;
/** @type {ReturnType<typeof capture>} */
let portal_context;
block(() => { block(() => {
effect = /** @type {Effect} */ (active_effect); effect = /** @type {Effect} */ (active_effect);
portal_context = capture();
const target = get_target(); const target = get_target();
/** @type {Map<any, PortalTarget>} */ /** @type {Map<any, PortalTarget>} */
@ -280,16 +332,18 @@ export function portal(get_target, content) {
target.appendChild((anchor = document.createTextNode(''))); target.appendChild((anchor = document.createTextNode('')));
} }
targets.set(target, { anchor, hydrate: false, client_only: true }); targets.set(target, { anchor });
} else if (target != null) { } else if (target != null) {
const outlets_source = outlet_map.get(target) ?? source([]); const entry = get_outlet_entry(target);
outlet_map.set(target, outlets_source); 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, { targets.set(outlet, {
anchor: outlet.anchor, anchor: outlet.anchor,
hydrate: outlet.hydrate,
client_only: false,
outlet outlet
}); });
} }
@ -305,11 +359,10 @@ export function portal(get_target, content) {
let portal = onscreen.get(key) ?? offscreen.get(key); let portal = onscreen.get(key) ?? offscreen.get(key);
if (portal !== undefined) { if (portal !== undefined) {
portal.anchor = target.anchor;
if (defer) batch.unskip_effect(portal.effect); if (defer) batch.unskip_effect(portal.effect);
} else { } else {
portal = create_portal(key, target, defer && !target.hydrate); portal = create_portal(key, target, defer && !(hydrating && target.outlet !== undefined));
(defer && !target.hydrate ? offscreen : onscreen).set(key, portal); (portal.fragment !== null ? offscreen : onscreen).set(key, portal);
} }
} }
@ -339,6 +392,8 @@ export function portal(get_target, content) {
} }
return () => { return () => {
clear_unrendered();
if (/** @type {Effect} */ (effect).f & DESTROYING) { if (/** @type {Effect} */ (effect).f & DESTROYING) {
destroy_portals(onscreen); destroy_portals(onscreen);
destroy_portals(offscreen); destroy_portals(offscreen);

@ -427,7 +427,7 @@ export class Renderer {
this.push(BLOCK_OPEN); this.push(BLOCK_OPEN);
const portal = this.global.portals.get(key) ?? { id: undefined, renderers: [] }; 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.global.portals.set(key, portal);
this.push(portal.id); this.push(portal.id);
this.push(BLOCK_CLOSE); this.push(BLOCK_CLOSE);
@ -448,7 +448,7 @@ export class Renderer {
const tmp_payload = new Renderer(this.global, this); const tmp_payload = new Renderer(this.global, this);
content(tmp_payload); content(tmp_payload);
tmp_payload.push(EMPTY_COMMENT); 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) { if (item instanceof Renderer) {
const portal_content = item.#collect_content(); const portal_content = item.#collect_content();
item.#out.length = 0; item.#out.length = 0;
content.body = content.body.replace(portal.id, portal.id + portal_content.body); content.body = content.body.replaceAll(portal.id, portal.id + portal_content.body);
content.head = content.head.replace(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) { for (const item of portal.renderers) {
if (item instanceof Renderer) { if (item instanceof Renderer) {
const portal_content = await item.#collect_content_async(); const portal_content = await item.#collect_content_async();
content.body = content.body.replace(portal.id, portal.id + portal_content.body); content.body = content.body.replaceAll(portal.id, portal.id + portal_content.body);
content.head = content.head.replace(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'; import { test } from '../../test';
export default test({ export default test({
solo: true,
mode: ['client'], mode: ['client'],
html: `<main><p>portaled</p></main>` html: `<main><p>portaled</p></main>`
}); });

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

@ -1,7 +1,7 @@
{#portal 'example'} {#portal 'origin-first'}
<p>portaled</p> <p>portaled</p>
{/portal} {/portal}
<main> <main>
{@portal 'example'} {@portal 'origin-first'}
</main> </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'; import { test } from '../../test';
export default test({ export default test({
solo: true,
html: `<main><p>portaled</p></main>` html: `<main><p>portaled</p></main>`
}); });

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

Loading…
Cancel
Save