ripple
Version:
Ripple is an elegant TypeScript UI framework
615 lines (551 loc) • 17.1 kB
JavaScript
/** @import { Block, TryBoundaryState, BlockWithTryBoundary, BlockWithTryBoundaryAndCatch } from '#client' */
import {
boundary_fn_running_block,
create_try_block,
destroy_block,
is_destroyed,
move_block,
resume_block,
} from './blocks.js';
import { TRY_BLOCK } from './constants.js';
import {
COMMENT_NODE,
HYDRATION_START,
HYDRATION_START_PENDING,
HYDRATION_START_ERRORED,
STREAM_ERROR_SCRIPT_PREFIX,
} from '../../../constants.js';
import {
hydrate_next,
hydrate_node,
hydrating,
set_hydrate_node,
set_hydrating,
skip_to_hydration_end,
} from './hydration.js';
import { append } from './template.js';
import {
active_block,
queue_microtask,
queue_post_block_flush_callback,
with_block,
} from './runtime.js';
/**
@typedef {(
anchor: Node,
error: any,
reset?: () => void
) => void} CatchFunction;
@typedef {(anchor: Node) => void} PendingFunction
*/
/**
* @param {Node} node
* @param {(anchor: Node, block?: Block) => void} try_fn
* @param {CatchFunction | null} catch_fn
* @param {PendingFunction | null} [pending_fn=null]
* @returns {void}
*/
export function try_block(node, try_fn, catch_fn, pending_fn = null, root_controlled = false) {
var anchor = node;
/** @type {Node | undefined} */
var boundary;
var pending_count = 0;
var request_version = 0;
/** @type {Set<number>} */
var active_requests = new Set();
/** @type {Block | null} */
var try_block = null;
/** @type {Block | null} */
var resolved_branch = null;
/** @type {Block | null} */
var pending_branch = null;
/** @type {Block | null} */
var catch_branch = null;
/** @type {DocumentFragment | null} */
var offscreen_fragment = null;
var has_resolved = false;
/** @type {'resolved' | 'pending' | 'catch'} */
var mode = 'resolved';
/** @type {Map<number, (reason: any) => void>} */
var pending_deferreds = new Map();
/** @type {Set<Block>} */
var paused_blocks = new Set();
/** @type {string | null} */
var streamed_id = null;
var streamed_errored = false;
var streamed_fallback = false;
/** @type {Comment | null} */
var slot_open = null;
/** @type {Comment | null} */
var slot_close = null;
function clear_paused_blocks() {
paused_blocks.clear();
}
/**
* @returns {boolean}
*/
function resume_paused_blocks() {
if (paused_blocks.size === 0) {
return false;
}
var blocks = paused_blocks;
paused_blocks = new Set();
var resumed = false;
for (var block of blocks) {
if (!is_destroyed(block)) {
resume_block(block);
resumed = true;
}
}
return resumed;
}
function show_resolved_fragment() {
if (offscreen_fragment !== null) {
/** @type {ChildNode} */ (anchor).before(offscreen_fragment);
offscreen_fragment = null;
}
has_resolved = true;
mode = 'resolved';
}
function render_resolved() {
if (
try_block !== null &&
!is_destroyed(try_block) &&
(resolved_branch === null || is_destroyed(resolved_branch))
) {
if (catch_branch !== null) {
destroy_block(catch_branch);
catch_branch = null;
}
mode = 'resolved';
if (active_block !== try_block) {
with_block(try_block, () => {
resolved_branch = boundary_fn_running_block(() => try_fn(anchor));
});
} else {
resolved_branch = boundary_fn_running_block(() => try_fn(anchor));
}
}
}
function destroy_resolved() {
if (resolved_branch !== null && !is_destroyed(resolved_branch)) {
destroy_block(resolved_branch);
}
resolved_branch = null;
offscreen_fragment = null;
}
function move_resolved_offscreen() {
if (resolved_branch !== null) {
if (!offscreen_fragment) {
// if offcreen_fragment exists, it means the resolved_branch is already offscreen,
// so we can skip moving it again
offscreen_fragment = document.createDocumentFragment();
move_block(resolved_branch, offscreen_fragment);
}
}
}
function render_pending() {
if (pending_fn === null || mode === 'pending') {
return;
}
move_resolved_offscreen();
mode = 'pending';
var create_pending = () => {
pending_branch = boundary_fn_running_block(() => {
/** @type {PendingFunction} */ (pending_fn)(anchor);
});
};
// with_block ensures the branch is parented under the TRY_BLOCK when called
// from async contexts (microtasks) where active_block is null. During synchronous
// execution (try_block not yet assigned), active_block is already the TRY_BLOCK.
if (try_block !== null && !is_destroyed(try_block) && active_block !== try_block) {
with_block(try_block, create_pending);
} else {
create_pending();
}
}
function destroy_pending() {
if (pending_branch !== null && !is_destroyed(pending_branch)) {
destroy_block(pending_branch);
}
pending_branch = null;
}
/**
* @param {any} error
* @returns {void}
*/
function handle_error(error) {
if (mode === 'catch') {
// we don't want to do this again and render catch block again
return;
}
pending_count = 0;
active_requests.clear();
clear_paused_blocks();
// Reject all pending deferred promises so dependent async tracked settle
// handlers fire and clean up. The settle will see the request already
// cleared and skip error routing, avoiding double-catch.
if (pending_deferreds.size > 0) {
for (var [, reject_fn] of pending_deferreds) {
reject_fn(error);
}
pending_deferreds.clear();
}
if (mode === 'pending') {
destroy_pending();
} else if (mode === 'resolved') {
move_resolved_offscreen();
}
mode = 'catch';
var create_catch = () => {
catch_branch = boundary_fn_running_block(() => {
/** @type {CatchFunction} */ (catch_fn)(anchor, error, render_resolved);
});
};
// with_block ensures the branch is parented under the TRY_BLOCK when called
// from async contexts where active_block is null. During synchronous
// execution (try_block not yet assigned), active_block is already the TRY_BLOCK.
if (try_block !== null && !is_destroyed(try_block) && active_block !== try_block) {
with_block(try_block, create_catch);
} else {
create_catch();
}
destroy_resolved();
}
/**
* Retires the slot wrapper markers once the slot's fate is decided: the
* open comment loses its marker data (it may still serve as the boundary
* anchor, so it must stay in the DOM) and the close comment is dropped —
* keeping `[`/`]` depth balanced for scans over surrounding slots.
* @returns {void}
*/
function neutralize_slot_markers() {
/** @type {Comment} */ (slot_open).data = '';
/** @type {ChildNode} */ (slot_close).remove();
}
/**
* Reads the streamed unit error envelope for this slot and routes the
* error into this boundary, or the nearest catch boundary above it.
* @param {string} id
* @returns {void}
*/
function route_streamed_error(id) {
if (try_block === null || is_destroyed(try_block)) {
return;
}
neutralize_slot_markers();
var message = 'An error occurred during server rendering';
var script = document.getElementById(STREAM_ERROR_SCRIPT_PREFIX + id);
if (script !== null) {
try {
message = JSON.parse(/** @type {string} */ (script.textContent)).message ?? message;
} catch {
// keep the generic message
}
script.remove();
}
var error = new Error(message);
if (catch_fn !== null) {
handle_error(error);
return;
}
var outer = get_boundary_with_catch(/** @type {Block} */ (try_block));
if (outer === null) {
throw error;
}
outer.s.c(error);
}
/**
* Empties the streamed slot: destroys the hydrated fallback branch and
* sweeps whatever remains between the wrapper comments (leftover marker
* comments included).
* @returns {void}
*/
function clear_streamed_slot() {
destroy_pending();
var node = /** @type {ChildNode} */ (slot_open).nextSibling;
while (node !== null && node !== slot_close) {
var next = node.nextSibling;
/** @type {ChildNode} */ (node).remove();
node = next;
}
}
/**
* Called by the inline stream runtime when this slot's chunk arrives after
* hydration: swaps the fallback for the streamed content and claims the
* new DOM through a boundary-scoped hydration walk (trackAsync inside the
* body picks its serialized envelope up from the same chunk).
* @param {HTMLTemplateElement | null} template
* @param {number | undefined} [errored]
* @returns {void}
*/
function activate_streamed_chunk(template, errored) {
if (try_block === null || is_destroyed(try_block)) {
return;
}
var id = /** @type {string} */ (streamed_id);
streamed_id = null;
if (errored) {
clear_streamed_slot();
route_streamed_error(id);
return;
}
clear_streamed_slot();
if (template !== null) {
/** @type {ChildNode} */ (slot_close).before(template.content);
}
var first = /** @type {ChildNode} */ (slot_open).nextSibling;
if (first === null || first === slot_close) {
neutralize_slot_markers();
return;
}
// adopt the streamed body's own <!--[--> as the boundary anchor (the
// node the buffered-SSR hydration path would have used) and drop the
// slot wrapper comments, so the resulting DOM matches buffered SSR
// exactly like the pre-hydration swap path does
if (anchor === slot_open) {
anchor = first;
}
/** @type {ChildNode} */ (slot_open).remove();
/** @type {ChildNode} */ (slot_close).remove();
var previous_hydrating = hydrating;
var previous_hydrate_node = hydrate_node;
set_hydrating(true);
set_hydrate_node(first);
hydrate_next(); // consume the streamed body's <!--[-->
try {
has_resolved = true;
render_resolved();
} finally {
set_hydrating(previous_hydrating);
set_hydrate_node(previous_hydrate_node, true);
}
}
function begin_request() {
var request_id = ++request_version;
active_requests.add(request_id);
if (pending_count++ === 0 && pending_fn !== null && !has_resolved) {
queue_microtask(() => {
if (try_block !== null && !is_destroyed(try_block) && pending_count > 0 && !has_resolved) {
render_pending();
}
});
}
return request_id;
}
/**
* @param {number} old_request_id
* @returns {number}
*/
function replace_request(old_request_id) {
active_requests.delete(old_request_id);
pending_deferreds.delete(old_request_id);
// pending_count unchanged — one out, one in
var request_id = ++request_version;
active_requests.add(request_id);
return request_id;
}
/**
* @param {number} request_id
* @param {boolean} [show_resolved_branch=true]
* @returns {boolean}
*/
function complete_request(request_id, show_resolved_branch = true) {
if (!active_requests.delete(request_id)) {
return false;
}
pending_deferreds.delete(request_id);
pending_count--;
if (pending_count === 0) {
if (!show_resolved_branch) {
clear_paused_blocks();
return true;
}
resume_paused_blocks();
queue_post_block_flush_callback(() => {
// run this only after the blocks have a chance to run
// and find more pending requests (and pause themselves) before we are
// certain to render the resolved state.
// Otherwise, we'll have multiple renders.
if (try_block === null || is_destroyed(try_block) || pending_count > 0) {
return;
}
if (mode === 'pending') {
destroy_pending();
show_resolved_fragment();
}
has_resolved = true;
mode = 'resolved';
});
// this is more just in case here and shouldn't really cause anything to run
// most likely the scheduling is already there
// leaving it here in case there are some weird edge cases
queue_microtask();
}
return true;
}
/** @type {TryBoundaryState} */
var state = {
p: pending_fn !== null,
b: begin_request,
r: complete_request,
c: catch_fn !== null ? handle_error : null,
/** @param {number} request_id @param {(reason: any) => void} reject_fn */
rd: (request_id, reject_fn) => {
pending_deferreds.set(request_id, reject_fn);
},
/** @param {Block} block */
pb: (block) => {
paused_blocks.add(block);
},
rp: replace_request,
};
if (hydrating && (pending_fn !== null || catch_fn !== null)) {
// Server wraps a settled try body with <!--[-->...<!--]--> markers when
// pending or catch is present, and the boundary hydrates as resolved.
// Streaming SSR instead emits a slot wrapper around the fallback:
// <!--[?N--> while unit N's content is still on its way, or
// <!--[!N--> when the unit errored after its region was already sent.
if (root_controlled) {
boundary = /** @type {Node} */ (hydrate_node);
}
var marker = /** @type {Comment} */ (hydrate_node);
var data = marker.nodeType === COMMENT_NODE ? marker.data : '';
// a slot marker at the boundary's own anchor always belongs to this
// boundary — the root boundary included: when the root suspends, the
// server emits its slot as the whole body, so the marker doubles as
// the hydration anchor
if (data.startsWith(HYDRATION_START_PENDING) || data.startsWith(HYDRATION_START_ERRORED)) {
// live streamed slot
streamed_id = data.slice(HYDRATION_START_PENDING.length);
streamed_errored = data.startsWith(HYDRATION_START_ERRORED);
slot_open = marker;
hydrate_next(); // consume the slot wrapper open
slot_close = /** @type {Comment} */ (skip_to_hydration_end());
var fallback_start = /** @type {Comment} */ (hydrate_node);
streamed_fallback =
!streamed_errored &&
pending_fn !== null &&
fallback_start.nodeType === COMMENT_NODE &&
fallback_start.data === HYDRATION_START;
if (streamed_fallback) {
hydrate_next(); // consume the fallback's <!--[-->
}
} else {
// settled content: mark as already resolved so begin_request's
// microtask won't transition to pending
if (pending_fn !== null) {
has_resolved = true;
}
hydrate_next(); // consume <!--[-->
}
}
try_block = create_try_block(() => {
if (streamed_id !== null) {
// the streamed body hasn't arrived yet — hydrate the fallback (if
// any) as the pending branch and wait for the chunk to activate us
if (streamed_fallback) {
mode = 'pending';
pending_branch = boundary_fn_running_block(() => {
/** @type {PendingFunction} */ (pending_fn)(anchor);
});
}
return;
}
resolved_branch = boundary_fn_running_block(() => try_fn(anchor));
}, state);
if (streamed_id !== null) {
// continue the outer hydration walk after the slot
set_hydrate_node(slot_close);
var registry = (window.__RIPPLE_B__ ??= {});
var unit_id = streamed_id;
if (streamed_errored) {
// the inline runtime already emptied the slot and marked it errored
// — route the error once the surrounding hydration has finished
queue_microtask(() => route_streamed_error(unit_id));
} else {
registry[unit_id] = { a: activate_streamed_chunk };
}
}
if (hydrating && root_controlled) {
append(/** @type {ChildNode} */ (node), /** @type {Node} */ (boundary));
}
}
/**
* @param {Block | null} block
* @returns {BlockWithTryBoundary | null}
*/
export function get_pending_boundary(block) {
var current = block;
while (current !== null) {
var state = /** @type {BlockWithTryBoundary} */ (current).s;
if ((current.f & TRY_BLOCK) !== 0 && state.p) {
return /** @type {BlockWithTryBoundary} */ (current);
}
current = current.p;
}
return null;
}
/**
* @param {Block} block
* @returns {BlockWithTryBoundaryAndCatch | null}
*/
export function get_boundary_with_catch(block) {
/** @type {Block | null} */
var current = block;
while (current !== null) {
var state = /** @type {BlockWithTryBoundary} */ (current).s;
if ((current.f & TRY_BLOCK) !== 0 && state.c !== null) {
return /** @type {BlockWithTryBoundaryAndCatch} */ (current);
}
current = current.p;
}
return null;
}
/**
* @param {BlockWithTryBoundary} boundary
* @returns {number}
*/
export function begin_boundary_request(boundary) {
return boundary.s.b();
}
/**
* @param {BlockWithTryBoundary} boundary
* @param {number} old_request_id
* @returns {number}
*/
export function replace_boundary_request(boundary, old_request_id) {
return boundary.s.rp(old_request_id);
}
/**
* @param {BlockWithTryBoundary | null} boundary
* @param {number} request_id
* @param {boolean} [show_resolved_branch=true]
* @returns {boolean}
*/
export function complete_boundary_request(boundary, request_id, show_resolved_branch = true) {
return boundary !== null && !is_destroyed(boundary)
? boundary.s.r(request_id, show_resolved_branch)
: false;
}
/**
* @param {BlockWithTryBoundary | null} boundary
* @param {number} request_id
* @param {(reason: any) => void} reject_fn
* @returns {void}
*/
export function register_boundary_deferred(boundary, request_id, reject_fn) {
if (boundary !== null && !is_destroyed(boundary) && boundary.s?.rd) {
boundary.s.rd(request_id, reject_fn);
}
}
/**
* @param {BlockWithTryBoundary | null} boundary
* @param {Block} block
* @returns {void}
*/
export function register_boundary_paused_block(boundary, block) {
if (boundary !== null && !is_destroyed(boundary) && boundary.s?.pb) {
boundary.s.pb(block);
}
}