@webjsdev/core 0.7.51 → 0.7.53
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/dist/webjs-core-browser.js +5 -5
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-testing.js +14 -14
- package/dist/webjs-core-testing.js.map +1 -1
- package/dist/webjs-core.js +7 -7
- package/dist/webjs-core.js.map +3 -3
- package/index.d.ts +2 -2
- package/package.json +1 -1
- package/src/css.d.ts +9 -0
- package/src/escape.d.ts +2 -0
- package/src/html.d.ts +9 -0
- package/src/render-client/parts.js +34 -2
- package/src/render-server/template-renderer.js +15 -0
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/constants.js +25 -6
- package/src/router-client/events.js +49 -6
- package/src/router-client/fetch-apply.js +71 -26
- package/src/router-client/navigator.js +272 -101
- package/src/router-client/prefetch.js +11 -4
- package/src/router-client/scroll.js +169 -120
- package/src/router-client/snapshot-cache.js +18 -2
- package/src/router-client/state.js +63 -0
- package/src/router-client/swap.js +7 -4
- package/src/router-client.d.ts +1 -1
- package/src/router-client.js +3 -0
- package/src/suspense.d.ts +9 -0
- package/src/websocket-client.d.ts +22 -0
package/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* inference helpers. Zero runtime cost.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
export * from './src/component.
|
|
11
|
+
export * from './src/component.js';
|
|
12
12
|
export type {
|
|
13
13
|
Metadata,
|
|
14
14
|
MetadataContext,
|
|
@@ -88,7 +88,7 @@ export { enableClientRouter, disableClientRouter, revalidate, refreshPage, loadF
|
|
|
88
88
|
// `string`, so this is non-breaking; once generated, a bogus in-app path is a
|
|
89
89
|
// tsserver error. The runtime is the same async function in router-client.js.
|
|
90
90
|
import type { Route } from './src/routes.d.ts';
|
|
91
|
-
export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void>;
|
|
91
|
+
export function navigate(url: Route, opts?: { replace?: boolean; scroll?: boolean }): Promise<void>;
|
|
92
92
|
// The full lit-html-parity directive set (mirrors index.js); the per-directive
|
|
93
93
|
// declarations live in src/directives.d.ts. `repeat` is re-exported above.
|
|
94
94
|
export {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.53",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
|
|
6
6
|
"types": "./index.d.ts",
|
package/src/css.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface CSSResult {
|
|
2
|
+
_$webjsCss: true;
|
|
3
|
+
text: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function css(strings: TemplateStringsArray | string[], ...values: unknown[]): CSSResult;
|
|
7
|
+
export function isCSS(x: unknown): x is CSSResult;
|
|
8
|
+
export function adoptStyles(root: ShadowRoot | Document, styles: CSSResult[]): void;
|
|
9
|
+
export function stylesToString(styles: CSSResult[]): string;
|
package/src/escape.d.ts
ADDED
package/src/html.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface TemplateResult {
|
|
2
|
+
_$webjs: 'template';
|
|
3
|
+
strings: TemplateStringsArray | string[];
|
|
4
|
+
values: unknown[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function html(strings: TemplateStringsArray | string[], ...values: unknown[]): TemplateResult;
|
|
8
|
+
export function isTemplate(x: unknown): x is TemplateResult;
|
|
9
|
+
export const MARKER: 'wjm-';
|
|
@@ -267,7 +267,15 @@ export function applyPart(part, value, _prev, allValues, reconcileFormActionsCb)
|
|
|
267
267
|
const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
|
|
268
268
|
let val = mp.statics[0];
|
|
269
269
|
for (let j = 0; j < mp.group.length; j++) {
|
|
270
|
-
|
|
270
|
+
let piece = allValues ? allValues[mp.group[j]] : value;
|
|
271
|
+
// Unwrap live() PER PIECE (#1443). The top-of-function unwrap only
|
|
272
|
+
// sees the anchor hole's own value, and every hole inside a QUOTED
|
|
273
|
+
// attribute lands here (single-hole included, the compiler classifies
|
|
274
|
+
// them all attr-mixed), so without this a `title="${live(v)}"` the
|
|
275
|
+
// server now emits correctly would be rewritten to the wrapper's
|
|
276
|
+
// stringification on the next client commit. Mirrors the reconciler's
|
|
277
|
+
// effectiveFormAttr, which already unwraps each group piece.
|
|
278
|
+
if (isLive(piece)) piece = /** @type any */ (piece).value;
|
|
271
279
|
// #1154: same function guard for each piece of a mixed attribute.
|
|
272
280
|
assertNotFunctionActionAttr(piece, part.name, part.el.localName);
|
|
273
281
|
val += String(piece ?? '');
|
|
@@ -487,6 +495,20 @@ export function applyChildInner(part, value, reconcileFormActionsCb) {
|
|
|
487
495
|
export function applyChildInnerRaw(part, value, reconcileFormActionsCb) {
|
|
488
496
|
const marker = part.marker;
|
|
489
497
|
|
|
498
|
+
// live() in a CHILD position (#1443). applyPart's top-of-function unwrap only
|
|
499
|
+
// sees the hole's own value, so a live() nested inside an ARRAY child arrives
|
|
500
|
+
// here still wrapped and would stringify to "[object Object]". The server
|
|
501
|
+
// recurses through isLive in both machines (template-renderer.js render() and
|
|
502
|
+
// streamRender()), so without this the renderers disagree and an SSR'd list
|
|
503
|
+
// built with live() per item corrupts on upgrade. A plain unwrap is the whole
|
|
504
|
+
// job here: live()'s dirty-check is attribute/property-shaped and has no
|
|
505
|
+
// meaning for a child, which is exactly why the server treats it the same way.
|
|
506
|
+
// Recursing first (rather than after the unsafeHTML branch) matches the
|
|
507
|
+
// server's outcome for a nested directive such as live(unsafeHTML(x)).
|
|
508
|
+
if (isLive(value)) {
|
|
509
|
+
return applyChildInnerRaw(part, /** @type any */ (value).value, reconcileFormActionsCb);
|
|
510
|
+
}
|
|
511
|
+
|
|
490
512
|
// unsafeHTML directive: inject raw HTML string as DOM nodes.
|
|
491
513
|
if (isUnsafeHTML(value)) {
|
|
492
514
|
teardownChild(part);
|
|
@@ -1096,6 +1118,9 @@ function removeArrayItem(item) {
|
|
|
1096
1118
|
* @returns {{ item: ArrayItem, frag: Node | null }}
|
|
1097
1119
|
*/
|
|
1098
1120
|
function buildArrayItem(v, reconcileFormActionsCb) {
|
|
1121
|
+
// #1443: unwrap live() per ITEM. applyPart unwraps the hole's own value, so
|
|
1122
|
+
// an array child arrives unwrapped at the top and its ITEMS do not.
|
|
1123
|
+
if (isLive(v)) v = /** @type any */ (v).value;
|
|
1099
1124
|
if (isTemplate(v)) {
|
|
1100
1125
|
const { inst, frag } = buildDetached(/** @type any */ (v), reconcileFormActionsCb);
|
|
1101
1126
|
return { item: { type: 'tpl', inst }, frag };
|
|
@@ -1153,7 +1178,11 @@ function reconcileArray(part, value, reconcileFormActionsCb) {
|
|
|
1153
1178
|
|
|
1154
1179
|
try {
|
|
1155
1180
|
for (let i = 0; i < value.length; i++) {
|
|
1156
|
-
|
|
1181
|
+
// #1443: same per-item unwrap as buildArrayItem, on the in-place update
|
|
1182
|
+
// path. Without it a re-render writes the wrapper's stringification into
|
|
1183
|
+
// the existing text node.
|
|
1184
|
+
const raw = value[i];
|
|
1185
|
+
const v = isLive(raw) ? /** @type any */ (raw).value : raw;
|
|
1157
1186
|
const o = old[i];
|
|
1158
1187
|
if (isTemplate(v)) {
|
|
1159
1188
|
const tr = /** @type any */ (v);
|
|
@@ -1955,6 +1984,9 @@ async function consumeAsyncStream(state, part, dir, reconcileFormActionsCb) {
|
|
|
1955
1984
|
* @returns {ChildNode[]}
|
|
1956
1985
|
*/
|
|
1957
1986
|
function renderToNodes(value, reconcileFormActionsCb) {
|
|
1987
|
+
// #1443: the streamed / detached counterpart of the per-item unwrap above.
|
|
1988
|
+
// Recursion covers a nested array, since each level re-enters here.
|
|
1989
|
+
if (isLive(value)) value = /** @type any */ (value).value;
|
|
1958
1990
|
if (value == null || value === false || value === true) return [];
|
|
1959
1991
|
if (isTemplate(value)) {
|
|
1960
1992
|
const tr = /** @type any */ (value);
|
|
@@ -384,6 +384,18 @@ export async function renderTemplate(tr, ctx) {
|
|
|
384
384
|
if (val && typeof /** @type any */ (val).then === 'function') {
|
|
385
385
|
val = await val;
|
|
386
386
|
}
|
|
387
|
+
// Unwrap live() once, here, before the position dispatch, exactly where
|
|
388
|
+
// the client's applyPart() unwraps it (render-client/parts.js). The
|
|
389
|
+
// directive's only job is to dirty-check against the LIVE DOM value, a
|
|
390
|
+
// client-only concern, so on the server it is transparent and every
|
|
391
|
+
// position must see the inner value. Doing it per-position instead let
|
|
392
|
+
// the two renderers drift: the wrapper is truthy, so a `?bool=${live(v)}`
|
|
393
|
+
// emitted its attribute whatever v was, and `attr=${live(v)}` stringified
|
|
394
|
+
// to `[object Object]` (#1443). render() / streamRender() keep their own
|
|
395
|
+
// isLive branch below for a live() nested inside an array child, which
|
|
396
|
+
// this hole-level unwrap never sees. AFTER the await: a hole may hold a
|
|
397
|
+
// promise that resolves TO a live().
|
|
398
|
+
if (isLive(val)) val = /** @type any */ (val).value;
|
|
387
399
|
if (state === 'comment') {
|
|
388
400
|
// Holes inside <!-- comments --> are emitted raw (no escaping; comments
|
|
389
401
|
// are inert and not rendered by browsers).
|
|
@@ -823,6 +835,9 @@ export async function streamTemplate(tr, ctx, controller) {
|
|
|
823
835
|
if (val && typeof /** @type any */ (val).then === 'function') {
|
|
824
836
|
val = await val;
|
|
825
837
|
}
|
|
838
|
+
// Same unwrap as the buffered machine above (#1443). The two machines
|
|
839
|
+
// must emit identical bytes, so this is not optional here.
|
|
840
|
+
if (isLive(val)) val = /** @type any */ (val).value;
|
|
826
841
|
if (state === 'comment') {
|
|
827
842
|
buf += String(val ?? '');
|
|
828
843
|
commentDashes = 0;
|
package/src/repeat.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The runtime value also carries a module-private `Symbol.for('webjs.repeat')`
|
|
2
|
+
// key, the marker the renderers check. It is deliberately absent here: it is not
|
|
3
|
+
// exported, so it cannot be named, and no consumer constructs one by hand.
|
|
4
|
+
export interface RepeatDirective<T> {
|
|
5
|
+
items: T[];
|
|
6
|
+
keyFn: (item: T, i: number) => string | number;
|
|
7
|
+
templateFn: (item: T, i: number) => unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function repeat<T>(
|
|
11
|
+
items: Iterable<T>,
|
|
12
|
+
keyFn: (item: T, i: number) => string | number,
|
|
13
|
+
templateFn: (item: T, i: number) => unknown,
|
|
14
|
+
): RepeatDirective<T>;
|
|
15
|
+
export function isRepeat(x: unknown): x is RepeatDirective<unknown>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// `body` is widened off `RequestInit` on purpose: richFetch also accepts a plain
|
|
2
|
+
// object, which it serializes with the WebJs wire format. `Omit` first, because
|
|
3
|
+
// an intersection would narrow the property back to `BodyInit | null`.
|
|
4
|
+
export function richFetch<T>(url: string | URL, init?: Omit<RequestInit, 'body'> & { body?: unknown }): Promise<T>;
|
|
@@ -46,12 +46,19 @@ export const STREAM_MIME = 'text/vnd.webjs-stream.html';
|
|
|
46
46
|
* 5. Commit, in this order: `history.pushState`, then merge head, then apply
|
|
47
47
|
* the replace/morph from step 3, then re-run scripts and upgrade custom
|
|
48
48
|
* elements. Only the push moved (#1406); the rest of the sequence is
|
|
49
|
-
* unchanged. The push leads
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
49
|
+
* unchanged. The push leads so the entry for the page being left is
|
|
50
|
+
* finalized while that page is still the live document, rather than
|
|
51
|
+
* against the content that replaced it. This is Turbo Drive's ordering
|
|
52
|
+
* too.
|
|
53
|
+
*
|
|
54
|
+
* #1406 justified the move as the fix for the blank iOS back-swipe
|
|
55
|
+
* preview, on the theory that WebKit binds the gesture snapshot to page
|
|
56
|
+
* state at the moment the entry is recorded. That theory is FALSE, shown
|
|
57
|
+
* on-device in #1428: Turbo Drive has this exact ordering and previews
|
|
58
|
+
* blank the same way, and the real cause was
|
|
59
|
+
* `history.scrollRestoration = 'manual'` suppressing the browser's
|
|
60
|
+
* per-entry scroll recording. The ordering stands on its own merits;
|
|
61
|
+
* it just never fixed that bug.
|
|
55
62
|
*
|
|
56
63
|
* Optimizations bundled into the same response cycle:
|
|
57
64
|
* - `X-Webjs-Have` request header lists `segment:route-key` entries for
|
|
@@ -185,3 +192,15 @@ export const META_KEY_CSP_NONCE = 'name=csp-nonce';
|
|
|
185
192
|
export function _isNonHtmlPath(pathname) {
|
|
186
193
|
return NON_HTML_EXTENSIONS.test(pathname);
|
|
187
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* How long the restore window's write-back stays armed (#1428).
|
|
198
|
+
*
|
|
199
|
+
* It exists to reconcile ONE event, the browser's replay of its own recorded
|
|
200
|
+
* scroll offset, which lands about a frame after the popstate handler. This is
|
|
201
|
+
* generous for that (roughly fifteen frames at 60Hz) and far short of the
|
|
202
|
+
* window's own life, so a legitimate programmatic scroll arriving later, a
|
|
203
|
+
* component's `scrollIntoView()` during upgrade or a find-in-page jump, is left
|
|
204
|
+
* alone rather than reverted.
|
|
205
|
+
*/
|
|
206
|
+
export const WRITE_BACK_ARMED_MS = 250;
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { findAnchorInPath } from './anchors.js';
|
|
10
10
|
import { NON_HTML_EXTENSIONS } from './constants.js';
|
|
11
|
-
import { enabled } from './state.js';
|
|
11
|
+
import { enabled, markFragmentNav } from './state.js';
|
|
12
12
|
import { warnIfActionSubmissionCannotDeliver } from './diagnostics.js';
|
|
13
13
|
import { buildSubmitFormData, encodeSubmitBody, getSubmitAction, getSubmitEnctype, getSubmitMethod } from './form-encoder.js';
|
|
14
14
|
import { resolveTargetFrameId } from './frames.js';
|
|
15
|
-
import {
|
|
15
|
+
import { resolvePreserveScroll } from './scroll.js';
|
|
16
|
+
import { absorbFragmentClickPopState, performNavigation, performSubmission } from './navigator.js';
|
|
16
17
|
|
|
17
18
|
/** @param {MouseEvent} e */
|
|
18
19
|
export function onClick(e) {
|
|
@@ -23,7 +24,6 @@ export function onClick(e) {
|
|
|
23
24
|
const anchor = findAnchorInPath(e);
|
|
24
25
|
if (!anchor) return;
|
|
25
26
|
if (anchor.hasAttribute('download')) return;
|
|
26
|
-
if (anchor.hasAttribute('data-no-router')) return;
|
|
27
27
|
if (anchor.target && anchor.target !== '_self') return;
|
|
28
28
|
|
|
29
29
|
const href = anchor.href;
|
|
@@ -31,7 +31,31 @@ export function onClick(e) {
|
|
|
31
31
|
|
|
32
32
|
const url = new URL(href);
|
|
33
33
|
if (url.origin !== location.origin) return;
|
|
34
|
-
|
|
34
|
+
// `href.includes('#')` rather than `url.hash`, because the URL serializer
|
|
35
|
+
// reports BOTH a null fragment and an empty one as `''`, and only the second
|
|
36
|
+
// is a fragment navigation. `href="#"` keeps its `#` in `href` and per the
|
|
37
|
+
// spec navigates to the document element (the back-to-top idiom), while
|
|
38
|
+
// `href=""` resolves to the current url with the fragment REMOVED, which the
|
|
39
|
+
// spec reloads rather than jumping, so it must stay a router navigation. A
|
|
40
|
+
// `#` cannot appear anywhere else in a serialized url: the parser encodes it
|
|
41
|
+
// in the path and starts the fragment at it in the query (#1437).
|
|
42
|
+
if (url.pathname === location.pathname && url.search === location.search && url.href.includes('#')) {
|
|
43
|
+
// Leave a mark for the popstate this jump is about to fire. A REPEAT click
|
|
44
|
+
// replaces its history entry rather than pushing, so that popstate arrives
|
|
45
|
+
// with an unchanged url and is indistinguishable by comparison from a real
|
|
46
|
+
// Back between two entries that share one. Provenance is the only thing
|
|
47
|
+
// that separates them, and this is where the router has it (#1437).
|
|
48
|
+
markFragmentNav(url.href);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
// Checked AFTER the fragment bow-out on purpose. `data-no-router` opts out of
|
|
52
|
+
// ROUTING, and the bow-out above routes nothing either way, but the browser
|
|
53
|
+
// still performs the native jump and still fires the popstate that has to be
|
|
54
|
+
// recognised. Returning here first would leave EVERY click of a
|
|
55
|
+
// `data-no-router` in-page anchor unmarked, first and repeat alike, since the
|
|
56
|
+
// mark is the only thing that absorbs one, so each would be re-navigated
|
|
57
|
+
// destructively (#1437).
|
|
58
|
+
if (anchor.hasAttribute('data-no-router')) return;
|
|
35
59
|
if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
|
|
36
60
|
|
|
37
61
|
e.preventDefault();
|
|
@@ -40,11 +64,23 @@ export function onClick(e) {
|
|
|
40
64
|
// external sidebar/nav link), `_top` breaks out to a full-page nav, and
|
|
41
65
|
// absence falls back to the closest enclosing frame (today's default).
|
|
42
66
|
const frameId = resolveTargetFrameId(anchor);
|
|
43
|
-
|
|
67
|
+
// #1436: `data-preserve-scroll` on the anchor or an ancestor keeps the reader
|
|
68
|
+
// where they are instead of scrolling to top. Read here, beside the other
|
|
69
|
+
// per-link opt-outs, and carried on the navigation's opts bag. Inert on a
|
|
70
|
+
// frame-targeted link, which already writes no scroll (#1427).
|
|
71
|
+
performNavigation(href, false, frameId, { preserveScroll: resolvePreserveScroll(anchor) });
|
|
44
72
|
}
|
|
45
73
|
|
|
46
74
|
/** @param {PopStateEvent} _e */
|
|
47
75
|
export function onPopState(_e) {
|
|
76
|
+
// The popstate an in-page fragment CLICK produces is not a navigation: the
|
|
77
|
+
// browser has already done the jump, so re-fetching and re-swapping would
|
|
78
|
+
// destroy live DOM identity and undo it (#1437). Absorbed only when the
|
|
79
|
+
// router MARKED that click on its way out (the bow-out above); a popstate
|
|
80
|
+
// with no mark behind it is a traversal and stays on the normal path,
|
|
81
|
+
// whatever its url. The callee's docstring has the full reasoning, including
|
|
82
|
+
// why no url comparison can stand in for the mark.
|
|
83
|
+
if (absorbFragmentClickPopState(location.href)) return;
|
|
48
84
|
// popstate has no DOM anchor, so no frame context: restore via cache or
|
|
49
85
|
// refetch the whole document.
|
|
50
86
|
performNavigation(location.href, true, null);
|
|
@@ -134,6 +170,13 @@ export function onSubmit(e) {
|
|
|
134
170
|
// an explicit `data-webjs-frame` on (or above) the form or its submitter
|
|
135
171
|
// wins, `_top` breaks out, absence falls back to the enclosing frame.
|
|
136
172
|
const frameId = resolveTargetFrameId(submitter || form);
|
|
137
|
-
|
|
173
|
+
// Same trigger precedence as the frame line above. The form is passed as a
|
|
174
|
+
// FALLBACK rather than relied on through the submitter's ancestors, because a
|
|
175
|
+
// submitter is form-associated by `form="id"` and may sit outside the form
|
|
176
|
+
// entirely, where `closest()` would never reach it. The trigger still wins
|
|
177
|
+
// when it resolves a carrier, so `data-preserve-scroll="false"` on a button
|
|
178
|
+
// inside a marked form still opts that button out.
|
|
179
|
+
const preserveScroll = resolvePreserveScroll(submitter || form, form);
|
|
180
|
+
performSubmission(url.href, method, body, frameId, form, { preserveScroll });
|
|
138
181
|
}
|
|
139
182
|
|
|
@@ -35,13 +35,23 @@ import { _swapCommit, applySwap } from './swap.js';
|
|
|
35
35
|
* @param {boolean} [revalidating] True for the BACKGROUND refresh after a
|
|
36
36
|
* snapshot restore: the user is already viewing a page, so a boundary
|
|
37
37
|
* mismatch must degrade in place (never a jarring `location.href` load).
|
|
38
|
-
* @param {'page' | 'shell'
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
38
|
+
* @param {{ refresh?: 'page' | 'shell', noPrefetch?: boolean, preserveScroll?: boolean }} [opts]
|
|
39
|
+
* Per-navigation POLICY, as a bag rather than three more positionals. The
|
|
40
|
+
* nine parameters above are request inputs; these three decide what the
|
|
41
|
+
* pipeline does with the response, and the list was already at eleven.
|
|
42
|
+
*
|
|
43
|
+
* `refresh` is a same-URL in-place refresh (#1398). It suppresses the
|
|
44
|
+
* `X-Webjs-Have` header and picks the swap tier; see `refreshPage`.
|
|
45
|
+
*
|
|
46
|
+
* `noPrefetch` never consumes a speculative entry, whatever the cache holds
|
|
47
|
+
* (#1407). Set by `loadFrame`: a `<webjs-frame src>` self-load or `src`
|
|
48
|
+
* mutation asks for THIS frame's content now, which is a freshness request
|
|
49
|
+
* rather than the click-follows-hover shape the warm cache serves.
|
|
50
|
+
*
|
|
51
|
+
* `preserveScroll` keeps the reader's current offset instead of scrolling to
|
|
52
|
+
* top on a forward navigation (#1436), from `data-preserve-scroll` on the
|
|
53
|
+
* link or form, or from `navigate(url, { scroll: false })`. It suppresses only
|
|
54
|
+
* the scroll-to-top writes, never the hash-anchor scroll.
|
|
45
55
|
* @returns {Promise<{ ok: boolean, status: number | null, aborted: boolean, applied: boolean }>}
|
|
46
56
|
* The fetch outcome, so a caller (the form-submission busy/event lifecycle)
|
|
47
57
|
* can report whether the submission settled as a success, an error, or an
|
|
@@ -62,8 +72,11 @@ import { _swapCommit, applySwap } from './swap.js';
|
|
|
62
72
|
* boundary scan). A caller deciding whether to fall back to a full page load
|
|
63
73
|
* wants `applied`; one reporting the submission's success wants `ok`.
|
|
64
74
|
*/
|
|
65
|
-
export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating,
|
|
75
|
+
export async function fetchAndApply(href, frameId, recordHistory, optimisticState, method, body, signal, token, revalidating, opts) {
|
|
66
76
|
method = method || 'GET';
|
|
77
|
+
const refresh = (opts && opts.refresh) || undefined;
|
|
78
|
+
const noPrefetch = !!(opts && opts.noPrefetch);
|
|
79
|
+
const preserveScroll = !!(opts && opts.preserveScroll);
|
|
67
80
|
const myToken = typeof token === 'number' ? token : currentNavigationToken;
|
|
68
81
|
let html;
|
|
69
82
|
// Set when the response streams Suspense boundaries (#473): holds the open
|
|
@@ -272,13 +285,18 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
272
285
|
// recover in place rather than a destructive full reload.
|
|
273
286
|
if (!doc) { restoreOptimistic(optimisticState); handleNavigationError(href, null, new Error('navigation response did not parse as HTML')); return { ok: false, status: respStatus, aborted: false, applied: false }; }
|
|
274
287
|
|
|
275
|
-
// #1406: the
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
288
|
+
// #1406: recording the push after the swap finalizes the outgoing page's
|
|
289
|
+
// entry against the DESTINATION document, at a scroll offset the browser has
|
|
290
|
+
// already clamped to that document's height (measured on the gallery: 1600 to
|
|
291
|
+
// 252). Ordering it ahead of the mutation keeps the entry tied to the page it
|
|
292
|
+
// belongs to.
|
|
293
|
+
//
|
|
294
|
+
// #1406 also claimed this fixed the blank iOS back-swipe preview, via WebKit
|
|
295
|
+
// binding the gesture snapshot at the moment the entry is recorded. That is
|
|
296
|
+
// FALSE (#1428, on-device): Turbo Drive uses this same ordering and previews
|
|
297
|
+
// blank too, and the cause was `history.scrollRestoration = 'manual'`
|
|
298
|
+
// suppressing per-entry scroll recording. The ordering is kept on its own
|
|
299
|
+
// merits. So the push rides into `applySwap` as a COMMIT-time callback
|
|
282
300
|
// and fires ahead of the mutation, which is Turbo Drive's ordering
|
|
283
301
|
// (`PageView.renderPage` calls `visit.changeHistory()` ahead of
|
|
284
302
|
// `this.render(renderer)`).
|
|
@@ -335,23 +353,50 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
|
|
|
335
353
|
// Scroll only for foreground (history-recording) navigations. When
|
|
336
354
|
// `recordHistory` is false we're either:
|
|
337
355
|
// (a) the background revalidation after a cached popstate restore
|
|
338
|
-
// -
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// leaving scroll alone preserves the
|
|
343
|
-
|
|
356
|
+
// - the restore is already settled (the browser replayed the
|
|
357
|
+
// offset and the restore window is guarding it); a write here
|
|
358
|
+
// would land on top of it.
|
|
359
|
+
// (b) a cache-miss popstate: the browser performs its own restore
|
|
360
|
+
// for the entry, so leaving scroll alone preserves the
|
|
361
|
+
// browser-native UX. Note the UA's replay lands a frame AFTER
|
|
362
|
+
// the popstate handler, not before it (#1428, measured), which
|
|
363
|
+
// is the ordering the restore window is built around.
|
|
364
|
+
//
|
|
365
|
+
// And never for a FRAME-scoped response (#1427). `recordHistory` means "a
|
|
366
|
+
// foreground navigation the reader initiated", which a frame click is (it
|
|
367
|
+
// advances the URL, deliberately), so a frame swap used to fall into the
|
|
368
|
+
// page-navigation scroll by omission rather than by decision. A frame swaps
|
|
369
|
+
// ONE region and leaves the rest of the document standing, the scroll offset
|
|
370
|
+
// included, so the router writes no scroll for it: on a page whose frame sits
|
|
371
|
+
// below the fold, scrolling to top throws the region the reader just clicked
|
|
372
|
+
// in off screen. Turbo, which `<webjs-frame>` is modelled on, likewise never
|
|
373
|
+
// scrolls on a frame navigation (its `autoscroll` opt-in is a separate
|
|
374
|
+
// feature WebJs does not have). This is the same rule `restoreGeneration` in
|
|
375
|
+
// `scroll.js` already applies when deciding what ends a scroll-restore
|
|
376
|
+
// window, so the two now agree on what a frame nav is.
|
|
377
|
+
//
|
|
378
|
+
// The hash branch is excluded too. A `#anchor` on a frame link is no more a
|
|
379
|
+
// request to move the document viewport than the frame swap itself is, and
|
|
380
|
+
// one rule ("a frame swap never moves the window") beats two. `_top` and an
|
|
381
|
+
// unresolvable `data-webjs-frame` id both resolve to a null `frameId` in
|
|
382
|
+
// `resolveTargetFrameId`, so they stay page navigations and still scroll.
|
|
383
|
+
// `preserveScroll` (#1436) suppresses the scroll-to-TOP writes and nothing
|
|
384
|
+
// else. A hash anchor still wins, because the reader named a target and a
|
|
385
|
+
// named target beats a blanket preference; that is the one arm below that is
|
|
386
|
+
// not guarded. The arm where the hash names an element the response does not
|
|
387
|
+
// contain scrolls to top today and is guarded with the rest, so "preserve"
|
|
388
|
+
// means one thing on every path.
|
|
389
|
+
if (recordHistory && !frameId) {
|
|
344
390
|
// Use the final URL (after any server-side redirect) so hash
|
|
345
391
|
// anchors point at the document we actually rendered.
|
|
346
392
|
const url = new URL(finalUrl);
|
|
347
|
-
|
|
348
|
-
|
|
393
|
+
const target = url.hash ? document.getElementById(url.hash.slice(1)) : null;
|
|
394
|
+
if (target) {
|
|
349
395
|
// A hash anchor is the one nav scroll we DON'T force instant: a
|
|
350
396
|
// `#section` link is exactly where an app's `scroll-behavior: smooth`
|
|
351
397
|
// is wanted, and native browsers animate it too.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
} else {
|
|
398
|
+
target.scrollIntoView();
|
|
399
|
+
} else if (!preserveScroll) {
|
|
355
400
|
// Scroll-to-top on a forward nav. behavior:'instant' so an app-level
|
|
356
401
|
// `scroll-behavior: smooth` does not animate it (match native nav).
|
|
357
402
|
warnIfSmoothScrollOnHtml();
|