@esmx/router 3.0.0-rc.117 → 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/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 server-side rendering support
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
- root: '#app', // Required in browser environment
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 就绪** - 完整的 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
- root: '#app', // 浏览器环境中必需
59
+ appId: 'app', // 应用挂载容器 ID(可选,默认 'app')
60
60
  mode: RouterMode.history,
61
61
  routes: [
62
62
  { path: '/', component: () => '首页' },
@@ -1,13 +1,13 @@
1
1
  import type { Router } from './router';
2
2
  import type { RouterMicroAppOptions } from './types';
3
3
  /**
4
- * Resolves the root container element.
5
- * Supports a DOM selector string or a direct HTMLElement.
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 rootConfig - The root container configuration, can be a selector string or an HTMLElement.
7
+ * @param appId - The application container ID.
8
8
  * @returns The resolved HTMLElement.
9
9
  */
10
- export declare function resolveRootElement(rootConfig?: string | HTMLElement): HTMLElement;
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
  }
@@ -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 resolveRootElement(rootConfig) {
6
- let el = null;
7
- if (rootConfig instanceof HTMLElement) {
8
- el = rootConfig;
5
+ export function getRootElement(appId) {
6
+ const el = document.getElementById(appId);
7
+ if (el) {
8
+ return el;
9
9
  }
10
- if (typeof rootConfig === "string" && rootConfig) {
11
- try {
12
- el = document.querySelector(rootConfig);
13
- } catch (error) {
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 = resolveRootElement(router.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
- app.mount(root);
50
- if (root.parentNode === null) {
51
- document.body.appendChild(root);
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, _b;
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
- root: options.root || "",
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) {
@@ -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
- route,
130
- type,
131
- isActive,
132
- isExactActive,
133
- isExternal,
134
- tag: props.tag || "a",
135
- attributes,
136
- navigate,
137
- createEventHandlers
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 root(): string | HTMLElement;
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 { isNotNullish, isPlainObject, isRouteMatched } from "./util.mjs";
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 root() {
51
- return this.parsedOptions.root;
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
- root: void 0,
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
- return result != null ? result : null;
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 mounting container
226
- * - Can be a DOM selector string (e.g., '#app', '.container', '[data-mount]')
227
- * - Can be an HTMLElement object
228
- * - Defaults to '#root'
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
- * // Using ID selector
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
- root?: string | HTMLElement;
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.4",
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.4"
65
+ "vitest": "3.2.6"
41
66
  },
42
- "version": "3.0.0-rc.117",
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": "c8470bb917221494ee36f0c4ec1cd03560b01f4c"
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
- * Resolves the root container element.
7
- * Supports a DOM selector string or a direct HTMLElement.
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 rootConfig - The root container configuration, can be a selector string or an HTMLElement.
9
+ * @param appId - The application container ID.
10
10
  * @returns The resolved HTMLElement.
11
11
  */
12
- export function resolveRootElement(
13
- rootConfig?: string | HTMLElement
14
- ): HTMLElement {
15
- let el: HTMLElement | null = null;
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
- if (typeof rootConfig === 'string' && rootConfig) {
21
- try {
22
- el = document.querySelector(rootConfig);
23
- } catch (error) {
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 = resolveRootElement(router.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
- app.mount(root);
61
- if (root.parentNode === null) {
62
- document.body.appendChild(root);
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
- root: options.root || '',
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
 
@@ -224,15 +224,18 @@ export function createLinkResolver(
224
224
 
225
225
  const navigate = createNavigateFunction(router, props, type);
226
226
 
227
- return {
228
- route,
229
- type,
230
- isActive,
231
- isExactActive,
232
- isExternal,
233
- tag: props.tag || 'a',
234
- attributes,
235
- navigate,
236
- createEventHandlers
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 { isNotNullish, isPlainObject, isRouteMatched } from './util';
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 root() {
51
- return this.parsedOptions.root;
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
- root: undefined,
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
- return result ?? null;
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 mounting container
299
- * - Can be a DOM selector string (e.g., '#app', '.container', '[data-mount]')
300
- * - Can be an HTMLElement object
301
- * - Defaults to '#root'
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
- * // Using ID selector
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
- root?: string | HTMLElement;
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
- // Avoid trailing hash symbol impact from empty hash
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
+ }