@webjsdev/core 0.7.22 → 0.7.24

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-browser.js CHANGED
@@ -38,6 +38,15 @@ export {
38
38
  stringify, parse,
39
39
  serialize, deserialize,
40
40
  } from './src/serialize.js';
41
+ // The client router auto-enables on bundle load: `router-client.js` calls
42
+ // `enableClientRouter()` at module end (idempotent). This is INTENTIONAL, so
43
+ // any page that loads `@webjsdev/core` (i.e. ships any component) gets client
44
+ // navigation with no per-app opt-in. A layout no longer needs to
45
+ // `import '@webjsdev/core/client-router'`. The bare side-effect import below
46
+ // pins that contract even if the named re-export is later refactored away;
47
+ // the re-export still also evaluates the module, so the two are belt-and-
48
+ // suspenders, not a double-enable (ESM evaluates once, enable is idempotent).
49
+ import './src/router-client.js';
41
50
  export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
42
51
  export { WebjsFrame } from './src/webjs-frame.js';
43
52
  export { WebjsStream, renderStream } from './src/webjs-stream.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.22",
3
+ "version": "0.7.24",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -146,10 +146,14 @@ export type InferProps<S> = {
146
146
  [K in keyof S]: S[K] extends PropertyDeclaration<infer T> ? T : Infer<S[K]>
147
147
  };
148
148
 
149
- export interface WebComponentConstructor {
150
- new (): WebComponentBase;
151
- prototype: WebComponentBase;
152
-
149
+ /**
150
+ * The static surface every `WebComponent` constructor carries (the bare base,
151
+ * a factory-produced base, and a registered subclass alike). Declared ONCE
152
+ * here and referenced from both `WebComponentConstructor` and the factory-call
153
+ * return type below, so adding or changing a static can never let the two
154
+ * positions drift. Mirrors the `static` members on `WebComponentBase`.
155
+ */
156
+ interface WebComponentStatics {
153
157
  shadow: boolean;
154
158
  hydrate: 'visible' | undefined;
155
159
  properties: Record<string, PropertyDeclaration>;
@@ -157,18 +161,15 @@ export interface WebComponentConstructor {
157
161
  lazy?: boolean;
158
162
  register(tag: string): void;
159
163
  readonly observedAttributes: string[];
164
+ }
160
165
 
161
- <S extends Record<string, any>>(shape: S): {
166
+ export interface WebComponentConstructor extends WebComponentStatics {
167
+ new (): WebComponentBase;
168
+ prototype: WebComponentBase;
169
+
170
+ <S extends Record<string, any>>(shape: S): WebComponentStatics & {
162
171
  new (): WebComponentBase & InferProps<S>;
163
172
  prototype: WebComponentBase & InferProps<S>;
164
-
165
- shadow: boolean;
166
- hydrate: 'visible' | undefined;
167
- properties: Record<string, PropertyDeclaration>;
168
- styles: CSSResult | CSSResult[] | null;
169
- lazy?: boolean;
170
- register(tag: string): void;
171
- readonly observedAttributes: string[];
172
173
  };
173
174
  }
174
175
 
@@ -27,11 +27,11 @@ const STREAM_MIME = 'text/vnd.webjs-stream.html';
27
27
  * children of that boundary. Outer layout DOM (header, sidenav, footer)
28
28
  * stays mounted: no re-render, no flicker, scroll positions preserved.
29
29
  *
30
- * To enable, import this module from a layout or boot script:
31
- *
32
- * import '@webjsdev/core/client-router';
33
- *
34
- * Or call `enableClientRouter()` for programmatic control.
30
+ * Enablement is automatic: this module calls `enableClientRouter()` at its
31
+ * end (idempotent), and the `@webjsdev/core` browser entry loads it, so any
32
+ * page that ships a component gets the router with no import to add. Call
33
+ * `disableClientRouter()` to opt out, or `enableClientRouter()` for
34
+ * programmatic control.
35
35
  *
36
36
  * Mechanism: auto-derived from folder structure:
37
37
  * 1. SSR injects `<!--wj:children:<segment-path>-->...<!--/wj:children-->`
@@ -606,6 +606,64 @@ function warnOnce(key, message) {
606
606
  if (typeof console !== 'undefined' && console.warn) console.warn(message);
607
607
  }
608
608
 
609
+ /**
610
+ * Dev-only, fire-once hint: the router forces an INSTANT scroll-to-top on a
611
+ * forward navigation (matching a native page load), so an app-level
612
+ * `scroll-behavior: smooth` on <html> does not affect route transitions (it
613
+ * still applies to in-page #anchor links via `scrollIntoView`). A developer
614
+ * who set smooth expecting smooth nav scrolling would otherwise be puzzled.
615
+ * Also flags the iOS sticky-`backdrop-filter` flash this combination can
616
+ * cause (#610). Never warns in production, never throws.
617
+ *
618
+ * The `smoothScrollChecked` flag gates the `getComputedStyle` read (a forced
619
+ * style flush) to AT MOST ONCE per page, so a dev session does not pay a
620
+ * per-navigation reflow after the first forward nav.
621
+ */
622
+ let smoothScrollChecked = false;
623
+ function warnIfSmoothScrollOnHtml() {
624
+ if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
625
+ if (smoothScrollChecked) return;
626
+ if (typeof document === 'undefined' || typeof getComputedStyle !== 'function') return;
627
+ const root = document.documentElement;
628
+ if (!root) return;
629
+ smoothScrollChecked = true;
630
+ let behavior;
631
+ try { behavior = getComputedStyle(root).scrollBehavior; } catch { return; }
632
+ if (behavior !== 'smooth') return;
633
+ warnOnce(
634
+ 'scroll-behavior-smooth-html',
635
+ '[webjs] Detected `scroll-behavior: smooth` on <html>. The client router scrolls ' +
636
+ 'to the top instantly on navigation (like a native page load), so route transitions ' +
637
+ 'are not affected by it. It still applies to in-page #anchor links. Pairing it with a ' +
638
+ 'sticky `backdrop-filter` header can also flash on iOS during navigation.'
639
+ );
640
+ }
641
+
642
+ /**
643
+ * Nav-in-flight signalling. The router can expose `data-navigating` on <html>
644
+ * so an app may style a loading indicator with `html[data-navigating] { … }`.
645
+ *
646
+ * This is OPT-IN, set only when the app marks `<html data-webjs-nav-progress>`.
647
+ * The reason it is not unconditional: toggling ANY attribute on the root
648
+ * re-runs global style resolution, and on WebKit (so every iOS browser, since
649
+ * they all use it) that re-resolves `oklch()` / `color-mix(in oklch, …)` token
650
+ * values to an equivalent oklab representation and repaints them for one frame.
651
+ * On a token-driven theme that is a visible background flash on navigation
652
+ * (#610). The flash only shows on a nav slow enough to reach the deferred set
653
+ * below, which a desktop nav rarely is but a mobile forward fetch routinely is,
654
+ * so the symptom is iOS-and-forward-only. With no opt-in the attribute is never
655
+ * written, so the re-resolution never happens and the flash cannot occur.
656
+ */
657
+ function setNavigating(on) {
658
+ if (typeof document === 'undefined') return;
659
+ const root = document.documentElement;
660
+ if (!root || !root.hasAttribute('data-webjs-nav-progress')) return;
661
+ try {
662
+ if (on) root.setAttribute('data-navigating', '');
663
+ else root.removeAttribute('data-navigating');
664
+ } catch { /* non-DOM environment */ }
665
+ }
666
+
609
667
  /* ====================================================================
610
668
  * Marker discovery (the heart of the partial-swap mechanism)
611
669
  * ==================================================================== */
@@ -772,17 +830,11 @@ async function performNavigation(href, isPopState, frameId) {
772
830
  // about to read in the popstate-restore branch below.
773
831
  if (currentPageUrl) snapshotCurrent(currentPageUrl);
774
832
 
775
- // Show a subtle loading indicator, but only if the nav takes long
776
- // enough to be worth showing one. Setting an attribute on <html>
777
- // invalidates global style computation: which forces CSS like
778
- // `color-mix(in oklch, …)` to re-resolve. For values that use
779
- // wide-gamut color spaces the re-resolution can switch between
780
- // equivalent representations (oklch ↔ oklab) and fire any
781
- // `transition` rules listening on that property, producing a
782
- // visible flash on every nav. Defer the attribute set so quick
783
- // navs (sub-150ms) never set it at all.
833
+ // Expose the opt-in `data-navigating` loading-indicator hook (see
834
+ // setNavigating), but only if the nav takes long enough to be worth showing
835
+ // one. Deferred so quick navs (sub-150ms) never set it at all.
784
836
  let navigatingFlagTimer = setTimeout(() => {
785
- document.documentElement.setAttribute('data-navigating', '');
837
+ setNavigating(true);
786
838
  navigatingFlagTimer = null;
787
839
  }, 150);
788
840
 
@@ -801,9 +853,11 @@ async function performNavigation(href, isPopState, frameId) {
801
853
  const cachedDoc = parseHTML(cached.html);
802
854
  if (cachedDoc) {
803
855
  applySwap(cachedDoc, frameId, /* revalidating */ true, /* href */ null);
804
- // Restore window scroll to where the user left it.
856
+ // Restore window scroll to where the user left it. Use
857
+ // behavior:'instant' so an app-level `scroll-behavior: smooth`
858
+ // stylesheet does not animate the restore (native nav jumps).
805
859
  if (typeof window !== 'undefined') {
806
- window.scrollTo(cached.scrollX, cached.scrollY);
860
+ window.scrollTo({ left: cached.scrollX, top: cached.scrollY, behavior: 'instant' });
807
861
  }
808
862
  // Fire-and-forget revalidation. Uses a fresh AbortController
809
863
  // since this background fetch is allowed to overlap with the
@@ -819,7 +873,7 @@ async function performNavigation(href, isPopState, frameId) {
819
873
  // on the page they popped FROM. Scroll to top as the reasonable
820
874
  // default; fetchAndApply skips its own scroll handling when
821
875
  // recordHistory=false (which is the case here).
822
- if (typeof window !== 'undefined') window.scrollTo(0, 0);
876
+ if (typeof window !== 'undefined') window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
823
877
  }
824
878
 
825
879
  await fetchAndApply(href, frameId, !isPopState, optimisticState, 'GET', null, signal, myToken);
@@ -828,7 +882,7 @@ async function performNavigation(href, isPopState, frameId) {
828
882
  // Only clear the navigating flag if WE are still the active nav.
829
883
  // A newer nav has its own flag lifecycle.
830
884
  if (myToken === currentNavigationToken) {
831
- document.documentElement.removeAttribute('data-navigating');
885
+ setNavigating(false);
832
886
  // Record where the user is NOW so the next navigation can
833
887
  // snapshot under the right URL key.
834
888
  if (typeof location !== 'undefined') currentPageUrl = location.href;
@@ -895,7 +949,7 @@ async function performSubmission(href, method, body, frameId, form) {
895
949
  if (currentPageUrl) snapshotCurrent(currentPageUrl);
896
950
 
897
951
  let navigatingFlagTimer = setTimeout(() => {
898
- document.documentElement.setAttribute('data-navigating', '');
952
+ setNavigating(true);
899
953
  navigatingFlagTimer = null;
900
954
  }, 150);
901
955
 
@@ -935,7 +989,7 @@ async function performSubmission(href, method, body, frameId, form) {
935
989
  if (busyForm) clearFormBusy(busyForm, myToken, url.href, outcomeOk);
936
990
  if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
937
991
  if (myToken === currentNavigationToken) {
938
- document.documentElement.removeAttribute('data-navigating');
992
+ setNavigating(false);
939
993
  if (typeof location !== 'undefined') currentPageUrl = location.href;
940
994
  }
941
995
  }
@@ -1719,10 +1773,16 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1719
1773
  const url = new URL(finalUrl);
1720
1774
  if (url.hash) {
1721
1775
  const t = document.getElementById(url.hash.slice(1));
1776
+ // A hash anchor is the one nav scroll we DON'T force instant: a
1777
+ // `#section` link is exactly where an app's `scroll-behavior: smooth`
1778
+ // is wanted, and native browsers animate it too.
1722
1779
  if (t) t.scrollIntoView();
1723
- else window.scrollTo(0, 0);
1780
+ else { warnIfSmoothScrollOnHtml(); window.scrollTo({ left: 0, top: 0, behavior: 'instant' }); }
1724
1781
  } else {
1725
- window.scrollTo(0, 0);
1782
+ // Scroll-to-top on a forward nav. behavior:'instant' so an app-level
1783
+ // `scroll-behavior: smooth` does not animate it (match native nav).
1784
+ warnIfSmoothScrollOnHtml();
1785
+ window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
1726
1786
  }
1727
1787
  }
1728
1788
 
@@ -3199,6 +3259,8 @@ export function _bumpNavToken() { return ++currentNavigationToken; }
3199
3259
  export function _currentPageUrl() { return currentPageUrl; }
3200
3260
  /** Test-only: set the tracker (simulates being on a specific page). */
3201
3261
  export function _setCurrentPageUrl(u) { currentPageUrl = u; }
3262
+ /** Test-only: clear the fire-once warning guards so a case can be re-exercised. */
3263
+ export function _resetWarnOnce() { warnedKeys.clear(); smoothScrollChecked = false; }
3202
3264
 
3203
3265
  /**
3204
3266
  * Predicate used by the onClick handler to decide whether a same-origin
@@ -3211,7 +3273,13 @@ export function _isNonHtmlPath(pathname) {
3211
3273
  return NON_HTML_EXTENSIONS.test(pathname);
3212
3274
  }
3213
3275
 
3214
- // Auto-enable on import (standard Turbo-Drive convention). Placed last so
3215
- // every top-level binding the router touches (notably the prefetch state)
3216
- // is initialised before enableClientRouter() runs.
3217
- enableClientRouter();
3276
+ // Auto-enable on import (standard Turbo-Drive convention) UNLESS the app opted
3277
+ // out with `webjs.clientRouter: false` (#629), which the server signals by
3278
+ // setting `window.__WEBJS_CLIENT_ROUTER__ = false` in an inline script emitted
3279
+ // BEFORE this (deferred) bundle runs. On the server `window` is undefined, so
3280
+ // the call still runs and no-ops behind its own `typeof document` guard, as
3281
+ // before. Placed last so every top-level binding the router touches (notably
3282
+ // the prefetch state) is initialised before enableClientRouter() runs.
3283
+ if (typeof window === 'undefined' || window.__WEBJS_CLIENT_ROUTER__ !== false) {
3284
+ enableClientRouter();
3285
+ }
@@ -15,7 +15,8 @@
15
15
  * LOCKSTEP: this file, the JSON Schema at
16
16
  * packages/server/webjs-config.schema.json, and the server reader
17
17
  * functions MUST stay in sync. The readers are: readElideEnabled
18
- * (dev.js, elide), compileHeaderRules (headers.js, headers),
18
+ * (dev.js, elide), readSeedEnabled (dev.js, seed), readClientRouterEnabled
19
+ * (dev.js, clientRouter), compileHeaderRules (headers.js, headers),
19
20
  * compileRedirectRules / readTrailingSlashPolicy (redirects.js,
20
21
  * redirects / trailingSlash), readBasePath (base-path.js, basePath),
21
22
  * readCspConfig (csp.js, csp), and
@@ -144,6 +145,15 @@ export interface WebjsConfig {
144
145
  */
145
146
  seed?: boolean;
146
147
 
148
+ /**
149
+ * Client-router switch (#629). Default `true`: the client router auto-enables
150
+ * in the browser whenever `@webjsdev/core` loads, so any page that ships a
151
+ * component gets SPA-style navigation with no import. Set to `false` to opt
152
+ * the whole app out (pure MPA, full-page navigation; `disableClientRouter()`
153
+ * stays the programmatic escape hatch).
154
+ */
155
+ clientRouter?: boolean;
156
+
147
157
  /**
148
158
  * Dev/start task orchestration (#550). `webjs dev` / `webjs start` run these
149
159
  * so a bare CLI invocation matches `npm run dev` / `start`. Read by the CLI
@@ -178,6 +188,16 @@ export interface WebjsConfig {
178
188
  */
179
189
  basePath?: string;
180
190
 
191
+ /**
192
+ * Cross-origin hosts (or full origins) the action CSRF check accepts even
193
+ * when the request is cross-site. The action endpoint defends against CSRF
194
+ * with a `Sec-Fetch-Site` check plus an `Origin`-vs-host fallback; list any
195
+ * additional origins a reverse-proxy / multi-domain setup must allow here,
196
+ * e.g. `['admin.example.com', 'https://studio.example.com']`. A bare host or
197
+ * a full origin are both accepted. Default `[]` (same-origin only).
198
+ */
199
+ allowedOrigins?: string[];
200
+
181
201
  /**
182
202
  * Content-Security-Policy config. Off by default. `true` enables a
183
203
  * strict nonce-based default policy. An object customizes directives