ripple
Version:
Ripple is an elegant TypeScript UI framework
2,045 lines (1,853 loc) • 61.7 kB
JavaScript
/**
* @import { Component, Dependency, Block, TryBlockWithCatch } from '#server';
* @import { NestedArray } from '@tsrx/core/types/helpers';
* @import { Props } from '#public';
* @import { RenderResult, BaseRenderOptions, RenderStreamResult, Stream, StreamSink } from 'ripple/server';
*/
// Export-only Types
/** @typedef {Output} OutputInterface */
// Internal Types
/** @typedef {(props?: Props) => void} RenderComponent */
/** @typedef {{ tag: string; parent: undefined | ElementContext; filename: undefined | string; line: number; column: number; }} ElementContext */
/** @typedef {{ cancel: () => void }} RegisteredAsyncOperation */
// Both
/** @typedef {TrackedValue} Tracked */
/** @typedef {DerivedValue} Derived */
/**
* A streaming flush unit: a try boundary whose body suspended during
* rendering. Its slot (wrapper markers + pending fallback) ships with the
* enclosing region while the real body — kept as a detached buffer tree whose
* holes async re-runs fill in place — streams later as a framed chunk once
* every async operation registered on the boundary settles.
* @typedef {{
* id: number;
* block: Block;
* output: Output;
* content: NestedArray<string>;
* head: NestedArray<string>;
* css: Set<string>;
* scripts: string[];
* settled: boolean;
* flushed: boolean;
* slot_sent: boolean;
* sealed: boolean;
* errored: boolean;
* error: string | null;
* }} FlushUnit
*/
/** @typedef {{ css: Set<string>, scripts: string[], head: string }} StreamChunk */
import {
DERIVED,
UNINITIALIZED,
TRACKED,
SUSPENSE_PENDING,
SUSPENSE_REJECTED,
ASYNC_DERIVED_READ_THROWN,
TRACKED_UPDATED,
} from '../client/constants.js';
import { DEV } from 'esm-env';
import { is_ripple_object } from '../client/utils.js';
import { iterable_array_from, array_slice, is_array } from '@tsrx/core/runtime/language-helpers';
import {
escape,
escape_script,
is_boolean_attribute,
normalize_css_property_name,
} from '@tsrx/core/runtime/html';
import { clsx } from 'clsx';
import { create_ref_prop } from '@tsrx/core/runtime/ref';
import {
BLOCK_CLOSE,
BLOCK_OPEN,
HYDRATION_START_PENDING,
HYDRATION_START_ERRORED,
STREAM_CHUNK_ATTR,
STREAM_HEAD_ATTR,
STREAM_ERROR_SCRIPT_PREFIX,
} from '../../../constants.js';
import { get_css_text } from './css-registry.js';
import { STREAM_RUNTIME_SCRIPT } from './stream-runtime.js';
import { is_tsrx_element, normalize_children, tsrx_element } from '../../element.js';
import {
is_tag_valid_with_parent,
is_tag_valid_with_ancestor,
} from '../../../html-tree-validation.js';
import { get_async_track_result } from '../../../utils/async.js';
import {
throw_tracked_index_reference_error,
throw_tracked_index_value_error,
} from '../../../utils/errors.js';
import { get_track_async_script_id } from '../../../utils/track-async-serialization.js';
import * as devalue from 'devalue';
import {
cancel_async_operations,
component_block,
get_closest_catch_block,
get_closest_try_block,
try_block,
} from './blocks.js';
import { COMPONENT_BLOCK, ROOT_BLOCK, TRY_BLOCK } from './constants.js';
export { escape };
export { register_component_css as register_css } from './css-registry.js';
export { simple_hash, strong_hash } from '@tsrx/core/runtime/hash';
export { context } from './context.js';
export { try_block, component_block, regular_block } from './blocks.js';
export { array_slice };
export { is_tsrx_element, tsrx_element, normalize_children };
export { create_ref_prop };
/** @extends Error */
export class TrackAsyncRunError extends Error {
/** @type {Tracked} */
tracked;
/** @type {Error} */
cause;
/**
* @param {string} message
* @param {{tracked: Tracked, cause: Error}} options
*/
constructor(message, options) {
super(message);
this.name = 'TrackAsyncRunError';
this.tracked = options.tracked;
this.cause = options.cause;
}
}
export function noop() {}
/**
* @param {any[]} value
* @returns {void}
*/
function render_tsrx_collection(value) {
for (var i = 0; i < value.length; i++) {
var item = value[i];
if (is_tsrx_element(item)) {
render_tsrx_element(item);
} else if (is_array(item)) {
render_tsrx_collection(item);
} else if (item != null) {
output_push(escape(item));
}
}
}
/**
* @param {import('../../element.js').TSRXElement} value
* @returns {void}
*/
export function render_tsrx_element(value) {
const result = value.render({});
if (is_tsrx_element(result)) {
render_tsrx_element(result);
} else if (is_array(result)) {
render_tsrx_collection(result);
} else if (result != null) {
output_push(escape(result));
}
}
/**
* @param {any} value
* @returns {void}
*/
export function render_expression(value) {
output_push(BLOCK_OPEN);
if (is_tsrx_element(value)) {
render_tsrx_element(value);
} else if (is_array(value)) {
render_tsrx_collection(value);
} else {
output_push(escape(value ?? ''));
}
output_push(BLOCK_CLOSE);
}
/**
* @param {Function} fn
* @param {Props} props
* @returns {void}
*/
export function render_component(fn, props) {
if (typeof fn !== 'function' || is_tsrx_element(fn)) {
throw_invalid_component_type(fn);
}
run_component(fn, props);
}
/**
* @param {Function} fn
* @param {Props} props
* @returns {void}
*/
function run_component(fn, props) {
push_component();
try {
const value = fn(props);
if (is_tsrx_element(value)) {
render_tsrx_element(value);
} else {
render_expression(value);
}
} finally {
pop_component();
}
}
/**
* @param {any} value
* @returns {never}
*/
function throw_invalid_component_type(value) {
if (is_tsrx_element(value)) {
throw new TypeError('Invalid component type: received a TSRXElement value.');
}
throw new TypeError('Invalid component type: expected a component function.');
}
/**
* @returns {Stream}
*/
export function create_ssr_stream() {
/** @type {ReadableStreamDefaultController<Uint8Array> | null} */
var c = null;
/** @type {ReadableStream<Uint8Array>} */
var stream = new ReadableStream({
start(controller) {
// this runs synchronously
c = controller;
},
});
var encoder = new TextEncoder();
var is_closed = false;
var controller = /** @type {ReadableStreamDefaultController<Uint8Array>} */ (
/** @type {unknown} */ (c)
);
var close = controller.close;
var error = controller.error;
controller.close = function (...args) {
is_closed = true;
close.call(controller, ...args);
};
controller.error = function (...args) {
is_closed = true;
error.call(controller, ...args);
};
return {
controller,
textEncoder: encoder,
stream,
sink: {
push(chunk) {
if (is_closed) {
return;
}
controller.enqueue(encoder.encode(chunk));
},
close() {
controller.close();
},
error(reason) {
controller.error(reason);
},
},
};
}
/** @type {null | Component} */
export let active_component = null;
/** @type {null | Block} */
export let active_block = null;
export let tracking = false;
/** @type {null | Dependency} */
let active_dependency = null;
let inside_async_track = false;
/** @type {ElementContext | undefined} */
let current_element;
/** @type {Set<string>} */
let seen_warnings = new Set();
/**
* @returns {void}
*/
export function reset_state() {
active_component = null;
active_block = null;
active_dependency = null;
inside_async_track = false;
tracking = false;
seen_warnings = new Set();
current_element = undefined;
}
/** @type {number} */
let clock = 0;
/**
* @returns {number}
*/
function increment_clock() {
return ++clock;
}
/**
* @param {Block} block
*/
export function set_active_block(block) {
active_block = block;
}
/**
* @param {Tracked | Derived} tracked
* @returns {Dependency}
*/
function create_dependency(tracked) {
return {
c: tracked.c,
t: tracked,
n: null,
};
}
/**
* @param {Tracked | Derived} tracked
*/
function register_dependency(tracked) {
var dependency = active_dependency;
if (dependency === null) {
dependency = create_dependency(tracked);
active_dependency = dependency;
} else {
var current = dependency;
while (current !== null) {
if (current.t === tracked) {
current.c = tracked.c;
return;
}
var next = current.n;
if (next === null) {
break;
}
current = next;
}
dependency = create_dependency(tracked);
current.n = dependency;
}
}
/**
* @param {Dependency | null} tracking
*/
function is_tracking_dirty(tracking) {
if (tracking === null) {
return false;
}
while (tracking !== null) {
var tracked = tracking.t;
if ((tracked.f & DERIVED) !== 0) {
update_derived(/** @type {Derived} **/ (tracked));
}
if (tracked.c > tracking.c) {
return true;
}
tracking = tracking.n;
}
return false;
}
/**
* @template T
* @param {() => T} fn
* @returns {T}
*/
export function untrack(fn) {
var previous_tracking = tracking;
var previous_dependency = active_dependency;
tracking = false;
active_dependency = null;
try {
return fn();
} finally {
tracking = previous_tracking;
active_dependency = previous_dependency;
}
}
/**
* @param {Derived} computed
*/
function update_derived(computed) {
var value = computed.v;
if (value === UNINITIALIZED || is_tracking_dirty(computed.d)) {
value = run_derived(computed);
if (value !== computed.v) {
computed.v = value;
computed.c = increment_clock();
}
}
}
/**
* @param {Tracked} computed
* @param {any} value
*/
function update_tracked_value_clock(computed, value) {
computed.v = value;
computed.c = increment_clock();
}
/**
* @param {Derived} computed
*/
function run_derived(computed) {
var previous_tracking = tracking;
var previous_dependency = active_dependency;
var previous_component = active_component;
try {
tracking = true;
active_dependency = null;
active_component = computed.co;
var value = computed.fn();
computed.d = active_dependency;
return value;
} catch (error) {
computed.d = active_dependency;
if (error === ASYNC_DERIVED_READ_THROWN) {
// Check if any dependency is rejected — if so, propagate rejection
var dep = active_dependency;
while (dep !== null) {
if (dep.t.v === SUSPENSE_REJECTED) {
return SUSPENSE_REJECTED;
}
dep = dep.n;
}
return SUSPENSE_PENDING;
}
throw error;
} finally {
tracking = previous_tracking;
active_dependency = previous_dependency;
active_component = previous_component;
}
}
/**
* `<div translate={false}>` should be rendered as `<div translate="no">` and _not_
* `<div translate="false">`, which is equivalent to `<div translate="yes">`. There
* may be other odd cases that need to be added to this list in future
* @type {Record<string, Map<any, string>>}
*/
const replacements = {
translate: new Map([
[true, 'yes'],
[false, 'no'],
]),
};
export class Output {
/** @type {Output} */
#root;
/** @type {NestedArray<string>} */
#head = [];
/** @type {NestedArray<string>} */
#body = [];
/** @type {Set<string>} */
#css = new Set();
/** @type {null | Output} */
#parent = null;
/** @type {StreamSink | null} */
#streamOutput = null;
/** @type {{ before: string, between: string, after: string } | null} */
#stream_template = null;
#stream_started = false;
#stream_finished = false;
/** @type {null | number} */
#pending_count = null;
/** @type {null | Promise<void>} */
#promise = null;
/** @type {null | (() => void)} */
#promise_resolve = null;
/** @type {null | ((reason?: any) => void)} */
#promise_reject = null;
#is_root = false;
#sync_run = false;
/** @type {Set<RegisteredAsyncOperation>} */
#async_operations = new Set();
/** @type {null | 'head'} */
target = null;
/** @type {null | FlushUnit} */
unit = null;
/** @type {NestedArray<string> | null} */
#content_redirect = null;
/** @type {NestedArray<string> | null} */
#head_redirect = null;
// streaming state, only used on the root instance
#next_unit_id = 1;
/** @type {FlushUnit[]} */
#units = [];
/** @type {WeakMap<NestedArray<string>, FlushUnit>} */
#unit_slots = new WeakMap();
/** @type {Set<string>} */
#sent_css = new Set();
#shell_flushed = false;
get root() {
return this.#root;
}
get body() {
return this.#body;
}
get head() {
return this.#head;
}
get css() {
return this.#css;
}
get promise() {
if (this.#is_root) {
return /** @type {Promise<void>} */ (this.#promise);
}
throw new Error('getPromise() can only be called on the root Output');
}
/**
* @param {Output | null} parent
*/
constructor(parent) {
if (!parent) {
this.#root = this;
this.#is_root = true;
this.#promise = new Promise((resolve, reject) => {
this.#promise_resolve = resolve;
this.#promise_reject = reject;
});
this.#pending_count = 1;
this.#sync_run = true;
} else {
this.#root = parent.root;
this.#parent = parent;
// branches created after a flush unit sealed its slot belong to the
// unit's detached content tree, not to the already-shaped slot
(parent.#content_redirect ?? parent.#body).push(this.#body);
(parent.#head_redirect ?? parent.#head).push(this.#head);
}
}
/**
* @param {string} str
* @param {boolean} [is_root=false]
* @param {boolean} [is_prepend=false]
* @returns {void}
*/
#push(str, is_root = false, is_prepend = false) {
var instance = is_root ? this.#root : this;
// we never write to `head` in the root instance
if (instance !== this.#root && instance.target === 'head') {
var head = instance.#head_redirect ?? instance.#head;
if (is_prepend) {
head.unshift(str);
} else {
head.push(str);
}
return;
}
var body = instance.#content_redirect ?? instance.#body;
if (is_prepend) {
body.unshift(str);
} else {
body.push(str);
}
}
/**
* @param {string} str
* @returns {void}
*/
push(str) {
this.#push(str);
}
/**
* @param {string} str
* @returns {void}
*/
push_serialized_error(str) {
var root = this.#root;
if (root.#streamOutput !== null && root.#shell_flushed) {
// past the shell the root buffer is already on the wire — emit the
// envelope directly so it reaches the document before any chunk
// whose hydration depends on it
root.#streamOutput.push(str);
return;
}
// prepend to the root block to avoid messing up the hydration markers
// writing to the root to avoid being cleared in the local instance when an error occurs
this.#push(str, true, true);
}
/**
* @param {string} str
* @returns {void}
*/
push_serialized_result(str) {
var root = this.#root;
if (root.#streamOutput !== null && !root.#sync_run) {
var unit = this.#nearest_unflushed_unit();
if (unit !== null) {
// ships with the unit's chunk, ahead of the template, so the
// boundary can hydrate from it synchronously
unit.scripts.push(str);
return;
}
if (root.#shell_flushed) {
root.#streamOutput.push(str);
return;
}
}
this.#push(str);
}
clear() {
if (this.#content_redirect !== null) {
// a sealed flush unit clears its detached content, never the slot
this.#content_redirect.length = 0;
/** @type {NestedArray<string>} */ (this.#head_redirect).length = 0;
/** @type {FlushUnit} */ (this.unit).scripts.length = 0;
return;
}
this.#head.length = 0;
this.#body.length = 0;
this.#css.clear();
}
/**
* @param {string} hash
* @returns {void}
*/
register_css(hash) {
var root = this.#root;
if (root.#streamOutput !== null && !root.#sync_run) {
var unit = this.#nearest_unflushed_unit();
if (unit !== null) {
unit.css.add(hash);
return;
}
if (root.#shell_flushed) {
if (!root.#sent_css.has(hash)) {
root.#sent_css.add(hash);
var css_text = get_css_text(new Set([hash]));
if (css_text) {
root.#streamOutput.push('<style data-ripple-ssr>' + css_text + '</style>');
}
}
return;
}
}
root.#css.add(hash);
}
/**
* @returns {FlushUnit | null}
*/
#nearest_unflushed_unit() {
/** @type {Output | null} */
var output = this;
while (output !== null) {
var unit = output.unit;
// an unsealed unit is still rendering its slot (wrapper + fallback),
// which ships with the enclosing region — keep walking up
if (unit !== null && !unit.flushed && unit.sealed) {
return unit;
}
output = output.#parent;
}
return null;
}
/**
* @returns {FlushUnit | null}
*/
nearestUnflushedUnit() {
return this.#nearest_unflushed_unit();
}
/**
* Whether the region this output writes into has already been streamed —
* its bytes are on the wire and can no longer be replaced server-side.
* @returns {boolean}
*/
isRegionSent() {
/** @type {Output | null} */
var output = this;
while (output !== null) {
if (output.unit !== null) {
return output.unit.flushed;
}
output = output.#parent;
}
return this.#root.#shell_flushed;
}
/**
* @param {RegisteredAsyncOperation} operation
* @return {void}
*/
registerAsync(operation) {
this.#async_operations.add(operation);
this.#root._incrementPending();
}
/**
* @param {RegisteredAsyncOperation} operation
* @returns {void}
*/
resolveAsync(operation) {
this.#async_operations.delete(operation);
this.#root._decrementPending();
var unit = this.unit;
if (unit !== null && !unit.flushed && !unit.settled && this.#async_operations.size === 0) {
unit.settled = true;
this.#root._maybeFlushUnit(unit);
}
}
cancelAsyncOperations() {
for (const operation of this.#async_operations) {
operation.cancel();
this.#async_operations.delete(operation);
this.clear();
this.#root._decrementPending();
}
}
/**
* @returns {boolean}
*/
hasPendingAsyncOperations() {
return this.#async_operations.size > 0;
}
/**
* Marks the flush unit of a boundary abandoned by an ancestor error as
* done so it can never stream a stale chunk.
* @returns {void}
*/
abandonUnit() {
var unit = this.unit;
if (unit !== null && !unit.flushed) {
unit.settled = true;
unit.flushed = true;
}
}
/**
* Converts this try-block output into a streaming flush unit: the
* partially-rendered body (whose holes async re-runs fill in place) is
* detached as the unit's content tree, and the now-empty live buffer
* becomes the slot that receives the wrapper markers and fallback.
* @param {Block} block
* @returns {FlushUnit}
*/
_createFlushUnit(block) {
var root = this.#root;
/** @type {FlushUnit} */
var unit = {
id: root.#next_unit_id++,
block,
output: this,
content: this.#body.splice(0, this.#body.length),
head: this.#head.splice(0, this.#head.length),
css: new Set(),
scripts: [],
settled: false,
flushed: false,
slot_sent: false,
sealed: false,
errored: false,
error: null,
};
this.unit = unit;
root.#unit_slots.set(this.#body, unit);
root.#units.push(unit);
return unit;
}
/**
* Called once the slot (wrapper markers + fallback) is fully rendered:
* from here on, direct writes to this output (e.g. a late catch render)
* belong to the unit's content, not to the already-shaped slot.
* @returns {void}
*/
_sealSlot() {
var unit = /** @type {FlushUnit} */ (this.unit);
unit.sealed = true;
this.#content_redirect = unit.content;
this.#head_redirect = unit.head;
}
/**
* Flattens a buffer tree to HTML. A nested array that is the slot of a
* flush unit gets special treatment: a settled, unflushed, non-errored
* unit is inlined (coalesced into the current chunk, merging its css,
* scripts and head); anything else emits the slot as-is (wrapper +
* fallback) and is marked sent so the unit may flush itself later.
* @param {NestedArray<string>} tree
* @param {StreamChunk} chunk
* @returns {string}
*/
#serialize(tree, chunk) {
var out = '';
for (var i = 0; i < tree.length; i++) {
var item = tree[i];
if (typeof item === 'string') {
out += item;
continue;
}
var unit = this.#unit_slots.get(item);
if (unit !== undefined && !unit.flushed) {
if (unit.settled && !unit.errored) {
out += this.#collect_unit(unit, chunk);
continue;
}
unit.slot_sent = true;
}
out += this.#serialize(item, chunk);
}
return out;
}
/**
* @param {FlushUnit} unit
* @param {StreamChunk} chunk
* @returns {string} the unit's content HTML
*/
#collect_unit(unit, chunk) {
unit.flushed = true;
for (var hash of unit.css) {
chunk.css.add(hash);
}
for (var i = 0; i < unit.scripts.length; i++) {
chunk.scripts.push(unit.scripts[i]);
}
chunk.head += this.#serialize(unit.head, chunk);
return this.#serialize(unit.content, chunk);
}
/**
* @param {Set<string>} hashes
* @returns {string}
*/
#chunk_css(hashes) {
/** @type {Set<string>} */
var fresh = new Set();
for (var hash of hashes) {
if (!this.#sent_css.has(hash)) {
this.#sent_css.add(hash);
fresh.add(hash);
}
}
if (fresh.size === 0) {
return '';
}
var css_text = get_css_text(fresh);
return css_text ? '<style data-ripple-ssr>' + css_text + '</style>' : '';
}
/**
* @param {FlushUnit} unit
* @returns {void}
*/
_maybeFlushUnit(unit) {
if (!this.#is_root) {
throw new Error('_maybeFlushUnit() is an internal method.');
}
if (this.#streamOutput === null || this.#stream_finished || !this.#shell_flushed) {
return;
}
if (!unit.settled || unit.flushed || !unit.slot_sent) {
return;
}
this.#flush_unit(unit);
this.#sweep_units();
}
/**
* @param {FlushUnit} unit
* @returns {void}
*/
#flush_unit(unit) {
var sink = /** @type {StreamSink} */ (this.#streamOutput);
if (unit.errored) {
unit.flushed = true;
sink.push(
'<script id="' +
STREAM_ERROR_SCRIPT_PREFIX +
unit.id +
'" type="application/json">' +
escape_script(JSON.stringify({ message: unit.error })) +
'</script>' +
'<script>__RIPPLE_S__(' +
unit.id +
',1)</script>',
);
return;
}
/** @type {StreamChunk} */
var chunk = { css: new Set(), scripts: [], head: '' };
var html = this.#collect_unit(unit, chunk);
var out = this.#chunk_css(chunk.css);
if (chunk.head !== '') {
out += '<template ' + STREAM_HEAD_ATTR + '="' + unit.id + '">' + chunk.head + '</template>';
}
out += chunk.scripts.join('');
out += '<template ' + STREAM_CHUNK_ATTR + '="' + unit.id + '">' + html + '</template>';
out += '<script>__RIPPLE_S__(' + unit.id + ')</script>';
sink.push(out);
}
/**
* Flushes every unit whose slot has been sent and whose async work has
* settled. Flushing a parent chunk marks nested unsettled slots as sent,
* which can make further units eligible — loop until a fixpoint.
* @returns {void}
*/
#sweep_units() {
var units = this.#units;
var progressed = true;
while (progressed) {
progressed = false;
for (var i = 0; i < units.length; i++) {
var unit = units[i];
if (unit.settled && !unit.flushed && unit.slot_sent) {
this.#flush_unit(unit);
progressed = true;
}
}
}
}
/**
* Emits the shell: everything rendered synchronously (suspended
* boundaries show their fallback inside `<!--[?N-->…<!--]-->` slots), all
* CSS registered so far, and — when unresolved slots remain — the inline
* swap runtime that later chunks call into.
* @returns {void}
*/
_streamShell() {
if (!this.#is_root) {
throw new Error('_streamShell() is an internal method.');
}
var sink = /** @type {StreamSink} */ (this.#streamOutput);
this._startStream();
/** @type {StreamChunk} */
var chunk = { css: new Set(), scripts: [], head: '' };
var head_html = this.#serialize(this.#head, chunk);
var body = this.#serialize(this.#body, chunk);
if (this.unit !== null && !this.unit.flushed) {
// the root boundary's slot is the shell body itself
this.unit.slot_sent = true;
}
for (var hash of this.#css) {
chunk.css.add(hash);
}
var template = this.#stream_template;
var out = template !== null ? template.before : '';
out += head_html + chunk.head + this.#chunk_css(chunk.css) + chunk.scripts.join('');
if (template !== null) {
out += template.between;
}
if (this.unit !== null) {
// the root boundary suspended: its slot IS the body (the body
// markers travel with the unit's content instead), so the slot
// marker doubles as the hydration anchor and is unambiguously
// root-owned
out += body;
} else {
out += BLOCK_OPEN + body + BLOCK_CLOSE;
}
var has_open_units = false;
for (var i = 0; i < this.#units.length; i++) {
if (!this.#units[i].flushed) {
has_open_units = true;
break;
}
}
if (has_open_units) {
out += STREAM_RUNTIME_SCRIPT;
}
sink.push(out);
this.#shell_flushed = true;
this.#sweep_units();
}
_incrementPending() {
if (this.#is_root) {
/** @type {number} */ (this.#pending_count)++;
return;
}
throw new Error('_incrementPending() is an internal method.');
}
_decrementPending() {
if (this.#is_root) {
/** @type {number} */ (this.#pending_count)--;
if (this.#pending_count === 0) {
this.#promise_resolve?.();
}
return;
}
throw new Error('_decrementPending() is an internal method.');
}
_finishSyncRun() {
if (this.#is_root) {
this.#sync_run = false;
return;
}
throw new Error('_finishSyncRun() is an internal method.');
}
/**
* @param {StreamSink} stream
* @param {{ before: string, between: string, after: string } | null} [template]
*/
_setStream(stream, template = null) {
if (this.#is_root) {
this.#streamOutput = stream;
this.#stream_template = template;
return;
}
throw new Error('_setStream() is an internal method.');
}
_startStream() {
if (this.#is_root) {
this.#stream_started = true;
return;
}
throw new Error('_startStream() is an internal method.');
}
_closeStream() {
if (this.#is_root) {
if (this.#streamOutput && this.#stream_started && !this.#stream_finished) {
this.#stream_finished = true;
if (this.#stream_template !== null && this.#stream_template.after !== '') {
this.#streamOutput.push(this.#stream_template.after);
}
this.#streamOutput.close();
}
return;
}
throw new Error('_closeStream() is an internal method.');
}
/**
* @param {unknown} reason
* @returns {void}
*/
_errorStream(reason) {
if (this.#is_root) {
if (this.#streamOutput && this.#stream_started && !this.#stream_finished) {
this.#stream_finished = true;
this.#streamOutput.error(reason);
}
return;
}
throw new Error('_errorStream() is an internal method.');
}
isStreamMode() {
return this.#root.#streamOutput !== null;
}
isSyncRun() {
return this.#root.#sync_run;
}
branch() {
return new Output(this);
}
}
/**
* @param {RenderComponent} component
* @param {BaseRenderOptions} [passed_in_options]
* @returns {Promise<RenderResult | RenderStreamResult>}
*/
export async function render(component, passed_in_options = {}) {
/** @type {BaseRenderOptions} */
var options = {
...(passed_in_options.stream ? { closeStream: true } : {}),
...passed_in_options,
};
/** @type {Error | null } */
var top_level_error = null;
var head = '';
var body = '';
/** @type {Set<string>} */
var css = new Set();
/** @type {Block | null} */
var root_block = null;
// Reset dev-mode element tracking state at the start of each render
reset_state();
try_block(
// since there is no `active_block` yet, the usual automatic block run will be skipped
() => {
// this will run only once and immediately when we call the `try_block`
root_block = /** @type {Block} */ (active_block);
const output = root_block.o;
if (options.stream) {
output._setStream(options.stream, options.streamTemplate ?? null);
}
render_component(component, {});
output._decrementPending();
output._finishSyncRun();
},
(error) => {
// We're not going to send the error in the stream via stream.error()
// as we should send the error template
// store the error to be returned
top_level_error = error;
const output = /** @type {Block | null} */ (root_block)?.o;
if (output?.isSyncRun()) {
output._decrementPending();
output._finishSyncRun();
}
if (options.rootBoundary?.catch) {
render_component(options.rootBoundary.catch, { error, reset: noop });
} else {
console.error(error);
}
},
() => {
if (options.rootBoundary?.pending) {
render_component(options.rootBoundary.pending, {});
}
},
);
const output = /** @type {Block} */ (/** @type {unknown} */ (root_block)).o;
if (output.isStreamMode()) {
// shell: sync content + fallbacks in open slots + css + swap runtime
output._streamShell();
}
await output.promise;
reset_state();
if (output.isStreamMode() && options.closeStream) {
output._closeStream();
}
if (!output.isStreamMode()) {
sync_buffers_to_string(output);
}
return options.stream
? { stream: options.stream, topLevelError: top_level_error }
: { head, body, css, topLevelError: top_level_error };
/**
* @param {Output} output
* @returns {void}
*/
function sync_buffers_to_string(output) {
head = /** @type {string[]} */ (output.head).flat(Infinity).join('');
body = BLOCK_OPEN + /** @type {string[]} */ (output.body).flat(Infinity).join('') + BLOCK_CLOSE;
css = output.css;
}
}
/**
* @returns {void}
*/
export function push_component() {
active_component = {
c: null,
p: active_component,
};
active_block = component_block(() => {});
}
/**
* @returns {void}
*/
export function pop_component() {
active_component = /** @type {Component} */ (active_component).p;
active_block = /** @type {Block} */ (active_block).p;
}
/**
* @param {string} str
* @returns {void}
*/
export function output_push(str) {
/** @type {Block} */ (active_block).o.push(str);
}
/**
* @param {string} str
* @returns {void}
*/
export function output_push_serialized_error(str) {
/** @type {Block} */ (active_block).o.push_serialized_error(str);
}
/**
* @param {Output['target']} target
*/
export function set_output_target(target) {
/** @type {Block} */ (active_block).o.target = target;
}
/**
* @param {string} hash
* @returns {void}
*/
export function output_register_css(hash) {
/** @type {Block} */ (active_block).o.register_css(hash);
}
/**
* @param {string} message
*/
function print_nesting_error(message) {
message =
`node_invalid_placement_ssr: ${message}\n\n` +
'This can cause content to shift around as the browser repairs the HTML, and will likely result in a hydration mismatch.';
if (seen_warnings.has(message)) return;
seen_warnings.add(message);
// eslint-disable-next-line no-console
console.error(message);
}
/**
* Pushes an element onto the element stack and validates its nesting.
* Used during DEV mode SSR to detect invalid HTML nesting that would cause
* the browser to repair the HTML, breaking hydration.
* @param {string} tag
* @param {string} filename
* @param {number} line
* @param {number} column
* @returns {void}
*/
export function push_element(tag, filename, line, column) {
var parent = current_element;
var element = { tag, parent, filename, line, column };
if (parent !== undefined) {
var ancestor = parent.parent;
var ancestors = [parent.tag];
const child_loc = filename ? `${filename}:${line}:${column}` : undefined;
const parent_loc = parent.filename
? `${parent.filename}:${parent.line}:${parent.column}`
: undefined;
const message = is_tag_valid_with_parent(tag, parent.tag, child_loc, parent_loc);
if (message) print_nesting_error(message);
while (ancestor != null) {
ancestors.push(ancestor.tag);
const ancestor_loc = ancestor.filename
? `${ancestor.filename}:${ancestor.line}:${ancestor.column}`
: undefined;
const ancestor_message = is_tag_valid_with_ancestor(tag, ancestors, child_loc, ancestor_loc);
if (ancestor_message) print_nesting_error(ancestor_message);
ancestor = ancestor.parent;
}
}
current_element = element;
}
/**
* Pops the current element from the element stack.
* @returns {void}
*/
export function pop_element() {
if (current_element !== undefined) {
current_element = current_element.parent;
}
}
/**
* @param {any} tracked
* @returns {any}
*/
export function get(tracked) {
if (!is_ripple_object(tracked)) {
return tracked;
}
return (tracked.f & DERIVED) !== 0
? get_derived(/** @type {Derived} */ (tracked))
: get_tracked(/** @type {Tracked} */ (tracked));
}
/**
* @param {Tracked} tracked
* @returns {any}
*/
export function get_tracked(tracked) {
if (tracking) {
register_dependency(tracked);
}
if (tracked.v === SUSPENSE_PENDING || tracked.v === SUSPENSE_REJECTED) {
var is_try_block = false;
if (
!inside_async_track &&
(!active_block ||
active_block.f & COMPONENT_BLOCK ||
(is_try_block = (active_block.f & TRY_BLOCK) !== 0))
) {
throw new Error(
`Reads on pending tracked or derived values directly inside ${is_try_block ? 'try' : 'component'} body are prohibited. Use trackPending() test for safe access or create another derived instead.`,
);
}
// this will be caught by the run_block and the block will be re-run
// once the async tracked dependency's promise resolves
throw ASYNC_DERIVED_READ_THROWN;
}
var g = tracked.a.get;
return g ? g(tracked.v) : tracked.v;
}
/**
* @param {Derived} tracked
* @returns {any}
*/
export function get_derived(tracked) {
update_derived(tracked);
if (tracking) {
register_dependency(tracked);
}
if (tracked.v === SUSPENSE_PENDING || tracked.v === SUSPENSE_REJECTED) {
var is_try_block = false;
if (
!inside_async_track &&
(!active_block ||
active_block.f & COMPONENT_BLOCK ||
(is_try_block = (active_block.f & TRY_BLOCK) !== 0))
) {
throw new Error(
`Reads on pending tracked or derived values directly inside ${is_try_block ? 'try' : 'component'} body are prohibited. Use trackPending() test for safe access or create another derived instead.`,
);
}
// this will be caught by the run_block and the block will be re-run
// once the async tracked dependency's promise resolves
throw ASYNC_DERIVED_READ_THROWN;
}
var g = tracked.a.get;
return g ? g(tracked.v) : tracked.v;
}
/**
* @param {any} lazy
* @param {number} [index]
* @returns {any}
*/
export function lazy_array_get(lazy, index = 0) {
if (is_array(lazy)) {
return lazy[index];
}
var flags = lazy.f;
if (flags === TRACKED) {
return index === 0
? get_tracked(/** @type {Tracked} */ (lazy))
: index === 1
? lazy
: undefined;
}
if (flags === DERIVED) {
return index === 0
? get_derived(/** @type {Derived} */ (lazy))
: index === 1
? lazy
: undefined;
}
return iterable_array_from(lazy, index)[0];
}
/**
* @param {any} lazy
* @param {number} [index]
* @returns {any[]}
*/
export function lazy_array_rest(lazy, index = 0) {
if (is_array(lazy)) {
return lazy.slice(index);
}
var flags = lazy.f;
if (flags === TRACKED) {
return index === 0
? [get_tracked(/** @type {Tracked} */ (lazy)), lazy]
: index === 1
? [lazy]
: [];
}
if (flags === DERIVED) {
return index === 0
? [get_derived(/** @type {Derived} */ (lazy)), lazy]
: index === 1
? [lazy]
: [];
}
return iterable_array_from(lazy, index);
}
/**
* @param {Derived | Tracked} tracked
* @param {any} value
*/
export function set(tracked, value) {
var old_value = tracked.v;
if (value !== old_value) {
var s = tracked.a.set;
tracked.v = s ? s(value, tracked.v) : value;
tracked.c = increment_clock();
}
}
/**
* @param {any} lazy
* @param {any} value
* @param {number} [index]
* @returns {void}
*/
export function lazy_array_set(lazy, value, index = 0) {
if (is_array(lazy)) {
lazy[index] = value;
return;
}
var flags = lazy.f;
if (flags === TRACKED || flags === DERIVED) {
if (index === 0) {
set(/** @type {Derived | Tracked} */ (lazy), value);
return;
}
if (index === 1) {
throw_tracked_index_reference_error();
}
return;
}
lazy[index] = value;
}
/**
* @param {any} lazy
* @param {number} [index]
* @param {number} [d]
* @returns {number}
*/
export function lazy_array_update(lazy, index = 0, d = 1) {
var value = lazy_array_get(lazy, index);
var result = d === 1 ? value++ : value--;
lazy_array_set(lazy, value, index);
return result;
}
/**
* @param {any} lazy
* @param {number} [index]
* @param {number} [d]
* @returns {number}
*/
export function lazy_array_update_pre(lazy, index = 0, d = 1) {
var value = lazy_array_get(lazy, index);
var new_value = d === 1 ? ++value : --value;
lazy_array_set(lazy, new_value, index);
return new_value;
}
/**
* @param {Tracked} tracked
* @param {number} [d]
* @returns {number}
*/
export function update(tracked, d = 1) {
var value = get(tracked);
var result = d === 1 ? value++ : value--;
set(tracked, value);
return result;
}
/**
* @param {Tracked} tracked
* @param {number} [d]
* @returns {number}
*/
export function update_pre(tracked, d = 1) {
var value = get(tracked);
var new_value = d === 1 ? ++value : --value;
set(tracked, new_value);
return new_value;
}
/**
* @param {any} obj
* @param {string | number | symbol} property
* @param {any} value
* @returns {void}
*/
export function set_property(obj, property, value) {
var tracked = obj[property];
set(tracked, value);
}
/**
* @param {any} obj
* @param {string | number | symbol} property
* @param {boolean} [chain=false]
* @returns {any}
*/
export function get_property(obj, property, chain = false) {
if (chain && obj == null) {
return undefined;
}
var tracked = obj[property];
if (tracked == null) {
return tracked;
}
return get(tracked);
}
/**
* @param {any} obj
* @param {string | number | symbol} property
* @param {number} [d=1]
* @returns {number}
*/
export function update_property(obj, property, d = 1) {
var tracked = obj[property];
var value = get(tracked);
var new_value = d === 1 ? value++ : value--;
set(tracked, value);
return new_value;
}
/**
* @param {any} obj
* @param {string | number | symbol} property
* @param {number} [d=1]
* @returns {number}
*/
export function update_pre_property(obj, property, d = 1) {
var tracked = obj[property];
var value = get(tracked);
var new_value = d === 1 ? ++value : --value;
set(tracked, new_value);
return new_value;
}
/**
* @template V
* @param {string} name
* @param {V} value
* @param {boolean} [is_boolean]
* @returns {string}
*/
export function attr(name, value, is_boolean = false) {
if (name === 'hidden' && value !== 'until-found') {
is_boolean = true;
}
if (value == null || (!value && is_boolean)) return '';
const normalized = (name in replacements && replacements[name].get(value)) || value;
let value_to_escape = name === 'class' ? clsx(normalized) : normalized;
value_to_escape =
name === 'style'
? typeof value !== 'string'
? get_styles(value)
: String(normalized).trim()
: value_to_escape;
const assignment = is_boolean ? '' : `="${escape(value_to_escape, true)}"`;
return ` ${name}${assignment}`;
}
/**
* @param {Record<string, string | number>} styles
* @returns {string}
*/
function get_styles(styles) {
var result = '';
for (const key in styles) {
const css_prop = normalize_css_property_name(key);
const value = String(styles[key]).trim();
result += `${css_prop}: ${value}; `;
}
return result.trim();
}
/**
* @param {Record<string, any>} attrs
* @param {string | undefined} css_hash
* @param {string} [exclude_prop]
* @returns {string}
*/
export function spread_attrs(attrs, css_hash, exclude_prop) {
let attr_str = '';
let name;
if (css_hash === undefined && Object.prototype.hasOwnProperty.call(attrs, '#class')) {
css_hash = attrs['#class'];
}
for (name in attrs) {
var value = attrs[name];
if (
name === 'children' ||
name === 'innerHTML' ||
name === '#class' ||
name === exclude_prop ||
typeof value === 'function' ||
is_tsrx_element(value)
)
continue;
if (is_ripple_object(value)) {
value = get(value);
}
if (name === 'class' && css_hash) {
value = value == null || value === css_hash ? css_hash : [value, css_hash];
}
attr_str += attr(name, value, is_boolean_attribute(name));
}
return attr_str;
}
/**
* @param {Record<string, any>} attrs
* @returns {string | undefined}
*/
export function spread_inner_html(attrs) {
if (!Object.prototype.hasOwnProperty.call(attrs, 'innerHTML')) {
return undefined;
}
var value = attrs.innerHTML;
if (is_ripple_object(value)) {
value = get(value);
}
return String(value ?? '');
}
var empty_get_set = { get: undefined, set: undefined };
class TrackedValue {
/**
* @param {any} v
* @param {{ get?: Function; set?: Function }} a
* @param {string} hash
*/
constructor(v, a, hash) {
/** @type {{ get?: Function; set?: Function }} */
this.a = a;
/** @type {AbortController | null} */
this.aa = null;
/** @type {PromiseLike<any> | null} */
this.ap = null;
/** @type {Block} */
this.b = /** @type {Block} */ (active_block);
/** @type {number} */
this.c = 0;
/** @type {number} */
this.f = TRACKED;
/** @type {string} */
this.h = hash;
/** @type {any} */
this.v = v;
}
/** @returns {any} */
get [0]() {
return throw_tracked_index_value_error();
}
/** @param {any} v */
set [0](v) {
throw_tracked_index_value_error();
}
/** @returns {Tracked} */
get [1]() {
return throw_tracked_index_reference_error();
}
/** @returns {any} */
get value() {
return get_tracked(/** @type {Tracked} */ (this));
}
/** @param {any} v */
set value(v) {
set(/** @type {Tracked} */ (this), v);
}
/** @returns {2} */
get length() {
return 2;
}
/** @returns {Iterator<any | Tracked>} */
*[Symbol.iterator]() {
yield get_tracked(/** @type {Tracked} */ (this));
yield this;
}
}
class DerivedValue {
/**
* @param {Function} fn
* @param {{ get?: Function; set?: Function }} a
* @param {string} hash
*/
constructor(fn, a, hash) {
/** @type {{ get?: Function; set?: Function }} */
this.a = a;
// we always should have an active block
/** @type {Block} */
this.b = /** @type {Block} */ (active_block);
/** @type {number} */
this.c = 0;
/** @type {Component | null} */
this.co = active_component;
/** @type {Dependency | null} */
this.d = null;
/** @type {number} */
this.f = DERIVED;
/** @type {Function} */
this.fn = fn;
/** @type {string} */
this.h = hash;
/** @type {any} */
this.v = UNINITIALIZED;
}
/** @returns {any} */
get [0]() {
return throw_tracked_index_value_error();
}
/** @param {any} v */
set [0](v) {
throw_tracked_index_value_error();
}
/** @returns {Derived} */
get [1]() {
return throw_tracked_index_reference_error();
}
/** @returns {any} */
get value() {
return get_derived(/** @type {Derived} */ (this));
}
/** @param {any} v */
set value(v) {
set(/** @type {Derived} */ (this), v);
}
/** @returns {2} */
get length() {
return 2;
}
/** @returns {Iterator<any | Derived>} */
*[Symbol.iterator]() {
yield get_derived(/** @type {Derived} */ (this));
yield this;
}
}
/**
* @param {any} v
* @param {string} hash
* @param {(value: any) => any} [get]
* @param {(next: any, prev: any) => any} [set]
* @returns {Tracked}
*/
function tracked(v, hash, get, set) {
return /** @type {Tracked} */ (
new TrackedValue(v, get || set ? { get, set } : empty_get_set, hash)
);
}
/**
* @param {Record<string, unknown>} obj
* @param {string[]} exclude_keys
* @returns {Record<string, unknown>}
*/
export function exclude_from_object(obj, exclude_keys) {
/** @type {Record<string, unknown>} */
var new_obj = {};
for (const key of Object.keys(obj)) {
if (!exclude_keys.includes(key)) {
new_obj[key] = obj[key];
}
}
return new_obj;
}
/**
* @param {any} v
* @param {string} hash
* @param {(value: any) => any} [get]
* @param {(next: any, prev: any) => any} [set]
* @returns {Derived}
*/
function derived(v, hash, get, set) {
return /** @type {Derived} */ (
new DerivedValue(v, get || set ? { get, set } : empty_get_set, hash)
);
}
/**
* @param {any} v
* @param {string} hash
* @param {(value: any) => any} [get]
* @param {(next: any, prev: any) => any} [set]
* @returns {Tracked | Derived}
*/
export function track(v, hash, get, set) {
var is_tracked = is_ripple_object(v);
if (is_tracked) {
return v;
}
if (typeof v === 'function') {
return derived(v, hash, get, set);
}
return tracked(v, hash, get, set);
}
/**
* Serializes a resolved trackAsync result as a script tag for hydration.
* @param {OutputInterface} output - The output push function captured at call time
* @param {string} hash - The unique hash for this trackAsync call
* @param {any} value - The resolved value
* @param {string[] | null} [deps] - Hashes of direct reactive dependencies read by fn()
* @returns {void}
*/
function serialize_track_async_result(output, hash, value, deps) {
/** @type {{ ok: true, payload: string, deps?: string[] }} */
var envelope = { ok: true, payload: devalue.stringify(value) };
if (deps && deps.length > 0) {
envelope.deps = deps;
}
push_script_for_hydration((str) => output.push_serialized_result(str), hash, envelope);
}
/**
* Serializes a rejected trackAsync error as a script tag for hydration.
* Must be called after route_error_to_catch_block so active_block is the catch block.
* @param {string} hash
* @param {any} error
* @returns {void}
*/
export function serialize_track_async_error(hash, error) {
var error_message = get_public_track_async_error_message(error);
// we can just use the output_push_serialized directly so it's added to the root block
// if we here then the try's block failed to render and the output was cleared
// so we're writing to the root otherwise it will be cleared in the local output
push_script_for_hydration(output_push_serialized_error, hash, {
ok: false,
error: { message: error_message },
});
}
/**
* @param {string} hash
* @param {any} error
* @returns {void}
*/
export function route_track_async_error_to_catch_block(hash, error) {
route_track_async_error_to_catch_block_with_boundary(
get_closest_catch_block(/** @type {Block} */ (active_block)),
hash,
error,
);
}
/**
* @param {any} error
* @returns {any}
*/
export function create_public_track_async_error(error) {
if (DEV) {
return error;
}
return new Error(get_public_track_async_error_message(error));
}
/**
* We avoid leaking arbitrary server errors in production while still keeping
* rich error messages in development and tests.
* @param {any} error
* @returns {string}
*/
function get_public_track_async_error_message(error) {
if (DEV) {
return error?.message ?? String(error);
}
return 'An error occurred during async rendering';
}
/**
* Routes trackAsync errors to a catch boundary and serializes the same
* public error for hydration, preventing SSR/hydration message mismatches.
* @param {TryBlockWithCatch} catch_block
* @param {string} hash
* @param {any} error
* @returns {void}
*/
function route_track_async_error_to_catch_block_with_boundary(catch_block, hash, error) {
var public_error = create_public_track_async_error(error);
route_error_to_catch_block(catch_block, public_error);
// has to run after routing as it sets the active_block to the catch block
serialize_track_async_error(hash, public_error);
}
/**
* Turns a try boundary whose body suspended into a streaming flush unit:
* the partially-rendered body is detached (async re-runs keep filling its
* holes in place) and the live slot receives the `<!--[?N-->` wrapper with
* the pending fallback (nothing for catch-only boundaries).
* @param {Block} block
* @param {(() => void) | null} pending_fn
* @returns {void}
*/
export function begin_stream_unit(block, pending_fn) {
var output = block.o;
var unit = output._createFlushUnit(block);
if (block.f & ROOT_BLOCK) {
// a user try body carries its own compiled <!--[-->…<!--]--> markers;
// the root boundary's content does not — add them so every unit's
// content hydrates (and swaps) through the same shape
unit.content.unshift(BLOCK_OPEN);
unit.content.push(BLOCK_CLOSE);
}
output.push('<!--' + HYDRATION_START_PENDING + unit.id + '-->');
if (pending_fn !== null) {
pending_fn();
}
output.push(BLOCK_CLOSE);
output._sealSlot();
}
/**
* Handles an error surfaced by async rendering work after the initial sync
* pass: routes it to the nearest catch boundary when that boundary's region
* can still be rendered server-side, otherwise marks the nearest unflushed
* flush unit as errored so the client resolves the boundary at hydration.
* @param {Block} origin_block
* @param {Block} op_block
* @param {string | null} hash
* @param {any} error
* @returns {void}
*/
function handle_async_render_error(origin_block, op_block, hash, error) {
var catch_block = get_closest_catch_block(origin_block);
if (catch_block.o.isStreamMode() && catch_block.o.isRegionSent()) {
fail_stream_unit(op_block, error);
return;
}
if (hash !== null) {
route_track_async_error_to_catch_block_with_boundary(catch_block, hash, error);
} else {
route_error_to_catch_block(catch_block, error);
}
settle_unit_after_catch(catch_block);
}
/**
* The nearest catch boundary's bytes are already on the wire — emit a
* unit-level error envelope instead, so the client routes the error into the
* live boundary during hydration.
* @param {Block} op_block
* @param {any} error
* @returns {void}
*/
function fail_stream_unit(op_block, error) {
var unit = op_block.o.nearestUnflushedUnit();
if (unit === null) {
// everything around the failure is already streamed — nothing the
// server can do beyond reporting it
console.error(error);
return;
}
cancel_async_operations(unit.block);
unit.errored = true;
unit.error = get_public_track_async_error_message(error);
unit.settled = true;
unit.output.root._maybeFlushUnit(unit);
}
/**