start converting boundary to a class

async-changeset
Rich Harris 7 months ago
parent 7bd6969711
commit 7bf7e0dd78

@ -37,361 +37,400 @@ import { from_async_derived, set_from_async_derived } from '../../reactivity/der
import { raf } from '../../timing.js'; import { raf } from '../../timing.js';
import { loop } from '../../loop.js'; import { loop } from '../../loop.js';
const ASYNC_INCREMENT = Symbol(); /** @type {Boundary | null} */
const ASYNC_DECREMENT = Symbol(); export let active_boundary = null;
const ADD_CALLBACK = Symbol();
const ADD_RENDER_EFFECT = Symbol();
const ADD_EFFECT = Symbol();
const COMMIT = Symbol();
/** /** @param {Boundary | null} boundary */
* @param {Effect} boundary export function set_active_boundary(boundary) {
* @param {() => Effect | null} fn active_boundary = boundary;
* @returns {Effect | null}
*/
function with_boundary(boundary, fn) {
var previous_effect = active_effect;
var previous_reaction = active_reaction;
var previous_ctx = component_context;
set_active_effect(boundary);
set_active_reaction(boundary);
set_component_context(boundary.ctx);
try {
return fn();
} finally {
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
set_component_context(previous_ctx);
}
} }
class Boundary {
/** @type {Boundary | null} */
#parent;
/** @type {Effect} */
#effect;
/** @type {Set<() => void>} */
#callbacks = new Set();
/**
* @param {TemplateNode} node
* @param {{
* onerror?: (error: unknown, reset: () => void) => void;
* failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void;
* pending?: (anchor: Node) => void;
* showPendingAfter?: number;
* showPendingFor?: number;
* }} props
* @param {((anchor: Node) => void)} children
*/
constructor(node, props, children) {
var anchor = node;
this.#parent = active_boundary;
active_boundary = this;
var parent_boundary = find_boundary(active_effect);
this.#effect = block(() => {
/** @type {Effect | null} */
var main_effect = null;
/** @type {Effect | null} */
var pending_effect = null;
/** @type {Effect | null} */
var failed_effect = null;
/** @type {DocumentFragment | null} */
var offscreen_fragment = null;
var async_count = 0;
var boundary_effect = /** @type {Effect} */ (active_effect);
var hydrate_open = hydrate_node;
var is_creating_fallback = false;
/** @type {Effect[]} */
var render_effects = [];
/** @type {Effect[]} */
var effects = [];
var keep_pending_snippet = false;
/**
* @param {() => void} snippet_fn
* @returns {Effect | null}
*/
const render_snippet = (snippet_fn) => {
return this.#run(() => {
is_creating_fallback = true;
try {
return branch(snippet_fn);
} catch (error) {
handle_error(error, boundary_effect, null, boundary_effect.ctx);
return null;
} finally {
reset_is_throwing_error();
is_creating_fallback = false;
}
});
};
var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT; const reset = () => {
async_count = 0;
/**
* @param {TemplateNode} node
* @param {{
* onerror?: (error: unknown, reset: () => void) => void;
* failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void;
* pending?: (anchor: Node) => void;
* showPendingAfter?: number;
* showPendingFor?: number;
* }} props
* @param {((anchor: Node) => void)} children
* @returns {void}
*/
export function boundary(node, props, children) {
var anchor = node;
var parent_boundary = find_boundary(active_effect);
block(() => {
/** @type {Effect | null} */
var main_effect = null;
/** @type {Effect | null} */
var pending_effect = null;
/** @type {Effect | null} */
var failed_effect = null;
/** @type {DocumentFragment | null} */
var offscreen_fragment = null;
var async_count = 0;
var boundary = /** @type {Effect} */ (active_effect);
var hydrate_open = hydrate_node;
var is_creating_fallback = false;
/** @type {Set<() => void>} */ if ((boundary_effect.f & BOUNDARY_SUSPENDED) !== 0) {
var callbacks = new Set(); boundary_effect.f ^= BOUNDARY_SUSPENDED;
}
/** @type {Effect[]} */ if (failed_effect !== null) {
var render_effects = []; pause_effect(failed_effect, () => {
failed_effect = null;
});
}
/** @type {Effect[]} */ main_effect = this.#run(() => {
var effects = []; is_creating_fallback = false;
var keep_pending_snippet = false; try {
return branch(() => children(anchor));
} finally {
reset_is_throwing_error();
}
});
/** if (async_count > 0) {
* @param {() => void} snippet_fn boundary_effect.f |= BOUNDARY_SUSPENDED;
* @returns {Effect | null} show_pending_snippet(true);
*/ }
function render_snippet(snippet_fn) { };
return with_boundary(boundary, () => {
is_creating_fallback = true;
try { const unsuspend = () => {
return branch(snippet_fn); if (keep_pending_snippet || async_count > 0) {
} catch (error) { return;
handle_error(error, boundary, null, boundary.ctx);
return null;
} finally {
reset_is_throwing_error();
is_creating_fallback = false;
} }
});
}
function reset() { if ((boundary_effect.f & BOUNDARY_SUSPENDED) !== 0) {
async_count = 0; boundary_effect.f ^= BOUNDARY_SUSPENDED;
}
if ((boundary.f & BOUNDARY_SUSPENDED) !== 0) { for (const e of render_effects) {
boundary.f ^= BOUNDARY_SUSPENDED; try {
} if (check_dirtiness(e)) {
update_effect(e);
}
} catch (error) {
handle_error(error, e, null, e.ctx);
}
}
if (failed_effect !== null) { for (const fn of this.#callbacks) fn();
pause_effect(failed_effect, () => { this.#callbacks.clear();
failed_effect = null;
});
}
main_effect = with_boundary(boundary, () => { if (pending_effect) {
is_creating_fallback = false; pause_effect(pending_effect, () => {
pending_effect = null;
});
}
try { if (offscreen_fragment) {
return branch(() => children(anchor)); anchor.before(offscreen_fragment);
} finally { offscreen_fragment = null;
reset_is_throwing_error();
} }
});
if (async_count > 0) { for (const e of effects) {
boundary.f |= BOUNDARY_SUSPENDED; try {
show_pending_snippet(true); if (check_dirtiness(e)) {
} update_effect(e);
} }
} catch (error) {
handle_error(error, e, null, e.ctx);
}
}
};
function unsuspend() { /**
if (keep_pending_snippet || async_count > 0) { * @param {boolean} initial
return; */
} function show_pending_snippet(initial) {
const pending = props.pending;
if ((boundary.f & BOUNDARY_SUSPENDED) !== 0) { if (pending !== undefined) {
boundary.f ^= BOUNDARY_SUSPENDED; // TODO can this be false?
} if (main_effect !== null) {
offscreen_fragment = document.createDocumentFragment();
move_effect(main_effect, offscreen_fragment);
}
for (const e of render_effects) { if (pending_effect === null) {
try { pending_effect = branch(() => pending(anchor));
if (check_dirtiness(e)) {
update_effect(e);
} }
} catch (error) {
handle_error(error, e, null, e.ctx);
}
}
for (const fn of callbacks) fn(); // TODO do we want to differentiate between initial render and updates here?
callbacks.clear(); if (!initial) {
keep_pending_snippet = true;
if (pending_effect) { var end = raf.now() + (props.showPendingFor ?? 300);
pause_effect(pending_effect, () => {
pending_effect = null;
});
}
if (offscreen_fragment) { loop((now) => {
anchor.before(offscreen_fragment); if (now >= end) {
offscreen_fragment = null; keep_pending_snippet = false;
} unsuspend();
return false;
}
for (const e of effects) { return true;
try { });
if (check_dirtiness(e)) {
update_effect(e);
} }
} catch (error) { } else if (parent_boundary) {
handle_error(error, e, null, e.ctx); throw new Error('TODO show pending snippet on parent');
} else {
throw new Error('no pending snippet to show');
} }
} }
}
/** // @ts-ignore We re-use the effect's fn property to avoid allocation of an additional field
* @param {boolean} initial boundary_effect.fn = (/** @type {unknown} */ input, /** @type {any} */ payload) => {
*/ if (input === ASYNC_INCREMENT) {
function show_pending_snippet(initial) { // post-init, show the pending snippet after a timeout
const pending = props.pending; if (
(boundary_effect.f & BOUNDARY_SUSPENDED) === 0 &&
(boundary_effect.f & EFFECT_RAN) !== 0
) {
var start = raf.now();
var end = start + (props.showPendingAfter ?? 500);
loop((now) => {
if (async_count === 0) return false;
if (now < end) return true;
show_pending_snippet(false);
});
}
if (pending !== undefined) { boundary_effect.f |= BOUNDARY_SUSPENDED;
// TODO can this be false? async_count++;
if (main_effect !== null) {
offscreen_fragment = document.createDocumentFragment();
move_effect(main_effect, offscreen_fragment);
}
if (pending_effect === null) { return;
pending_effect = branch(() => pending(anchor));
} }
// TODO do we want to differentiate between initial render and updates here? if (input === ASYNC_DECREMENT) {
if (!initial) { if (--async_count === 0 && !keep_pending_snippet) {
keep_pending_snippet = true; unsuspend();
var end = raf.now() + (props.showPendingFor ?? 300); if (main_effect !== null) {
// TODO do we also need to `resume_effect` here?
loop((now) => { schedule_effect(main_effect);
if (now >= end) {
keep_pending_snippet = false;
unsuspend();
return false;
} }
}
return true; return;
});
} }
} else if (parent_boundary) {
throw new Error('TODO show pending snippet on parent');
} else {
throw new Error('no pending snippet to show');
}
}
// @ts-ignore We re-use the effect's fn property to avoid allocation of an additional field if (input === ADD_RENDER_EFFECT) {
boundary.fn = (/** @type {unknown} */ input, /** @type {any} */ payload) => { render_effects.push(payload);
if (input === ASYNC_INCREMENT) { return;
// post-init, show the pending snippet after a timeout }
if ((boundary.f & BOUNDARY_SUSPENDED) === 0 && (boundary.f & EFFECT_RAN) !== 0) {
var start = raf.now();
var end = start + (props.showPendingAfter ?? 500);
loop((now) => { if (input === ADD_EFFECT) {
if (async_count === 0) return false; effects.push(payload);
if (now < end) return true; return;
}
show_pending_snippet(false); if (input === COMMIT) {
}); unsuspend();
return;
} }
boundary.f |= BOUNDARY_SUSPENDED; var error = input;
async_count++; var onerror = props.onerror;
let failed = props.failed;
return; // If we have nothing to capture the error, or if we hit an error while
} // rendering the fallback, re-throw for another boundary to handle
if (is_creating_fallback || (!onerror && !failed)) {
throw error;
}
if (input === ASYNC_DECREMENT) { onerror?.(error, reset);
if (--async_count === 0 && !keep_pending_snippet) {
unsuspend();
if (main_effect !== null) { if (main_effect) {
// TODO do we also need to `resume_effect` here? destroy_effect(main_effect);
schedule_effect(main_effect); main_effect = null;
}
} }
return; if (pending_effect) {
} destroy_effect(pending_effect);
pending_effect = null;
if (input === ADD_CALLBACK) { }
callbacks.add(payload);
return;
}
if (input === ADD_RENDER_EFFECT) { if (failed_effect) {
render_effects.push(payload); destroy_effect(failed_effect);
return; failed_effect = null;
} }
if (input === ADD_EFFECT) { if (hydrating) {
effects.push(payload); set_hydrate_node(hydrate_open);
return; next();
} set_hydrate_node(remove_nodes());
}
if (input === COMMIT) { if (failed) {
unsuspend(); queue_boundary_micro_task(() => {
return; failed_effect = render_snippet(() => {
} failed(
anchor,
() => error,
() => reset
);
});
});
}
};
var error = input; // @ts-ignore
var onerror = props.onerror; boundary_effect.fn.is_pending = () => props.pending;
let failed = props.failed;
// If we have nothing to capture the error, or if we hit an error while if (hydrating) {
// rendering the fallback, re-throw for another boundary to handle hydrate_next();
if (is_creating_fallback || (!onerror && !failed)) {
throw error;
} }
onerror?.(error, reset); const pending = props.pending;
if (main_effect) { if (hydrating && pending) {
destroy_effect(main_effect); pending_effect = branch(() => pending(anchor));
main_effect = null;
}
if (pending_effect) { // ...now what? we need to start rendering `boundary_fn` offscreen,
destroy_effect(pending_effect); // and either insert the resulting fragment (if nothing suspends)
pending_effect = null; // or keep the pending effect alive until it unsuspends.
} // not exactly sure how to do that.
if (failed_effect) { // future work: when we have some form of async SSR, we will
destroy_effect(failed_effect); // need to use hydration boundary comments to report whether
failed_effect = null; // the pending or main block was rendered for a given
} // boundary, and hydrate accordingly
queueMicrotask(() => {
destroy_effect(/** @type {Effect} */ (pending_effect));
if (hydrating) { main_effect = this.#run(() => {
set_hydrate_node(hydrate_open); return branch(() => children(anchor));
next();
set_hydrate_node(remove_nodes());
}
if (failed) {
queue_boundary_micro_task(() => {
failed_effect = render_snippet(() => {
failed(
anchor,
() => error,
() => reset
);
}); });
}); });
} else {
main_effect = branch(() => children(anchor));
if (async_count > 0) {
boundary_effect.f |= BOUNDARY_SUSPENDED;
show_pending_snippet(true);
}
} }
};
// @ts-ignore reset_is_throwing_error();
boundary.fn.is_pending = () => props.pending; }, flags);
if (hydrating) { if (hydrating) {
hydrate_next(); anchor = hydrate_node;
} }
const pending = props.pending; active_boundary = this.#parent;
}
if (hydrating && pending) {
pending_effect = branch(() => pending(anchor));
// ...now what? we need to start rendering `boundary_fn` offscreen,
// and either insert the resulting fragment (if nothing suspends)
// or keep the pending effect alive until it unsuspends.
// not exactly sure how to do that.
// future work: when we have some form of async SSR, we will /**
// need to use hydration boundary comments to report whether * @param {() => Effect | null} fn
// the pending or main block was rendered for a given */
// boundary, and hydrate accordingly #run(fn) {
queueMicrotask(() => { var previous_boundary = active_boundary;
destroy_effect(/** @type {Effect} */ (pending_effect)); var previous_effect = active_effect;
var previous_reaction = active_reaction;
var previous_ctx = component_context;
active_boundary = this;
set_active_effect(this.#effect);
set_active_reaction(this.#effect);
set_component_context(this.#effect.ctx);
try {
return fn();
} finally {
active_boundary = previous_boundary;
set_active_effect(previous_effect);
set_active_reaction(previous_reaction);
set_component_context(previous_ctx);
}
}
main_effect = with_boundary(boundary, () => { /** @param {() => void} fn */
return branch(() => children(anchor)); add_callback(fn) {
}); this.#callbacks.add(fn);
}); }
} else { }
main_effect = branch(() => children(anchor));
if (async_count > 0) { const ASYNC_INCREMENT = Symbol();
boundary.f |= BOUNDARY_SUSPENDED; const ASYNC_DECREMENT = Symbol();
show_pending_snippet(true); const ADD_RENDER_EFFECT = Symbol();
} const ADD_EFFECT = Symbol();
} const COMMIT = Symbol();
reset_is_throwing_error(); var flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED | BOUNDARY_EFFECT;
}, flags);
if (hydrating) { /**
anchor = hydrate_node; * @param {TemplateNode} node
} * @param {{
* onerror?: (error: unknown, reset: () => void) => void;
* failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void;
* pending?: (anchor: Node) => void;
* showPendingAfter?: number;
* showPendingFor?: number;
* }} props
* @param {((anchor: Node) => void)} children
* @returns {void}
*/
export function boundary(node, props, children) {
new Boundary(node, props, children);
} }
/** /**
@ -500,19 +539,6 @@ export function find_boundary(effect) {
return effect; return effect;
} }
/**
* @param {Effect | null} boundary
* @param {Function} fn
*/
export function add_boundary_callback(boundary, fn) {
if (boundary === null) {
throw new Error('TODO');
}
// @ts-ignore
boundary.fn(ADD_CALLBACK, fn);
}
/** /**
* @param {Effect} boundary * @param {Effect} boundary
* @param {Effect} effect * @param {Effect} effect

@ -39,7 +39,7 @@ import { queue_micro_task } from '../task.js';
import { active_effect, get } from '../../runtime.js'; import { active_effect, get } from '../../runtime.js';
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { derived_safe_equal } from '../../reactivity/deriveds.js';
import { add_boundary_callback, find_boundary } from './boundary.js'; import { active_boundary } from './boundary.js';
/** /**
* The row of a keyed each block that is currently updating. We track this * The row of a keyed each block that is currently updating. We track this
@ -139,7 +139,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
var was_empty = false; var was_empty = false;
var boundary = find_boundary(active_effect); var boundary = active_boundary;
/** @type {Map<any, EachItem>} */ /** @type {Map<any, EachItem>} */
var offscreen_items = new Map(); var offscreen_items = new Map();
@ -268,9 +268,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
fallback = branch(() => fallback_fn(anchor)); fallback = branch(() => fallback_fn(anchor));
} }
} else { } else {
var defer = boundary !== null && should_defer_append(); if (boundary !== null && should_defer_append()) {
if (defer) {
for (i = 0; i < length; i += 1) { for (i = 0; i < length; i += 1) {
value = array[i]; value = array[i];
key = get_key(value, i); key = get_key(value, i);
@ -301,7 +299,7 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
} }
} }
add_boundary_callback(boundary, commit); boundary?.add_callback(commit);
} else { } else {
commit(); commit();
} }

@ -10,8 +10,7 @@ import {
} from '../hydration.js'; } from '../hydration.js';
import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js'; import { block, branch, pause_effect, resume_effect } from '../../reactivity/effects.js';
import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js'; import { HYDRATION_START_ELSE, UNINITIALIZED } from '../../../../constants.js';
import { active_effect } from '../../runtime.js'; import { active_boundary } from './boundary.js';
import { add_boundary_callback, find_boundary } from './boundary.js';
import { create_text, should_defer_append } from '../operations.js'; import { create_text, should_defer_append } from '../operations.js';
/** /**
@ -51,7 +50,7 @@ export function if_block(node, fn, elseif = false) {
/** @type {Effect | null} */ /** @type {Effect | null} */
var pending_effect = null; var pending_effect = null;
var boundary = find_boundary(active_effect); var boundary = active_boundary;
function commit() { function commit() {
if (offscreen_fragment !== null) { if (offscreen_fragment !== null) {
@ -123,7 +122,7 @@ export function if_block(node, fn, elseif = false) {
} }
if (defer) { if (defer) {
add_boundary_callback(boundary, commit); boundary?.add_callback(commit);
target.remove(); target.remove();
} else { } else {
commit(); commit();

@ -2,10 +2,9 @@
import { UNINITIALIZED } from '../../../../constants.js'; import { UNINITIALIZED } from '../../../../constants.js';
import { block, branch, pause_effect } from '../../reactivity/effects.js'; import { block, branch, pause_effect } from '../../reactivity/effects.js';
import { not_equal, safe_not_equal } from '../../reactivity/equality.js'; import { not_equal, safe_not_equal } from '../../reactivity/equality.js';
import { active_effect } from '../../runtime.js';
import { is_runes } from '../../context.js'; import { is_runes } from '../../context.js';
import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js';
import { add_boundary_callback, find_boundary } from './boundary.js'; import { active_boundary } from './boundary.js';
import { create_text, should_defer_append } from '../operations.js'; import { create_text, should_defer_append } from '../operations.js';
/** /**
@ -34,7 +33,7 @@ export function key_block(node, get_key, render_fn) {
/** @type {DocumentFragment | null} */ /** @type {DocumentFragment | null} */
var offscreen_fragment = null; var offscreen_fragment = null;
var boundary = find_boundary(active_effect); var boundary = active_boundary;
var changed = is_runes() ? not_equal : safe_not_equal; var changed = is_runes() ? not_equal : safe_not_equal;
@ -68,7 +67,7 @@ export function key_block(node, get_key, render_fn) {
pending_effect = branch(() => render_fn(target)); pending_effect = branch(() => render_fn(target));
if (defer) { if (defer) {
add_boundary_callback(boundary, commit); boundary?.add_callback(commit);
target.remove(); target.remove();
} else { } else {
commit(); commit();

@ -1,10 +1,9 @@
/** @import { TemplateNode, Dom, Effect } from '#client' */ /** @import { TemplateNode, Dom, Effect } from '#client' */
import { EFFECT_TRANSPARENT } from '../../constants.js'; import { EFFECT_TRANSPARENT } from '../../constants.js';
import { block, branch, pause_effect } from '../../reactivity/effects.js'; import { block, branch, pause_effect } from '../../reactivity/effects.js';
import { active_effect } from '../../runtime.js';
import { hydrate_next, hydrate_node, hydrating } from '../hydration.js'; import { hydrate_next, hydrate_node, hydrating } from '../hydration.js';
import { create_text, should_defer_append } from '../operations.js'; import { create_text, should_defer_append } from '../operations.js';
import { add_boundary_callback, find_boundary } from './boundary.js'; import { active_boundary } from './boundary.js';
/** /**
* @template P * @template P
@ -33,7 +32,7 @@ export function component(node, get_component, render_fn) {
/** @type {Effect | null} */ /** @type {Effect | null} */
var pending_effect = null; var pending_effect = null;
var boundary = find_boundary(active_effect); var boundary = active_boundary;
function commit() { function commit() {
if (effect) { if (effect) {
@ -70,7 +69,7 @@ export function component(node, get_component, render_fn) {
} }
if (defer) { if (defer) {
add_boundary_callback(boundary, commit); boundary?.add_callback(commit);
} else { } else {
commit(); commit();
} }

Loading…
Cancel
Save