vite-plugin-vanjs 0.1.25 → 0.2.1
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/package.json +17 -17
- package/plugin/helpers.mjs +30 -12
- package/router/a.mjs +1 -1
- package/router/dataCache.mjs +1 -0
- package/router/global.d.ts +39 -0
- package/router/helpers.mjs +3 -3
- package/router/lazy.mjs +7 -2
- package/router/router.mjs +179 -14
- package/router/state.mjs +28 -6
- package/router/types.d.ts +39 -0
- package/server/types.d.ts +15 -1
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vite-plugin-vanjs",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"author": "thednp",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "An async first mini meta-framework for VanJS powered by Vite",
|
|
7
|
-
"repository":
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/thednp/vite-plugin-vanjs"
|
|
10
|
+
},
|
|
8
11
|
"type": "module",
|
|
9
12
|
"sideEffects": false,
|
|
10
13
|
"main": "./plugin/index.mjs",
|
|
@@ -84,26 +87,23 @@
|
|
|
84
87
|
"dependencies": {
|
|
85
88
|
"csstype": "^3.2.3",
|
|
86
89
|
"mini-van-plate": "^0.6.3",
|
|
87
|
-
"vanjs-core": "^1.6.
|
|
90
|
+
"vanjs-core": "^1.6.1",
|
|
88
91
|
"vanjs-ext": "^0.6.3"
|
|
89
92
|
},
|
|
90
93
|
"devDependencies": {
|
|
91
|
-
"@types/node": "^
|
|
92
|
-
"@vitest/browser": "^
|
|
93
|
-
"@vitest/coverage-istanbul": "^
|
|
94
|
-
"@vitest/ui": "^
|
|
95
|
-
"happy-dom": "^20.
|
|
96
|
-
"typescript": "
|
|
97
|
-
"vite": "^8.0
|
|
98
|
-
"vitest": "^
|
|
99
|
-
},
|
|
100
|
-
"engines": {
|
|
101
|
-
"node": ">=20",
|
|
102
|
-
"pnpm": ">=8.6.0"
|
|
94
|
+
"@types/node": "^26.5.1",
|
|
95
|
+
"@vitest/browser": "^5.0.1",
|
|
96
|
+
"@vitest/coverage-istanbul": "^5.0.1",
|
|
97
|
+
"@vitest/ui": "^5.0.1",
|
|
98
|
+
"happy-dom": "^20.14.5",
|
|
99
|
+
"typescript": "7.0.2",
|
|
100
|
+
"vite": "^8.3.0",
|
|
101
|
+
"vitest": "^5.0.1"
|
|
103
102
|
},
|
|
104
103
|
"scripts": {
|
|
105
|
-
"test": "vitest --
|
|
106
|
-
"test
|
|
104
|
+
"test": "vitest run --coverage",
|
|
105
|
+
"test:watch": "vitest --coverage",
|
|
106
|
+
"test:ui": "vitest --config --ui=true",
|
|
107
107
|
"format": "deno fmt plugin meta router setup client server jsx",
|
|
108
108
|
"lint": "pnpm lint:ts && pnpm check:ts",
|
|
109
109
|
"lint:ts": "deno lint plugin meta router setup client server jsx",
|
package/plugin/helpers.mjs
CHANGED
|
@@ -16,10 +16,11 @@ import process from "node:process";
|
|
|
16
16
|
export const fileToRoute = (file, routesDir) => {
|
|
17
17
|
const cleanPath = file
|
|
18
18
|
.slice(routesDir.length + 1) // also remove initial slash
|
|
19
|
+
.replace(/\\/g, "/") // normalize Windows backslashes to forward slashes
|
|
19
20
|
.replace(/\.(jsx|tsx|ts|js)$/, "")
|
|
20
21
|
.replace(/index$/, "")
|
|
21
|
-
.replace(/\(
|
|
22
|
-
.replace(/\([^)]+\)\/?/g, "") // Remove (folder_name) from path
|
|
22
|
+
.replace(/\([^()]*\)$/, "") // Remove (file_name) from path
|
|
23
|
+
.replace(/\([^()]+\)\/?/g, "") // Remove (folder_name) from path
|
|
23
24
|
.replace(/\[\.\.\.[^\]]+\]/g, "*")
|
|
24
25
|
.replace(/\[([^\]]+)\]/g, ":$1");
|
|
25
26
|
const slashPath = cleanPath.endsWith("/")
|
|
@@ -136,7 +137,7 @@ export const findLayouts = (routePath, config, pluginConfig) => {
|
|
|
136
137
|
const { routesDir, extensions } = pluginConfig;
|
|
137
138
|
const layouts = [];
|
|
138
139
|
let dir = dirname(routePath);
|
|
139
|
-
const routesPath = join(config.root, routesDir);
|
|
140
|
+
const routesPath = normalizePath(join(config.root, routesDir));
|
|
140
141
|
|
|
141
142
|
// Walk up the directory tree looking for layout files
|
|
142
143
|
while (dir.startsWith(routesPath) && dir !== routesPath) { // Stop at routes dir
|
|
@@ -148,8 +149,13 @@ export const findLayouts = (routePath, config, pluginConfig) => {
|
|
|
148
149
|
// Look for a layout file in the current directory
|
|
149
150
|
for (const ext of extensions) {
|
|
150
151
|
const layoutPaths = [
|
|
151
|
-
join(dirname(dir), `${dirName}${ext}`),
|
|
152
|
-
|
|
152
|
+
normalizePath(join(dirname(dir), `${dirName}${ext}`)),
|
|
153
|
+
normalizePath(
|
|
154
|
+
join(
|
|
155
|
+
dirname(dir),
|
|
156
|
+
`(${dirName.replace(/^\((.*)\)$/, "$1")})${ext}`,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
153
159
|
];
|
|
154
160
|
|
|
155
161
|
for (const path of layoutPaths) {
|
|
@@ -162,10 +168,10 @@ export const findLayouts = (routePath, config, pluginConfig) => {
|
|
|
162
168
|
}
|
|
163
169
|
|
|
164
170
|
// istanbul ignore else
|
|
165
|
-
if (layoutFile && layoutFile !== routePath) {
|
|
171
|
+
if (layoutFile && normalizePath(layoutFile) !== routePath) {
|
|
166
172
|
layouts.unshift({
|
|
167
173
|
id: `Layout${layouts.length}`,
|
|
168
|
-
path: layoutFile,
|
|
174
|
+
path: normalizePath(layoutFile),
|
|
169
175
|
});
|
|
170
176
|
}
|
|
171
177
|
|
|
@@ -246,17 +252,24 @@ load: async (params) => {
|
|
|
246
252
|
/** @type {(route: RouteFile) => string} */
|
|
247
253
|
export const generateComponentRoute = (route) => {
|
|
248
254
|
if (route.layouts?.length > 0) {
|
|
249
|
-
// Only generate imports for unique layouts
|
|
250
255
|
const layoutImports = route.layouts.map(
|
|
251
256
|
(layout) =>
|
|
252
257
|
`const ${layout.id}Module = await import('${layout.path}');\n` +
|
|
253
258
|
`const ${layout.id}Page = ${layout.id}Module.Layout || ${layout.id}Module.Page || ${layout.id}Module.default;`,
|
|
254
259
|
).join("\n");
|
|
255
260
|
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
const layoutsArray = `[${
|
|
262
|
+
route.layouts.map(
|
|
263
|
+
(layout) =>
|
|
264
|
+
`{ path: ${
|
|
265
|
+
JSON.stringify(layout.path)
|
|
266
|
+
}, component: ${layout.id}Page }`,
|
|
267
|
+
).join(", ")
|
|
268
|
+
}]`;
|
|
269
|
+
|
|
270
|
+
const chainBuild = route.layouts.reduceRight(
|
|
258
271
|
(acc, layout) => `${layout.id}Page({ children: ${acc} })`,
|
|
259
|
-
"
|
|
272
|
+
"leaf()",
|
|
260
273
|
);
|
|
261
274
|
|
|
262
275
|
return `lazy(() => {
|
|
@@ -264,10 +277,15 @@ export const generateComponentRoute = (route) => {
|
|
|
264
277
|
${layoutImports}
|
|
265
278
|
const PageModule = await import('${route.path}');
|
|
266
279
|
const Page = PageModule?.Page || PageModule?.default;
|
|
280
|
+
const layouts = ${layoutsArray};
|
|
281
|
+
const leaf = () => Page();
|
|
282
|
+
const component = () => ${chainBuild};
|
|
267
283
|
|
|
268
284
|
return Promise.resolve({
|
|
269
285
|
route: ${generateRouteProloaders(route)},
|
|
270
|
-
|
|
286
|
+
component,
|
|
287
|
+
layouts,
|
|
288
|
+
leaf,
|
|
271
289
|
});
|
|
272
290
|
};
|
|
273
291
|
return importFn();
|
package/router/a.mjs
CHANGED
package/router/dataCache.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const evictIfNeeded = () => {
|
|
|
27
27
|
if (maxRoutes <= 0) return;
|
|
28
28
|
while (dataCacheMap.size > maxRoutes) {
|
|
29
29
|
const firstKey = dataCacheMap.keys().next().value;
|
|
30
|
+
/* istanbul ignore else - the map is guaranteed to have entries here */
|
|
30
31
|
if (firstKey !== undefined) {
|
|
31
32
|
dataCacheMap.delete(firstKey);
|
|
32
33
|
}
|
package/router/global.d.ts
CHANGED
|
@@ -119,6 +119,12 @@ declare module "@vanjs/router" {
|
|
|
119
119
|
*/
|
|
120
120
|
export const getValue: (v: unknown) => string;
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Build the data cache key for the current route, based on the
|
|
124
|
+
* current route params and the current search params.
|
|
125
|
+
*/
|
|
126
|
+
export const getCacheKey: () => string;
|
|
127
|
+
|
|
122
128
|
/**
|
|
123
129
|
* Check if selected page is the current page
|
|
124
130
|
*/
|
|
@@ -253,14 +259,23 @@ declare module "@vanjs/router" {
|
|
|
253
259
|
export type ComponentModule = {
|
|
254
260
|
component: ComponentFn;
|
|
255
261
|
route?: Pick<RouteEntry, "load" | "preload">;
|
|
262
|
+
layouts?: RouteLayout[];
|
|
263
|
+
leaf?: ComponentFn;
|
|
256
264
|
};
|
|
257
265
|
|
|
258
266
|
export type LazyComponent = Promise<{
|
|
259
267
|
default?: ComponentFn;
|
|
260
268
|
Page?: ComponentFn;
|
|
261
269
|
route?: Pick<RouteEntry, "load" | "preload">;
|
|
270
|
+
layouts?: RouteLayout[];
|
|
271
|
+
leaf?: ComponentFn;
|
|
262
272
|
}>;
|
|
263
273
|
|
|
274
|
+
export type RouteLayout = {
|
|
275
|
+
path: string;
|
|
276
|
+
component: ComponentFn;
|
|
277
|
+
};
|
|
278
|
+
|
|
264
279
|
/**
|
|
265
280
|
* Registers a lazy component.
|
|
266
281
|
* @param importFn
|
|
@@ -290,6 +305,30 @@ declare module "@vanjs/router" {
|
|
|
290
305
|
} | null,
|
|
291
306
|
) => Promise<boolean>;
|
|
292
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Resolve a route component, execute its lifecycle methods and render
|
|
310
|
+
* the resulting children into the given wrapper.
|
|
311
|
+
* @param route the matched route
|
|
312
|
+
* @param wrapper the element that hosts the route children
|
|
313
|
+
* @param ssr when true the children are appended instead of replaced
|
|
314
|
+
*/
|
|
315
|
+
export const executeModule: (
|
|
316
|
+
route: RouteEntry,
|
|
317
|
+
wrapper: HTMLElement,
|
|
318
|
+
ssr?: boolean,
|
|
319
|
+
) => Promise<HTMLElement | void>;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Resolve the children of a component module, an element or an array of elements.
|
|
323
|
+
*/
|
|
324
|
+
export const resolveChildren: (
|
|
325
|
+
module:
|
|
326
|
+
| ComponentModule
|
|
327
|
+
| VanElement
|
|
328
|
+
| VanElement[]
|
|
329
|
+
| { component?: ComponentFn | VanElement },
|
|
330
|
+
) => VanNode[];
|
|
331
|
+
|
|
293
332
|
/**
|
|
294
333
|
* Find a registered route that matches the given path
|
|
295
334
|
*/
|
package/router/helpers.mjs
CHANGED
|
@@ -148,9 +148,9 @@ export const executeLifecycle = async (route) => {
|
|
|
148
148
|
* @returns
|
|
149
149
|
*/
|
|
150
150
|
export const executeModule = async (route, wrapper, ssr) => {
|
|
151
|
-
if (routerState.loading === true) return;
|
|
151
|
+
if (routerState._oldVal.loading === true) return;
|
|
152
152
|
// 0. Set Loading State
|
|
153
|
-
routerState.loading = true;
|
|
153
|
+
routerState._oldVal.loading = true;
|
|
154
154
|
try {
|
|
155
155
|
// 1. Resolve the module first (to get route lifecycle hooks)
|
|
156
156
|
const module = await route.component();
|
|
@@ -165,7 +165,7 @@ export const executeModule = async (route, wrapper, ssr) => {
|
|
|
165
165
|
else wrapper.replaceChildren(...children);
|
|
166
166
|
} finally {
|
|
167
167
|
// 5. Set Loading State
|
|
168
|
-
routerState.loading = false;
|
|
168
|
+
routerState._oldVal.loading = false;
|
|
169
169
|
}
|
|
170
170
|
};
|
|
171
171
|
|
package/router/lazy.mjs
CHANGED
|
@@ -21,9 +21,14 @@ export const lazy = (importFn) => {
|
|
|
21
21
|
|
|
22
22
|
const module = await importFn();
|
|
23
23
|
/** @type {ComponentFn} */
|
|
24
|
-
const component = module?.default || module.Page;
|
|
24
|
+
const component = module?.default || module.Page || module.component;
|
|
25
25
|
/** @type {ComponentModule} */
|
|
26
|
-
const result = {
|
|
26
|
+
const result = {
|
|
27
|
+
component,
|
|
28
|
+
route: module.route,
|
|
29
|
+
layouts: module.layouts,
|
|
30
|
+
leaf: module.leaf,
|
|
31
|
+
};
|
|
27
32
|
|
|
28
33
|
cacheRoute(importFn, result);
|
|
29
34
|
return result;
|
package/router/router.mjs
CHANGED
|
@@ -3,8 +3,14 @@ import isServer from "../setup/isServer.mjs";
|
|
|
3
3
|
import { MODE } from "../plugin/const.mjs";
|
|
4
4
|
import { routerState, setRouterState } from "./state.mjs";
|
|
5
5
|
import { matchRoute } from "./matchRoute.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
executeLifecycle,
|
|
8
|
+
executeModule,
|
|
9
|
+
resolveChildren,
|
|
10
|
+
} from "./helpers.mjs";
|
|
7
11
|
import { initializeHeadTags } from "../meta/index.mjs";
|
|
12
|
+
import { hydrate } from "../client/index.mjs";
|
|
13
|
+
import { Head } from "../meta/index.mjs";
|
|
8
14
|
import * as dataCache from "./dataCache.mjs";
|
|
9
15
|
import "virtual:@vanjs/routes";
|
|
10
16
|
|
|
@@ -13,6 +19,7 @@ const isDev = MODE === "development";
|
|
|
13
19
|
/** @typedef {import("./types.d.ts").ComponentModule} ComponentModule */
|
|
14
20
|
/** @typedef {import("./types.d.ts").RouteEntry} RouteEntry */
|
|
15
21
|
/** @typedef {import("./types.d.ts").VanNode} VanNode */
|
|
22
|
+
/** @typedef {import("./types.d.ts").RouteLayout} RouteLayout */
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Initialize client-side router (Head + popstate listener)
|
|
@@ -40,32 +47,79 @@ const initClient = () => {
|
|
|
40
47
|
_initialized = true;
|
|
41
48
|
};
|
|
42
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Build the layout chain + leaf into DOM nodes.
|
|
52
|
+
* @param {{ layouts?: RouteLayout[], leaf?: () => any }} mod
|
|
53
|
+
* @param {HTMLElement} outlet
|
|
54
|
+
* @returns {import("@vanjs/router").DOMElement[]}
|
|
55
|
+
*/
|
|
56
|
+
const buildChain = (mod, outlet) => {
|
|
57
|
+
const chain = mod.layouts ?? /* istanbul ignore next */ [];
|
|
58
|
+
const leafFn = mod.leaf;
|
|
59
|
+
|
|
60
|
+
if (chain.length === 0) {
|
|
61
|
+
const nodes = leafFn ? leafFn() : /* istanbul ignore next */ [];
|
|
62
|
+
/* istanbul ignore next */
|
|
63
|
+
return Array.isArray(nodes) ? nodes : [nodes];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fill the outlet with the leaf content
|
|
67
|
+
const leafContent = leafFn ? leafFn() : /* istanbul ignore next */ [];
|
|
68
|
+
/* istanbul ignore else */
|
|
69
|
+
if (Array.isArray(leafContent)) {
|
|
70
|
+
outlet.replaceChildren(...leafContent);
|
|
71
|
+
} else {
|
|
72
|
+
outlet.replaceChildren(leafContent);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Build outside-in: outermost layout wraps innermost → ... → leaf outlet
|
|
76
|
+
let content = [outlet];
|
|
77
|
+
for (let k = chain.length - 1; k >= 0; k--) {
|
|
78
|
+
content = chain[k].component({ children: content });
|
|
79
|
+
/* istanbul ignore else */
|
|
80
|
+
if (!Array.isArray(content)) content = [content];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return content;
|
|
84
|
+
};
|
|
85
|
+
|
|
43
86
|
export const Router = (initialProps = /* istanbul ignore next */ {}) => {
|
|
44
87
|
const { div, main } = van.tags;
|
|
45
88
|
const props = Object.fromEntries(
|
|
46
89
|
Object.entries(initialProps).filter(([_, val]) => val !== undefined),
|
|
47
90
|
);
|
|
48
91
|
const wrapper = main({ ...props, "data-root": "" });
|
|
49
|
-
|
|
50
|
-
|
|
92
|
+
// Read the initial route without subscribing: Router() is typically
|
|
93
|
+
// invoked inside a reactive context (e.g. hydrate(main, App)), and a
|
|
94
|
+
// reactive read here would re-create the whole Router on every
|
|
95
|
+
// navigation. Only the internal derive below subscribes, to pathname
|
|
96
|
+
// and searchParams.
|
|
97
|
+
const route = matchRoute(routerState._oldVal.pathname);
|
|
98
|
+
let _searchParams = routerState._oldVal.searchParams;
|
|
51
99
|
|
|
52
100
|
/* istanbul ignore else */
|
|
53
101
|
if (!route) return van.add(wrapper, div("No Route Found"));
|
|
54
102
|
// It's important to READ the params
|
|
55
103
|
Object.assign(routerState.params, route.params);
|
|
56
104
|
|
|
57
|
-
// Server-side rendering
|
|
105
|
+
// Server-side rendering — single-pass full render in one shot.
|
|
106
|
+
// No reactivity and no shared loading flag: concurrent requests share
|
|
107
|
+
// the routerState singleton, so the client-side loading guard would
|
|
108
|
+
// wrongly bail out with `undefined` on overlapping requests.
|
|
58
109
|
if (isServer) {
|
|
59
|
-
return async () => {
|
|
110
|
+
return (async () => {
|
|
60
111
|
try {
|
|
61
|
-
|
|
112
|
+
const module = await route.component();
|
|
113
|
+
await executeLifecycle(Object.assign(route, module.route));
|
|
114
|
+
const children = resolveChildren(module);
|
|
115
|
+
return van.add(wrapper, ...children);
|
|
62
116
|
} catch (error) {
|
|
63
117
|
/* istanbul ignore next */
|
|
64
118
|
console.error("Router error:", error);
|
|
65
119
|
/* istanbul ignore next */
|
|
66
120
|
return van.add(wrapper, div("Error loading page"));
|
|
67
121
|
}
|
|
68
|
-
};
|
|
122
|
+
})();
|
|
69
123
|
}
|
|
70
124
|
|
|
71
125
|
// Init client here
|
|
@@ -79,31 +133,119 @@ export const Router = (initialProps = /* istanbul ignore next */ {}) => {
|
|
|
79
133
|
dataCache.hydrateFromJSON(globalThis.__DATA_CACHE);
|
|
80
134
|
}
|
|
81
135
|
|
|
136
|
+
// Persistent layout chain keys for prefix-diff tracking (client only)
|
|
137
|
+
/** @type {string[]} */
|
|
138
|
+
let layoutKeys = [];
|
|
139
|
+
/** @type {HTMLElement | null} */
|
|
140
|
+
let outlet = null;
|
|
141
|
+
let navToken = 0;
|
|
142
|
+
// Live DOM target for client-side navigations. On the hydration path this
|
|
143
|
+
// starts as the detached wrapper (used for the initial render that hydrate()
|
|
144
|
+
// diffs into the SSR root) and is adopted to the real root once the initial
|
|
145
|
+
// render completes. On the SPA path it stays the wrapper, which is live.
|
|
146
|
+
/** @type {any} */
|
|
147
|
+
let liveTarget = wrapper;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Navigate to a new route, rendering the layout chain.
|
|
151
|
+
* Uses prefix-diff to detect shared layouts and only rebuilds the diverged suffix.
|
|
152
|
+
* @param {{ layouts?: RouteLayout[], leaf?: () => any }} mod
|
|
153
|
+
*/
|
|
154
|
+
const navigateToModule = (mod) => {
|
|
155
|
+
const newKeys = (mod.layouts ?? /* istanbul ignore next */ []).map((l) =>
|
|
156
|
+
l.path
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Find shared prefix length
|
|
160
|
+
let keepCount = 0;
|
|
161
|
+
while (
|
|
162
|
+
keepCount < layoutKeys.length && keepCount < newKeys.length &&
|
|
163
|
+
layoutKeys[keepCount] === newKeys[keepCount]
|
|
164
|
+
) {
|
|
165
|
+
keepCount++;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
layoutKeys = newKeys;
|
|
169
|
+
|
|
170
|
+
if (keepCount > 0 && keepCount === layoutKeys.length && outlet) {
|
|
171
|
+
// All layouts shared — only the leaf changed.
|
|
172
|
+
// Directly swap the outlet's children; layout DOM is untouched.
|
|
173
|
+
const leafFn = mod.leaf;
|
|
174
|
+
const leafContent = leafFn ? leafFn() : /* istanbul ignore next */ [];
|
|
175
|
+
/* istanbul ignore else */
|
|
176
|
+
if (Array.isArray(leafContent)) {
|
|
177
|
+
outlet.replaceChildren(...leafContent);
|
|
178
|
+
} else {
|
|
179
|
+
outlet.replaceChildren(leafContent);
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// Diverged layout chain — full rebuild with a fresh outlet.
|
|
183
|
+
const div = van.tags.div;
|
|
184
|
+
outlet = div();
|
|
185
|
+
const children = buildChain(mod, outlet);
|
|
186
|
+
liveTarget.replaceChildren(...children);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
82
190
|
// Client-side: check if hydrating SSR content or pure SPA
|
|
83
191
|
const root = document.querySelector("[data-root]");
|
|
84
192
|
|
|
85
193
|
if (root) {
|
|
86
194
|
van.derive(() => {
|
|
195
|
+
// Subscribe to pathname AND searchParams: either one triggers a
|
|
196
|
+
// navigation (e.g. search-only changes keep the same pathname).
|
|
197
|
+
// VanJS batches the synchronous writes from setRouterState into a
|
|
198
|
+
// single run, and same-value writes don't re-trigger at all.
|
|
199
|
+
// Everything else is read via _oldVal to avoid extra runs.
|
|
200
|
+
const pathname = routerState.pathname;
|
|
201
|
+
_searchParams = routerState.searchParams;
|
|
87
202
|
if (!initialized) return;
|
|
88
|
-
const matchedRoute = matchRoute(
|
|
203
|
+
const matchedRoute = matchRoute(pathname);
|
|
89
204
|
if (!matchedRoute) {
|
|
90
|
-
|
|
205
|
+
liveTarget.replaceChildren(div("No Route Found"));
|
|
91
206
|
return;
|
|
92
207
|
}
|
|
93
208
|
(async () => {
|
|
94
|
-
|
|
95
|
-
|
|
209
|
+
const token = ++navToken;
|
|
210
|
+
routerState._oldVal.loading = true;
|
|
211
|
+
try {
|
|
212
|
+
const module = await matchedRoute.component();
|
|
213
|
+
/* istanbul ignore next */
|
|
214
|
+
if (token !== navToken) return;
|
|
215
|
+
await executeLifecycle(Object.assign(matchedRoute, module.route));
|
|
216
|
+
// A newer navigation may have started during the (possibly slow)
|
|
217
|
+
// lifecycle: never render stale results over fresh ones.
|
|
218
|
+
if (token !== navToken) return;
|
|
219
|
+
/* istanbul ignore else */
|
|
220
|
+
if (document.head) hydrate(document.head, Head());
|
|
221
|
+
if (module.layouts) {
|
|
222
|
+
navigateToModule(module);
|
|
223
|
+
} else {
|
|
224
|
+
layoutKeys = [];
|
|
225
|
+
outlet = null;
|
|
226
|
+
const children = resolveChildren(module);
|
|
227
|
+
liveTarget.replaceChildren(...children);
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
routerState._oldVal.loading = false;
|
|
231
|
+
}
|
|
96
232
|
})();
|
|
97
233
|
});
|
|
98
234
|
return async () => {
|
|
99
235
|
const result = await executeModule(route, wrapper, true);
|
|
236
|
+
// Adopt the live SSR root: subsequent navigations mutate the real DOM,
|
|
237
|
+
// not the detached wrapper used for the initial render.
|
|
238
|
+
liveTarget = root;
|
|
100
239
|
initialized = true;
|
|
101
240
|
return result;
|
|
102
241
|
};
|
|
103
242
|
}
|
|
104
243
|
|
|
105
|
-
// Pure SPA path - reactive routing
|
|
244
|
+
// Pure SPA path - reactive routing.
|
|
245
|
+
// Subscribed to pathname and searchParams (batched into a single run);
|
|
246
|
+
// everything else goes through _oldVal.
|
|
106
247
|
van.derive(() => {
|
|
248
|
+
_searchParams = routerState.searchParams;
|
|
107
249
|
const matchedRoute = matchRoute(routerState.pathname);
|
|
108
250
|
if (!matchedRoute) {
|
|
109
251
|
wrapper.replaceChildren(div("No Route Found"));
|
|
@@ -111,8 +253,31 @@ export const Router = (initialProps = /* istanbul ignore next */ {}) => {
|
|
|
111
253
|
}
|
|
112
254
|
|
|
113
255
|
(async () => {
|
|
114
|
-
|
|
115
|
-
|
|
256
|
+
const token = ++navToken;
|
|
257
|
+
// routerState._oldVal.loading = true;
|
|
258
|
+
routerState.loading = true;
|
|
259
|
+
try {
|
|
260
|
+
_searchParams = routerState.searchParams;
|
|
261
|
+
const module = await matchedRoute.component();
|
|
262
|
+
if (token !== navToken) return;
|
|
263
|
+
await executeLifecycle(Object.assign(matchedRoute, module.route));
|
|
264
|
+
// A newer navigation may have started during the (possibly slow)
|
|
265
|
+
// lifecycle: never render stale results over fresh ones.
|
|
266
|
+
if (token !== navToken) return;
|
|
267
|
+
/* istanbul ignore else */
|
|
268
|
+
if (document.head) hydrate(document.head, Head());
|
|
269
|
+
if (module.layouts) {
|
|
270
|
+
navigateToModule(module);
|
|
271
|
+
} else {
|
|
272
|
+
layoutKeys = [];
|
|
273
|
+
outlet = null;
|
|
274
|
+
const children = resolveChildren(module);
|
|
275
|
+
wrapper.replaceChildren(...children);
|
|
276
|
+
}
|
|
277
|
+
} finally {
|
|
278
|
+
// routerState._oldVal.loading = false;
|
|
279
|
+
routerState.loading = false;
|
|
280
|
+
}
|
|
116
281
|
})();
|
|
117
282
|
});
|
|
118
283
|
|
package/router/state.mjs
CHANGED
|
@@ -42,7 +42,7 @@ const proxyProps = {
|
|
|
42
42
|
* @param {Record<string, string | number>} target
|
|
43
43
|
* @returns {T}
|
|
44
44
|
*/
|
|
45
|
-
const defineProxy = (key, value, target) => {
|
|
45
|
+
const defineProxy = (key, value, target, oldVal) => {
|
|
46
46
|
const stateObj = van.state(value);
|
|
47
47
|
|
|
48
48
|
const getter = () => stateObj.val;
|
|
@@ -51,6 +51,9 @@ const defineProxy = (key, value, target) => {
|
|
|
51
51
|
};
|
|
52
52
|
stateObj.val = value;
|
|
53
53
|
|
|
54
|
+
const hasOwnRawVal = Object.getOwnPropertyDescriptor(stateObj, "rawVal")
|
|
55
|
+
?.writable;
|
|
56
|
+
|
|
54
57
|
Object.defineProperties(target, {
|
|
55
58
|
[STATE_PROXY]: proxyProps,
|
|
56
59
|
[key]: {
|
|
@@ -60,7 +63,14 @@ const defineProxy = (key, value, target) => {
|
|
|
60
63
|
},
|
|
61
64
|
});
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
Object.defineProperty(oldVal, key, {
|
|
67
|
+
get: () => stateObj.rawVal,
|
|
68
|
+
set: (v) => {
|
|
69
|
+
if (hasOwnRawVal) stateObj.rawVal = v;
|
|
70
|
+
else stateObj.val = v;
|
|
71
|
+
},
|
|
72
|
+
enumerable: true,
|
|
73
|
+
});
|
|
64
74
|
};
|
|
65
75
|
|
|
66
76
|
/** @typedef */
|
|
@@ -73,22 +83,34 @@ const defineProxy = (key, value, target) => {
|
|
|
73
83
|
export function microStore(init) {
|
|
74
84
|
/** @type {T} */
|
|
75
85
|
const target = {};
|
|
86
|
+
/** @type {Record<string, unknown>} */
|
|
87
|
+
const oldVal = {};
|
|
76
88
|
for (const [prop, value] of Object.entries(init)) {
|
|
77
89
|
const isPlainObject = value && typeof value === "object" &&
|
|
78
|
-
!Array.isArray(value) &&
|
|
90
|
+
!Array.isArray(value) &&
|
|
91
|
+
Object.getPrototypeOf(value) === Object.prototype;
|
|
79
92
|
|
|
80
93
|
if (isPlainObject && Object.keys(value).length > 0) {
|
|
94
|
+
/** @type {Record<string, string | number>} */
|
|
95
|
+
const nested = {};
|
|
81
96
|
for (const [sp, sv] of Object.entries(value)) {
|
|
82
|
-
|
|
97
|
+
defineProxy(sp, sv, nested, oldVal);
|
|
83
98
|
}
|
|
99
|
+
defineProxy(prop, nested, target, oldVal);
|
|
84
100
|
} else if (isPlainObject) {
|
|
85
|
-
defineProxy(prop, value, target);
|
|
101
|
+
defineProxy(prop, value, target, oldVal);
|
|
86
102
|
} else if (!Array.isArray(value) && value != null) {
|
|
87
|
-
defineProxy(prop, value, target);
|
|
103
|
+
defineProxy(prop, value, target, oldVal);
|
|
88
104
|
} else {
|
|
89
105
|
console.warn(typeof value + " is not supported.");
|
|
90
106
|
}
|
|
91
107
|
}
|
|
108
|
+
|
|
109
|
+
Object.defineProperty(target, "_oldVal", {
|
|
110
|
+
get: () => oldVal,
|
|
111
|
+
enumerable: false,
|
|
112
|
+
});
|
|
113
|
+
|
|
92
114
|
return target;
|
|
93
115
|
}
|
|
94
116
|
|
package/router/types.d.ts
CHANGED
|
@@ -75,6 +75,12 @@ export const redirect: (href?: string) => void | (() => void);
|
|
|
75
75
|
|
|
76
76
|
export const getValue: (v: unknown) => string;
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Build the data cache key for the current route, based on the
|
|
80
|
+
* current route params and the current search params.
|
|
81
|
+
*/
|
|
82
|
+
export const getCacheKey: () => string;
|
|
83
|
+
|
|
78
84
|
export const isCurrentPage: (pageName: string) => boolean;
|
|
79
85
|
|
|
80
86
|
export const isCurrentLocation: (pageName: string) => boolean;
|
|
@@ -88,6 +94,30 @@ export const executeLifecycle: (
|
|
|
88
94
|
} | null,
|
|
89
95
|
) => Promise<boolean>;
|
|
90
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Resolve a route component, execute its lifecycle methods and render
|
|
99
|
+
* the resulting children into the given wrapper.
|
|
100
|
+
* @param route the matched route
|
|
101
|
+
* @param wrapper the element that hosts the route children
|
|
102
|
+
* @param ssr when true the children are appended instead of replaced
|
|
103
|
+
*/
|
|
104
|
+
export const executeModule: (
|
|
105
|
+
route: RouteEntry,
|
|
106
|
+
wrapper: HTMLElement,
|
|
107
|
+
ssr?: boolean,
|
|
108
|
+
) => Promise<HTMLElement | void>;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the children of a component module, an element or an array of elements.
|
|
112
|
+
*/
|
|
113
|
+
export const resolveChildren: (
|
|
114
|
+
module:
|
|
115
|
+
| ComponentModule
|
|
116
|
+
| VanElement
|
|
117
|
+
| VanElement[]
|
|
118
|
+
| { component?: ComponentFn | VanElement },
|
|
119
|
+
) => VanNode[];
|
|
120
|
+
|
|
91
121
|
export const useRouteData: <T>() => T | undefined;
|
|
92
122
|
|
|
93
123
|
export const matchRoute: (path: string) => RouteEntry | null;
|
|
@@ -190,14 +220,23 @@ export type ComponentFn = FragmentFn | VanComponent | JSXComponentFn;
|
|
|
190
220
|
export type ComponentModule = {
|
|
191
221
|
component: ComponentFn;
|
|
192
222
|
route?: Pick<RouteEntry, "load" | "preload">;
|
|
223
|
+
layouts?: RouteLayout[];
|
|
224
|
+
leaf?: ComponentFn;
|
|
193
225
|
};
|
|
194
226
|
|
|
195
227
|
export type LazyComponent = Promise<{
|
|
196
228
|
default?: ComponentFn;
|
|
197
229
|
Page?: ComponentFn;
|
|
198
230
|
route?: Pick<RouteEntry, "load" | "preload">;
|
|
231
|
+
layouts?: RouteLayout[];
|
|
232
|
+
leaf?: ComponentFn;
|
|
199
233
|
}>;
|
|
200
234
|
|
|
235
|
+
export type RouteLayout = {
|
|
236
|
+
path: string;
|
|
237
|
+
component: ComponentFn;
|
|
238
|
+
};
|
|
239
|
+
|
|
201
240
|
// dataCache.mjs
|
|
202
241
|
export type CacheEntry<T = unknown> = {
|
|
203
242
|
data: T;
|
package/server/types.d.ts
CHANGED
|
@@ -38,6 +38,13 @@ export type Source =
|
|
|
38
38
|
*/
|
|
39
39
|
export const renderToString: (source: Source) => Promise<string>;
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A function that generates a <script> for initial hydration data.
|
|
43
|
+
* Serializes the full path-keyed data cache into window.__DATA_CACHE.
|
|
44
|
+
* @returns HTML string
|
|
45
|
+
*/
|
|
46
|
+
export const getDataPreload: () => string;
|
|
47
|
+
|
|
41
48
|
// FILE SYSTEM ROUTER
|
|
42
49
|
/**
|
|
43
50
|
* Get the file most probable route path for a given potential route.
|
|
@@ -47,7 +54,14 @@ export const fileToRoute: (file: string, routesDir: string) => string;
|
|
|
47
54
|
export type PageFile = { path: string; routePath: string };
|
|
48
55
|
export type LayoutFile = { id: string; path: string };
|
|
49
56
|
export type RouteFile = PageFile & { layouts: Array<LayoutFile> };
|
|
50
|
-
export type PluginConfig = {
|
|
57
|
+
export type PluginConfig = {
|
|
58
|
+
routesDir: string;
|
|
59
|
+
extensions: string[];
|
|
60
|
+
/** route paths excluded in all environments */
|
|
61
|
+
excludeRoutes?: string[];
|
|
62
|
+
/** route paths excluded in production only */
|
|
63
|
+
excludeRoutesProd?: string[];
|
|
64
|
+
};
|
|
51
65
|
/**
|
|
52
66
|
* Find all layout files for a given route.
|
|
53
67
|
*/
|
package/tsconfig.json
CHANGED