@zerotal/flow 1.3.0 → 1.5.0

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.
@@ -277,7 +277,7 @@ const _loadingTimers = new Map<string, ReturnType<typeof setTimeout>>();
277
277
  const LOADING_DELAY_MS = 200;
278
278
 
279
279
  // A `live` text input debounces its per-keystroke sync by default, so fast typing doesn't fire
280
- // a WebSocket $set on every character (matching Livewire's `wire:model.live` default). Discrete
280
+ // a WebSocket $set on every character. Discrete
281
281
  // controls — checkbox, radio, select, range, date, colour — send immediately (a deliberate pick,
282
282
  // not a stream of keystrokes). The default binding (no `live`) is deferred and never affected.
283
283
  const MODEL_DEBOUNCE_MS = 150;
@@ -452,9 +452,16 @@ function _updateDirty(comp: FlowComponent): void {
452
452
  let _isOnline = true;
453
453
 
454
454
  function _setConnectionState(online: boolean): void {
455
+ // The attribute mirrors the *current* state, so it is stamped even when that
456
+ // state hasn't changed. The bridge starts optimistic (`_isOnline = true`), so
457
+ // a page whose socket connects on the first attempt — the normal case — would
458
+ // otherwise never carry the attribute at all, and a stylesheet or a readiness
459
+ // check keyed on `[data-flow-connection="online"]` would wait forever for a
460
+ // page that is in fact perfectly connected.
461
+ document.body.setAttribute("data-flow-connection", online ? "online" : "offline");
462
+
455
463
  if (_isOnline === online) return;
456
464
  _isOnline = online;
457
- document.body.setAttribute("data-flow-connection", online ? "online" : "offline");
458
465
  document.dispatchEvent(new CustomEvent(online ? "flow:online" : "flow:offline"));
459
466
 
460
467
  const offline = !online;
@@ -495,10 +502,37 @@ let _httpMode = false;
495
502
  let _wsFailures = 0;
496
503
  const HTTP_FALLBACK_AFTER = 3; // consecutive failed WS handshakes before switching to HTTP
497
504
 
505
+ /**
506
+ * The origin guard refused this page, and no retry will change that.
507
+ *
508
+ * Reported once, in full, because the alternative is the worst failure this app has: the
509
+ * HTML renders, the console shows a generic "action failed", and the only symptom is that
510
+ * nothing a user clicks does anything. The connection state drops to offline too — the app
511
+ * genuinely cannot act, so actions queue behind `flow:offline` directives instead of being
512
+ * fired at an endpoint that will refuse every one of them.
513
+ */
514
+ let _originRefused = false;
515
+ function _reportOriginRefused(body: string): void {
516
+ if (_originRefused) return;
517
+ _originRefused = true;
518
+ console.error(
519
+ `[Flow] Actions refused: the server rejected this page's origin (${location.origin}).\n` +
520
+ `Behind a reverse proxy the app's own origin is the loopback address it bound to, so ` +
521
+ `the public origin has to be configured for it to accept browser-initiated actions.\n` +
522
+ `Fix: set \`url\` in config/app.ts to ${location.origin} — AppConfig fills ` +
523
+ `app.allowedOrigins from it.` +
524
+ (body ? `\nServer said: ${body}` : ""),
525
+ );
526
+ _setConnectionState(false);
527
+ }
528
+
498
529
  function _enableHttpMode(): void {
499
530
  if (_httpMode) return;
500
531
  _httpMode = true;
501
- console.warn("[Flow] WebSocket unavailable — falling back to HTTP requests.");
532
+ console.warn(
533
+ "[Flow] WebSocket unavailable — falling back to HTTP requests. If actions also fail, " +
534
+ "check that the proxy forwards /__flow/* over HTTP/1.1 and does not gate it behind auth.",
535
+ );
502
536
  _setConnectionState(true); // HTTP works, so we're "online" for offline directives
503
537
  // The socket that dropped may never come back, so this is the reconnect for anything it
504
538
  // left mid-action — resync over HTTP before replaying, same order as the WS path.
@@ -517,12 +551,25 @@ function _httpSend(frame: unknown, compId: string): void {
517
551
  credentials: "include",
518
552
  body: JSON.stringify(frame),
519
553
  })
520
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
554
+ .then(async (r) => {
555
+ if (r.ok) return r.json();
556
+ // The status is the diagnosis, and the body carries the server's own words for it.
557
+ // Both are dropped by a bare `HTTP 403`, which is what made this failure so quiet.
558
+ const body = await r.text().catch(() => "");
559
+ const err = new Error(`HTTP ${r.status}${body ? `: ${body}` : ""}`) as Error & {
560
+ status?: number;
561
+ body?: string;
562
+ };
563
+ err.status = r.status;
564
+ err.body = body;
565
+ throw err;
566
+ })
521
567
  .then((frames: unknown) => {
522
568
  if (Array.isArray(frames)) for (const f of frames) void _handleServerFrame(f as ServerFrame);
523
569
  })
524
- .catch((e) => {
525
- console.error("[Flow] HTTP action failed:", e);
570
+ .catch((e: Error & { status?: number; body?: string }) => {
571
+ if (e.status === 403) _reportOriginRefused(e.body ?? "");
572
+ else console.error("[Flow] HTTP action failed:", e);
526
573
  _resolveAck(compId); // never wedge the per-component queue
527
574
  });
528
575
  }
@@ -865,7 +912,11 @@ function _syncUrlParams(comp: FlowComponent): void {
865
912
 
866
913
  if (newUrl !== location.pathname + location.search + location.hash) {
867
914
  if (pushed) {
868
- history.pushState({ flowNavigate: true }, "", newUrl);
915
+ // Bank the scroll offset first: this entry is about to become a Back
916
+ // target, and the viewport has not moved, so where the user is now is
917
+ // where Back should return them.
918
+ _rememberScroll();
919
+ _pushHistoryEntry(newUrl);
869
920
  } else {
870
921
  history.replaceState(history.state, "", newUrl);
871
922
  }
@@ -1202,16 +1253,37 @@ function _renderTimelineInto(el: HTMLElement): void {
1202
1253
  };
1203
1254
  }
1204
1255
 
1205
- // A compact, dev-only scrubbing panel docked bottom-left. The console API above is the primary
1206
- // interface; this is a convenience over it.
1256
+ /** The four corners the timeline pill can dock to. */
1257
+ const _TL_CORNERS = ["bottom-left", "bottom-right", "top-left", "top-right"] as const;
1258
+ type _TlCorner = (typeof _TL_CORNERS)[number];
1259
+
1260
+ /**
1261
+ * Which corner the fallback pill docks to. `data-flow-tl-corner` on <html> or <body>
1262
+ * overrides; the default is bottom-left, where it stays clear of an app's own left-rail
1263
+ * navigation (a top-docked panel at max z-index sat exactly on a sidebar's first item).
1264
+ */
1265
+ function _timelineCorner(): _TlCorner {
1266
+ const raw =
1267
+ document.documentElement.dataset["flowTlCorner"] ?? document.body.dataset["flowTlCorner"];
1268
+ return (_TL_CORNERS as readonly string[]).includes(raw ?? "")
1269
+ ? (raw as _TlCorner)
1270
+ : "bottom-left";
1271
+ }
1272
+
1273
+ // A compact, dev-only scrubbing panel docked bottom-left by default (see _timelineCorner).
1274
+ // The console API above is the primary interface; this is a convenience over it.
1207
1275
  function _setupTimelinePanel(): void {
1208
1276
  if (document.getElementById("flow-tl-panel")) return;
1209
1277
  const style = document.createElement("style");
1210
1278
  style.textContent = `
1211
- #flow-tl-panel{position:fixed;left:10px;top:88px;z-index:2147483647;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb}
1279
+ #flow-tl-panel{position:fixed;z-index:2147483647;display:flex;flex-direction:column;gap:6px;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#e5e7eb}
1280
+ #flow-tl-panel[data-corner="bottom-left"]{left:10px;bottom:10px;flex-direction:column-reverse}
1281
+ #flow-tl-panel[data-corner="bottom-right"]{right:10px;bottom:10px;flex-direction:column-reverse;align-items:flex-end}
1282
+ #flow-tl-panel[data-corner="top-left"]{left:10px;top:10px}
1283
+ #flow-tl-panel[data-corner="top-right"]{right:10px;top:10px;align-items:flex-end}
1212
1284
  #flow-tl-panel *{box-sizing:border-box}
1213
- .flow-tl-toggle{background:#111827;color:#e5e7eb;border:1px solid #374151;border-radius:9999px;padding:5px 10px;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.3)}
1214
- .flow-tl-body{margin-top:6px;width:320px;max-height:50vh;display:flex;flex-direction:column;background:#0b1220;border:1px solid #374151;border-radius:10px;overflow:hidden;box-shadow:0 8px 28px rgba(0,0,0,.45)}
1285
+ .flow-tl-toggle{background:#111827;color:#e5e7eb;border:1px solid #374151;border-radius:9999px;padding:5px 10px;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.3);align-self:inherit}
1286
+ .flow-tl-body{width:320px;max-height:50vh;display:flex;flex-direction:column;background:#0b1220;border:1px solid #374151;border-radius:10px;overflow:hidden;box-shadow:0 8px 28px rgba(0,0,0,.45)}
1215
1287
  .flow-tl-head{display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid #1f2937;font-weight:600}
1216
1288
  .flow-tl-head .sp{flex:1}
1217
1289
  .flow-tl-live{background:#059669;color:#fff;border:0;border-radius:6px;padding:3px 8px;cursor:pointer}
@@ -1229,6 +1301,7 @@ function _setupTimelinePanel(): void {
1229
1301
 
1230
1302
  const root = document.createElement("div");
1231
1303
  root.id = "flow-tl-panel";
1304
+ root.dataset["corner"] = _timelineCorner();
1232
1305
  root.innerHTML =
1233
1306
  `<button class="flow-tl-toggle" type="button">⏱ <b class="flow-tl-count">0</b></button>` +
1234
1307
  `<div class="flow-tl-body" hidden>` +
@@ -1842,16 +1915,6 @@ function _focusFirstError(comp: FlowComponent): void {
1842
1915
  if (invalid) (invalid as HTMLElement).focus();
1843
1916
  }
1844
1917
 
1845
- // Local copy by design: this file ships to browsers as part of the client
1846
- // bundle and cannot import server-side @zerotal/core helpers.
1847
- function debounce<T extends unknown[]>(fn: (...args: T) => void, ms: number): (...args: T) => void {
1848
- let timer: ReturnType<typeof setTimeout>;
1849
- return (...args: T) => {
1850
- clearTimeout(timer);
1851
- timer = setTimeout(() => fn(...args), ms);
1852
- };
1853
- }
1854
-
1855
1918
  /**
1856
1919
  * Find the nearest ancestor carrying `flow:<event>`, with or without modifiers.
1857
1920
  *
@@ -2001,12 +2064,75 @@ function _takeLastAction(compId: string): string {
2001
2064
  }
2002
2065
 
2003
2066
  function _callAction(comp: FlowComponent, method: string, args: unknown[]): void {
2067
+ _countActionDispatch(comp);
2004
2068
  _setActionError(comp, false); // retrying clears any prior failed state
2005
2069
  _setLoading(comp, true, method);
2006
2070
  if (isTimelineEnabled()) _pendingActionByComp.set(comp.id, method);
2007
2071
  _dispatchFrame(comp, method, args);
2008
2072
  }
2009
2073
 
2074
+ // ── Client-expression writes sync to the server ───────────────────────────────
2075
+ // `onClick={() => (this.selected = row.id)}` writes the reactive store, but render()
2076
+ // runs on the server — without a round-trip the page never reflects the write, and
2077
+ // nothing errors. (It *looked* reactive whenever a later action happened to flush it,
2078
+ // which is the worst kind of sometimes.) So: while a client expression evaluates,
2079
+ // every write to an exposed prop is recorded; if the expression wrote state and did
2080
+ // not itself dispatch an action on that component (an action's frame already carries
2081
+ // all pending writes), one `$rerender` is sent — state applied through the same
2082
+ // allowlist + updating()/updated() hooks, re-rendered, patched. No loading state:
2083
+ // it is a sync, not a user action. Client-only UI state belongs in `this.store()`,
2084
+ // which never round-trips.
2085
+
2086
+ /** Writes recorded during the currently-evaluating client expression (null: none active). */
2087
+ let _exprWrites: Set<string> | null = null;
2088
+ /** Actions dispatched per component, to detect "the expression already sent one". */
2089
+ const _actionSeq = new Map<string, number>();
2090
+
2091
+ /** @internal Count an action dispatch on `comp` (called by _callAction; exported for tests). */
2092
+ export function _countActionDispatch(comp: { id: string }): void {
2093
+ _actionSeq.set(comp.id, (_actionSeq.get(comp.id) ?? 0) + 1);
2094
+ }
2095
+
2096
+ /** @internal Record a write to an exposed prop; no-op outside an expression evaluation. */
2097
+ export function _noteExprWrite(key: string): void {
2098
+ _exprWrites?.add(key);
2099
+ }
2100
+
2101
+ /** @internal Opaque state carried from {@link _beginExprEval} to {@link _endExprEval}. */
2102
+ export interface ExprEvalToken {
2103
+ writes: Set<string>;
2104
+ prev: Set<string> | null;
2105
+ seqBefore: number;
2106
+ }
2107
+
2108
+ /** @internal Start recording exposed-prop writes for one expression evaluation. */
2109
+ export function _beginExprEval(comp: { id: string }): ExprEvalToken {
2110
+ const token: ExprEvalToken = {
2111
+ writes: new Set(),
2112
+ prev: _exprWrites,
2113
+ seqBefore: _actionSeq.get(comp.id) ?? 0,
2114
+ };
2115
+ _exprWrites = token.writes;
2116
+ return token;
2117
+ }
2118
+
2119
+ /**
2120
+ * @internal Stop recording and decide whether the component needs a sync round-trip:
2121
+ * it does when the expression wrote at least one exposed prop, dispatched no action on
2122
+ * this component itself, and the writes left actual pending changes (a toggle-back to
2123
+ * the canonical value needs nothing).
2124
+ */
2125
+ export function _endExprEval(
2126
+ comp: { id: string },
2127
+ token: ExprEvalToken,
2128
+ pendingCount: number,
2129
+ ): boolean {
2130
+ _exprWrites = token.prev;
2131
+ if (token.writes.size === 0) return false;
2132
+ if ((_actionSeq.get(comp.id) ?? 0) !== token.seqBefore) return false;
2133
+ return pendingCount > 0;
2134
+ }
2135
+
2010
2136
  /**
2011
2137
  * Execute a flow:click / flow:submit value that contains $flow references.
2012
2138
  * Emitted from inline arrow handlers in JSX — `onClick={(e) => this.x = 0}` — where
@@ -2016,15 +2142,24 @@ function _callAction(comp: FlowComponent, method: string, args: unknown[]): void
2016
2142
  * Mirrors Alpine's own `x-on` semantics: if the expression evaluates to a function
2017
2143
  * (the author wrote an arrow), call it with the DOM event. A bare statement
2018
2144
  * (`$flow.x = 0`) evaluates directly and returns a non-function, so nothing is called.
2145
+ *
2146
+ * A write to an exposed prop syncs to the server afterwards (see _beginExprEval) —
2147
+ * a partial write before a throw still syncs, since the client state did change.
2019
2148
  */
2020
2149
  function _evalClientExpr(comp: FlowComponent, el: Element, expr: string, event?: Event): void {
2021
2150
  const A = Alpine as unknown as { evaluate(el: Element, expr: string): unknown };
2151
+ const token = _beginExprEval(comp);
2152
+ let needsSync = false;
2022
2153
  try {
2023
2154
  const result = A.evaluate(el, expr);
2024
2155
  if (typeof result === "function") (result as (e?: Event) => unknown)(event);
2025
2156
  } catch (e) {
2026
2157
  console.error("[Flow] client expression error:", e, "\nExpr:", expr);
2158
+ } finally {
2159
+ needsSync = _endExprEval(comp, token, Object.keys(_pendingUpdates(comp)).length);
2027
2160
  }
2161
+ // Serialised like any action; carries every pending write as the frame's `updates`.
2162
+ if (needsSync) _dispatchFrame(comp, "$rerender", []);
2028
2163
  }
2029
2164
 
2030
2165
  /**
@@ -2289,7 +2424,7 @@ function _linkActive(href: string, path: string, exact = false): boolean {
2289
2424
  function _updateCurrentLinks(): void {
2290
2425
  const path = window.location.pathname;
2291
2426
 
2292
- // Auto data-current on every navigate link matching the URL (Livewire behavior).
2427
+ // Auto data-current on every navigate link matching the URL.
2293
2428
  // Style with `class="data-current:font-bold"` or `[data-current] {}`. Opt out
2294
2429
  // per-link with `flow:current.ignore` (the <Link current={false}> prop), or force it
2295
2430
  // always-on with `flow:current.force` (the <Link current={true}> prop) so the link stays
@@ -2357,7 +2492,127 @@ function _takePrefetched(href: string): string | null {
2357
2492
  return Date.now() - cached.at < PREFETCH_TTL_MS ? cached.html : null;
2358
2493
  }
2359
2494
 
2360
- async function _navigateTo(href: string, pushState = true): Promise<void> {
2495
+ // ── Scroll position across SPA navigations ────────────────────────────────────
2496
+ //
2497
+ // A real navigation puts you at the top of the new page, and Back puts you back
2498
+ // where you were. Swapping the DOM under a stationary viewport does neither: you
2499
+ // click a link near the bottom of a long list and land halfway down the next
2500
+ // page, which reads as the page having failed to load.
2501
+ //
2502
+ // Positions live in a map keyed by history entry rather than inside
2503
+ // `history.state`, because the entry you are *leaving* on a Back cannot be
2504
+ // stamped — by the time `popstate` fires, `history.state` is already the
2505
+ // destination's. A key per entry lets both directions be recorded.
2506
+
2507
+ let _nextHistoryKey = 1;
2508
+ let _currentHistoryKey = 0;
2509
+ const _scrollByHistoryKey = new Map<number, [number, number]>();
2510
+
2511
+ /**
2512
+ * The live scroll offset, tracked passively.
2513
+ *
2514
+ * Read from here rather than from `window.scrollY` at navigation time: on a
2515
+ * `popstate` the browser may already have applied its own scroll restoration
2516
+ * before our handler runs, and by then the outgoing page's real position is gone.
2517
+ */
2518
+ let _liveScroll: [number, number] = [0, 0];
2519
+
2520
+ function _trackScroll(): void {
2521
+ const update = (): void => {
2522
+ _liveScroll = [window.scrollX, window.scrollY];
2523
+ };
2524
+ update();
2525
+ window.addEventListener("scroll", update, { passive: true });
2526
+ }
2527
+
2528
+ /** Record where the current history entry was left, so Back can return to it. */
2529
+ function _rememberScroll(): void {
2530
+ _scrollByHistoryKey.set(_currentHistoryKey, _liveScroll);
2531
+ }
2532
+
2533
+ /** Start a new history entry, keyed so its scroll position can be found again. */
2534
+ function _pushHistoryEntry(url: string): void {
2535
+ _currentHistoryKey = _nextHistoryKey++;
2536
+ history.pushState({ flowNavigate: true, flowKey: _currentHistoryKey }, "", url);
2537
+ }
2538
+
2539
+ /** Where a navigation should leave the viewport. */
2540
+ export type ScrollIntent = "top" | "preserve" | readonly [number, number];
2541
+
2542
+ /** The fragment of an href, without the `#`. Empty when it has none. */
2543
+ export function _hashOf(href: string): string {
2544
+ const at = href.indexOf("#");
2545
+ return at === -1 ? "" : href.slice(at + 1);
2546
+ }
2547
+
2548
+ /**
2549
+ * Resolve what a navigation should do to the viewport.
2550
+ *
2551
+ * A recorded position wins outright: on Back/Forward the whole point is to land
2552
+ * where the user was, and `preserveScroll` is passed alongside it only as the
2553
+ * fallback for an entry with nothing recorded.
2554
+ */
2555
+ export function _scrollIntent(options: {
2556
+ preserveScroll?: boolean;
2557
+ restoreScroll?: readonly [number, number] | undefined;
2558
+ }): ScrollIntent {
2559
+ return options.restoreScroll ?? (options.preserveScroll ? "preserve" : "top");
2560
+ }
2561
+
2562
+ /** What {@link _applyScroll} will do, separated out so the decision is testable. */
2563
+ export type ScrollAction =
2564
+ | { kind: "none" }
2565
+ | { kind: "offset"; left: number; top: number }
2566
+ | { kind: "fragment"; id: string };
2567
+
2568
+ export function _scrollAction(intent: ScrollIntent, hash: string): ScrollAction {
2569
+ if (intent === "preserve") return { kind: "none" };
2570
+ if (intent !== "top") return { kind: "offset", left: intent[0], top: intent[1] };
2571
+
2572
+ // A fragment in the URL beats the top of the page — that is what the browser
2573
+ // would have done for the same link without the SPA swap.
2574
+ let id = hash;
2575
+ if (id) {
2576
+ try {
2577
+ id = decodeURIComponent(id);
2578
+ } catch {
2579
+ /* malformed %-escape — take the fragment literally, as browsers do */
2580
+ }
2581
+ }
2582
+ return id ? { kind: "fragment", id } : { kind: "offset", left: 0, top: 0 };
2583
+ }
2584
+
2585
+ function _applyScroll(intent: ScrollIntent, hash: string): void {
2586
+ const action = _scrollAction(intent, hash);
2587
+ if (action.kind === "none") return;
2588
+
2589
+ // `behavior: "instant"` on purpose: a page with `scroll-behavior: smooth` in
2590
+ // its CSS would otherwise animate the whole way up on every navigation, which
2591
+ // on a long page is a visible scroll-back the user did not ask for.
2592
+ if (action.kind === "offset") {
2593
+ window.scrollTo({ left: action.left, top: action.top, behavior: "instant" });
2594
+ return;
2595
+ }
2596
+
2597
+ const target = document.getElementById(action.id);
2598
+ // A fragment naming nothing on the page lands at the top, same as a real load.
2599
+ if (target) target.scrollIntoView({ behavior: "instant", block: "start" });
2600
+ else window.scrollTo({ left: 0, top: 0, behavior: "instant" });
2601
+ }
2602
+
2603
+ /** Options for {@link _navigateTo}. */
2604
+ interface NavigateOptions {
2605
+ /** Add a history entry. False on Back/Forward, where the entry already exists. */
2606
+ push?: boolean;
2607
+ /** Leave the viewport where it is instead of going to the top of the new page. */
2608
+ preserveScroll?: boolean;
2609
+ /** Offsets to restore, for Back/Forward. Takes precedence over `preserveScroll`. */
2610
+ restoreScroll?: readonly [number, number] | undefined;
2611
+ }
2612
+
2613
+ async function _navigateTo(href: string, options: NavigateOptions = {}): Promise<void> {
2614
+ const { push = true } = options;
2615
+ const scrollIntent = _scrollIntent(options);
2361
2616
  let html: string;
2362
2617
  const prefetched = _takePrefetched(href);
2363
2618
  if (prefetched !== null) {
@@ -2391,8 +2646,9 @@ async function _navigateTo(href: string, pushState = true): Promise<void> {
2391
2646
  // document.write() here would re-inject the classic runtime <script> into the
2392
2647
  // *current* realm and redeclare its top-level bindings (e.g. `class
2393
2648
  // FlowComponent` → "already declared"). A hard navigation also re-bootstraps
2394
- // the client cleanly for the new layout.
2395
- if (pushState) window.location.assign(href);
2649
+ // the client cleanly for the new layout — including the scroll position,
2650
+ // which the browser owns on a real document load.
2651
+ if (push) window.location.assign(href);
2396
2652
  else window.location.reload();
2397
2653
  return;
2398
2654
  }
@@ -2417,6 +2673,12 @@ async function _navigateTo(href: string, pushState = true): Promise<void> {
2417
2673
  const incomingClone = incomingRoot.cloneNode(true) as HTMLElement;
2418
2674
  _carryPersisted(currentRoot, incomingClone);
2419
2675
 
2676
+ // Where this page is sitting, banked before the swap. Replacing the root
2677
+ // changes the document height, and the browser clamps the scroll offset
2678
+ // against it — read afterwards, a position near the bottom of a long page
2679
+ // comes back as somewhere near the bottom of the short one.
2680
+ if (push) _rememberScroll();
2681
+
2420
2682
  // Swap state script + component root. Wrapped in a View Transition when the
2421
2683
  // browser supports it, for a smooth cross-page animation; falls back to an
2422
2684
  // instant swap otherwise.
@@ -2427,6 +2689,11 @@ async function _navigateTo(href: string, pushState = true): Promise<void> {
2427
2689
  _cleanupDisconnected();
2428
2690
  _scanComponents();
2429
2691
  _processHead(doc); // re-apply head from the incoming page (shell stylesheet + page meta)
2692
+ // Inside the swap, not after it: the document only has its new height (and
2693
+ // its fragment targets) once the root is in place. Inside the View
2694
+ // Transition callback this is also what the "after" snapshot captures, so
2695
+ // the animation ends at the right offset instead of sliding there.
2696
+ _applyScroll(scrollIntent, _hashOf(href));
2430
2697
  };
2431
2698
  const _startVT = (document as unknown as { startViewTransition?: (cb: () => void) => unknown })
2432
2699
  .startViewTransition;
@@ -2435,7 +2702,7 @@ async function _navigateTo(href: string, pushState = true): Promise<void> {
2435
2702
  if (typeof _startVT === "function" && !_reduceMotion) _startVT.call(document, _swap);
2436
2703
  else _swap(); // reduced-motion (or unsupported): swap instantly, no cross-page animation
2437
2704
 
2438
- if (pushState) history.pushState({ flowNavigate: true }, "", href);
2705
+ if (push) _pushHistoryEntry(href);
2439
2706
  const incomingTitle = doc.querySelector("title");
2440
2707
  if (incomingTitle) document.title = incomingTitle.textContent ?? "";
2441
2708
  _updateCurrentLinks();
@@ -2542,14 +2809,22 @@ function _setupPrefetch(): void {
2542
2809
  function _setupNavigationLinks(): void {
2543
2810
  _updateCurrentLinks();
2544
2811
  _setupPrefetch();
2812
+ _trackScroll();
2545
2813
 
2546
2814
  // Stamp the initial history entry as ours. A fresh page load has `history.state === null`,
2547
2815
  // so without this, pressing Back to the first page fires `popstate` with a null state — the
2548
2816
  // handler below ignores it and the URL changes while the page stays put. Replacing (not
2549
- // pushing) keeps the entry in place; it just gains the flowNavigate marker.
2550
- if (!(history.state as { flowNavigate?: boolean } | null)?.flowNavigate) {
2817
+ // pushing) keeps the entry in place; it just gains the flowNavigate marker and its scroll key.
2818
+ const initial = history.state as { flowNavigate?: boolean; flowKey?: number } | null;
2819
+ if (typeof initial?.flowKey === "number") {
2820
+ // Restored from bfcache or a reload — keep the key so the entry's recorded
2821
+ // position still matches, and mint above it so a new entry can't collide.
2822
+ _currentHistoryKey = initial.flowKey;
2823
+ _nextHistoryKey = Math.max(_nextHistoryKey, initial.flowKey + 1);
2824
+ } else {
2825
+ _currentHistoryKey = _nextHistoryKey++;
2551
2826
  history.replaceState(
2552
- { ...(history.state as object | null), flowNavigate: true },
2827
+ { ...(initial as object | null), flowNavigate: true, flowKey: _currentHistoryKey },
2553
2828
  "",
2554
2829
  location.href,
2555
2830
  );
@@ -2566,13 +2841,31 @@ function _setupNavigationLinks(): void {
2566
2841
  return;
2567
2842
  }
2568
2843
  e.preventDefault();
2569
- void _navigateTo(href);
2844
+ void _navigateTo(href, {
2845
+ preserveScroll: anchor.hasAttribute("flow:navigate.preserve"),
2846
+ });
2570
2847
  });
2571
2848
 
2572
2849
  window.addEventListener("popstate", (e) => {
2573
- if ((e.state as { flowNavigate?: boolean } | null)?.flowNavigate) {
2574
- void _navigateTo(window.location.pathname + window.location.search, false);
2575
- }
2850
+ const state = e.state as { flowNavigate?: boolean; flowKey?: number } | null;
2851
+ if (!state?.flowNavigate) return;
2852
+
2853
+ // Bank the outgoing entry before the key moves on, so Forward lands correctly
2854
+ // too — not just Back. This is the half that cannot be done from
2855
+ // `history.state`, which by now already describes the destination.
2856
+ _rememberScroll();
2857
+ const destination = state.flowKey;
2858
+ const restore = destination === undefined ? undefined : _scrollByHistoryKey.get(destination);
2859
+ if (destination !== undefined) _currentHistoryKey = destination;
2860
+
2861
+ void _navigateTo(location.pathname + location.search + location.hash, {
2862
+ push: false,
2863
+ restoreScroll: restore,
2864
+ // Nothing recorded for this entry — it predates the runtime, or a reload
2865
+ // cleared the map. Leave the viewport alone rather than jumping to the
2866
+ // top, which is not what Back does.
2867
+ preserveScroll: restore === undefined,
2868
+ });
2576
2869
  });
2577
2870
  }
2578
2871
 
@@ -2584,12 +2877,21 @@ function _setupNavigationLinks(): void {
2584
2877
  * Both read the live URL from window.location (kept in sync by flow:navigate).
2585
2878
  * Ideal for instant filters, e.g.:
2586
2879
  * <select @change="$flow.navigateCurrent({ query: { status: $event.target.value || null } })">
2880
+ *
2881
+ * Like any navigation this lands at the top of the page. For a control sitting
2882
+ * partway down — a filter row the user is looking at while they change it —
2883
+ * pass `preserveScroll: true` to leave the viewport alone:
2884
+ * $flow.navigateCurrent({ query: { status: … }, preserveScroll: true })
2587
2885
  */
2588
2886
  function _currentUrl(options: CurrentUrlOptions = {}): string {
2589
2887
  return buildUrlWithQuery(window.location.href, options);
2590
2888
  }
2591
- function _navigateCurrent(options: CurrentUrlOptions = {}): Promise<void> {
2592
- return _navigateTo(buildUrlWithQuery(window.location.href, options));
2889
+ function _navigateCurrent(
2890
+ options: CurrentUrlOptions & { preserveScroll?: boolean } = {},
2891
+ ): Promise<void> {
2892
+ return _navigateTo(buildUrlWithQuery(window.location.href, options), {
2893
+ preserveScroll: options.preserveScroll ?? false,
2894
+ });
2593
2895
  }
2594
2896
 
2595
2897
  const _debouncedModelSend = (() => {
@@ -3233,11 +3535,13 @@ function _isWritable(comp: FlowComponent, key: string): boolean {
3233
3535
  * Register $flow and $errors Alpine magic helpers.
3234
3536
  * Called from index.ts with the Alpine instance before Alpine.start().
3235
3537
  *
3236
- * $flow mirrors Livewire's $wire API:
3538
+ * The $flow client API:
3237
3539
  *
3238
3540
  * $flow.method(args) — call an @expose method
3239
3541
  * $flow.prop — read a reactive property
3240
- * $flow.prop = value — write locally (sent with next action)
3542
+ * $flow.prop = value — write; inside a client expression that dispatches no
3543
+ * action, the write syncs to the server afterwards so
3544
+ * render() reflects it (client-only state → $store)
3241
3545
  *
3242
3546
  * $flow.$refresh() — re-render the component
3243
3547
  * $flow.$commit() — alias for $refresh
@@ -3447,6 +3751,9 @@ export function registerFlowMagic(alpine: AlpineType): void {
3447
3751
  _syncDeclarative(comp);
3448
3752
  // flow:model inputs aren't Alpine-bound, so push the new value into them explicitly.
3449
3753
  _syncModelInputs(comp);
3754
+ // Inside a client expression, the write syncs to the server when the
3755
+ // expression dispatches no action of its own (see _beginExprEval).
3756
+ _noteExprWrite(key);
3450
3757
  return true;
3451
3758
  },
3452
3759
  });
@@ -901,7 +901,8 @@ export function flowCommand(config: { items: FlowCommandItem[]; hotkey?: string
901
901
  this.open = false;
902
902
  },
903
903
  toggle(this: AlpineCtx & CommandState) {
904
- this.open ? this.hide() : this.show();
904
+ if (this.open) this.hide();
905
+ else this.show();
905
906
  },
906
907
  move(this: AlpineCtx & CommandState, by: number) {
907
908
  const total = this.results().length;
@@ -22,6 +22,7 @@ export const BOOLEAN_DIRECTIVES: Record<string, string> = {
22
22
  hideOnDirty: "flow:dirty.remove",
23
23
  navigate: "flow:navigate",
24
24
  navigateHover: "flow:navigate.hover",
25
+ navigatePreserveScroll: "flow:navigate.preserve",
25
26
  currentExact: "flow:current.exact",
26
27
  currentStrict: "flow:current.strict",
27
28
  cloak: "flow:cloak",
@@ -1634,6 +1634,10 @@ function _compileRenderMethod(
1634
1634
  emitStatic(" flow:navigate.hover");
1635
1635
  continue;
1636
1636
  }
1637
+ if (ln === "preserveScroll" && attr.initializer === undefined) {
1638
+ emitStatic(" flow:navigate.preserve");
1639
+ continue;
1640
+ }
1637
1641
  if (ln === "current") {
1638
1642
  const ci = attr.initializer;
1639
1643
  // current={false} → opt out of the auto active class; current={true} → default (skip).