@jskit-ai/kernel 0.1.244 → 0.1.245

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/client/index.d.ts CHANGED
@@ -91,8 +91,10 @@ export function resolveClientBootstrapDebugEnabled(options?: {
91
91
  debugEnvKey?: string;
92
92
  }): boolean;
93
93
 
94
+ export function shellScrollBehavior(to: any, from: any, savedPosition: any): any;
95
+
94
96
  export function createShellRouter(options?: {
95
- createRouter: (options: { history?: any; routes: any[] }) => any;
97
+ createRouter: (options: { history?: any; routes: any[]; scrollBehavior?: (to: any, from: any, savedPosition: any) => any }) => any;
96
98
  history?: any;
97
99
  routes?: any[];
98
100
  surfaceRuntime: any;
@@ -100,6 +102,8 @@ export function createShellRouter(options?: {
100
102
  fallbackRoute?: any;
101
103
  notFoundComponent?: any;
102
104
  guard?: false | ((to: any) => any) | Record<string, any>;
105
+ /** Defaults to shellScrollBehavior; pass your own function, or false for none. */
106
+ scrollBehavior?: false | ((to: any, from: any, savedPosition: any) => any);
103
107
  }): Readonly<{
104
108
  router: any;
105
109
  activeRoutes: readonly any[];
package/client/index.js CHANGED
@@ -9,5 +9,10 @@ export {
9
9
  notifyAsyncModuleLoadError
10
10
  } from "./asyncModuleRecovery.js";
11
11
  export { normalizeIncomingAppUrl, registerMobileLaunchRouting } from "./mobileLaunchRouting.js";
12
- export { resolveClientBootstrapDebugEnabled, createSurfaceShellRouter as createShellRouter, bootstrapClientShellApp } from "./shellBootstrap.js";
12
+ export {
13
+ resolveClientBootstrapDebugEnabled,
14
+ createSurfaceShellRouter as createShellRouter,
15
+ shellScrollBehavior,
16
+ bootstrapClientShellApp
17
+ } from "./shellBootstrap.js";
13
18
  export { createComponentInteractionEmitter } from "./componentInteraction.js";
@@ -56,6 +56,38 @@ function installAppPlugins(app, appPlugins = []) {
56
56
  }
57
57
  }
58
58
 
59
+ function isSurfaceRootRecord(record) {
60
+ return Boolean(record?.meta?.jskit?.surface);
61
+ }
62
+
63
+ // A page that hosts tabs or sub-views keeps its own record above the changing
64
+ // child, so the two locations share a non-leaf record below the surface root.
65
+ function staysOnSamePage(to, from) {
66
+ if (to.path === from.path) {
67
+ return true;
68
+ }
69
+ const fromParents = new Set((from.matched || []).slice(0, -1));
70
+ return (to.matched || []).slice(0, -1).some((record) => fromParents.has(record) && !isSurfaceRootRecord(record));
71
+ }
72
+
73
+ /**
74
+ * JSKIT's default router scroll behaviour: Back/Forward restore the saved
75
+ * position, a new screen opens at the top (or at its hash target), and tab,
76
+ * query or hash changes within the same page keep the current position.
77
+ */
78
+ function shellScrollBehavior(to, from, savedPosition) {
79
+ if (savedPosition) {
80
+ return savedPosition;
81
+ }
82
+ if (staysOnSamePage(to, from)) {
83
+ return false;
84
+ }
85
+ if (to.hash) {
86
+ return { el: to.hash, top: 0 };
87
+ }
88
+ return { top: 0 };
89
+ }
90
+
59
91
  function createSurfaceShellRouter({
60
92
  createRouter,
61
93
  history,
@@ -64,7 +96,8 @@ function createSurfaceShellRouter({
64
96
  surfaceMode,
65
97
  fallbackRoute = null,
66
98
  notFoundComponent = null,
67
- guard = false
99
+ guard = false,
100
+ scrollBehavior = shellScrollBehavior
68
101
  } = {}) {
69
102
  if (typeof createRouter !== "function") {
70
103
  throw new TypeError("createSurfaceShellRouter requires createRouter().");
@@ -79,7 +112,8 @@ function createSurfaceShellRouter({
79
112
  });
80
113
  const router = createRouter({
81
114
  history,
82
- routes: activeRoutes
115
+ routes: activeRoutes,
116
+ ...(typeof scrollBehavior === "function" ? { scrollBehavior } : {})
83
117
  });
84
118
 
85
119
  if (guard !== false) {
@@ -245,6 +279,7 @@ async function bootstrapClientShellApp({
245
279
  }
246
280
 
247
281
  export {
282
+ shellScrollBehavior,
248
283
  resolveClientBootstrapDebugEnabled,
249
284
  createClientBootstrapLogger,
250
285
  createSurfaceShellRouter,
@@ -4,7 +4,8 @@ import { createSurfaceRuntime } from "../shared/surface/runtime.js";
4
4
  import {
5
5
  bootstrapClientShellApp,
6
6
  createClientBootstrapLogger,
7
- createSurfaceShellRouter
7
+ createSurfaceShellRouter,
8
+ shellScrollBehavior
8
9
  } from "./shellBootstrap.js";
9
10
  import { getClientAppConfig } from "./appConfig.js";
10
11
 
@@ -68,6 +69,57 @@ test("createSurfaceShellRouter builds active routes and installs a guard", () =>
68
69
  assert.equal(activeRoutes.some((route) => route.path === "/admin/home"), false);
69
70
  assert.equal(activeRoutes.some((route) => route.path === "/:pathMatch(.*)*"), true);
70
71
  assert.equal(fallbackRoute.path, "/:pathMatch(.*)*");
72
+ assert.equal(routerState.options.scrollBehavior, shellScrollBehavior);
73
+ });
74
+
75
+ test("createSurfaceShellRouter lets an app replace or disable the scroll behaviour", () => {
76
+ const surfaceRuntime = createSurfaceRuntimeFixture();
77
+ const custom = () => ({ top: 42 });
78
+ const seen = [];
79
+ for (const scrollBehavior of [custom, false]) {
80
+ createSurfaceShellRouter({
81
+ createRouter(options) {
82
+ seen.push(options);
83
+ return {};
84
+ },
85
+ routes: [],
86
+ surfaceRuntime,
87
+ surfaceMode: "app",
88
+ notFoundComponent: {},
89
+ scrollBehavior
90
+ });
91
+ }
92
+ assert.equal(seen[0].scrollBehavior, custom);
93
+ assert.equal(Object.hasOwn(seen[1], "scrollBehavior"), false);
94
+ });
95
+
96
+ test("shellScrollBehavior opens new screens at the top and keeps in-page changes still", () => {
97
+ const surface = { path: "/home", meta: { jskit: { surface: "home" } } };
98
+ const settings = { path: "/home/settings", meta: {} };
99
+ const general = { path: "/home/settings/general", meta: {} };
100
+ const profile = { path: "/home/settings/profile", meta: {} };
101
+ const contacts = { path: "/home/contacts", meta: {} };
102
+ const contact = { path: "/home/contacts/:id", meta: {} };
103
+ const at = (path, matched, extra = {}) => ({ path, matched, hash: "", ...extra });
104
+
105
+ const saved = { left: 0, top: 640 };
106
+ assert.equal(shellScrollBehavior(at("/home/contacts", [surface, contacts]), at("/home", [surface]), saved), saved);
107
+ assert.deepEqual(shellScrollBehavior(at("/home/contacts", [surface, contacts]), at("/home", [surface]), null), { top: 0 });
108
+ // Another record of the same screen type is still a new screen.
109
+ assert.deepEqual(
110
+ shellScrollBehavior(at("/home/contacts/2", [surface, contact]), at("/home/contacts/1", [surface, contact]), null),
111
+ { top: 0 }
112
+ );
113
+ // Query, hash or tab changes within one page keep the position.
114
+ assert.equal(shellScrollBehavior(at("/home/contacts", [surface, contacts]), at("/home/contacts", [surface, contacts]), null), false);
115
+ assert.equal(
116
+ shellScrollBehavior(at("/home/settings/profile", [surface, settings, profile]), at("/home/settings/general", [surface, settings, general]), null),
117
+ false
118
+ );
119
+ assert.deepEqual(
120
+ shellScrollBehavior(at("/home/contacts", [surface, contacts], { hash: "#recent" }), at("/home", [surface]), null),
121
+ { el: "#recent", top: 0 }
122
+ );
71
123
  });
72
124
 
73
125
  test("createClientBootstrapLogger enables debug from env flag", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.244",
3
+ "version": "0.1.245",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "json-rest-schema": "^1.0.17"
@@ -1,5 +1,17 @@
1
1
  import { normalizePathname } from "../surface/paths.js";
2
2
 
3
+ const SAME_ORIGIN_PROBE = "http://return-to.invalid";
4
+
5
+ // Browsers read "/\\host" and "/<tab>/host" as another site, so a path counts
6
+ // only if it resolves on the page's own origin.
7
+ function staysOnSameOrigin(path) {
8
+ try {
9
+ return new URL(path, SAME_ORIGIN_PROBE).origin === SAME_ORIGIN_PROBE;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
3
15
  function normalizeHttpOrigin(value = "") {
4
16
  const rawValue = String(value || "").trim();
5
17
  if (!rawValue) {
@@ -113,6 +125,9 @@ function normalizeReturnToPath(
113
125
  const normalizedBlockedPathnames = normalizeBlockedPathnames(blockedPathnames);
114
126
 
115
127
  if (rawValue.startsWith("/") && !rawValue.startsWith("//")) {
128
+ if (!staysOnSameOrigin(rawValue)) {
129
+ return fallback;
130
+ }
116
131
  const normalizedPathname = normalizePathname(rawValue);
117
132
  if (normalizedBlockedPathnames.includes(normalizedPathname)) {
118
133
  return fallback;
@@ -121,3 +121,10 @@ test("resolveAllowedOriginsFromPlacementContext collects current and surface ori
121
121
  }
122
122
  }
123
123
  });
124
+
125
+ test("normalizeReturnToPath refuses paths a browser reads as another site", () => {
126
+ for (const value of ["/\\evil.example", "/\\/evil.example/x", "/\t/evil.example", "/\n/evil.example", "\\\\evil.example"]) {
127
+ assert.equal(normalizeReturnToPath(value, { fallback: "/home" }), "/home", JSON.stringify(value));
128
+ }
129
+ assert.equal(normalizeReturnToPath("/a/b?c=1#d", { fallback: "/home" }), "/a/b?c=1#d");
130
+ });
@@ -117,7 +117,8 @@ const BARREL_EXPECTATIONS = Object.freeze([
117
117
  "notifyAsyncModuleLoadError",
118
118
  "registerMobileLaunchRouting",
119
119
  "resolveMobileConfig",
120
- "resolveClientBootstrapDebugEnabled"
120
+ "resolveClientBootstrapDebugEnabled",
121
+ "shellScrollBehavior"
121
122
  ])
122
123
  }),
123
124
  Object.freeze({