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 CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "vite-plugin-vanjs",
3
- "version": "0.1.25",
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": "https://github.com/thednp/vite-plugin-vanjs",
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.0",
90
+ "vanjs-core": "^1.6.1",
88
91
  "vanjs-ext": "^0.6.3"
89
92
  },
90
93
  "devDependencies": {
91
- "@types/node": "^25.9.3",
92
- "@vitest/browser": "^4.1.8",
93
- "@vitest/coverage-istanbul": "^4.1.8",
94
- "@vitest/ui": "^4.1.8",
95
- "happy-dom": "^20.10.3",
96
- "typescript": "6.0.3",
97
- "vite": "^8.0.16",
98
- "vitest": "^4.1.8"
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 --config vitest.config.ts",
106
- "test-ui": "vitest --config vitest.config.ts --ui=true",
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",
@@ -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(/\(.*\)$/, "") // Remove (file_name) from path
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
- join(dirname(dir), `(${dirName.replace(/^\((.*)\)$/, "$1")})${ext}`),
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
- // Use both shared and unique layouts for the component chain
257
- const pageComponent = route.layouts.reduce(
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
- "Page()",
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
- Page: () => ${pageComponent},
286
+ component,
287
+ layouts,
288
+ leaf,
271
289
  });
272
290
  };
273
291
  return importFn();
package/router/a.mjs CHANGED
@@ -41,7 +41,7 @@ export const A = (
41
41
 
42
42
  navigate(HREF);
43
43
  },
44
- onmouseenter: async () => {
44
+ onmouseenter: async (e) => {
45
45
  const HREF = getValue(href);
46
46
  const route = matchRoute(HREF);
47
47
 
@@ -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
  }
@@ -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
  */
@@ -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 = { component, route: module.route };
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 { executeModule } from "./helpers.mjs";
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
- const route = matchRoute(routerState.pathname);
50
- let _searchParams = routerState.searchParams;
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
- return await executeModule(route, wrapper, true);
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(routerState.pathname);
203
+ const matchedRoute = matchRoute(pathname);
89
204
  if (!matchedRoute) {
90
- wrapper.replaceChildren(div("No Route Found"));
205
+ liveTarget.replaceChildren(div("No Route Found"));
91
206
  return;
92
207
  }
93
208
  (async () => {
94
- _searchParams = routerState.searchParams;
95
- await executeModule(matchedRoute, wrapper);
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
- _searchParams = routerState.searchParams;
115
- await executeModule(matchedRoute, wrapper);
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
- return stateObj;
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) && Object.getPrototypeOf(value) === Object;
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
- target[prop] = defineProxy(sp, sv, {});
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 = { routesDir: string; extensions: string[] };
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
@@ -35,5 +35,5 @@
35
35
  "./server/*.ts",
36
36
  "./client/*.ts",
37
37
  ],
38
- "exclude": ["node_modules", "experiments", "coverage"],
38
+ "exclude": ["node_modules", "experiments", "coverage", "tests"],
39
39
  }