UNPKG

ripple

Version:

Ripple is an elegant TypeScript UI framework

485 lines (437 loc) • 13.8 kB
/** @import { AppendIntoAnchor, Block, TryPendingFunction, TryState } from '#client' */ import { COMMENT_NODE, HYDRATION_END, HYDRATION_START, HYDRATION_START_ERRORED, HYDRATION_START_PENDING, STREAM_ERROR_SCRIPT_PREFIX, TEXT_NODE, } from '../../../constants.js'; import { is_destroyed } from './blocks.js'; import { hydration_mismatch } from './errors.js'; import { clear_expression_range } from './expression.js'; import { hydrate_next, hydrate_node, hydrating, set_hydrate_node, set_hydration, set_hydration_runtime, } from './hydration.js'; import { create_text, get_next_sibling, next_sibling_getter } from './operations.js'; import { active_block, queue_microtask } from './runtime.js'; import { assign_nodes } from './template.js'; import { boundary_branch, catch_error, destroy_pending, get_boundary_with_catch, render_resolved, } from './try.js'; /** * The hydration paths of the DOM runtime. `hydrate()` installs them on the * hydration module (see `H` there); the runtime's insert, template, expression * and boundary code calls through that object only while hydrating, so a * client-only mount compiles and ships none of this. * @typedef {{ * a: typeof hydrate_append; * t: typeof hydrate_template; * x: typeof hydrate_text; * e: typeof expression_end; * h: typeof hydrated_text; * m: typeof try_marker; * s: typeof try_slot; * f: typeof try_fallback; * }} HydrationRuntime */ /** @type {HydrationRuntime} */ var runtime = { a: hydrate_append, t: hydrate_template, x: hydrate_text, e: expression_end, h: hydrated_text, m: try_marker, s: try_slot, f: try_fallback, }; export function install_hydration() { set_hydration_runtime(runtime); } /** * The hydration path of `append`: repositions the hydration cursor instead * of inserting. * * Every hydrated node, block, and component leaves the cursor on its last * DOM node, and whoever owns the next node steps past it: a parent element * with its sibling traversal, a control-flow block by reaching its end * marker, an append-into sentinel by adopting the cursor as the next * component's first node. * @param {ChildNode | AppendIntoAnchor} anchor * @param {Node} dom */ function hydrate_append(anchor, dom) { var node = /** @type {Node} */ (hydrate_node); // The cursor descended into dom's children (child()/sibling() traversal // inside a single-node template) without a compiler-emitted pop(): bring it // back up to dom before deciding where to leave it. if (node !== dom && dom.contains(node)) { node = dom; } // A child component renders into the anchor it was handed, which is its // own first node. Its content is hydrated, so leave the cursor on the // content's last node for the parent's sibling traversal. if (anchor === dom) { set_hydrate_node(node); return; } if (node !== dom) { // A fragment's cursor sits on its last top-level node, past the // template's first node: widen the block's end to cover the whole // fragment rather than only the node assign_nodes saw. var s = /** @type {Block} */ (active_block).s; if (s !== null) { s.end = node; } } // Step past the content: a branch lands on its block's end marker, the // root on the boundary's end marker, an append-into sentinel on the next // component's first node. set_hydrate_node(next_sibling_getter.call(node), true); } /** * Scans forward from the current hydrate_node to find the matching HYDRATION_END * comment, handling nested blocks by tracking depth. * Should be called after hydrate_next() has consumed the opening HYDRATION_START. * Any `[`-prefixed comment opens a nested region — this includes the plain * `<!--[-->` markers as well as streaming slot markers (`<!--[?N-->`, * `<!--[!N-->`), which always pair with a `<!--]-->`. * @returns {Node} The HYDRATION_END comment node. */ function skip_to_hydration_end() { var depth = 0; var node = /** @type {Node} */ (hydrate_node); while (true) { if (node.nodeType === COMMENT_NODE) { var data = /** @type {Comment} */ (node).data; if (data === HYDRATION_END) { if (depth === 0) return node; depth -= 1; } else if (data.startsWith(HYDRATION_START)) { depth += 1; } } node = /** @type {Node} */ (next_sibling_getter.call(node)); } } /** * The hydration path of a `template()` instance: adopts the server node(s) * at the cursor instead of cloning. * @param {boolean} is_fragment * @param {number} count * @returns {Node} */ function hydrate_template(is_fragment, count) { var node = /** @type {Node} */ (hydrate_node); assign_nodes(node, is_fragment ? hydrate_fragment_end(node, count) : node); return node; } /** * The last top-level node of a hydrated fragment template. Walks using the * compiler-provided hop count so hydration never parses template HTML. * @param {Node} start * @param {number} count * @returns {Node} */ function hydrate_fragment_end(start, count) { var end = start; for (var i = 1; i < count; i++) { var next = get_next_sibling(end); while (next !== null && next.nodeType === COMMENT_NODE) { next = get_next_sibling(next); } if (next === null) { break; } end = next; } return end; } /** * The hydration path of an expression's text: adopts the server text node at * the cursor, splitting it when the server rendered adjacent text into one. * @param {string} value * @param {ChildNode} anchor * @param {boolean} assign * @returns {void} */ function hydrate_text(value, anchor, assign) { var node = hydrate_node; if (node?.nodeType === COMMENT_NODE && /** @type {Comment} */ (node).data === HYDRATION_START) { node = get_next_sibling(node); } if (node?.nodeType === TEXT_NODE) { var current_value = /** @type {Text} */ (node).nodeValue ?? ''; if (current_value !== value) { /** @type {Text} */ (node).nodeValue = value; if (current_value.startsWith(value)) { var remaining = current_value.slice(value.length); if (remaining !== '') { var remaining_text = create_text(remaining); /** @type {ChildNode} */ (node).after(remaining_text); if (assign) { assign_nodes(node, node); } set_hydrate_node(remaining_text); return; } } } if (assign) { assign_nodes(node, node); } set_hydrate_node(get_next_sibling(node) ?? anchor); return; } var new_text = create_text(value); if (node !== null && node !== anchor) { /** @type {ChildNode} */ (node).before(new_text); } else { anchor.before(new_text); } if (assign) { assign_nodes(new_text, new_text); } set_hydrate_node(node ?? anchor); } /** * The end marker of a server-rendered expression: the `<!--]-->` that pairs * with the `<!--[-->` at `anchor`. * @param {Node} anchor * @returns {Comment} */ function expression_end(anchor) { /** @type {Node | null} */ var current = get_next_sibling(anchor); var depth = 0; while (current !== null) { if (current.nodeType === COMMENT_NODE) { var data = /** @type {Comment} */ (current).data; if (data === HYDRATION_START) { depth += 1; } else if (data === HYDRATION_END) { if (depth === 0) { return /** @type {Comment} */ (current); } depth -= 1; } } current = get_next_sibling(current); } hydration_mismatch('expression'); } /** * The single text node a server-rendered expression holds between its * markers, or null once whatever else was there is cleared. * @param {Node} anchor * @param {Node} end * @returns {Text | null} */ function hydrated_text(anchor, end) { var first = get_next_sibling(anchor); if (first === end) { return null; } if (first?.nodeType === TEXT_NODE && get_next_sibling(first) === end) { return /** @type {Text} */ (first); } clear_expression_range(anchor, end); return null; } /** * A boundary's hydration walk begins: consumes its `<!--[-->` marker, or * adopts the streamed slot that stands in for it (see `try_slot`). * @param {TryState} state */ function try_marker(state) { var marker = /** @type {Comment} */ (hydrate_node); var data = marker.nodeType === COMMENT_NODE ? marker.data : ''; // A slot at this anchor belongs to this boundary, including the root. if (data.startsWith(HYDRATION_START_PENDING) || data.startsWith(HYDRATION_START_ERRORED)) { hydrate_streamed_slot(state, marker, data); } else { // Settled SSR content must not transition back to pending. if (state.p !== null) { state.h = true; } hydrate_next(); // consume <!--[--> } } /** * @param {TryState} state * @param {Comment} marker * @param {string} data */ function hydrate_streamed_slot(state, marker, data) { // live streamed slot state.si = data.slice(HYDRATION_START_PENDING.length); state.se = data.startsWith(HYDRATION_START_ERRORED); state.so = marker; hydrate_next(); // consume the slot wrapper open state.sc = /** @type {Comment} */ (skip_to_hydration_end()); var fallback_start = /** @type {Comment} */ (hydrate_node); state.sf = !state.se && state.p !== null && fallback_start.nodeType === COMMENT_NODE && fallback_start.data === HYDRATION_START; if (state.sf) { hydrate_next(); // consume the fallback's <!--[--> } } /** * A boundary that hydrated a still-pending streamed slot: the outer walk * continues after the slot, and the slot's chunk activates the boundary when * it arrives. * @param {TryState} state */ function try_slot(state) { // continue the outer hydration walk after the slot set_hydrate_node(state.sc); var registry = (window.__RIPPLE_B__ ??= {}); var unit_id = /** @type {string} */ (state.si); if (state.se) { // 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(state, unit_id)); } else { registry[unit_id] = { a: (template, errored) => activate_streamed_chunk(state, template, errored), }; } } /** * The first run of a boundary whose chunk has not arrived: hydrates its * fallback until activation. * @param {TryState} state */ function try_fallback(state) { if (state.sf) { state.m = 1; state.pb = boundary_branch(() => { /** @type {TryPendingFunction} */ (state.p)(state.a); }); } } /** * 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. * @param {TryState} state * @returns {void} */ function neutralize_slot_markers(state) { /** @type {Comment} */ (state.so).data = ''; /** @type {ChildNode} */ (state.sc).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 {TryState} state * @param {string} id * @returns {void} */ function route_streamed_error(state, id) { if (state.b === null || is_destroyed(state.b)) { return; } neutralize_slot_markers(state); 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 (state.c !== null) { catch_error(state, error); return; } var outer = get_boundary_with_catch(/** @type {Block} */ (state.b)); if (outer === null) { throw error; } catch_error(outer.s, error); } /** * Empties the streamed slot: destroys the hydrated fallback branch and * sweeps whatever remains between the wrapper comments (leftover marker * comments included). * @param {TryState} state * @returns {void} */ function clear_streamed_slot(state) { destroy_pending(state); var node = /** @type {ChildNode} */ (state.so).nextSibling; while (node !== null && node !== state.sc) { 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 {TryState} state * @param {HTMLTemplateElement | null} template * @param {number | undefined} [errored] * @returns {void} */ function activate_streamed_chunk(state, template, errored) { if (state.b === null || is_destroyed(state.b)) { return; } var id = /** @type {string} */ (state.si); state.si = null; if (errored) { clear_streamed_slot(state); route_streamed_error(state, id); return; } clear_streamed_slot(state); if (template !== null) { /** @type {ChildNode} */ (state.sc).before(template.content); } var first = /** @type {ChildNode} */ (state.so).nextSibling; if (first === null || first === state.sc) { neutralize_slot_markers(state); 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 (state.a === state.so) { state.a = first; } /** @type {ChildNode} */ (state.so).remove(); /** @type {ChildNode} */ (state.sc).remove(); var previous_hydrating = hydrating; var previous_hydrate_node = hydrate_node; set_hydration(true, first); hydrate_next(); // consume the streamed body's <!--[--> try { state.h = true; render_resolved(state); } finally { set_hydration(previous_hydrating, previous_hydrate_node); } }