ripple 0.3.91 → 0.3.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # ripple
2
2
 
3
+ ## 0.3.92
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1326](https://github.com/Ripple-TS/ripple/pull/1326)
8
+ [`fea49bf`](https://github.com/Ripple-TS/ripple/commit/fea49bfb4410a05e0c915dfa39acdaba7f542737)
9
+ Thanks [@leonidaz](https://github.com/leonidaz)! - Streaming SSR:
10
+ `render(App, { stream })` now streams progressively. The synchronous shell (with
11
+ pending fallbacks for suspended `@try` boundaries and all CSS registered so far)
12
+ is flushed immediately; each boundary's content streams out of order as a framed
13
+ chunk once its async work settles, including per-chunk CSS, trackAsync envelopes
14
+ and `<head>` content. A tiny inline runtime swaps chunks into their slots before
15
+ hydration, and hydrated boundaries activate streamed chunks in place afterwards
16
+ — claiming the streamed DOM without re-rendering. Catch-only async boundaries
17
+ stream an empty slot and resolve to their body or server-rendered catch; errors
18
+ whose catch region is already on the wire hand off to the client boundary via an
19
+ error envelope. `render` also gains a `streamTemplate` option for document
20
+ scaffolding, and the vite plugin streams render-route responses when
21
+ `ssr.streaming` is enabled in ripple.config.ts (falling back to buffered SSR
22
+ when index.html lacks the `<!--ssr-head-->`/`<!--ssr-body-->` markers).
23
+
3
24
  ## 0.3.91
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Ripple is an elegant TypeScript UI framework",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.3.91",
6
+ "version": "0.3.92",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
package/src/constants.js CHANGED
@@ -13,6 +13,19 @@ export const HYDRATION_START = '[';
13
13
  export const HYDRATION_END = ']';
14
14
  export const HYDRATION_ERROR = {};
15
15
 
16
+ // Streaming SSR slot markers. A `<!--[?N-->` comment opens the slot of flush
17
+ // unit N whose content has not been streamed yet (the slot shows the pending
18
+ // fallback, or nothing for catch-only boundaries). The inline stream runtime
19
+ // rewrites the slot to plain `<!--[-->…<!--]-->` when the unit's chunk
20
+ // arrives, or to `<!--[!N-->` when the unit errored after its region was
21
+ // already on the wire.
22
+ export const HYDRATION_START_PENDING = '[?';
23
+ export const HYDRATION_START_ERRORED = '[!';
24
+
25
+ export const STREAM_CHUNK_ATTR = 'data-ripple-chunk';
26
+ export const STREAM_HEAD_ATTR = 'data-ripple-head';
27
+ export const STREAM_ERROR_SCRIPT_PREFIX = '__ripple_te_';
28
+
16
29
  export const BLOCK_OPEN = `<!--${HYDRATION_START}-->`;
17
30
  export const BLOCK_CLOSE = `<!--${HYDRATION_END}-->`;
18
31
  export const EMPTY_COMMENT = `<!---->`;
@@ -133,7 +133,11 @@ export function hydrate(component, options) {
133
133
  try {
134
134
  while (
135
135
  anchor &&
136
- (anchor.nodeType !== COMMENT_NODE || /** @type {Comment} */ (anchor).data !== HYDRATION_START)
136
+ (anchor.nodeType !== COMMENT_NODE ||
137
+ // any `[`-prefixed marker anchors the root boundary — a streamed
138
+ // shell whose root suspended starts with a `<!--[?N-->` slot
139
+ // marker instead of a plain `<!--[-->`
140
+ !(/** @type {Comment} */ (anchor).data.startsWith(HYDRATION_START)))
137
141
  ) {
138
142
  anchor = get_next_sibling(anchor);
139
143
  }
@@ -73,6 +73,9 @@ export function pop(node) {
73
73
  * Scans forward from the current hydrate_node to find the matching HYDRATION_END
74
74
  * comment, handling nested blocks by tracking depth.
75
75
  * Should be called after hydrate_next() has consumed the opening HYDRATION_START.
76
+ * Any `[`-prefixed comment opens a nested region — this includes the plain
77
+ * `<!--[-->` markers as well as streaming slot markers (`<!--[?N-->`,
78
+ * `<!--[!N-->`), which always pair with a `<!--]-->`.
76
79
  * @returns {Node} The HYDRATION_END comment node.
77
80
  */
78
81
  export function skip_to_hydration_end() {
@@ -84,7 +87,7 @@ export function skip_to_hydration_end() {
84
87
  if (data === HYDRATION_END) {
85
88
  if (depth === 0) return node;
86
89
  depth -= 1;
87
- } else if (data === HYDRATION_START) {
90
+ } else if (data.startsWith(HYDRATION_START)) {
88
91
  depth += 1;
89
92
  }
90
93
  }
@@ -223,7 +223,8 @@ function is_after_hydration_block(start, target) {
223
223
  if (current.nodeType === COMMENT_NODE) {
224
224
  var data = /** @type {Comment} */ (current).data;
225
225
 
226
- if (data === HYDRATION_START) {
226
+ // `[`-prefixed covers plain block starts and streaming slot markers
227
+ if (data.startsWith(HYDRATION_START)) {
227
228
  depth += 1;
228
229
  } else if (data === HYDRATION_END) {
229
230
  if (depth === 0) {
@@ -9,7 +9,21 @@ import {
9
9
  resume_block,
10
10
  } from './blocks.js';
11
11
  import { TRY_BLOCK } from './constants.js';
12
- import { hydrate_next, hydrate_node, hydrating } from './hydration.js';
12
+ import {
13
+ COMMENT_NODE,
14
+ HYDRATION_START,
15
+ HYDRATION_START_PENDING,
16
+ HYDRATION_START_ERRORED,
17
+ STREAM_ERROR_SCRIPT_PREFIX,
18
+ } from '../../../constants.js';
19
+ import {
20
+ hydrate_next,
21
+ hydrate_node,
22
+ hydrating,
23
+ set_hydrate_node,
24
+ set_hydrating,
25
+ skip_to_hydration_end,
26
+ } from './hydration.js';
13
27
  import { append } from './template.js';
14
28
  import {
15
29
  active_block,
@@ -60,6 +74,14 @@ export function try_block(node, try_fn, catch_fn, pending_fn = null, root_contro
60
74
  var pending_deferreds = new Map();
61
75
  /** @type {Set<Block>} */
62
76
  var paused_blocks = new Set();
77
+ /** @type {string | null} */
78
+ var streamed_id = null;
79
+ var streamed_errored = false;
80
+ var streamed_fallback = false;
81
+ /** @type {Comment | null} */
82
+ var slot_open = null;
83
+ /** @type {Comment | null} */
84
+ var slot_close = null;
63
85
 
64
86
  function clear_paused_blocks() {
65
87
  paused_blocks.clear();
@@ -218,6 +240,119 @@ export function try_block(node, try_fn, catch_fn, pending_fn = null, root_contro
218
240
  destroy_resolved();
219
241
  }
220
242
 
243
+ /**
244
+ * Retires the slot wrapper markers once the slot's fate is decided: the
245
+ * open comment loses its marker data (it may still serve as the boundary
246
+ * anchor, so it must stay in the DOM) and the close comment is dropped —
247
+ * keeping `[`/`]` depth balanced for scans over surrounding slots.
248
+ * @returns {void}
249
+ */
250
+ function neutralize_slot_markers() {
251
+ /** @type {Comment} */ (slot_open).data = '';
252
+ /** @type {ChildNode} */ (slot_close).remove();
253
+ }
254
+
255
+ /**
256
+ * Reads the streamed unit error envelope for this slot and routes the
257
+ * error into this boundary, or the nearest catch boundary above it.
258
+ * @param {string} id
259
+ * @returns {void}
260
+ */
261
+ function route_streamed_error(id) {
262
+ if (try_block === null || is_destroyed(try_block)) {
263
+ return;
264
+ }
265
+ neutralize_slot_markers();
266
+ var message = 'An error occurred during server rendering';
267
+ var script = document.getElementById(STREAM_ERROR_SCRIPT_PREFIX + id);
268
+ if (script !== null) {
269
+ try {
270
+ message = JSON.parse(/** @type {string} */ (script.textContent)).message ?? message;
271
+ } catch {
272
+ // keep the generic message
273
+ }
274
+ script.remove();
275
+ }
276
+ var error = new Error(message);
277
+ if (catch_fn !== null) {
278
+ handle_error(error);
279
+ return;
280
+ }
281
+ var outer = get_boundary_with_catch(/** @type {Block} */ (try_block));
282
+ if (outer === null) {
283
+ throw error;
284
+ }
285
+ outer.s.c(error);
286
+ }
287
+
288
+ /**
289
+ * Empties the streamed slot: destroys the hydrated fallback branch and
290
+ * sweeps whatever remains between the wrapper comments (leftover marker
291
+ * comments included).
292
+ * @returns {void}
293
+ */
294
+ function clear_streamed_slot() {
295
+ destroy_pending();
296
+ var node = /** @type {ChildNode} */ (slot_open).nextSibling;
297
+ while (node !== null && node !== slot_close) {
298
+ var next = node.nextSibling;
299
+ /** @type {ChildNode} */ (node).remove();
300
+ node = next;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Called by the inline stream runtime when this slot's chunk arrives after
306
+ * hydration: swaps the fallback for the streamed content and claims the
307
+ * new DOM through a boundary-scoped hydration walk (trackAsync inside the
308
+ * body picks its serialized envelope up from the same chunk).
309
+ * @param {HTMLTemplateElement | null} template
310
+ * @param {number | undefined} [errored]
311
+ * @returns {void}
312
+ */
313
+ function activate_streamed_chunk(template, errored) {
314
+ if (try_block === null || is_destroyed(try_block)) {
315
+ return;
316
+ }
317
+ var id = /** @type {string} */ (streamed_id);
318
+ streamed_id = null;
319
+ if (errored) {
320
+ clear_streamed_slot();
321
+ route_streamed_error(id);
322
+ return;
323
+ }
324
+ clear_streamed_slot();
325
+ if (template !== null) {
326
+ /** @type {ChildNode} */ (slot_close).before(template.content);
327
+ }
328
+ var first = /** @type {ChildNode} */ (slot_open).nextSibling;
329
+ if (first === null || first === slot_close) {
330
+ neutralize_slot_markers();
331
+ return;
332
+ }
333
+ // adopt the streamed body's own <!--[--> as the boundary anchor (the
334
+ // node the buffered-SSR hydration path would have used) and drop the
335
+ // slot wrapper comments, so the resulting DOM matches buffered SSR
336
+ // exactly like the pre-hydration swap path does
337
+ if (anchor === slot_open) {
338
+ anchor = first;
339
+ }
340
+ /** @type {ChildNode} */ (slot_open).remove();
341
+ /** @type {ChildNode} */ (slot_close).remove();
342
+ var previous_hydrating = hydrating;
343
+ var previous_hydrate_node = hydrate_node;
344
+ set_hydrating(true);
345
+ set_hydrate_node(first);
346
+ hydrate_next(); // consume the streamed body's <!--[-->
347
+ try {
348
+ has_resolved = true;
349
+ render_resolved();
350
+ } finally {
351
+ set_hydrating(previous_hydrating);
352
+ set_hydrate_node(previous_hydrate_node, true);
353
+ }
354
+ }
355
+
221
356
  function begin_request() {
222
357
  var request_id = ++request_version;
223
358
  active_requests.add(request_id);
@@ -312,23 +447,78 @@ export function try_block(node, try_fn, catch_fn, pending_fn = null, root_contro
312
447
  };
313
448
 
314
449
  if (hydrating && (pending_fn !== null || catch_fn !== null)) {
315
- // Server wraps try_fn body with <!--[-->...<!--]--> markers when pending or catch is present.
316
- // Server resolves all async content fully (pending is only for future streaming SSR),
317
- // so the SSR HTML contains resolved content.
318
- // Mark as already resolved so begin_request's microtask won't transition to pending.
319
- if (pending_fn !== null) {
320
- has_resolved = true;
321
- }
450
+ // Server wraps a settled try body with <!--[-->...<!--]--> markers when
451
+ // pending or catch is present, and the boundary hydrates as resolved.
452
+ // Streaming SSR instead emits a slot wrapper around the fallback:
453
+ // <!--[?N--> while unit N's content is still on its way, or
454
+ // <!--[!N--> when the unit errored after its region was already sent.
322
455
  if (root_controlled) {
323
456
  boundary = /** @type {Node} */ (hydrate_node);
324
457
  }
325
- hydrate_next(); // consume <!--[-->
458
+
459
+ var marker = /** @type {Comment} */ (hydrate_node);
460
+ var data = marker.nodeType === COMMENT_NODE ? marker.data : '';
461
+
462
+ // a slot marker at the boundary's own anchor always belongs to this
463
+ // boundary — the root boundary included: when the root suspends, the
464
+ // server emits its slot as the whole body, so the marker doubles as
465
+ // the hydration anchor
466
+ if (data.startsWith(HYDRATION_START_PENDING) || data.startsWith(HYDRATION_START_ERRORED)) {
467
+ // live streamed slot
468
+ streamed_id = data.slice(HYDRATION_START_PENDING.length);
469
+ streamed_errored = data.startsWith(HYDRATION_START_ERRORED);
470
+ slot_open = marker;
471
+ hydrate_next(); // consume the slot wrapper open
472
+ slot_close = /** @type {Comment} */ (skip_to_hydration_end());
473
+ var fallback_start = /** @type {Comment} */ (hydrate_node);
474
+ streamed_fallback =
475
+ !streamed_errored &&
476
+ pending_fn !== null &&
477
+ fallback_start.nodeType === COMMENT_NODE &&
478
+ fallback_start.data === HYDRATION_START;
479
+ if (streamed_fallback) {
480
+ hydrate_next(); // consume the fallback's <!--[-->
481
+ }
482
+ } else {
483
+ // settled content: mark as already resolved so begin_request's
484
+ // microtask won't transition to pending
485
+ if (pending_fn !== null) {
486
+ has_resolved = true;
487
+ }
488
+ hydrate_next(); // consume <!--[-->
489
+ }
326
490
  }
327
491
 
328
492
  try_block = create_try_block(() => {
493
+ if (streamed_id !== null) {
494
+ // the streamed body hasn't arrived yet — hydrate the fallback (if
495
+ // any) as the pending branch and wait for the chunk to activate us
496
+ if (streamed_fallback) {
497
+ mode = 'pending';
498
+ pending_branch = boundary_fn_running_block(() => {
499
+ /** @type {PendingFunction} */ (pending_fn)(anchor);
500
+ });
501
+ }
502
+ return;
503
+ }
329
504
  resolved_branch = boundary_fn_running_block(() => try_fn(anchor));
330
505
  }, state);
331
506
 
507
+ if (streamed_id !== null) {
508
+ // continue the outer hydration walk after the slot
509
+ set_hydrate_node(slot_close);
510
+
511
+ var registry = (window.__RIPPLE_B__ ??= {});
512
+ var unit_id = streamed_id;
513
+ if (streamed_errored) {
514
+ // the inline runtime already emptied the slot and marked it errored
515
+ // — route the error once the surrounding hydration has finished
516
+ queue_microtask(() => route_streamed_error(unit_id));
517
+ } else {
518
+ registry[unit_id] = { a: activate_streamed_chunk };
519
+ }
520
+ }
521
+
332
522
  if (hydrating && root_controlled) {
333
523
  append(/** @type {ChildNode} */ (node), /** @type {Node} */ (boundary));
334
524
  }
@@ -68,7 +68,32 @@ export type RootBoundaryOptions = {
68
68
  catch?: (anchor: Node, props: { error: unknown; reset: () => void }, block: Block | null) => void;
69
69
  };
70
70
 
71
+ /**
72
+ * Called by the inline stream runtime when a boundary's chunk arrives after
73
+ * hydration: receives the chunk template (null for error-only chunks) and a
74
+ * truthy flag when the unit errored server-side.
75
+ */
76
+ export type StreamBoundaryActivator = (
77
+ template: HTMLTemplateElement | null,
78
+ errored?: number,
79
+ ) => void;
80
+
81
+ /**
82
+ * `window.__RIPPLE_B__` — shared between the inline stream runtime and
83
+ * hydrated try boundaries. A boundary that hydrated a still-pending slot
84
+ * registers `{ a: activator }` under its unit id; the runtime stores `1`
85
+ * once the slot has been swapped or handed over.
86
+ */
87
+ export type StreamBoundaryRegistry = Record<string | number, 1 | { a: StreamBoundaryActivator }>;
88
+
71
89
  declare global {
90
+ interface Window {
91
+ /** streamed-boundary registry, see {@link StreamBoundaryRegistry} */
92
+ __RIPPLE_B__?: StreamBoundaryRegistry;
93
+ /** inline stream runtime entry point: swaps unit `id`'s chunk into its slot */
94
+ __RIPPLE_S__?: (id: number, errored?: number) => void;
95
+ }
96
+
72
97
  interface Element {
73
98
  __attributes?: {
74
99
  checked?: boolean;
@@ -13,6 +13,7 @@ import { ASYNC_DERIVED_READ_THROWN } from '../client/constants.js';
13
13
  import {
14
14
  TRY_CATCH_BLOCK,
15
15
  TRY_PENDING_BLOCK,
16
+ TRY_BLOCK,
16
17
  REGULAR_BLOCK,
17
18
  COMPONENT_BLOCK,
18
19
  ROOT_BLOCK,
@@ -27,6 +28,7 @@ import {
27
28
  TrackAsyncRunError,
28
29
  create_public_track_async_error,
29
30
  serialize_track_async_error,
31
+ begin_stream_unit,
30
32
  } from './index.js';
31
33
 
32
34
  /**
@@ -87,14 +89,21 @@ export function try_block(try_fn, catch_fn = null, pending_fn = null) {
87
89
 
88
90
  try {
89
91
  try_fn();
92
+
93
+ if (created_block.o.isStreamMode() && created_block.o.hasPendingAsyncOperations()) {
94
+ // the body suspended: turn this boundary into a streaming flush unit —
95
+ // the partially-rendered body is kept (async re-runs fill its holes in
96
+ // place) and the live slot gets the wrapper markers plus the pending
97
+ // fallback (nothing, for catch-only boundaries)
98
+ begin_stream_unit(created_block, pending_fn);
99
+ }
90
100
  } catch (error) {
91
101
  set_active_block(created_block);
92
- if (error === ASYNC_DERIVED_READ_THROWN && created_block.f & TRY_PENDING_BLOCK) {
93
- // we should only end up here in the streaming mode during the sync phase
94
- created_block.o.clear();
95
- pending_fn?.();
96
- // continue processing other try blocks
97
- return created_block;
102
+ if (error === ASYNC_DERIVED_READ_THROWN) {
103
+ // pending reads inside blocks are swallowed by run_block (they
104
+ // register a re-run); a sentinel escaping to here means a read
105
+ // happened outside any block — let an outer boundary deal with it
106
+ throw error;
98
107
  }
99
108
 
100
109
  if (created_block.f & TRY_CATCH_BLOCK) {
@@ -140,6 +149,26 @@ export function component_block(fn) {
140
149
  return block(COMPONENT_BLOCK, fn, null, true);
141
150
  }
142
151
 
152
+ /**
153
+ * Nearest try boundary (pending or catch) — async work is accounted on it,
154
+ * and in stream mode it is the boundary that becomes a flush unit.
155
+ * @param {Block} block
156
+ * @returns {TryBlock}
157
+ */
158
+ export function get_closest_try_block(block) {
159
+ var current = block;
160
+
161
+ while (current !== null) {
162
+ if (current.f & TRY_BLOCK) {
163
+ return /** @type {TryBlock} */ (current);
164
+ }
165
+ // the root block always carries try flags, so we never reach null
166
+ current = /** @type {Block} */ (current.p);
167
+ }
168
+
169
+ throw new Error('No try block found');
170
+ }
171
+
143
172
  /**
144
173
  * @param {Block} block
145
174
  * @returns {TryBlockWithCatch}
@@ -161,15 +190,20 @@ export function get_closest_catch_block(block) {
161
190
  }
162
191
 
163
192
  /**
193
+ * Cancels outstanding async work below an error boundary. Descendant try
194
+ * boundaries (`is_descendant`) additionally abandon their streaming flush
195
+ * units — their regions get replaced by the boundary's catch content, so
196
+ * they must never stream a stale chunk.
164
197
  * @param {Block | null} block
198
+ * @param {boolean} [is_descendant=false]
165
199
  * @returns {void}
166
200
  */
167
- export function cancel_async_operations(block) {
201
+ export function cancel_async_operations(block, is_descendant = false) {
168
202
  if (block === null) {
169
203
  return;
170
204
  }
171
205
 
172
- if (block.f & TRY_CATCH_BLOCK) {
206
+ if (block.f & TRY_BLOCK) {
173
207
  if (block.f & CAUGHT_ERROR) {
174
208
  // already handling an error — skip
175
209
  return;
@@ -177,11 +211,15 @@ export function cancel_async_operations(block) {
177
211
 
178
212
  block.o.cancelAsyncOperations();
179
213
  block.f |= CAUGHT_ERROR;
214
+
215
+ if (is_descendant) {
216
+ block.o.abandonUnit();
217
+ }
180
218
  }
181
219
 
182
220
  var child = block.first;
183
221
  while (child !== null) {
184
- cancel_async_operations(child);
222
+ cancel_async_operations(child, true);
185
223
  child = child.next;
186
224
  }
187
225
  }