@esmx/router 3.0.0-rc.116 → 3.0.0-rc.118
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/LICENSE +21 -0
- package/README.md +84 -3
- package/README.zh-CN.md +4 -4
- package/dist/micro-app.d.ts +5 -4
- package/dist/micro-app.mjs +67 -25
- package/dist/options.mjs +4 -3
- package/dist/router-link.mjs +14 -11
- package/dist/router.d.ts +1 -1
- package/dist/router.mjs +25 -14
- package/dist/types.d.ts +9 -17
- package/dist/util.d.ts +5 -0
- package/dist/util.mjs +33 -0
- package/package.json +35 -4
- package/src/micro-app.ts +77 -30
- package/src/options.ts +3 -2
- package/src/router-link.ts +14 -11
- package/src/router.ts +26 -16
- package/src/types.ts +12 -17
- package/src/util.ts +52 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Esmx Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
- **Universal Support** - Runs in both browser and Node.js environments
|
|
34
34
|
- **TypeScript Ready** - Full TypeScript support with excellent type inference
|
|
35
35
|
- **High Performance** - Optimized for production use with minimal bundle size
|
|
36
|
-
- **SSR Compatible** - Complete
|
|
36
|
+
- **SSR Compatible** - Complete SSR support
|
|
37
37
|
- **Modern API** - Clean and intuitive API design
|
|
38
38
|
|
|
39
39
|
## 📦 Installation
|
|
@@ -56,7 +56,7 @@ import { Router, RouterMode } from '@esmx/router';
|
|
|
56
56
|
|
|
57
57
|
// Create router instance
|
|
58
58
|
const router = new Router({
|
|
59
|
-
|
|
59
|
+
appId: 'app', // Application mount container ID (optional, defaults to 'app')
|
|
60
60
|
mode: RouterMode.history,
|
|
61
61
|
routes: [
|
|
62
62
|
{ path: '/', component: () => 'Home Page' },
|
|
@@ -72,6 +72,87 @@ await router.push('/about');
|
|
|
72
72
|
|
|
73
73
|
Visit the [official documentation](https://esmx.dev) for detailed usage guides and API reference.
|
|
74
74
|
|
|
75
|
+
### Route Navigation Flow
|
|
76
|
+
|
|
77
|
+
```mermaid
|
|
78
|
+
flowchart TD
|
|
79
|
+
start(["Start"]):::Terminal --> normalizeURL["normalizeURL"]
|
|
80
|
+
normalizeURL --> isExternalUrl{"Internal URL"}:::Decision
|
|
81
|
+
isExternalUrl -- Yes --> matchInRouteTable["Match in route table"]
|
|
82
|
+
isExternalUrl -- No --> fallback["fallback"] --> End
|
|
83
|
+
matchInRouteTable --> isExist{"Match found"}:::Decision
|
|
84
|
+
isExist -- No --> fallback
|
|
85
|
+
isExist -- Yes --> execGuard["Execute hooks/guards"] --> End(["End"]):::Terminal
|
|
86
|
+
classDef Terminal fill:#FFF9C4,color:#000
|
|
87
|
+
classDef Decision fill:#C8E6C9,color:#000
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### Route Hook Pipeline
|
|
91
|
+
|
|
92
|
+
| | fallback | override | beforeLeave | beforeEach | beforeUpdate | beforeEnter | asyncComponent | confirm |
|
|
93
|
+
|---------|----------|----------|-------------|------------|--------------|-------------|----------------|---------|
|
|
94
|
+
| `push` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
95
|
+
| `replace` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
96
|
+
| `pushWindow` | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
97
|
+
| `pushLayer` | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
98
|
+
| `replaceWindow` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
99
|
+
| `restartApp` | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
100
|
+
| `unknown` | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
101
|
+
|
|
102
|
+
```mermaid
|
|
103
|
+
gantt
|
|
104
|
+
title Route Hook Execution Comparison
|
|
105
|
+
dateFormat X
|
|
106
|
+
axisFormat %s
|
|
107
|
+
section push\nreplace
|
|
108
|
+
fallback :0, 1
|
|
109
|
+
override :1, 2
|
|
110
|
+
beforeLeave :2, 3
|
|
111
|
+
beforeEach :3, 4
|
|
112
|
+
beforeUpdate :4, 5
|
|
113
|
+
beforeEnter :5, 6
|
|
114
|
+
asyncComponent:6, 7
|
|
115
|
+
confirm :7, 8
|
|
116
|
+
section pushWindow\npushLayer
|
|
117
|
+
fallback :0, 1
|
|
118
|
+
override :1, 2
|
|
119
|
+
beforeEach :3, 4
|
|
120
|
+
confirm :7, 8
|
|
121
|
+
section replaceWindow
|
|
122
|
+
fallback :0, 1
|
|
123
|
+
override :1, 2
|
|
124
|
+
beforeLeave :2, 3
|
|
125
|
+
beforeEach :3, 4
|
|
126
|
+
confirm :7, 8
|
|
127
|
+
section restartApp\nunknown
|
|
128
|
+
fallback :0, 1
|
|
129
|
+
beforeLeave :2, 3
|
|
130
|
+
beforeEach :3, 4
|
|
131
|
+
beforeUpdate :4, 5
|
|
132
|
+
beforeEnter :5, 6
|
|
133
|
+
asyncComponent:6, 7
|
|
134
|
+
confirm :7, 8
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### Hook Functions
|
|
138
|
+
|
|
139
|
+
- **fallback**: Handle unmatched routes
|
|
140
|
+
- **override**: Allow route override logic
|
|
141
|
+
- **beforeLeave**: Execute before leaving current route
|
|
142
|
+
- **beforeEach**: Global navigation guard
|
|
143
|
+
- **beforeUpdate**: Execute before route update (same component)
|
|
144
|
+
- **beforeEnter**: Execute before entering new route
|
|
145
|
+
- **asyncComponent**: Load async component
|
|
146
|
+
- **confirm**: Final confirmation and navigation execution
|
|
147
|
+
|
|
148
|
+
#### Navigation Types
|
|
149
|
+
|
|
150
|
+
- **Standard Navigation** (`push`, `replace`): Execute full hook chain
|
|
151
|
+
- **Window Operations** (`pushWindow`, `replaceWindow`): Simplified hook chain for window-level navigation
|
|
152
|
+
- **Layer Operations** (`pushLayer`): Minimal hook chain for layer navigation
|
|
153
|
+
- **App Restart** (`restartApp`): Full hook chain but skip override
|
|
154
|
+
- **Unknown Type** (`unknown`): Full hook chain but skip override, used as default handling
|
|
155
|
+
|
|
75
156
|
## 📄 License
|
|
76
157
|
|
|
77
|
-
MIT © [Esmx Team](https://github.com/esmnext/esmx)
|
|
158
|
+
MIT © [Esmx Team](https://github.com/esmnext/esmx)
|
package/README.zh-CN.md
CHANGED
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
|
|
32
32
|
- **框架无关** - 适用于任何前端框架(Vue、React、Preact、Solid 等)
|
|
33
33
|
- **通用支持** - 在浏览器和 Node.js 环境中运行
|
|
34
|
-
- **TypeScript
|
|
35
|
-
- **高性能** -
|
|
36
|
-
- **SSR 兼容** -
|
|
34
|
+
- **TypeScript 支持** - 完整的 TypeScript 类型推断与类型安全
|
|
35
|
+
- **高性能** - 针对生产环境优化,极小的包体积
|
|
36
|
+
- **SSR 兼容** - 完整的 SSR 支持
|
|
37
37
|
- **现代 API** - 简洁直观的 API 设计
|
|
38
38
|
|
|
39
39
|
## 📦 安装
|
|
@@ -56,7 +56,7 @@ import { Router, RouterMode } from '@esmx/router';
|
|
|
56
56
|
|
|
57
57
|
// 创建路由器实例
|
|
58
58
|
const router = new Router({
|
|
59
|
-
|
|
59
|
+
appId: 'app', // 应用挂载容器 ID(可选,默认 'app')
|
|
60
60
|
mode: RouterMode.history,
|
|
61
61
|
routes: [
|
|
62
62
|
{ path: '/', component: () => '首页' },
|
package/dist/micro-app.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { Router } from './router';
|
|
2
2
|
import type { RouterMicroAppOptions } from './types';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Gets the root container element by ID.
|
|
5
|
+
* If not found, creates a new div with the given ID and appends it to document.body.
|
|
6
6
|
*
|
|
7
|
-
* @param
|
|
7
|
+
* @param appId - The application container ID.
|
|
8
8
|
* @returns The resolved HTMLElement.
|
|
9
9
|
*/
|
|
10
|
-
export declare function
|
|
10
|
+
export declare function getRootElement(appId: string): HTMLElement;
|
|
11
11
|
export declare class MicroApp {
|
|
12
12
|
app: RouterMicroAppOptions | null;
|
|
13
13
|
root: HTMLElement | null;
|
|
@@ -16,4 +16,5 @@ export declare class MicroApp {
|
|
|
16
16
|
_update(router: Router, force?: boolean): void;
|
|
17
17
|
private _getNextFactory;
|
|
18
18
|
destroy(): void;
|
|
19
|
+
private _clearRoot;
|
|
19
20
|
}
|
package/dist/micro-app.mjs
CHANGED
|
@@ -2,22 +2,15 @@ var __defProp = Object.defineProperty;
|
|
|
2
2
|
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
3
|
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
4
4
|
import { isBrowser, isPlainObject } from "./util.mjs";
|
|
5
|
-
export function
|
|
6
|
-
|
|
7
|
-
if (
|
|
8
|
-
el
|
|
5
|
+
export function getRootElement(appId) {
|
|
6
|
+
const el = document.getElementById(appId);
|
|
7
|
+
if (el) {
|
|
8
|
+
return el;
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
console.warn("Failed to resolve root element: ".concat(rootConfig));
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
if (el === null) {
|
|
18
|
-
el = document.createElement("div");
|
|
19
|
-
}
|
|
20
|
-
return el;
|
|
10
|
+
const newEl = document.createElement("div");
|
|
11
|
+
newEl.id = appId;
|
|
12
|
+
document.body.appendChild(newEl);
|
|
13
|
+
return newEl;
|
|
21
14
|
}
|
|
22
15
|
export class MicroApp {
|
|
23
16
|
constructor() {
|
|
@@ -39,21 +32,62 @@ export class MicroApp {
|
|
|
39
32
|
if (isBrowser && app) {
|
|
40
33
|
let root = this.root;
|
|
41
34
|
if (root === null) {
|
|
42
|
-
root =
|
|
35
|
+
root = getRootElement(router.appId);
|
|
43
36
|
const { rootStyle } = router.parsedOptions;
|
|
44
37
|
if (root && isPlainObject(rootStyle)) {
|
|
45
38
|
Object.assign(root.style, router.parsedOptions.rootStyle);
|
|
46
39
|
}
|
|
40
|
+
this.root = root;
|
|
47
41
|
}
|
|
48
42
|
if (root) {
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
43
|
+
const isHydration = root.hasAttribute("data-ssr");
|
|
44
|
+
if (isHydration) {
|
|
45
|
+
const appRoot = root.firstElementChild;
|
|
46
|
+
if (appRoot) {
|
|
47
|
+
if (app.hydration) {
|
|
48
|
+
app.hydration(appRoot);
|
|
49
|
+
} else {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"SSR content detected but hydration function not provided"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
const el = document.createElement("div");
|
|
56
|
+
root.appendChild(el);
|
|
57
|
+
try {
|
|
58
|
+
app.mount(el);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
el.remove();
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
root.removeAttribute("data-ssr");
|
|
65
|
+
} else {
|
|
66
|
+
const oldChildren = Array.from(root.childNodes);
|
|
67
|
+
const el = document.createElement("div");
|
|
68
|
+
root.appendChild(el);
|
|
69
|
+
try {
|
|
70
|
+
app.mount(el);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
el.remove();
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
if (oldApp) {
|
|
76
|
+
try {
|
|
77
|
+
oldApp.unmount();
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.error(
|
|
80
|
+
"[@esmx/router] MicroApp unmount failed during route transition. Check the framework unmount hook returned by your render function (Vue: app.unmount, React: root.unmount, etc.).",
|
|
81
|
+
e
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
oldChildren.forEach((child) => {
|
|
86
|
+
if (child.parentNode) {
|
|
87
|
+
child.remove();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
52
90
|
}
|
|
53
|
-
this.root = root;
|
|
54
|
-
}
|
|
55
|
-
if (oldApp) {
|
|
56
|
-
oldApp.unmount();
|
|
57
91
|
}
|
|
58
92
|
}
|
|
59
93
|
this.app = app;
|
|
@@ -79,12 +113,20 @@ export class MicroApp {
|
|
|
79
113
|
return null;
|
|
80
114
|
}
|
|
81
115
|
destroy() {
|
|
82
|
-
var _a
|
|
116
|
+
var _a;
|
|
83
117
|
(_a = this.app) == null ? void 0 : _a.unmount();
|
|
118
|
+
this._clearRoot();
|
|
84
119
|
this.app = null;
|
|
85
|
-
(_b = this.root) == null ? void 0 : _b.remove();
|
|
86
120
|
this.root = null;
|
|
87
121
|
this._factory = null;
|
|
88
122
|
this.destroyed = true;
|
|
89
123
|
}
|
|
124
|
+
_clearRoot() {
|
|
125
|
+
if (!this.root) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
Array.from(this.root.childNodes).forEach((child) => {
|
|
129
|
+
child.remove();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
90
132
|
}
|
package/dist/options.mjs
CHANGED
|
@@ -30,13 +30,13 @@ function getBaseUrl(options) {
|
|
|
30
30
|
return base;
|
|
31
31
|
}
|
|
32
32
|
export function parsedOptions(options = {}) {
|
|
33
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
33
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
34
34
|
const base = getBaseUrl(options);
|
|
35
35
|
const routes = (_a = options.routes) != null ? _a : [];
|
|
36
36
|
const compiledRoutes = createRouteMatches(routes);
|
|
37
37
|
return Object.freeze({
|
|
38
38
|
rootStyle: options.rootStyle || false,
|
|
39
|
-
|
|
39
|
+
appId: options.appId || "app",
|
|
40
40
|
context: options.context || {},
|
|
41
41
|
data: options.data || {},
|
|
42
42
|
req: options.req || null,
|
|
@@ -56,7 +56,8 @@ export function parsedOptions(options = {}) {
|
|
|
56
56
|
handleBackBoundary: (_f = options.handleBackBoundary) != null ? _f : (() => {
|
|
57
57
|
}),
|
|
58
58
|
handleLayerClose: (_g = options.handleLayerClose) != null ? _g : (() => {
|
|
59
|
-
})
|
|
59
|
+
}),
|
|
60
|
+
resolveLink: (_h = options.resolveLink) != null ? _h : ((link) => link)
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
63
|
export function fallback(to, from, router) {
|
package/dist/router-link.mjs
CHANGED
|
@@ -125,15 +125,18 @@ export function createLinkResolver(router, props) {
|
|
|
125
125
|
eventTypes
|
|
126
126
|
);
|
|
127
127
|
const navigate = createNavigateFunction(router, props, type);
|
|
128
|
-
return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
128
|
+
return router.parsedOptions.resolveLink(
|
|
129
|
+
{
|
|
130
|
+
route,
|
|
131
|
+
type,
|
|
132
|
+
isActive,
|
|
133
|
+
isExactActive,
|
|
134
|
+
isExternal,
|
|
135
|
+
tag: props.tag || "a",
|
|
136
|
+
attributes,
|
|
137
|
+
navigate,
|
|
138
|
+
createEventHandlers
|
|
139
|
+
},
|
|
140
|
+
props
|
|
141
|
+
);
|
|
139
142
|
}
|
package/dist/router.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export declare class Router {
|
|
|
14
14
|
get route(): Route;
|
|
15
15
|
get context(): Record<string | symbol, unknown>;
|
|
16
16
|
get data(): Record<string | symbol, unknown>;
|
|
17
|
-
get
|
|
17
|
+
get appId(): string;
|
|
18
18
|
get mode(): RouterMode;
|
|
19
19
|
get base(): URL;
|
|
20
20
|
get req(): import("http").IncomingMessage | null;
|
package/dist/router.mjs
CHANGED
|
@@ -9,7 +9,19 @@ import { Route } from "./route.mjs";
|
|
|
9
9
|
import { RouteTransition } from "./route-transition.mjs";
|
|
10
10
|
import { createLinkResolver } from "./router-link.mjs";
|
|
11
11
|
import { RouterMode, RouteType } from "./types.mjs";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
isNotNullish,
|
|
14
|
+
isPlainObject,
|
|
15
|
+
isRouteMatched,
|
|
16
|
+
validateSsrRootElement
|
|
17
|
+
} from "./util.mjs";
|
|
18
|
+
const LAYER_SKIP_TYPES = /* @__PURE__ */ new Set([
|
|
19
|
+
RouteType.pushWindow,
|
|
20
|
+
RouteType.replaceWindow,
|
|
21
|
+
RouteType.replace,
|
|
22
|
+
RouteType.restartApp,
|
|
23
|
+
RouteType.pushLayer
|
|
24
|
+
]);
|
|
13
25
|
export class Router {
|
|
14
26
|
constructor(options) {
|
|
15
27
|
__publicField(this, "options");
|
|
@@ -47,8 +59,8 @@ export class Router {
|
|
|
47
59
|
get data() {
|
|
48
60
|
return this.parsedOptions.data;
|
|
49
61
|
}
|
|
50
|
-
get
|
|
51
|
-
return this.parsedOptions.
|
|
62
|
+
get appId() {
|
|
63
|
+
return this.parsedOptions.appId;
|
|
52
64
|
}
|
|
53
65
|
get mode() {
|
|
54
66
|
return this.parsedOptions.mode;
|
|
@@ -234,7 +246,7 @@ export class Router {
|
|
|
234
246
|
...this.options,
|
|
235
247
|
context: this.parsedOptions.context,
|
|
236
248
|
mode: RouterMode.memory,
|
|
237
|
-
|
|
249
|
+
appId: void 0,
|
|
238
250
|
...layerOptions.routerOptions,
|
|
239
251
|
handleBackBoundary(router2) {
|
|
240
252
|
router2.destroy();
|
|
@@ -262,14 +274,7 @@ export class Router {
|
|
|
262
274
|
});
|
|
263
275
|
const initRoute = await router.replace(toInput);
|
|
264
276
|
router.afterEach(async (to, from) => {
|
|
265
|
-
if (
|
|
266
|
-
RouteType.pushWindow,
|
|
267
|
-
RouteType.replaceWindow,
|
|
268
|
-
RouteType.replace,
|
|
269
|
-
RouteType.restartApp,
|
|
270
|
-
RouteType.pushLayer
|
|
271
|
-
].includes(to.type))
|
|
272
|
-
return;
|
|
277
|
+
if (LAYER_SKIP_TYPES.has(to.type)) return;
|
|
273
278
|
let keepAlive = false;
|
|
274
279
|
if (layerOptions.keepAlive === "exact") {
|
|
275
280
|
keepAlive = to.path === initRoute.path;
|
|
@@ -334,10 +339,16 @@ export class Router {
|
|
|
334
339
|
var _a, _b;
|
|
335
340
|
try {
|
|
336
341
|
const result = await ((_b = (_a = this.microApp.app) == null ? void 0 : _a.renderToString) == null ? void 0 : _b.call(_a));
|
|
337
|
-
|
|
342
|
+
const trimmed = result == null ? void 0 : result.trim();
|
|
343
|
+
const hasContent = trimmed && trimmed.length > 0;
|
|
344
|
+
if (hasContent && process.env.NODE_ENV !== "production") {
|
|
345
|
+
validateSsrRootElement(trimmed);
|
|
346
|
+
}
|
|
347
|
+
const ssrAttr = hasContent ? " data-ssr" : "";
|
|
348
|
+
return '<div id="'.concat(this.appId, '"').concat(ssrAttr, ">").concat(result != null ? result : "", "</div>");
|
|
338
349
|
} catch (e) {
|
|
339
350
|
if (throwError) throw e;
|
|
340
|
-
else console.error(e);
|
|
351
|
+
else console.error("[@esmx/router] SSR render failed:", e);
|
|
341
352
|
return null;
|
|
342
353
|
}
|
|
343
354
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -215,6 +215,7 @@ export type RouteLayerResult = {
|
|
|
215
215
|
export type RouterLayerOptions = Omit<RouterOptions, 'handleBackBoundary' | 'handleLayerClose' | 'layer'>;
|
|
216
216
|
export interface RouterMicroAppOptions {
|
|
217
217
|
mount: (el: HTMLElement) => void;
|
|
218
|
+
hydration?: (el: HTMLElement) => void;
|
|
218
219
|
unmount: () => void;
|
|
219
220
|
renderToString?: () => Awaitable<string>;
|
|
220
221
|
}
|
|
@@ -222,28 +223,18 @@ export type RouterMicroAppCallback = (router: Router) => RouterMicroAppOptions;
|
|
|
222
223
|
export type RouterMicroApp = Record<string, RouterMicroAppCallback | undefined> | RouterMicroAppCallback;
|
|
223
224
|
export interface RouterOptions {
|
|
224
225
|
/**
|
|
225
|
-
* Application
|
|
226
|
-
* -
|
|
227
|
-
* -
|
|
228
|
-
* -
|
|
226
|
+
* Application mount container ID
|
|
227
|
+
* - Pure string ID, no '#' prefix needed
|
|
228
|
+
* - Client-side: uses document.getElementById(appId)
|
|
229
|
+
* - Server-side: generates <div id="${appId}"> wrapper
|
|
230
|
+
* - Defaults to 'app'
|
|
229
231
|
*
|
|
230
232
|
* @example
|
|
231
233
|
* ```typescript
|
|
232
|
-
*
|
|
233
|
-
* new Router({ root: '#my-app' })
|
|
234
|
-
*
|
|
235
|
-
* // Using class selector
|
|
236
|
-
* new Router({ root: '.app-container' })
|
|
237
|
-
*
|
|
238
|
-
* // Using attribute selector
|
|
239
|
-
* new Router({ root: '[data-router-mount]' })
|
|
240
|
-
*
|
|
241
|
-
* // Passing DOM element directly
|
|
242
|
-
* const element = document.getElementById('app');
|
|
243
|
-
* new Router({ root: element })
|
|
234
|
+
* new Router({ appId: 'app' })
|
|
244
235
|
* ```
|
|
245
236
|
*/
|
|
246
|
-
|
|
237
|
+
appId?: string;
|
|
247
238
|
context?: Record<string | symbol, unknown>;
|
|
248
239
|
data?: Record<string | symbol, unknown>;
|
|
249
240
|
routes?: RouteConfig[];
|
|
@@ -261,6 +252,7 @@ export interface RouterOptions {
|
|
|
261
252
|
zIndex?: number;
|
|
262
253
|
handleBackBoundary?: (router: Router) => void;
|
|
263
254
|
handleLayerClose?: (router: Router, data?: any) => void;
|
|
255
|
+
resolveLink?: (link: RouterLinkResolved, props: RouterLinkProps) => RouterLinkResolved;
|
|
264
256
|
}
|
|
265
257
|
export interface RouterParsedOptions extends Readonly<Required<RouterOptions>> {
|
|
266
258
|
readonly compiledRoutes: readonly RouteParsedConfig[];
|
package/dist/util.d.ts
CHANGED
|
@@ -25,3 +25,8 @@ export declare function isUrlEqual(url1: URL, url2?: URL | null): boolean;
|
|
|
25
25
|
*/
|
|
26
26
|
export declare function isRouteMatched(fromRoute: Route, toRoute: Route | null, matchType: RouteMatchType): boolean;
|
|
27
27
|
export declare function decodeParams<T extends Record<string, string | string[]>>(params: T): T;
|
|
28
|
+
/**
|
|
29
|
+
* Validates that SSR renderToString output contains exactly one root HTML element.
|
|
30
|
+
* Non-production only - throws if validation fails.
|
|
31
|
+
*/
|
|
32
|
+
export declare function validateSsrRootElement(html: string): void;
|
package/dist/util.mjs
CHANGED
|
@@ -65,3 +65,36 @@ export function decodeParams(params) {
|
|
|
65
65
|
}
|
|
66
66
|
return result;
|
|
67
67
|
}
|
|
68
|
+
export function validateSsrRootElement(html) {
|
|
69
|
+
var _a;
|
|
70
|
+
const trimmed = html.trim();
|
|
71
|
+
const firstMatch = trimmed.match(/^<([a-zA-Z][^\s>]*)/);
|
|
72
|
+
const firstTag = firstMatch == null ? void 0 : firstMatch[1];
|
|
73
|
+
const lastTag = (_a = trimmed.match(/<\/([a-zA-Z][^\s>]*)>\s*$/)) == null ? void 0 : _a[1];
|
|
74
|
+
if (!firstTag || firstTag !== lastTag) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"SSR renderToString() must return exactly one root HTML element. Current output: " + trimmed.slice(0, 100)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const tagRe = new RegExp("<(/?)".concat(firstTag, "(?:\\s[^>]*)?(/?)>"), "g");
|
|
80
|
+
let depth = 0;
|
|
81
|
+
let rootCloseEnd = -1;
|
|
82
|
+
let tag = tagRe.exec(trimmed);
|
|
83
|
+
while (tag !== null) {
|
|
84
|
+
if (tag[1] === "/") {
|
|
85
|
+
depth--;
|
|
86
|
+
if (depth === 0) {
|
|
87
|
+
rootCloseEnd = tagRe.lastIndex;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
} else if (tag[2] !== "/") {
|
|
91
|
+
depth++;
|
|
92
|
+
}
|
|
93
|
+
tag = tagRe.exec(trimmed);
|
|
94
|
+
}
|
|
95
|
+
if (rootCloseEnd === -1 || trimmed.slice(rootCloseEnd).trim().length > 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"SSR renderToString() must return exactly one root HTML element. Current output: " + trimmed.slice(0, 100)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/package.json
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esmx/router",
|
|
3
|
+
"description": "Framework-agnostic universal router for browser and Node.js SSR, with micro-frontend and layer navigation support",
|
|
4
|
+
"keywords": [
|
|
5
|
+
"router",
|
|
6
|
+
"routing",
|
|
7
|
+
"ssr",
|
|
8
|
+
"micro-frontend",
|
|
9
|
+
"typescript",
|
|
10
|
+
"universal",
|
|
11
|
+
"navigation",
|
|
12
|
+
"esmx",
|
|
13
|
+
"esm",
|
|
14
|
+
"spa",
|
|
15
|
+
"framework",
|
|
16
|
+
"frontend"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/esmnext/esmx.git",
|
|
21
|
+
"directory": "packages/router"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/esmnext/esmx",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/esmnext/esmx/issues"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
3
28
|
"template": "library",
|
|
4
29
|
"scripts": {
|
|
5
30
|
"lint:js": "biome check --write --no-errors-on-unmatched",
|
|
@@ -33,13 +58,13 @@
|
|
|
33
58
|
"devDependencies": {
|
|
34
59
|
"@biomejs/biome": "2.3.7",
|
|
35
60
|
"@types/node": "^24.0.0",
|
|
36
|
-
"@vitest/coverage-v8": "3.2.
|
|
61
|
+
"@vitest/coverage-v8": "3.2.6",
|
|
37
62
|
"happy-dom": "^20.0.10",
|
|
38
63
|
"typescript": "5.9.3",
|
|
39
64
|
"unbuild": "3.6.1",
|
|
40
|
-
"vitest": "3.2.
|
|
65
|
+
"vitest": "3.2.6"
|
|
41
66
|
},
|
|
42
|
-
"version": "3.0.0-rc.
|
|
67
|
+
"version": "3.0.0-rc.118",
|
|
43
68
|
"type": "module",
|
|
44
69
|
"private": false,
|
|
45
70
|
"exports": {
|
|
@@ -58,5 +83,11 @@
|
|
|
58
83
|
"template",
|
|
59
84
|
"public"
|
|
60
85
|
],
|
|
61
|
-
"gitHead": "
|
|
86
|
+
"gitHead": "2fdbd62470f64e6e45568550941c78cb259e4917",
|
|
87
|
+
"engines": {
|
|
88
|
+
"node": ">=24"
|
|
89
|
+
},
|
|
90
|
+
"publishConfig": {
|
|
91
|
+
"access": "public"
|
|
92
|
+
}
|
|
62
93
|
}
|
package/src/micro-app.ts
CHANGED
|
@@ -3,31 +3,21 @@ import type { RouterMicroAppCallback, RouterMicroAppOptions } from './types';
|
|
|
3
3
|
import { isBrowser, isPlainObject } from './util';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Gets the root container element by ID.
|
|
7
|
+
* If not found, creates a new div with the given ID and appends it to document.body.
|
|
8
8
|
*
|
|
9
|
-
* @param
|
|
9
|
+
* @param appId - The application container ID.
|
|
10
10
|
* @returns The resolved HTMLElement.
|
|
11
11
|
*/
|
|
12
|
-
export function
|
|
13
|
-
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
// Direct HTMLElement provided
|
|
17
|
-
if (rootConfig instanceof HTMLElement) {
|
|
18
|
-
el = rootConfig;
|
|
12
|
+
export function getRootElement(appId: string): HTMLElement {
|
|
13
|
+
const el = document.getElementById(appId);
|
|
14
|
+
if (el) {
|
|
15
|
+
return el;
|
|
19
16
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
console.warn(`Failed to resolve root element: ${rootConfig}`);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
if (el === null) {
|
|
28
|
-
el = document.createElement('div');
|
|
29
|
-
}
|
|
30
|
-
return el;
|
|
17
|
+
const newEl = document.createElement('div');
|
|
18
|
+
newEl.id = appId;
|
|
19
|
+
document.body.appendChild(newEl);
|
|
20
|
+
return newEl;
|
|
31
21
|
}
|
|
32
22
|
|
|
33
23
|
export class MicroApp {
|
|
@@ -50,21 +40,69 @@ export class MicroApp {
|
|
|
50
40
|
if (isBrowser && app) {
|
|
51
41
|
let root: HTMLElement | null = this.root;
|
|
52
42
|
if (root === null) {
|
|
53
|
-
root =
|
|
43
|
+
root = getRootElement(router.appId);
|
|
54
44
|
const { rootStyle } = router.parsedOptions;
|
|
55
45
|
if (root && isPlainObject(rootStyle)) {
|
|
56
46
|
Object.assign(root.style, router.parsedOptions.rootStyle);
|
|
57
47
|
}
|
|
48
|
+
this.root = root;
|
|
58
49
|
}
|
|
59
50
|
if (root) {
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
51
|
+
const isHydration = root.hasAttribute('data-ssr');
|
|
52
|
+
if (isHydration) {
|
|
53
|
+
const appRoot = root.firstElementChild as HTMLElement;
|
|
54
|
+
if (appRoot) {
|
|
55
|
+
if (app.hydration) {
|
|
56
|
+
app.hydration(appRoot);
|
|
57
|
+
} else {
|
|
58
|
+
throw new Error(
|
|
59
|
+
'SSR content detected but hydration function not provided'
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
// No child elements (e.g., Vue 2 comment nodes), fallback to mount
|
|
64
|
+
const el = document.createElement('div');
|
|
65
|
+
root.appendChild(el);
|
|
66
|
+
try {
|
|
67
|
+
app.mount(el);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
el.remove();
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Remove data-ssr attribute after hydration
|
|
74
|
+
root.removeAttribute('data-ssr');
|
|
75
|
+
} else {
|
|
76
|
+
// Capture all existing children before inserting the new container.
|
|
77
|
+
// Old app may have created multiple sibling nodes during its lifecycle.
|
|
78
|
+
const oldChildren = Array.from(root.childNodes);
|
|
79
|
+
const el = document.createElement('div');
|
|
80
|
+
root.appendChild(el);
|
|
81
|
+
try {
|
|
82
|
+
app.mount(el);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
el.remove();
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
if (oldApp) {
|
|
88
|
+
try {
|
|
89
|
+
oldApp.unmount();
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.error(
|
|
93
|
+
'[@esmx/router] MicroApp unmount failed during route transition. Check the framework unmount hook returned by your render function (Vue: app.unmount, React: root.unmount, etc.).',
|
|
94
|
+
e
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Remove old children that are still attached to the DOM.
|
|
99
|
+
// Some frameworks may have already removed their own nodes during unmount.
|
|
100
|
+
oldChildren.forEach((child) => {
|
|
101
|
+
if (child.parentNode) {
|
|
102
|
+
child.remove();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
63
105
|
}
|
|
64
|
-
this.root = root;
|
|
65
|
-
}
|
|
66
|
-
if (oldApp) {
|
|
67
|
-
oldApp.unmount();
|
|
68
106
|
}
|
|
69
107
|
}
|
|
70
108
|
this.app = app;
|
|
@@ -97,10 +135,19 @@ export class MicroApp {
|
|
|
97
135
|
|
|
98
136
|
public destroy() {
|
|
99
137
|
this.app?.unmount();
|
|
138
|
+
this._clearRoot();
|
|
100
139
|
this.app = null;
|
|
101
|
-
this.root?.remove();
|
|
102
140
|
this.root = null;
|
|
103
141
|
this._factory = null;
|
|
104
142
|
this.destroyed = true;
|
|
105
143
|
}
|
|
144
|
+
|
|
145
|
+
private _clearRoot(): void {
|
|
146
|
+
if (!this.root) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
Array.from(this.root.childNodes).forEach((child) => {
|
|
150
|
+
child.remove();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
106
153
|
}
|
package/src/options.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function parsedOptions(
|
|
|
64
64
|
const compiledRoutes = createRouteMatches(routes);
|
|
65
65
|
return Object.freeze<RouterParsedOptions>({
|
|
66
66
|
rootStyle: options.rootStyle || false,
|
|
67
|
-
|
|
67
|
+
appId: options.appId || 'app',
|
|
68
68
|
context: options.context || {},
|
|
69
69
|
data: options.data || {},
|
|
70
70
|
req: options.req || null,
|
|
@@ -86,7 +86,8 @@ export function parsedOptions(
|
|
|
86
86
|
fallback: options.fallback ?? fallback,
|
|
87
87
|
nextTick: options.nextTick ?? (() => {}),
|
|
88
88
|
handleBackBoundary: options.handleBackBoundary ?? (() => {}),
|
|
89
|
-
handleLayerClose: options.handleLayerClose ?? (() => {})
|
|
89
|
+
handleLayerClose: options.handleLayerClose ?? (() => {}),
|
|
90
|
+
resolveLink: options.resolveLink ?? ((link) => link)
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
93
|
|
package/src/router-link.ts
CHANGED
|
@@ -224,15 +224,18 @@ export function createLinkResolver(
|
|
|
224
224
|
|
|
225
225
|
const navigate = createNavigateFunction(router, props, type);
|
|
226
226
|
|
|
227
|
-
return
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
227
|
+
return router.parsedOptions.resolveLink(
|
|
228
|
+
{
|
|
229
|
+
route,
|
|
230
|
+
type,
|
|
231
|
+
isActive,
|
|
232
|
+
isExactActive,
|
|
233
|
+
isExternal,
|
|
234
|
+
tag: props.tag || 'a',
|
|
235
|
+
attributes,
|
|
236
|
+
navigate,
|
|
237
|
+
createEventHandlers
|
|
238
|
+
},
|
|
239
|
+
props
|
|
240
|
+
);
|
|
238
241
|
}
|
package/src/router.ts
CHANGED
|
@@ -19,7 +19,20 @@ import type {
|
|
|
19
19
|
RouteState
|
|
20
20
|
} from './types';
|
|
21
21
|
import { RouterMode, RouteType } from './types';
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
isNotNullish,
|
|
24
|
+
isPlainObject,
|
|
25
|
+
isRouteMatched,
|
|
26
|
+
validateSsrRootElement
|
|
27
|
+
} from './util';
|
|
28
|
+
|
|
29
|
+
const LAYER_SKIP_TYPES = new Set([
|
|
30
|
+
RouteType.pushWindow,
|
|
31
|
+
RouteType.replaceWindow,
|
|
32
|
+
RouteType.replace,
|
|
33
|
+
RouteType.restartApp,
|
|
34
|
+
RouteType.pushLayer
|
|
35
|
+
]);
|
|
23
36
|
|
|
24
37
|
export class Router {
|
|
25
38
|
public readonly options: RouterOptions;
|
|
@@ -47,8 +60,8 @@ export class Router {
|
|
|
47
60
|
return this.parsedOptions.data;
|
|
48
61
|
}
|
|
49
62
|
|
|
50
|
-
public get
|
|
51
|
-
return this.parsedOptions.
|
|
63
|
+
public get appId() {
|
|
64
|
+
return this.parsedOptions.appId;
|
|
52
65
|
}
|
|
53
66
|
public get mode(): RouterMode {
|
|
54
67
|
return this.parsedOptions.mode;
|
|
@@ -266,7 +279,7 @@ export class Router {
|
|
|
266
279
|
...this.options,
|
|
267
280
|
context: this.parsedOptions.context,
|
|
268
281
|
mode: RouterMode.memory,
|
|
269
|
-
|
|
282
|
+
appId: undefined,
|
|
270
283
|
...layerOptions.routerOptions,
|
|
271
284
|
handleBackBoundary(router) {
|
|
272
285
|
router.destroy();
|
|
@@ -295,16 +308,7 @@ export class Router {
|
|
|
295
308
|
const initRoute = await router.replace(toInput);
|
|
296
309
|
|
|
297
310
|
router.afterEach(async (to, from) => {
|
|
298
|
-
if (
|
|
299
|
-
[
|
|
300
|
-
RouteType.pushWindow,
|
|
301
|
-
RouteType.replaceWindow,
|
|
302
|
-
RouteType.replace,
|
|
303
|
-
RouteType.restartApp,
|
|
304
|
-
RouteType.pushLayer
|
|
305
|
-
].includes(to.type)
|
|
306
|
-
)
|
|
307
|
-
return;
|
|
311
|
+
if (LAYER_SKIP_TYPES.has(to.type)) return;
|
|
308
312
|
let keepAlive = false;
|
|
309
313
|
if (layerOptions.keepAlive === 'exact') {
|
|
310
314
|
keepAlive = to.path === initRoute.path;
|
|
@@ -372,10 +376,16 @@ export class Router {
|
|
|
372
376
|
public async renderToString(throwError = false): Promise<string | null> {
|
|
373
377
|
try {
|
|
374
378
|
const result = await this.microApp.app?.renderToString?.();
|
|
375
|
-
|
|
379
|
+
const trimmed = result?.trim();
|
|
380
|
+
const hasContent = trimmed && trimmed.length > 0;
|
|
381
|
+
if (hasContent && process.env.NODE_ENV !== 'production') {
|
|
382
|
+
validateSsrRootElement(trimmed);
|
|
383
|
+
}
|
|
384
|
+
const ssrAttr = hasContent ? ' data-ssr' : '';
|
|
385
|
+
return `<div id="${this.appId}"${ssrAttr}>${result ?? ''}</div>`;
|
|
376
386
|
} catch (e) {
|
|
377
387
|
if (throwError) throw e;
|
|
378
|
-
else console.error(e);
|
|
388
|
+
else console.error('[@esmx/router] SSR render failed:', e);
|
|
379
389
|
return null;
|
|
380
390
|
}
|
|
381
391
|
}
|
package/src/types.ts
CHANGED
|
@@ -280,6 +280,7 @@ export type RouterLayerOptions = Omit<
|
|
|
280
280
|
// ============================================================================
|
|
281
281
|
export interface RouterMicroAppOptions {
|
|
282
282
|
mount: (el: HTMLElement) => void;
|
|
283
|
+
hydration?: (el: HTMLElement) => void;
|
|
283
284
|
unmount: () => void;
|
|
284
285
|
renderToString?: () => Awaitable<string>;
|
|
285
286
|
}
|
|
@@ -295,28 +296,18 @@ export type RouterMicroApp =
|
|
|
295
296
|
// ============================================================================
|
|
296
297
|
export interface RouterOptions {
|
|
297
298
|
/**
|
|
298
|
-
* Application
|
|
299
|
-
* -
|
|
300
|
-
* -
|
|
301
|
-
* -
|
|
299
|
+
* Application mount container ID
|
|
300
|
+
* - Pure string ID, no '#' prefix needed
|
|
301
|
+
* - Client-side: uses document.getElementById(appId)
|
|
302
|
+
* - Server-side: generates <div id="${appId}"> wrapper
|
|
303
|
+
* - Defaults to 'app'
|
|
302
304
|
*
|
|
303
305
|
* @example
|
|
304
306
|
* ```typescript
|
|
305
|
-
*
|
|
306
|
-
* new Router({ root: '#my-app' })
|
|
307
|
-
*
|
|
308
|
-
* // Using class selector
|
|
309
|
-
* new Router({ root: '.app-container' })
|
|
310
|
-
*
|
|
311
|
-
* // Using attribute selector
|
|
312
|
-
* new Router({ root: '[data-router-mount]' })
|
|
313
|
-
*
|
|
314
|
-
* // Passing DOM element directly
|
|
315
|
-
* const element = document.getElementById('app');
|
|
316
|
-
* new Router({ root: element })
|
|
307
|
+
* new Router({ appId: 'app' })
|
|
317
308
|
* ```
|
|
318
309
|
*/
|
|
319
|
-
|
|
310
|
+
appId?: string;
|
|
320
311
|
context?: Record<string | symbol, unknown>;
|
|
321
312
|
data?: Record<string | symbol, unknown>;
|
|
322
313
|
routes?: RouteConfig[];
|
|
@@ -335,6 +326,10 @@ export interface RouterOptions {
|
|
|
335
326
|
zIndex?: number;
|
|
336
327
|
handleBackBoundary?: (router: Router) => void;
|
|
337
328
|
handleLayerClose?: (router: Router, data?: any) => void;
|
|
329
|
+
resolveLink?: (
|
|
330
|
+
link: RouterLinkResolved,
|
|
331
|
+
props: RouterLinkProps
|
|
332
|
+
) => RouterLinkResolved;
|
|
338
333
|
}
|
|
339
334
|
|
|
340
335
|
export interface RouterParsedOptions extends Readonly<Required<RouterOptions>> {
|
package/src/util.ts
CHANGED
|
@@ -71,7 +71,11 @@ export function isUrlEqual(url1: URL, url2?: URL | null): boolean {
|
|
|
71
71
|
// Copy and sort query parameters
|
|
72
72
|
(url1 = new URL(url1)).searchParams.sort();
|
|
73
73
|
(url2 = new URL(url2)).searchParams.sort();
|
|
74
|
-
//
|
|
74
|
+
// Normalize trailing empty hash:
|
|
75
|
+
// new URL('https://a.com/path#').href includes a trailing '#',
|
|
76
|
+
// but new URL('https://a.com/path').href does not.
|
|
77
|
+
// Assigning hash to itself triggers the setter to re-normalize the URL,
|
|
78
|
+
// ensuring both forms produce the same href.
|
|
75
79
|
url1.hash = url1.hash;
|
|
76
80
|
url2.hash = url2.hash;
|
|
77
81
|
return url1.href === url2.href;
|
|
@@ -131,3 +135,50 @@ export function decodeParams<T extends Record<string, string | string[]>>(
|
|
|
131
135
|
|
|
132
136
|
return result;
|
|
133
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Validates that SSR renderToString output contains exactly one root HTML element.
|
|
141
|
+
* Non-production only - throws if validation fails.
|
|
142
|
+
*/
|
|
143
|
+
export function validateSsrRootElement(html: string): void {
|
|
144
|
+
const trimmed = html.trim();
|
|
145
|
+
const firstMatch = trimmed.match(/^<([a-zA-Z][^\s>]*)/);
|
|
146
|
+
const firstTag = firstMatch?.[1];
|
|
147
|
+
const lastTag = trimmed.match(/<\/([a-zA-Z][^\s>]*)>\s*$/)?.[1];
|
|
148
|
+
if (!firstTag || firstTag !== lastTag) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'SSR renderToString() must return exactly one root HTML element. ' +
|
|
151
|
+
'Current output: ' +
|
|
152
|
+
trimmed.slice(0, 100)
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
// Find the ROOT element's matching close tag by depth-counting occurrences
|
|
156
|
+
// of the root tag, so a single root that nests same-tag children (e.g. a
|
|
157
|
+
// `<div>` wrapping child `<div>`s — very common) is not mistaken for
|
|
158
|
+
// multiple roots. A naive "first `</tag>`" scan matches an inner close and
|
|
159
|
+
// wrongly reports trailing content. Anything after the matched root close is
|
|
160
|
+
// a sibling root → invalid.
|
|
161
|
+
const tagRe = new RegExp(`<(/?)${firstTag}(?:\\s[^>]*)?(/?)>`, 'g');
|
|
162
|
+
let depth = 0;
|
|
163
|
+
let rootCloseEnd = -1;
|
|
164
|
+
let tag: RegExpExecArray | null = tagRe.exec(trimmed);
|
|
165
|
+
while (tag !== null) {
|
|
166
|
+
if (tag[1] === '/') {
|
|
167
|
+
depth--;
|
|
168
|
+
if (depth === 0) {
|
|
169
|
+
rootCloseEnd = tagRe.lastIndex;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
} else if (tag[2] !== '/') {
|
|
173
|
+
depth++;
|
|
174
|
+
}
|
|
175
|
+
tag = tagRe.exec(trimmed);
|
|
176
|
+
}
|
|
177
|
+
if (rootCloseEnd === -1 || trimmed.slice(rootCloseEnd).trim().length > 0) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
'SSR renderToString() must return exactly one root HTML element. ' +
|
|
180
|
+
'Current output: ' +
|
|
181
|
+
trimmed.slice(0, 100)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|