@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.
- package/CHANGELOG.md +317 -4
- package/README.md +1 -1
- package/package.json +8 -9
- package/src/Component.ts +214 -41
- package/src/browser/FlowBrowser.ts +270 -0
- package/src/browser/cdp.ts +273 -0
- package/src/browser/index.ts +19 -0
- package/src/client/FlowComponent.ts +1 -1
- package/src/client/bridge.ts +345 -38
- package/src/client/headless.ts +2 -1
- package/src/compiler/directives.ts +1 -0
- package/src/compiler/transform.ts +4 -0
- package/src/components.ts +286 -4
- package/src/config.ts +2 -2
- package/src/decorators.ts +122 -35
- package/src/dehydrate.ts +2 -2
- package/src/events.ts +1 -1
- package/src/index.ts +8 -2
- package/src/jsx-runtime.ts +90 -7
- package/src/pagination.ts +5 -7
- package/src/provider/FlowProvider.ts +46 -13
- package/src/router.ts +186 -116
- package/src/sections.ts +121 -0
- package/src/store.ts +0 -1
- package/src/streaming.ts +92 -0
- package/src/synths/index.ts +2 -2
- package/src/testing.ts +1 -1
- package/src/types.ts +1 -1
- package/src/uploads/FileUploads.ts +3 -4
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
package/src/client/bridge.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
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;
|
|
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{
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
{ ...(
|
|
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
|
-
|
|
2574
|
-
|
|
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(
|
|
2592
|
-
|
|
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
|
|
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
|
|
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
|
});
|
package/src/client/headless.ts
CHANGED
|
@@ -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
|
|
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).
|