@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/index.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * inference helpers. Zero runtime cost.
9
9
  */
10
10
 
11
- export * from './src/component.d.ts';
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.51",
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;
@@ -0,0 +1,2 @@
1
+ export function escapeText(s: string): string;
2
+ export function escapeAttr(s: string): string;
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
- const piece = allValues ? allValues[mp.group[j]] : value;
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
- const v = value[i];
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;
@@ -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 because
50
- * WebKit binds a same-document entry's
51
- * back-forward gesture snapshot to the page state when the entry is
52
- * recorded, so recording it after the content swap makes an iOS
53
- * back-swipe preview the destination instead of the page being returned
54
- * to (#1406).
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 { performNavigation, performSubmission } from './navigator.js';
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
- if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
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
- performNavigation(href, false, frameId);
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
- performSubmission(url.href, method, body, frameId, form);
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'} [refresh] Same-URL in-place refresh (#1398). It
39
- * suppresses the `X-Webjs-Have` header and picks the swap tier; see
40
- * `refreshPage`.
41
- * @param {boolean} [noPrefetch] Never consume a speculative entry, whatever the
42
- * cache holds (#1407). Set by `loadFrame`: a `<webjs-frame src>` self-load or
43
- * `src` mutation asks for THIS frame's content now, which is a freshness
44
- * request rather than the click-follows-hover shape the warm cache serves.
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, refresh, noPrefetch) {
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 history entry this navigation records is what the iOS back-swipe
276
- // gesture later previews, and WebKit binds a same-document entry's snapshot to
277
- // the page state at the moment the entry is recorded. Recording it after the
278
- // swap finalizes it against the DESTINATION document at a scroll offset the
279
- // browser has already clamped to that document's height (measured on the
280
- // gallery: 1600 to 252), so the gesture previews a page that never existed and
281
- // renders blank. So the push rides into `applySwap` as a COMMIT-time callback
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
- // - performNavigation already set scroll from the cached
339
- // position; we must NOT clobber it here.
340
- // (b) a cache-miss popstate: modern browsers fire scroll-
341
- // restoration themselves before dispatching popstate, so
342
- // leaving scroll alone preserves the browser-native UX.
343
- if (recordHistory) {
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
- if (url.hash) {
348
- const t = document.getElementById(url.hash.slice(1));
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
- if (t) t.scrollIntoView();
353
- else { warnIfSmoothScrollOnHtml(); window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); }
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();