@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 +5 -1
- package/client/index.js +6 -1
- package/client/shellBootstrap.js +37 -2
- package/client/shellBootstrap.test.js +53 -1
- package/package.json +1 -1
- package/shared/support/returnToPath.js +15 -0
- package/shared/support/returnToPath.test.js +7 -0
- package/test/barrelExposure.test.js +2 -1
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 {
|
|
12
|
+
export {
|
|
13
|
+
resolveClientBootstrapDebugEnabled,
|
|
14
|
+
createSurfaceShellRouter as createShellRouter,
|
|
15
|
+
shellScrollBehavior,
|
|
16
|
+
bootstrapClientShellApp
|
|
17
|
+
} from "./shellBootstrap.js";
|
|
13
18
|
export { createComponentInteractionEmitter } from "./componentInteraction.js";
|
package/client/shellBootstrap.js
CHANGED
|
@@ -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,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({
|