ruact 0.0.20 → 0.0.21

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.
@@ -8,12 +8,21 @@
8
8
  import { createFromReadableStream } from "virtual:ruact/flight-client";
9
9
  import { labelledStream } from "./transport.js";
10
10
 
11
+ /**
12
+ * What the router dispatches on the document before it stops one it leaves
13
+ * while it still loads (Story 19-2).
14
+ */
15
+ export const LEAVE_EVENT = "ruact:leave";
16
+
11
17
  /**
12
18
  * The rows the document pushes onto `__FLIGHT_DATA`, as a byte stream that
13
19
  * stays open while the document does (Story 19-1): the rows already in the
14
20
  * queue, then each one pushed later — a page that streams pushes one script per
15
21
  * `<Suspense>` — until the document has finished loading. A row pushed before
16
22
  * this runs is in the queue, and read first.
23
+ *
24
+ * A document the router leaves ({@link LEAVE_EVENT}) ends the stream as an
25
+ * aborted navigation does: the page being left reports no response cut short.
17
26
  */
18
27
  export function documentRows(queue, doc = globalThis.document) {
19
28
  const encoder = new TextEncoder();
@@ -27,12 +36,23 @@ export function documentRows(queue, doc = globalThis.document) {
27
36
  if (open) rows.forEach(read);
28
37
  return 0;
29
38
  };
30
- const close = () => {
39
+ const close = (error) => {
40
+ if (!open) return;
31
41
  open = false;
32
- controller.close();
42
+ doc?.removeEventListener?.("readystatechange", atEnd);
43
+ doc?.removeEventListener?.(LEAVE_EVENT, left);
44
+ if (error) controller.error(error);
45
+ else controller.close();
46
+ };
47
+ // Closed when the document stops loading: its end (`interactive`, just
48
+ // before DOMContentLoaded), or the router stopping it (Story 19-2).
49
+ const atEnd = () => {
50
+ if (doc.readyState !== "loading") close();
33
51
  };
52
+ const left = () => close(new DOMException("the page was left while it loaded", "AbortError"));
34
53
  if (doc?.readyState === "loading") {
35
- doc.addEventListener("DOMContentLoaded", close, { once: true });
54
+ doc.addEventListener("readystatechange", atEnd);
55
+ doc.addEventListener(LEAVE_EVENT, left);
36
56
  } else {
37
57
  close();
38
58
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { setBoundaryErrorHandler } from "./suspense-boundary.js";
14
- import { openPage } from "./page-stream.js";
14
+ import { LEAVE_EVENT, openPage } from "./page-stream.js";
15
15
 
16
16
  // Errors already reported once (by a Suspense boundary) that navigate() must
17
17
  // not report again on their way out to a revalidate() caller.
@@ -21,6 +21,12 @@ let _onNavigate = null;
21
21
  let _onError = null;
22
22
  let _currentAbort = null;
23
23
 
24
+ // Story 19-2 — whether the page on screen is a document the router stopped
25
+ // and has not replaced yet (a new tree, a full load, a native submit); and the
26
+ // path of the page on screen, which a `#anchor` does not leave.
27
+ let _pageStopped = false;
28
+ let _renderedPath = null;
29
+
24
30
  /**
25
31
  * @param {object} opts
26
32
  * @param {function} opts.onNavigate - called with the new React element tree
@@ -33,6 +39,8 @@ let _currentAbort = null;
33
39
  export function setupRouter({ onNavigate, onError = null, onBoundaryError = null }) {
34
40
  _onNavigate = onNavigate;
35
41
  _onError = onError;
42
+ _pageStopped = false;
43
+ _renderedPath = typeof location === "undefined" ? null : location.pathname + location.search;
36
44
  setBoundaryErrorHandler((error) => {
37
45
  console.error(error.message);
38
46
  onBoundaryError?.(error);
@@ -58,11 +66,15 @@ export function setupRouter({ onNavigate, onError = null, onBoundaryError = null
58
66
  // what link clicks and form submits want, but a programmatic
59
67
  // `revalidate()` caller HAS to be able to branch on success vs.
60
68
  // failure.
69
+ // `leave: false` (Story 19-2): revalidate() stays on the page, so a
70
+ // document still streaming is not stopped — stopping it would also fail the
71
+ // page's other requests in flight (a server function, a useQuery).
61
72
  globalThis.__ruact_revalidate = (target) =>
62
73
  navigate(target ?? location.pathname + location.search, {
63
74
  push: false,
64
75
  scroll: false,
65
76
  throwOnError: true,
77
+ leave: false,
66
78
  });
67
79
  }
68
80
 
@@ -112,9 +124,12 @@ function handleClick(event) {
112
124
  const anchor = event.target.closest("a[href]");
113
125
  if (!shouldIntercept(anchor)) return;
114
126
 
115
- event.preventDefault();
116
-
117
127
  const url = new URL(anchor.getAttribute("href"), location.href);
128
+ // Story 19-2 — this page with a `#anchor`: the browser scrolls; nothing to
129
+ // fetch, and a page still streaming is not left.
130
+ if (url.hash && url.pathname + url.search === location.pathname + location.search) return;
131
+
132
+ event.preventDefault();
118
133
  navigate(url.pathname + url.search + url.hash);
119
134
  }
120
135
 
@@ -304,6 +319,7 @@ function _isFlight(response) {
304
319
  // `response.url` never carries the #fragment (Fetch strips it), so the one
305
320
  // the link asked for is put back.
306
321
  function _fullLoad(response, targetUrl, { replace = false } = {}) {
322
+ _pageStopped = false;
307
323
  const requested = new URL(targetUrl, location.href);
308
324
  const destination = new URL(response.url || requested.href);
309
325
  if (!destination.hash && requested.hash) destination.hash = requested.hash;
@@ -340,6 +356,7 @@ const _SUBMITTER_OVERRIDES = [
340
356
  ];
341
357
 
342
358
  function _nativeSubmit(form, submitter) {
359
+ _pageStopped = false;
343
360
  const restore = [];
344
361
  const added = [];
345
362
 
@@ -405,6 +422,7 @@ async function _submitForm(form, submitter = null) {
405
422
  // Rails _method hidden field is included in FormData automatically;
406
423
  // Rack::MethodOverride reads it and routes to the correct action.
407
424
  _currentAbort?.abort();
425
+ _leaveOpenDocument();
408
426
  const controller = (_currentAbort = new AbortController());
409
427
 
410
428
  const headers = { Accept: "text/x-component", "Ruact-Request": "1" };
@@ -439,6 +457,8 @@ async function _submitForm(form, submitter = null) {
439
457
  if (err.name === "AbortError") return;
440
458
  console.error("[ruact-router] Form submission error:", err);
441
459
  _onError?.(err);
460
+ } finally {
461
+ _recoverStoppedPage(controller);
442
462
  }
443
463
  }
444
464
 
@@ -449,6 +469,9 @@ async function _submitForm(form, submitter = null) {
449
469
  function handlePopstate() {
450
470
  // Story 17.0f — `replace`: if this entry turns out not to be a ruact page, the
451
471
  // full load must not push a NEW entry into the history being walked.
472
+ // Story 19-2 — only the hash changed (a `#anchor`, Back over one): the page
473
+ // on screen is this one, and the browser scrolls.
474
+ if (location.pathname + location.search === _renderedPath) return;
452
475
  navigate(location.pathname + location.search + location.hash, { push: false, scroll: false, replace: true });
453
476
  }
454
477
 
@@ -456,6 +479,38 @@ function handlePopstate() {
456
479
  // Navigation
457
480
  // ---------------------------------------------------------------------------
458
481
 
482
+ /**
483
+ * Story 19-2 — the page being left may be a document still arriving: a page
484
+ * that streams, whose <Suspense> rows are still to come. Nobody will see them,
485
+ * so stop loading it: the connection closes, and the server, which asks before
486
+ * each block, starts no further block for it. Only a document that streams
487
+ * and has rows still to come (the server marks it, `__FLIGHT_DATA.streaming`):
488
+ * any other document still being parsed — a slow network, a long layout — is
489
+ * left to load, the part of its layout after `#root` included.
490
+ */
491
+ function _leaveOpenDocument() {
492
+ if (typeof document === "undefined" || document.readyState !== "loading") return;
493
+ if (globalThis.__FLIGHT_DATA?.streaming !== true) return;
494
+ // First its rows' stream ends as an aborted navigation: stopped, it would
495
+ // read as a response cut short, which the page being left reports.
496
+ if (typeof document.dispatchEvent === "function") document.dispatchEvent(new Event(LEAVE_EVENT));
497
+ if (typeof window !== "undefined" && typeof window.stop === "function") window.stop();
498
+ _pageStopped = true;
499
+ }
500
+
501
+ /**
502
+ * Story 19-2 — a navigation ended without replacing a page the router had
503
+ * stopped (a failed fetch, a 403 or a 500 to a form — this navigation's, or a
504
+ * later one's that superseded the one that stopped it): the user is still on
505
+ * that page, whose boundaries would keep their fallback. Fetch it again,
506
+ * without stopping anything, once. Not when a newer navigation took over.
507
+ */
508
+ function _recoverStoppedPage(controller) {
509
+ if (!_pageStopped || controller.signal.aborted) return;
510
+ _pageStopped = false;
511
+ navigate(location.pathname + location.search, { push: false, scroll: false, leave: false });
512
+ }
513
+
459
514
  /**
460
515
  * Story 8.2 review patch R7 (2026-05-17) — `throwOnError` opts a caller
461
516
  * out of the swallow-and-forward error mode. The default (false) is the
@@ -465,9 +520,10 @@ function handlePopstate() {
465
520
  * REJECTS on a failed Flight fetch — without this, callers cannot
466
521
  * branch on success vs. failure of a programmatic refetch.
467
522
  */
468
- async function navigate(url, { push = true, scroll = true, throwOnError = false, replace = false } = {}) {
523
+ async function navigate(url, { push = true, scroll = true, throwOnError = false, replace = false, leave = true } = {}) {
469
524
  // Abort any in-flight fetch so we don't apply a stale response
470
525
  _currentAbort?.abort();
526
+ if (leave) _leaveOpenDocument();
471
527
  const controller = (_currentAbort = new AbortController());
472
528
 
473
529
  try {
@@ -504,6 +560,8 @@ async function navigate(url, { push = true, scroll = true, throwOnError = false,
504
560
  console.error("[ruact-router] Navigation error:", err);
505
561
  _onError?.(err);
506
562
  if (throwOnError) throw err;
563
+ } finally {
564
+ _recoverStoppedPage(controller);
507
565
  }
508
566
  }
509
567
 
@@ -594,6 +652,8 @@ async function _processFlightResponse(response, {
594
652
  }
595
653
 
596
654
  if (pushEntry) history.pushState(null, "", finalPath);
655
+ _pageStopped = false;
656
+ _renderedPath = location.pathname + location.search;
597
657
  _onNavigate(root);
598
658
  if (scroll) window.scrollTo(0, 0);
599
659
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruact
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.20
4
+ version: 0.0.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Luiz Garcia
@@ -71,6 +71,7 @@ files:
71
71
  - lib/generators/ruact/scaffold/templates/views/new.html.erb.tt
72
72
  - lib/generators/ruact/scaffold/templates/views/show.html.erb.tt
73
73
  - lib/ruact.rb
74
+ - lib/ruact/cached_blocks.rb
74
75
  - lib/ruact/client_manifest.rb
75
76
  - lib/ruact/component_attributes.rb
76
77
  - lib/ruact/component_contract.rb
@@ -82,6 +83,7 @@ files:
82
83
  - lib/ruact/controller/page_rendering.rb
83
84
  - lib/ruact/controller/pages.rb
84
85
  - lib/ruact/controller/streaming.rb
86
+ - lib/ruact/declared_content.rb
85
87
  - lib/ruact/deferred_render.rb
86
88
  - lib/ruact/doctor.rb
87
89
  - lib/ruact/erb_preprocessor.rb
@@ -104,6 +106,7 @@ files:
104
106
  - lib/ruact/navigation_boundary.rb
105
107
  - lib/ruact/packaging.rb
106
108
  - lib/ruact/query.rb
109
+ - lib/ruact/query_watch.rb
107
110
  - lib/ruact/railtie.rb
108
111
  - lib/ruact/redirect_path.rb
109
112
  - lib/ruact/render_context.rb