vite-plugin-vanjs 0.2.2 → 0.2.4

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/AGENTS.md ADDED
@@ -0,0 +1,72 @@
1
+ # AGENTS.md
2
+
3
+ ## Project Overview
4
+
5
+ **vite-plugin-vanjs** is a mini meta-framework for [VanJS](https://vanjs.org/) powered by Vite. It provides file-system routing, SSR/SSG, JSX support, metadata management, and a data caching layer — all as a Vite plugin.
6
+
7
+ ## Architecture
8
+
9
+ | Directory | Package export | Purpose |
10
+ |-----------|---------------|---------|
11
+ | `plugin/` | `vite-plugin-vanjs` | Core Vite plugin — route scanning, virtual modules, build/dev config |
12
+ | `router/` | `@vanjs/router` | Router, Route, lazy, A, navigate, routerState, dataCache, matchRoute, extractParams |
13
+ | `client/` | `@vanjs/client` | Hydration — `hydrate()` for SSR-to-client DOM reconciliation |
14
+ | `server/` | `@vanjs/server` | `renderToString()`, preload link generation, data cache serialization |
15
+ | `meta/` | `@vanjs/meta` | Head/Title/Meta/Link — SSR-safe document metadata |
16
+ | `setup/` | `@vanjs/setup` | User setup file resolution |
17
+ | `jsx/` | `@vanjs/jsx` | JSX runtime (jsx, jsxs, jsxDev) |
18
+
19
+ ## Key Concepts
20
+
21
+ - **ESM only** — all source files are `.mjs`, types are `.d.ts`
22
+ - **Isomorphic** — same router code runs on server and client; `isServer` (from `setup/isServer.mjs`) gates SSR vs SPA paths
23
+ - **Reactive state** — `microStore()` (in `router/state.mjs`) creates reactive proxy objects using `van.state()` under the hood
24
+ - **Async-first lazy()** — both server and client return `async () => ComponentModule` with `.isLazy = true`
25
+ - **Data flow** — `route.load()` returns data → cached by `executeLifecycle()` in `dataCache` → read via `useRouteData()`
26
+ - **Cache key** — `""` for empty params, `JSON.stringify(params)` otherwise
27
+ - **No `DynamicModule`** — replaced by `LazyComponent = Promise<{ default?, Page?, route? }>`
28
+ - **`routerState._oldVal`** — non-reactive reads via `rawVal` property (cross-env: writable on client, getter fallback on server). Used to avoid re-triggering derivations
29
+ - **Single Router instance** — `Router()` setup reads via `_oldVal`; only internal `van.derive()` subscribes to `pathname` + `searchParams`
30
+ - **Stale navigation guard** — `navToken` counter re-checked after `executeLifecycle()` before any DOM mutation
31
+ - **Layout chain diffing** — `buildChain()` uses prefix-diff on layout paths to reuse shared layouts across sibling pages; `outlet` swapped on leaf-only changes
32
+ - **JSX Fragment flattening** — `buildChain()` and `resolveChildren()` call `.flat()` on results because JSX Fragments return nested arrays
33
+ - **Live-target hydration** — real DOM adopted after initial render; navigations mutate the live root, not the detached render wrapper
34
+
35
+ ## Commands
36
+
37
+ ```sh
38
+ pnpm test # Run vitest (watch mode)
39
+ pnpm test -- --run # Run vitest once
40
+ pnpm lint # deno lint + tsc -noEmit
41
+ pnpm format # deno fmt
42
+ pnpm check:ts # tsc -noEmit only
43
+ ```
44
+
45
+ ## Testing
46
+
47
+ - **Framework**: Vitest 4 with `@vitest/browser` and `happy-dom`
48
+ - **Environments**: `client.test.ts` uses `happy-dom`, `server.test.ts` uses `@vitest-environment node`
49
+ - **Config**: `vitest.config.ts` — routes dir is `tests/routes`
50
+ - **Coverage**: Istanbul, enabled by default, covers `plugin/`, `router/`, `setup/`, `client/`, `server/`, `meta/`, `jsx/`
51
+ - **Test files**: `tests/client.test.ts`, `tests/client.test.tsx`, `tests/server.test.ts`, `tests/server.test.tsx`, `tests/dataCache.test.ts`, `tests/state.test.ts`, `tests/router.helpers.test.ts`, `tests/router.hydration.test.ts`, `tests/router.spa.test.ts`, `tests/router.search.test.ts`, `tests/router.template-pattern.test.ts`, `tests/hydration-diff.test.ts`, `tests/setup.test.ts`
52
+ - **Coverage**: 100% statements / branches / functions / lines for all covered directories
53
+
54
+ ## Code Style
55
+
56
+ - **No comments** unless explicitly requested
57
+ - **No semicolons** at line ends (deno fmt default)
58
+ - **JSDoc** on public APIs only — `@typedef` references `./types.d.ts`
59
+ - **Formatting**: `deno fmt` handles all formatting — don't fight it
60
+ - **Linting**: `deno lint` — fix with `pnpm fix:ts`
61
+
62
+ ## Type Definitions
63
+
64
+ - `router/global.d.ts` — module declaration for `@vanjs/router` (augments `"@vanjs/router"`)
65
+ - `router/types.d.ts` — standalone type exports (references `global.d.ts`)
66
+ - Each package directory has its own `types.d.ts`
67
+ - **Keep them in sync** — when changing exports, update both `global.d.ts` and `types.d.ts`
68
+
69
+ ## Common Pitfalls
70
+
71
+ - `microStore()` skips proxying for empty plain objects (`{}`) — they're assigned directly, so `Object.assign` on `routerState.params` works but changes aren't reactive
72
+ - `lazy()` is **not** synchronous on the client anymore — always `await route.component()`
@@ -58,4 +58,17 @@ declare module "@vanjs/client" {
58
58
  | JSX.Element
59
59
  | Promise<ChildElement | ChildElement[] | JSX.Element>,
60
60
  ) => T;
61
+
62
+ /**
63
+ * Shallow compare two elements, optionally recursing into keyed children.
64
+ *
65
+ * @param el1 the server rendered element
66
+ * @param el2 the client rendered element or elements
67
+ * @param deep when true, compares the hydrated children of both elements
68
+ */
69
+ export const elementsMatch = (
70
+ el1: HTMLElement,
71
+ el2: HTMLElement | HTMLElement[],
72
+ deep?: boolean,
73
+ ) => boolean;
61
74
  }
package/client/index.mjs CHANGED
@@ -141,37 +141,99 @@ export function elementsMatch(el1, el2, deep) {
141
141
  }
142
142
 
143
143
  function createHydrationContext() {
144
- /** @type {WeakMap<Element, Element>} */
145
- const parentCache = new WeakMap();
144
+ /**
145
+ * Significant child nodes: elements + non-empty text.
146
+ * SSR omits empty text nodes while the client creates Text("") for
147
+ * empty bindings, so those are filtered out on both sides.
148
+ * @param {Element} el
149
+ * @returns {ChildNode[]}
150
+ */
151
+ const significantChildren = (el) =>
152
+ Array.from(el.childNodes).filter((n) =>
153
+ n.nodeType !== 3 || n.textContent !== ""
154
+ );
146
155
 
147
- /** @type {(element: HTMLElement, root: HTMLElement) => HTMLElement | null} */
148
- function getParent(element, root) {
149
- const cacheKey = element;
150
- // istanbul ignore if - must be connected to a read DOM
151
- if (parentCache.has(cacheKey)) {
152
- const cached = parentCache.get(cacheKey);
153
- // Verify the cached parent is still valid for this root
154
- if (cached && cached.isConnected && root.contains(cached)) {
155
- return cached;
156
- }
157
- // If not valid, remove from cache
158
- parentCache.delete(cacheKey);
159
- }
156
+ /**
157
+ * Whether an element carries a hydration key
158
+ * @param {Node} el
159
+ * @returns {el is Element}
160
+ */
161
+ const isKeyed = (el) => el instanceof Element && el.hasAttribute("data-hk");
160
162
 
161
- const chain = [];
162
- let current = element;
163
+ /**
164
+ * Consume hydration keys under a freshly hydrated root: adoption is done,
165
+ * subsequent renders are born keyless, so keys must not linger.
166
+ * @param {Element} root
167
+ */
168
+ function stripHydrationKeys(root) {
169
+ // istanbul ignore if - the diffed root is always an Element
170
+ if (!(root instanceof Element)) return;
171
+ if (root.hasAttribute("data-hk")) root.removeAttribute("data-hk");
172
+ root.querySelectorAll("[data-hk]").forEach((el) =>
173
+ el.removeAttribute("data-hk")
174
+ );
175
+ }
163
176
 
164
- while (current !== root && current) {
165
- chain.push(current);
166
- current = current.parentElement;
177
+ /**
178
+ * Whether two nodes can pair up: same kind, same tag/id/class, same text.
179
+ * className is coerced to String so SVG (SVGAnimatedString) pairs by tag+id.
180
+ * @param {ChildNode} oldN
181
+ * @param {ChildNode} newN
182
+ * @returns {boolean}
183
+ */
184
+ function nodesPairable(oldN, newN) {
185
+ if (oldN.nodeType === 3 || newN.nodeType === 3) {
186
+ return oldN.nodeType === newN.nodeType &&
187
+ oldN.textContent === newN.textContent;
188
+ }
189
+ if (oldN.nodeType === 8 || newN.nodeType === 8) {
190
+ return oldN.nodeType === newN.nodeType &&
191
+ oldN.textContent === newN.textContent;
167
192
  }
193
+ if (!(oldN instanceof Element) || !(newN instanceof Element)) {
194
+ return false;
195
+ }
196
+ return oldN.tagName === newN.tagName &&
197
+ (oldN.id || "") === (newN.id || "") &&
198
+ String(oldN.className || "") === String(newN.className || "");
199
+ }
168
200
 
169
- const parent = chain.slice(-1)[0];
170
- // istanbul ignore else
171
- if (parent) {
172
- parentCache.set(cacheKey, parent);
201
+ /**
202
+ * Adopt a node pair. Keyed SSR elements are replaced with their fresh
203
+ * client render (keys follow the client gate); static subtrees are walked
204
+ * so untouched nodes (e.g. images) are never re-instantiated. Text nodes
205
+ * are swapped for identical client ones so van bindings stay live.
206
+ * @param {ChildNode} oldNode
207
+ * @param {ChildNode} newNode
208
+ */
209
+ function adoptNode(oldNode, newNode) {
210
+ if (isKeyed(oldNode)) {
211
+ // istanbul ignore else - pairable nodes always share the same tag
212
+ if (newNode instanceof Element && oldNode.tagName === newNode.tagName) {
213
+ oldNode.replaceWith(newNode);
214
+ }
215
+ // else: no trustworthy counterpart — leave the SSR node in place
216
+ return;
217
+ }
218
+ if (!(oldNode instanceof Element) || !(newNode instanceof Element)) return;
219
+ const oldSiblings = significantChildren(oldNode);
220
+ const newSiblings = significantChildren(newNode);
221
+ if (
222
+ oldSiblings.length !== newSiblings.length ||
223
+ !oldSiblings.every((ok, i) => nodesPairable(ok, newSiblings[i]))
224
+ ) {
225
+ // structural divergence — swap the whole subtree with the fresh client render
226
+ oldNode.replaceWith(newNode);
227
+ return;
173
228
  }
174
- return parent;
229
+ oldSiblings.forEach((os, i) => {
230
+ const ns = newSiblings[i];
231
+ if (os.nodeType === 3) {
232
+ os.replaceWith(ns);
233
+ } else {
234
+ adoptNode(os, ns);
235
+ }
236
+ });
175
237
  }
176
238
 
177
239
  /** @type {(oldDom: HTMLElement, newDom: HTMLElement | HTMLElement[]) => HTMLElement} */
@@ -180,47 +242,38 @@ function createHydrationContext() {
180
242
  // SPA mode
181
243
  // istanbul ignore else
182
244
  if (!oldDom.children.length && !elementsMatch(oldDom, newDom)) {
183
- return oldDom.replaceChildren(...unwrap(newDom).children);
245
+ oldDom.replaceChildren(...unwrap(newDom).children);
246
+ stripHydrationKeys(oldDom);
247
+ return;
184
248
  }
185
249
  // istanbul ignore else
186
250
  if (newDom instanceof Array) {
187
251
  oldDom.replaceChildren(...unwrap(newDom).children);
252
+ stripHydrationKeys(oldDom);
188
253
  return;
189
254
  }
190
255
 
191
- // SSR Mode
192
- /** @type {Set<HTMLElement>} */
193
- const oldSet = new Set();
194
- /** @type {Set<HTMLElement>} */
195
- const newSet = new Set();
196
-
197
- const processElements = (root, set) => {
198
- const elements = root.querySelectorAll("[data-hk]");
199
- let lastParent = null;
200
-
201
- elements.forEach((el) => {
202
- const parent = getParent(el, root);
203
- if (parent && parent !== lastParent) {
204
- set.add(parent);
205
- lastParent = parent;
206
- }
207
- });
208
- };
209
-
210
- processElements(oldDom, oldSet);
211
- processElements(newDom, newSet);
212
-
213
- // istanbul ignore else
214
- if (newSet.size > 0) {
215
- const newArray = Array.from(newSet);
216
- oldSet.forEach((el) => {
217
- const match = newArray.find((m) => elementsMatch(m, el));
218
- // istanbul ignore else
219
- if (match) {
220
- el.replaceWith(match);
221
- }
222
- });
256
+ // SSR mode: adopt keyed elements 1:1 with fresh client renders,
257
+ // leave static subtrees untouched
258
+ const oldSiblings = significantChildren(oldDom);
259
+ const newSiblings = significantChildren(newDom);
260
+ if (
261
+ oldSiblings.length !== newSiblings.length ||
262
+ !oldSiblings.every((ok, i) => nodesPairable(ok, newSiblings[i]))
263
+ ) {
264
+ oldDom.replaceChildren(...newSiblings);
265
+ stripHydrationKeys(oldDom);
266
+ return;
223
267
  }
268
+ oldSiblings.forEach((ok, i) => {
269
+ const nk = newSiblings[i];
270
+ if (ok.nodeType === 3) {
271
+ ok.replaceWith(nk);
272
+ } else {
273
+ adoptNode(ok, nk);
274
+ }
275
+ });
276
+ stripHydrationKeys(oldDom);
224
277
  }
225
278
 
226
279
  return { diffAndHydrate };
package/client/types.d.ts CHANGED
@@ -52,3 +52,16 @@ export const hydrate: <T = HTMLElement>(
52
52
  | JSX.Element
53
53
  | Promise<HTMLElement | HTMLElement[] | JSX.Element>,
54
54
  ) => T;
55
+
56
+ /**
57
+ * Shallow compare two elements, optionally recursing into keyed children.
58
+ *
59
+ * @param el1 the server rendered element
60
+ * @param el2 the client rendered element or elements
61
+ * @param deep when true, compares the hydrated children of both elements
62
+ */
63
+ export const elementsMatch: (
64
+ el1: HTMLElement,
65
+ el2: HTMLElement | HTMLElement[],
66
+ deep?: boolean,
67
+ ) => boolean;
package/llms.txt ADDED
@@ -0,0 +1,55 @@
1
+ # vite-plugin-vanjs
2
+
3
+ An async-first mini meta-framework for VanJS powered by Vite. Provides file-system routing, SSR/SSG, JSX support, metadata management, and a data caching layer — all as a Vite plugin.
4
+
5
+ ## Packages
6
+
7
+ - `vite-plugin-vanjs` — Core Vite plugin: route scanning, virtual modules, build/dev config
8
+ - `@vanjs/router` — Router, Route, lazy, A, navigate, routerState, dataCache, matchRoute, extractParams
9
+ - `@vanjs/client` — Hydration: `hydrate()` for SSR-to-client DOM reconciliation
10
+ - `@vanjs/server` — `renderToString()`, preload link generation, data cache serialization
11
+ - `@vanjs/meta` — Head/Title/Meta/Link: SSR-safe document metadata
12
+ - `@vanjs/setup` — Isomorphic VanJS setup, `needsHydration`, `markHydrationComplete`
13
+ - `@vanjs/jsx` — JSX runtime (jsx, jsxs, jsxDev) with automatic namespace resolution
14
+
15
+ ## Key Concepts
16
+
17
+ - ESM only — all source `.mjs`, types `.d.ts`
18
+ - Isomorphic — `isServer` gates SSR vs SPA paths
19
+ - Reactive state — `microStore()` creates reactive proxy objects via `van.state()`
20
+ - `routerState._oldVal` — non-reactive reads via `rawVal` to avoid re-triggering derivations
21
+ - Single Router instance — only internal `van.derive()` subscribes to pathname + searchParams
22
+ - Stale navigation guard — `navToken` counter re-checked after lifecycle before DOM mutation
23
+ - Layout chain diffing — prefix-diff on layout paths to reuse shared layouts across sibling pages
24
+ - JSX Fragment flattening — `.flat()` on layout/leaf results because Fragments return nested arrays
25
+ - Live-target hydration — navigations mutate the adopted SSR root, not the detached render wrapper
26
+ - Server one-shot render — no shared loading flag, concurrent requests don't interfere
27
+
28
+ ## Commands
29
+
30
+ ```
31
+ pnpm test -- --run # Run vitest once
32
+ pnpm lint # deno lint + tsc -noEmit
33
+ pnpm format # deno fmt
34
+ pnpm check:ts # tsc -noEmit only
35
+ ```
36
+
37
+ ## Code Style
38
+
39
+ - No comments unless explicitly requested
40
+ - No semicolons (deno fmt)
41
+ - JSDoc on public APIs only
42
+ - deno fmt + deno lint
43
+
44
+ ## Source Layout
45
+
46
+ ```
47
+ plugin/ Core Vite plugin
48
+ router/ Router, state, helpers, lazy, dataCache, matchRoute, unwrap
49
+ client/ Hydration (hydrate, elementsMatch, setAttribute)
50
+ server/ renderToString, data cache serialization
51
+ meta/ Head/Title/Meta/Link
52
+ setup/ Isomorphic VanJS setup (van.mjs, van-ssr.mjs, helpers.mjs)
53
+ jsx/ JSX runtime (jsx, Fragment, ns)
54
+ tests/ Vitest tests (76 tests, 100% coverage)
55
+ ```
package/meta/tags.mjs CHANGED
@@ -2,7 +2,10 @@ import van from "vanjs-core";
2
2
  import { addMeta } from "./Head.mjs";
3
3
 
4
4
  /** @typedef {import("./types.d.ts").SupportedTags} SupportedTags */
5
- /** @typedef {import("vanjs-core").PropsWithKnownKeys<T = SupportedTags>} PropsWithKnownKeys<T> */
5
+ /**
6
+ * @template T
7
+ * @typedef {import("vanjs-core").PropsWithKnownKeys<T>} PropsWithKnownKeys
8
+ */
6
9
  /** @typedef {import("./types.d.ts").TagProps} TagProps */
7
10
 
8
11
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-vanjs",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "author": "thednp",
5
5
  "license": "MIT",
6
6
  "description": "An async first mini meta-framework for VanJS powered by Vite",
@@ -27,6 +27,8 @@
27
27
  "jsx-runtime.d.ts",
28
28
  "jsx-dev-runtime.d.ts",
29
29
  "README.md",
30
+ "AGENTS.md",
31
+ "llms.txt",
30
32
  "LICENSE"
31
33
  ],
32
34
  "keywords": [
@@ -91,7 +93,7 @@
91
93
  "vanjs-ext": "^0.6.3"
92
94
  },
93
95
  "devDependencies": {
94
- "@types/node": "^26.5.1",
96
+ "@types/node": "^26.6.1",
95
97
  "@vitest/browser": "^5.0.1",
96
98
  "@vitest/coverage-istanbul": "^5.0.1",
97
99
  "@vitest/ui": "^5.0.1",
package/plugin/index.mjs CHANGED
@@ -1,12 +1,11 @@
1
- /** @typedef {typeof import("./types").VitePluginVanJS} VitePluginVanJS */
2
- /** @typedef {import("./types").VanJSPluginOptions} VanJSPluginOptions */
3
1
  /** @typedef {import("vite").ResolvedConfig} ResolvedConfig */
4
- /** @typedef {import("./types").PageFile} PageFile */
5
- /** @typedef {import("./types").RouteFile} RouteFile */
6
2
  /** @typedef {import("vite").BuildAppHook} BuildAppHook */
7
3
  /** @typedef {import("vite").TransformResult} TransformResult */
8
4
  /** @typedef {import("vite").Plugin} Plugin */
9
- /** @typedef {ThisParameterType<BuildAppHook>} PluginContext */
5
+ /** @typedef {import("./types.d.ts").VitePluginVan} VitePluginVan */
6
+ /** @typedef {import("./types.d.ts").VanJSPluginOptions} VanJSPluginOptions */
7
+ /** @typedef {import("./types.d.ts").PageFile} PageFile */
8
+ /** @typedef {import("./types.d.ts").RouteFile} RouteFile */
10
9
 
11
10
  import { fileURLToPath } from "node:url";
12
11
  import { dirname, join, resolve } from "node:path";
@@ -56,7 +55,7 @@ const debugModuleAliases = {
56
55
  "@vanjs/setup": "../setup/index-debug",
57
56
  };
58
57
 
59
- /** @type {VitePluginVanJS} */
58
+ /** @type {VitePluginVan} */
60
59
  export default function VitePluginVanJS(options = {}) {
61
60
  const pluginConfig = { ...pluginDefaults, ...options };
62
61
  const { routesDir } = pluginConfig;
package/plugin/types.d.ts CHANGED
@@ -1,21 +1,29 @@
1
1
  // plugin/types.ts
2
+ import type { Plugin } from "vite";
2
3
  export * from "../jsx/types.d.ts";
3
4
  export * from "../setup/types.d.ts";
4
5
  export * from "../router/types.d.ts";
5
6
  export * from "../meta/types.d.ts";
6
7
  export * from "../server/types.d.ts";
7
8
  export * from "../client/types.d.ts";
8
- import type { Plugin } from "vite";
9
9
 
10
10
  export type VanJSPluginOptions = {
11
11
  routesDir?: string;
12
12
  extensions?: string[];
13
- excludeRoutes?: string[]; // NEW — excluded in all envs
14
- excludeRoutesProd?: string[]; // NEW — excluded in production only
13
+ excludeRoutes?: string[]; // excluded in all envs
14
+ excludeRoutesProd?: string[]; // excluded in production only
15
+ };
16
+
17
+ /**
18
+ * The plugin context members the plugin reads from `this`.
19
+ */
20
+ export type VitePluginContext = {
21
+ meta?: { viteVersion?: string };
15
22
  };
16
23
 
17
- const VitePluginVanJS: (
24
+ export type VitePluginVan = (
18
25
  config?: VanJSPluginOptions,
19
- ) => Plugin<VanJSPluginOptions>;
26
+ ) => Plugin;
20
27
 
28
+ declare const VitePluginVanJS: VitePluginVan;
21
29
  export default VitePluginVanJS;
@@ -266,6 +266,7 @@ declare module "@vanjs/router" {
266
266
  export type LazyComponent = Promise<{
267
267
  default?: ComponentFn;
268
268
  Page?: ComponentFn;
269
+ component?: ComponentFn;
269
270
  route?: Pick<RouteEntry, "load" | "preload">;
270
271
  layouts?: RouteLayout[];
271
272
  leaf?: ComponentFn;
@@ -334,6 +335,14 @@ declare module "@vanjs/router" {
334
335
  */
335
336
  export const matchRoute: (path: string) => RouteEntry | null;
336
337
 
338
+ /**
339
+ * Extract the route params from a route pattern and a path
340
+ */
341
+ export const extractParams: (
342
+ pattern: string,
343
+ path: string,
344
+ ) => Record<string, string> | null;
345
+
337
346
  /** * Convenience hook to get the current route's cached data.
338
347
  */
339
348
  export const useRouteData: <T>() => T | undefined;
@@ -9,8 +9,7 @@ import { Head } from "../meta/index.mjs";
9
9
  import * as dataCache from "./dataCache.mjs";
10
10
 
11
11
  /** @typedef {typeof import("./types.d.ts").navigate} Navigate */
12
- /** @typedef {import("./types.d.ts").SearchParamDef} SearchParamDef */
13
- /** @typedef {import("./types.d.ts").Route} Route */
12
+ /** @typedef {import("./types.d.ts").RouteEntry} RouteEntry */
14
13
  /** @typedef {import("./types.d.ts").VanNode} VanNode */
15
14
  /** @typedef {import("./types.d.ts").ComponentModule} ComponentModule */
16
15
  /** @typedef {import("./types.d.ts").ComponentFn} ComponentFn */
package/router/lazy.mjs CHANGED
@@ -2,7 +2,6 @@
2
2
  import { cacheRoute, getCachedRoute } from "./routeCache.mjs";
3
3
 
4
4
  /** @typedef {import('./types').VanNode} VanNode */
5
- /** @typedef {import('./types').DynamicModule} DynamicModule */
6
5
  /** @typedef {import('./types').ComponentModule} ComponentModule */
7
6
  /** @typedef {import('./types').ComponentFn} ComponentFn */
8
7
 
@@ -1,7 +1,7 @@
1
1
  /** @typedef {import("./types").ComponentModule} ComponentModule */
2
2
  /** @typedef {import("./types").ImportFn} ImportFn */
3
- /** @typedef {typeof import("./types").getCached} GetCachedRoute */
4
- /** @typedef {typeof import("./types").cache} CacheRoute */
3
+ /** @typedef {typeof import("./types").getCachedRoute} GetCachedRoute */
4
+ /** @typedef {typeof import("./types").cacheRoute} CacheRoute */
5
5
 
6
6
  /** @type {Map<ImportFn, ComponentModule>} */
7
7
  const routeCache = new Map();
package/router/router.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "./helpers.mjs";
11
11
  import { initializeHeadTags } from "../meta/index.mjs";
12
12
  import { hydrate } from "../client/index.mjs";
13
+ import { markHydrationComplete } from "../setup/helpers.mjs";
13
14
  import { Head } from "../meta/index.mjs";
14
15
  import * as dataCache from "./dataCache.mjs";
15
16
  import "virtual:@vanjs/routes";
@@ -132,9 +133,16 @@ export const Router = (initialProps = /* istanbul ignore next */ {}) => {
132
133
  let initialized = false;
133
134
 
134
135
  // Client-side: hydrate data cache from SSR output
135
- // This must happen BEFORE any component renders so useRouteData() works
136
- // Skip in dev mode: we manually clear dataCache on mutations for instant updates
137
- if (globalThis.__DATA_CACHE && !isDev) {
136
+ // This must happen BEFORE any component renders so useRouteData() works.
137
+ // Skip in dev mode for /admin pages: we manually clear dataCache on
138
+ // mutations for instant updates. Public pages reuse SSR data like prod.
139
+ // NOTE: read via _oldVal — subscribing here would re-create the whole
140
+ // Router on every navigation.
141
+ if (
142
+ globalThis.__DATA_CACHE &&
143
+ /* istanbul ignore next -- build-time constant, isDev is false in vitest */
144
+ (!isDev || !routerState._oldVal.pathname.startsWith("/admin"))
145
+ ) {
138
146
  dataCache.hydrateFromJSON(globalThis.__DATA_CACHE);
139
147
  }
140
148
 
@@ -243,6 +251,9 @@ export const Router = (initialProps = /* istanbul ignore next */ {}) => {
243
251
  // not the detached wrapper used for the initial render.
244
252
  liveTarget = root;
245
253
  initialized = true;
254
+ // Initial hydration is done: freshly rendered client nodes are born
255
+ // into live DOM and need no hydration keys from here on.
256
+ markHydrationComplete();
246
257
  return result;
247
258
  };
248
259
  }
package/router/types.d.ts CHANGED
@@ -227,6 +227,7 @@ export type ComponentModule = {
227
227
  export type LazyComponent = Promise<{
228
228
  default?: ComponentFn;
229
229
  Page?: ComponentFn;
230
+ component?: ComponentFn;
230
231
  route?: Pick<RouteEntry, "load" | "preload">;
231
232
  layouts?: RouteLayout[];
232
233
  leaf?: ComponentFn;
package/server/types.d.ts CHANGED
@@ -97,7 +97,7 @@ export const processLayoutRoutes: (
97
97
  routes: Array<PageFile>,
98
98
  config: ResolvedConfig,
99
99
  pluginConfig: PluginConfig,
100
- ) => Array<PRouteFile>;
100
+ ) => Array<RouteFile>;
101
101
 
102
102
  /**
103
103
  * Scan and process layouts and return them.
package/setup/helpers.mjs CHANGED
@@ -1,7 +1,31 @@
1
+ /**
2
+ * Once initial hydration completes, freshly rendered client nodes are born
3
+ * into live DOM that is never diffed again, so they need no hydration keys.
4
+ * Server bundles get their own module instance where this stays false.
5
+ */
6
+ let hydrationComplete = false;
7
+
8
+ /** @type {() => void} */
9
+ export function markHydrationComplete() {
10
+ hydrationComplete = true;
11
+ }
12
+
13
+ /** @type {() => void} */
14
+ export function resetHydrationState() {
15
+ hydrationComplete = false;
16
+ }
17
+
1
18
  /** @type {(props: Record<string, unknown>) => boolean} */
2
19
  export function needsHydration(props) {
20
+ if (hydrationComplete) return false;
3
21
  return props && (
4
22
  Object.keys(props).some((k) => k.startsWith("on")) || // has events
5
- Object.values(props).some((v) => v && typeof v === "object" && "val" in v) // has state
23
+ Object.entries(props).some(([k, v]) =>
24
+ v && typeof v === "object" && (
25
+ "val" in v || k === "style" && Object.values(v).some((sv) =>
26
+ sv && typeof sv === "object" && "val" in sv
27
+ )
28
+ )
29
+ ) // has state
6
30
  );
7
31
  }