@jasonshimmy/vite-plugin-cer-app 0.20.2 → 0.20.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/commits.txt +1 -2
  3. package/dist/cli/commands/dev.d.ts.map +1 -1
  4. package/dist/cli/commands/dev.js +5 -0
  5. package/dist/cli/commands/dev.js.map +1 -1
  6. package/dist/plugin/build-ssg.d.ts.map +1 -1
  7. package/dist/plugin/build-ssg.js +0 -11
  8. package/dist/plugin/build-ssg.js.map +1 -1
  9. package/dist/plugin/content/index.d.ts +5 -4
  10. package/dist/plugin/content/index.d.ts.map +1 -1
  11. package/dist/plugin/content/index.js +9 -11
  12. package/dist/plugin/content/index.js.map +1 -1
  13. package/dist/plugin/dev-server.d.ts.map +1 -1
  14. package/dist/plugin/dev-server.js +40 -2
  15. package/dist/plugin/dev-server.js.map +1 -1
  16. package/dist/plugin/dts-generator.d.ts.map +1 -1
  17. package/dist/plugin/dts-generator.js +9 -1
  18. package/dist/plugin/dts-generator.js.map +1 -1
  19. package/dist/plugin/index.d.ts.map +1 -1
  20. package/dist/plugin/index.js +14 -4
  21. package/dist/plugin/index.js.map +1 -1
  22. package/dist/plugin/virtual/plugins.d.ts +7 -1
  23. package/dist/plugin/virtual/plugins.d.ts.map +1 -1
  24. package/dist/plugin/virtual/plugins.js +12 -2
  25. package/dist/plugin/virtual/plugins.js.map +1 -1
  26. package/dist/runtime/app-template.d.ts +1 -1
  27. package/dist/runtime/app-template.d.ts.map +1 -1
  28. package/dist/runtime/app-template.js +9 -3
  29. package/dist/runtime/app-template.js.map +1 -1
  30. package/dist/runtime/composables/use-page-data.d.ts +7 -5
  31. package/dist/runtime/composables/use-page-data.d.ts.map +1 -1
  32. package/dist/runtime/composables/use-page-data.js +7 -5
  33. package/dist/runtime/composables/use-page-data.js.map +1 -1
  34. package/dist/runtime/entry-server-template.d.ts +1 -1
  35. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  36. package/dist/runtime/entry-server-template.js +8 -2
  37. package/dist/runtime/entry-server-template.js.map +1 -1
  38. package/docs/content.md +2 -13
  39. package/e2e/cypress/e2e/preview-hardening.cy.ts +42 -33
  40. package/e2e/cypress/e2e/use-page-data.cy.ts +122 -0
  41. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +4 -0
  42. package/e2e/kitchen-sink/app/pages/blog/index.ts +5 -0
  43. package/package.json +5 -2
  44. package/src/__tests__/plugin/build-ssg.test.ts +2 -2
  45. package/src/__tests__/plugin/content/loader.test.ts +19 -27
  46. package/src/__tests__/plugin/entry-server-template.test.ts +4 -1
  47. package/src/__tests__/plugin/virtual/plugins.test.ts +45 -0
  48. package/src/__tests__/runtime/app-template.test.ts +11 -2
  49. package/src/cli/commands/dev.ts +5 -0
  50. package/src/plugin/build-ssg.ts +0 -12
  51. package/src/plugin/content/index.ts +8 -11
  52. package/src/plugin/dev-server.ts +37 -2
  53. package/src/plugin/dts-generator.ts +7 -1
  54. package/src/plugin/index.ts +14 -4
  55. package/src/plugin/virtual/plugins.ts +12 -2
  56. package/src/runtime/app-template.ts +9 -3
  57. package/src/runtime/composables/use-page-data.ts +7 -5
  58. package/src/runtime/entry-server-template.ts +8 -2
@@ -9,6 +9,10 @@ component('page-blog', () => {
9
9
 
10
10
  const ssrData = usePageData<{ posts: Post[] }>()
11
11
  const posts = ref<Post[]>(ssrData?.posts ?? [])
12
+ // Captured once at element-creation time (during the hydration re-render).
13
+ // 'ssr' proves usePageData() was non-null — the queueMicrotask timing fix works.
14
+ // 'client' means __CER_DATA__ was deleted before setup ran (regression).
15
+ const dataSource = ssrData ? 'ssr' : 'client'
12
16
 
13
17
  useOnConnected(async () => {
14
18
  if (ssrData) return // already hydrated — skip client fetch
@@ -28,6 +32,7 @@ component('page-blog', () => {
28
32
  <div>
29
33
  <h1 data-cy="blog-heading">Blog</h1>
30
34
  <p>Posts are loaded via a page <strong>loader</strong> (SSR/SSG) or client-side fetch (SPA).</p>
35
+ <span data-cy="blog-data-source" hidden>${dataSource}</span>
31
36
  <ul data-cy="blog-list">
32
37
  ${posts.value.map(post => html`
33
38
  <li data-cy="blog-item">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -72,13 +72,16 @@
72
72
  "e2e:serve:spa": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4174",
73
73
  "e2e:serve:ssr": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4175 --ssr",
74
74
  "e2e:serve:ssg": "node dist/cli/index.js preview --root e2e/kitchen-sink --port 4176",
75
+ "e2e:serve:dev": "node dist/cli/index.js dev --root e2e/kitchen-sink --port 4177 --mode ssr",
75
76
  "e2e:run:spa": "cypress run --config baseUrl=http://localhost:4174 --env mode=spa",
76
77
  "e2e:run:ssr": "cypress run --config baseUrl=http://localhost:4175 --env mode=ssr",
77
78
  "e2e:run:ssg": "cypress run --config baseUrl=http://localhost:4176 --env mode=ssg",
79
+ "e2e:run:dev": "cypress run --config baseUrl=http://localhost:4177 --env mode=dev",
78
80
  "e2e:spa": "npm run e2e:build:spa && start-server-and-test e2e:serve:spa http://localhost:4174 e2e:run:spa",
79
81
  "e2e:ssr": "npm run e2e:build:ssr && start-server-and-test e2e:serve:ssr http://localhost:4175 e2e:run:ssr",
80
82
  "e2e:ssg": "npm run e2e:build:ssg && start-server-and-test e2e:serve:ssg http://localhost:4176 e2e:run:ssg",
81
- "e2e": "npm run e2e:ssr && npm run e2e:ssg && npm run e2e:spa",
83
+ "e2e:dev": "npm run e2e:clean && start-server-and-test e2e:serve:dev http://localhost:4177 e2e:run:dev",
84
+ "e2e": "npm run e2e:ssr && npm run e2e:ssg && npm run e2e:spa && npm run e2e:dev",
82
85
  "cypress:open": "cypress open"
83
86
  },
84
87
  "peerDependencies": {
@@ -137,8 +137,8 @@ describe('buildSSG — path collection', () => {
137
137
  it('calls fg when pagesDir exists and no explicit routes', async () => {
138
138
  vi.mocked(existsSync).mockReturnValue(true)
139
139
  await buildSSG(makeConfig())
140
- // fg is called once for page discovery and once by loadContentStore (content scan)
141
- expect(fg).toHaveBeenCalledTimes(2)
140
+ // fg is called once for page discovery
141
+ expect(fg).toHaveBeenCalledTimes(1)
142
142
  })
143
143
 
144
144
  it('skips Vite dev server when all discovered pages are static', async () => {
@@ -79,38 +79,37 @@ describe('resolveContentDir', () => {
79
79
 
80
80
  describe('loadContentStore — nonexistent dir', () => {
81
81
  it('returns empty array when contentDir does not exist', async () => {
82
- const items = await loadContentStore('/path/does/not/exist', false, false)
82
+ const items = await loadContentStore('/path/does/not/exist', false)
83
83
  expect(items).toEqual([])
84
84
  })
85
85
  })
86
86
 
87
- describe('loadContentStore — dev mode (isProduction=false)', () => {
88
- it('loads all files including drafts', async () => {
89
- const items = await loadContentStore(contentDir, false, false)
87
+ describe('loadContentStore — drafts excluded by default (includeDrafts=false)', () => {
88
+ it('loads all non-draft files', async () => {
89
+ const items = await loadContentStore(contentDir, false)
90
90
  const paths = items.map((i) => i._path).sort()
91
- // Root, about, blog/hello, blog/secret, data/products
91
+ // Root, about, blog/hello (not secret — it is a draft), data/products
92
92
  expect(paths).toContain('/')
93
93
  expect(paths).toContain('/about')
94
94
  expect(paths).toContain('/blog/hello')
95
- expect(paths).toContain('/blog/secret')
95
+ expect(paths).not.toContain('/blog/secret')
96
96
  expect(paths).toContain('/data/products')
97
97
  })
98
98
 
99
- it('includes draft items', async () => {
100
- const items = await loadContentStore(contentDir, false, false)
99
+ it('excludes draft items', async () => {
100
+ const items = await loadContentStore(contentDir, false)
101
101
  const secret = items.find((i) => i._path === '/blog/secret')
102
- expect(secret).toBeDefined()
103
- expect(secret?.draft).toBe(true)
102
+ expect(secret).toBeUndefined()
104
103
  })
105
104
 
106
105
  it('strips date prefix from slug', async () => {
107
- const items = await loadContentStore(contentDir, false, false)
106
+ const items = await loadContentStore(contentDir, false)
108
107
  expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
109
108
  expect(items.find((i) => i._path === '/blog/2026-04-03-hello')).toBeUndefined()
110
109
  })
111
110
 
112
111
  it('each item has required ContentItem fields', async () => {
113
- const items = await loadContentStore(contentDir, false, false)
112
+ const items = await loadContentStore(contentDir, false)
114
113
  for (const item of items) {
115
114
  expect(typeof item._path).toBe('string')
116
115
  expect(typeof item._file).toBe('string')
@@ -121,7 +120,7 @@ describe('loadContentStore — dev mode (isProduction=false)', () => {
121
120
  })
122
121
 
123
122
  it('normalises date fields to strings (not Date objects)', async () => {
124
- const items = await loadContentStore(contentDir, false, false)
123
+ const items = await loadContentStore(contentDir, false)
125
124
  const about = items.find((i) => i._path === '/about')
126
125
  expect(about).toBeDefined()
127
126
  expect(typeof about?.date).toBe('string')
@@ -129,31 +128,24 @@ describe('loadContentStore — dev mode (isProduction=false)', () => {
129
128
  })
130
129
  })
131
130
 
132
- describe('loadContentStore — production mode (isProduction=true, isDraft=false)', () => {
133
- it('excludes draft items', async () => {
134
- const items = await loadContentStore(contentDir, false, true)
131
+ describe('loadContentStore — drafts included (includeDrafts=true)', () => {
132
+ it('includes draft items when includeDrafts=true', async () => {
133
+ const items = await loadContentStore(contentDir, true)
135
134
  const secret = items.find((i) => i._path === '/blog/secret')
136
- expect(secret).toBeUndefined()
135
+ expect(secret).toBeDefined()
136
+ expect(secret?.draft).toBe(true)
137
137
  })
138
138
 
139
139
  it('includes non-draft items', async () => {
140
- const items = await loadContentStore(contentDir, false, true)
140
+ const items = await loadContentStore(contentDir, true)
141
141
  expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
142
142
  expect(items.find((i) => i._path === '/about')).toBeDefined()
143
143
  })
144
144
  })
145
145
 
146
- describe('loadContentStore — production mode with drafts enabled (isDraft=true)', () => {
147
- it('includes draft items when isDraft=true in production', async () => {
148
- const items = await loadContentStore(contentDir, true, true)
149
- const secret = items.find((i) => i._path === '/blog/secret')
150
- expect(secret).toBeDefined()
151
- })
152
- })
153
-
154
146
  describe('loadContentStore — JSON files', () => {
155
147
  it('includes JSON files with _type json', async () => {
156
- const items = await loadContentStore(contentDir, false, false)
148
+ const items = await loadContentStore(contentDir, false)
157
149
  const products = items.find((i) => i._path === '/data/products')
158
150
  expect(products).toBeDefined()
159
151
  expect(products?._type).toBe('json')
@@ -117,7 +117,10 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
117
117
  })
118
118
 
119
119
  it('merges SSR html with client template when available', () => {
120
- expect(src).toContain('_mergeWithClientTemplate(ssrHtml, _clientTemplate)')
120
+ // Dev mode: per-request global takes precedence over module-level _clientTemplate
121
+ expect(src).toContain('_resolvedClientTemplate')
122
+ expect(src).toContain('(globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate')
123
+ expect(src).toContain('_mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)')
121
124
  })
122
125
 
123
126
  it('exports handler as both named and default export', () => {
@@ -81,4 +81,49 @@ describe('generatePluginsCode', () => {
81
81
  expect(code).toContain('export const plugins')
82
82
  expect(code).toContain('export default plugins')
83
83
  })
84
+
85
+ // ─── .client.ts convention ────────────────────────────────────────────────
86
+
87
+ it('includes .client.ts plugins by default (client-side)', async () => {
88
+ vi.mocked(scanDirectory).mockResolvedValue([
89
+ `${PLUGINS}/auth.ts`,
90
+ `${PLUGINS}/cer-material.client.ts`,
91
+ ])
92
+ const code = await generatePluginsCode(PLUGINS)
93
+ expect(code).toContain('cer-material.client.ts')
94
+ expect(code).toContain('auth.ts')
95
+ })
96
+
97
+ it('excludes .client.ts plugins when serverSafeOnly is true', async () => {
98
+ vi.mocked(scanDirectory).mockResolvedValue([
99
+ `${PLUGINS}/auth.ts`,
100
+ `${PLUGINS}/cer-material.client.ts`,
101
+ ])
102
+ const code = await generatePluginsCode(PLUGINS, true)
103
+ expect(code).not.toContain('cer-material.client.ts')
104
+ expect(code).toContain('auth.ts')
105
+ })
106
+
107
+ it('returns empty plugins array when all plugins are .client.ts and serverSafeOnly is true', async () => {
108
+ vi.mocked(scanDirectory).mockResolvedValue([
109
+ `${PLUGINS}/cer-material.client.ts`,
110
+ ])
111
+ const code = await generatePluginsCode(PLUGINS, true)
112
+ expect(code).toContain('export const plugins = []')
113
+ expect(code).not.toContain('cer-material.client.ts')
114
+ })
115
+
116
+ it('preserves non-.client.ts plugins in server-safe mode', async () => {
117
+ vi.mocked(scanDirectory).mockResolvedValue([
118
+ `${PLUGINS}/01.analytics.ts`,
119
+ `${PLUGINS}/02.fonts.client.ts`,
120
+ `${PLUGINS}/03.auth.ts`,
121
+ ])
122
+ const code = await generatePluginsCode(PLUGINS, true)
123
+ expect(code).toContain('01.analytics.ts')
124
+ expect(code).toContain('03.auth.ts')
125
+ expect(code).not.toContain('02.fonts.client.ts')
126
+ // Two plugins remain: _p0 and _p1
127
+ expect(code).toContain('export const plugins = [_p0, _p1]')
128
+ })
84
129
  })
@@ -79,12 +79,21 @@ describe('APP_ENTRY_TEMPLATE — meta.hydrate', () => {
79
79
  expect(APP_ENTRY_TEMPLATE).toContain('__cerRouter')
80
80
  })
81
81
 
82
- it('_doHydrate clears __CER_DATA__ after navigation', () => {
83
- // delete must appear inside _doHydrate, i.e. after _replace and before the closing }
82
+ it('_doHydrate defers __CER_DATA__ deletion via queueMicrotask after navigation', () => {
83
+ // The delete must happen inside a queueMicrotask callback so that
84
+ // cer-layout-view's reactive re-render (queued by the router subscription)
85
+ // runs BEFORE the data is cleared. A synchronous delete would remove the
86
+ // data before the scheduled render can read it, causing usePageData() to
87
+ // always return null on initial SSR/SSG page load.
84
88
  const doHydrateStart = APP_ENTRY_TEMPLATE.indexOf('const _doHydrate')
85
89
  const doHydrateEnd = APP_ENTRY_TEMPLATE.indexOf('\n }', doHydrateStart)
86
90
  const doHydrateBlock = APP_ENTRY_TEMPLATE.slice(doHydrateStart, doHydrateEnd)
91
+ expect(doHydrateBlock).toContain('queueMicrotask')
87
92
  expect(doHydrateBlock).toContain('delete (globalThis).__CER_DATA__')
93
+ // The delete must be INSIDE a queueMicrotask callback, not inline
94
+ const microtaskIdx = doHydrateBlock.indexOf('queueMicrotask')
95
+ const deleteIdx = doHydrateBlock.indexOf('delete (globalThis).__CER_DATA__')
96
+ expect(deleteIdx).toBeGreaterThan(microtaskIdx)
88
97
  })
89
98
  })
90
99
 
@@ -77,9 +77,14 @@ export function devCommand(): Command {
77
77
  .option('-p, --port <port>', 'Port to listen on', '3000')
78
78
  .option('--host <host>', 'Host to bind to', 'localhost')
79
79
  .option('--root <root>', 'Project root directory', process.cwd())
80
+ .option('--mode <mode>', 'Dev mode: spa, ssr, or ssg (overrides cer.config.ts)')
80
81
  .action(async (options) => {
81
82
  const root = resolve(options.root)
82
83
  const userConfig = await loadCerConfig(root)
84
+ // CLI --mode flag overrides config file (mirrors build command behaviour)
85
+ if (options.mode) {
86
+ userConfig.mode = options.mode as 'spa' | 'ssr' | 'ssg'
87
+ }
83
88
  const port = options.port ? parseInt(options.port, 10) : (userConfig.port ?? 3000)
84
89
 
85
90
  console.log('[cer-app] Starting dev server...')
@@ -5,7 +5,6 @@ import { createServer, type UserConfig } from 'vite'
5
5
  import type { ResolvedCerConfig } from './dev-server.js'
6
6
  import { buildSSR } from './build-ssr.js'
7
7
  import { buildRouteEntry } from './path-utils.js'
8
- import { CONTENT_STORE_KEY, loadContentStore, resolveContentDir } from './content/index.js'
9
8
  import fg from 'fast-glob'
10
9
 
11
10
  interface SsgManifest {
@@ -250,17 +249,6 @@ export async function buildSSG(
250
249
  const paths = await collectSsgPaths(config, viteUserConfig)
251
250
  console.log(`[cer-app] Found ${paths.length} path(s) to generate:`, paths)
252
251
 
253
- // Restore the in-memory content store to the production (no-draft) content.
254
- // collectSsgPaths may spin up a Vite dev server (watchMode=true) whose
255
- // buildStart hook overwrites globalThis.__CER_CONTENT_STORE__ with drafts
256
- // included. Re-running loadContentStore with isProduction=true corrects this
257
- // so that every renderPath call sees only published content.
258
- {
259
- const contentDir = resolveContentDir(config.root)
260
- const productionItems = await loadContentStore(contentDir, false, true)
261
- ;(globalThis as Record<string, unknown>)[CONTENT_STORE_KEY] = productionItems
262
- }
263
-
264
252
  // Step 3+4: Render and write paths with bounded concurrency.
265
253
  // The server bundle uses per-request router instances (initRouter returns the
266
254
  // router; the factory passes it to createStreamingSSRHandler as { vnode, router })
@@ -24,14 +24,14 @@ export function resolveContentDir(root: string, contentConfig?: CerContentConfig
24
24
 
25
25
  /**
26
26
  * Loads all content files from `contentDir`, parses them concurrently, and
27
- * returns the full `ContentItem[]`. Excludes drafts in production unless
28
- * `drafts: true`. Uses async I/O + `Promise.all` for concurrent disk reads,
29
- * which is significantly faster than sequential `readFileSync` at 10k+ pages.
27
+ * returns the full `ContentItem[]`. Excludes drafts unless `includeDrafts: true`
28
+ * is set in the content config. Uses async I/O + `Promise.all` for concurrent
29
+ * disk reads, which is significantly faster than sequential `readFileSync` at
30
+ * 10k+ pages.
30
31
  */
31
32
  export async function loadContentStore(
32
33
  contentDir: string,
33
34
  isDraft: boolean,
34
- isProduction: boolean,
35
35
  ): Promise<ContentItem[]> {
36
36
  if (!existsSync(contentDir)) return []
37
37
 
@@ -41,8 +41,8 @@ export async function loadContentStore(
41
41
  files.map(async (file) => {
42
42
  try {
43
43
  const item = await parseContentFileAsync(file, contentDir)
44
- // Skip drafts in production (unless drafts flag is set)
45
- if (isProduction && !isDraft && item.draft === true) return null
44
+ // Skip drafts unless the user explicitly opted in via drafts: true
45
+ if (!isDraft && item.draft === true) return null
46
46
  return item
47
47
  } catch (err) {
48
48
  // Warn and skip unparseable / invalid files so one bad file does not
@@ -175,8 +175,7 @@ export function cerContent(
175
175
  },
176
176
 
177
177
  async buildStart() {
178
- const isProduction = this.meta.watchMode === false
179
- const items = await loadContentStore(_resolvedContentDir, includeDrafts, isProduction)
178
+ const items = await loadContentStore(_resolvedContentDir, includeDrafts)
180
179
  const g = globalThis as Record<string, unknown>
181
180
  g[CONTENT_STORE_KEY] = items
182
181
  },
@@ -225,9 +224,7 @@ export function cerContent(
225
224
  }
226
225
 
227
226
  async function refreshStore(contentDir: string, includeDrafts: boolean): Promise<void> {
228
- // HMR runs in dev (watchMode=true) — use isProduction=false so draft items
229
- // remain visible, matching the initial buildStart behaviour in dev mode.
230
- const items = await loadContentStore(contentDir, includeDrafts, false)
227
+ const items = await loadContentStore(contentDir, includeDrafts)
231
228
  const g = globalThis as Record<string, unknown>
232
229
  g[CONTENT_STORE_KEY] = items
233
230
  // Invalidate the dev middleware caches so the next request rebuilds manifest
@@ -1,5 +1,7 @@
1
1
  import type { ViteDevServer } from 'vite'
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
+ import { resolve } from 'node:path'
3
5
  import { join } from 'pathe'
4
6
  import { getGeneratedDir } from './generated-dir.js'
5
7
 
@@ -246,7 +248,7 @@ export function configureCerDevServer(
246
248
  const acceptsHtml =
247
249
  (req.headers['accept'] ?? '').includes('text/html') ||
248
250
  url === '/' ||
249
- (!url.includes('.') && !url.startsWith('/api/'))
251
+ (!url.includes('.') && !url.startsWith('/api/') && !url.startsWith('/@'))
250
252
 
251
253
  if (acceptsHtml) {
252
254
  // Check per-route render mode — skip SSR for 'spa' routes.
@@ -257,6 +259,20 @@ export function configureCerDevServer(
257
259
  for (const route of pageRoutes) {
258
260
  if (_matchDevRoute(route.path, urlPathOnly)) {
259
261
  if (route.meta?.render === 'spa') {
262
+ // In SSR/SSG dev mode, a route with render:'spa' should be served as
263
+ // the SPA shell (no server rendering). Serve .cer/index.html so the
264
+ // client bundle boots and handles the route client-side.
265
+ const _userHtml = resolve(config.root, 'index.html')
266
+ const _cerHtml = join(getGeneratedDir(config.root), 'index.html')
267
+ const _spaSrcPath = existsSync(_userHtml) ? _userHtml : _cerHtml
268
+ if (existsSync(_spaSrcPath)) {
269
+ const rawHtml = readFileSync(_spaSrcPath, 'utf-8')
270
+ const transformed = await server.transformIndexHtml(url, rawHtml)
271
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
272
+ res.statusCode = 200
273
+ res.end(transformed)
274
+ return
275
+ }
260
276
  next()
261
277
  return
262
278
  }
@@ -276,7 +292,26 @@ export function configureCerDevServer(
276
292
  ssrEntry.handler ?? ssrEntry.default?.handler
277
293
 
278
294
  if (typeof handler === 'function') {
279
- await handler(req, res)
295
+ // In dev mode _clientTemplate inside entry-server.ts is null because
296
+ // the dist/client/index.html path doesn't exist. Set the global that
297
+ // the handler reads per-request so the SSR response includes the
298
+ // Vite client scripts (/@vite/client, HMR, module imports for app.ts).
299
+ const _userIndexPath = resolve(config.root, 'index.html')
300
+ const _genIndexPath = join(getGeneratedDir(config.root), 'index.html')
301
+ const _rawHtml = existsSync(_userIndexPath)
302
+ ? readFileSync(_userIndexPath, 'utf-8')
303
+ : existsSync(_genIndexPath)
304
+ ? readFileSync(_genIndexPath, 'utf-8')
305
+ : null
306
+ if (_rawHtml) {
307
+ ;(globalThis as Record<string, unknown>).__CER_CLIENT_TEMPLATE__ =
308
+ await server.transformIndexHtml(url, _rawHtml)
309
+ }
310
+ try {
311
+ await handler(req, res)
312
+ } finally {
313
+ ;(globalThis as Record<string, unknown>).__CER_CLIENT_TEMPLATE__ = undefined
314
+ }
280
315
  return
281
316
  }
282
317
 
@@ -40,7 +40,13 @@ export function writeTsconfigPaths(root: string, srcDir: string): void {
40
40
  }
41
41
 
42
42
  const content = JSON.stringify(tsconfig, null, 2) + '\n'
43
- writeFileSync(join(cerDir, 'tsconfig.json'), content, 'utf-8')
43
+ const tsconfigPath = join(cerDir, 'tsconfig.json')
44
+ // Skip write when content is unchanged to avoid triggering Vite's tsconfig
45
+ // watcher, which would cause an unnecessary server restart on every dev start.
46
+ try {
47
+ if (existsSync(tsconfigPath) && readFileSync(tsconfigPath, 'utf-8') === content) return
48
+ } catch { /* proceed to write */ }
49
+ writeFileSync(tsconfigPath, content, 'utf-8')
44
50
  }
45
51
 
46
52
  const RUNTIME_GLOBALS = [
@@ -123,7 +123,7 @@ async function generateVirtualModule(
123
123
  case RESOLVED_IDS.composables:
124
124
  return generateComposablesCode(config.composablesDir)
125
125
  case RESOLVED_IDS.plugins:
126
- return generatePluginsCode(config.pluginsDir)
126
+ return generatePluginsCode(config.pluginsDir, ssr)
127
127
  case RESOLVED_IDS.middleware:
128
128
  return generateMiddlewareCode(config.middlewareDir)
129
129
  case RESOLVED_IDS.serverApi:
@@ -307,9 +307,12 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
307
307
  if (!allResolved.includes(id)) return null
308
308
 
309
309
  const ssr = options?.ssr ?? false
310
- // For virtual:cer-app-config the SSR and client variants differ (private
311
- // defaults are only included in the SSR bundle), so use separate cache keys.
312
- const cacheKey = id === RESOLVED_IDS.appConfig ? `${id}:${ssr ? 'ssr' : 'client'}` : id
310
+ // For virtual:cer-app-config and virtual:cer-plugins the SSR and client
311
+ // variants differ: app-config includes private defaults in SSR; plugins
312
+ // excludes .client.ts files in SSR. Use separate cache keys for both.
313
+ const cacheKey = (id === RESOLVED_IDS.appConfig || id === RESOLVED_IDS.plugins)
314
+ ? `${id}:${ssr ? 'ssr' : 'client'}`
315
+ : id
313
316
 
314
317
  // Return from cache if available
315
318
  if (moduleCache.has(cacheKey)) {
@@ -362,6 +365,13 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
362
365
  if (!existsSync(userHtml)) {
363
366
  const cerHtmlPath = join(getGeneratedDir(config.root), 'index.html')
364
367
  server.middlewares.use(async (req, res, next) => {
368
+ // In SSR/SSG mode, HTML requests must fall through to configureCerDevServer
369
+ // so the SSR handler can run loaders and inject __CER_DATA__ into the response.
370
+ // Only SPA mode (no server rendering) should serve the raw SPA shell here.
371
+ if (config.mode === 'ssr' || config.mode === 'ssg') {
372
+ next()
373
+ return
374
+ }
365
375
  const url = (req as { url?: string }).url ?? '/'
366
376
  const isHtmlRequest =
367
377
  url === '/' ||
@@ -5,8 +5,14 @@ import { sortPluginFiles } from '../path-utils.js'
5
5
  /**
6
6
  * Generates the virtual:cer-plugins module code.
7
7
  * Scans app/plugins/ and produces an ordered array of plugin imports.
8
+ *
9
+ * @param pluginsDir - Absolute path to app/plugins/
10
+ * @param serverSafeOnly - When true, excludes files ending in `.client.ts`.
11
+ * These are client-only plugins (CSS injections, browser component libraries,
12
+ * etc.) that must not be imported in the SSR server entry. Naming convention:
13
+ * `app/plugins/my-plugin.client.ts` → excluded from the server bundle.
8
14
  */
9
- export async function generatePluginsCode(pluginsDir: string): Promise<string> {
15
+ export async function generatePluginsCode(pluginsDir: string, serverSafeOnly = false): Promise<string> {
10
16
  if (!existsSync(pluginsDir)) {
11
17
  return `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app\nexport const plugins = []\nexport default plugins\n`
12
18
  }
@@ -18,10 +24,14 @@ export async function generatePluginsCode(pluginsDir: string): Promise<string> {
18
24
  }
19
25
 
20
26
  const sorted = sortPluginFiles(files)
27
+ // In server-safe mode, exclude `.client.ts` plugins — they are browser-only
28
+ // (CSS imports, client component libraries, etc.) and must not be loaded
29
+ // server-side. Rename the file to `*.client.ts` to mark it as client-only.
30
+ const filtered = serverSafeOnly ? sorted.filter(f => !f.endsWith('.client.ts')) : sorted
21
31
  const lines: string[] = ['// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app', '']
22
32
 
23
33
  const aliases: string[] = []
24
- sorted.forEach((file, i) => {
34
+ filtered.forEach((file, i) => {
25
35
  const alias = `_p${i}`
26
36
  aliases.push(alias)
27
37
  lines.push(`import ${alias} from ${JSON.stringify(file)}`)
@@ -287,9 +287,15 @@ if (typeof window !== 'undefined') {
287
287
  // the initial paint — the loading component must not flash over pre-rendered content.
288
288
  await _replace(_initPath)
289
289
  }
290
- // Clear SSR loader data after initial navigation so subsequent client-side
291
- // navigations don't accidentally reuse stale server data.
292
- delete (globalThis).__CER_DATA__
290
+ // Defer the data cleanup by one microtask so that cer-layout-view's reactive
291
+ // re-render scheduled via queueMicrotask by the reactive system when the
292
+ // router fires its subscribers during _replace — can still read __CER_DATA__.
293
+ // Clearing synchronously here would remove the data before the queued render
294
+ // runs, causing usePageData() to return null on the initial page load.
295
+ // Subsequent navigations via router.push / router.replace each delete
296
+ // __CER_DATA__ themselves before calling _loadPageForPath, so this deferred
297
+ // cleanup only matters for the very first hydration render.
298
+ queueMicrotask(() => { delete (globalThis).__CER_DATA__ })
293
299
  }
294
300
 
295
301
  if (_hydrateStrategy === 'idle') {
@@ -10,13 +10,15 @@
10
10
  * component renders with real data in the initial SSR/SSG HTML.
11
11
  *
12
12
  * 2. **Client hydration** — the server also serializes the data as
13
- * `window.__CER_DATA__` in the page `<head>`. The client entry captures
14
- * this into `globalThis.__CER_DATA__` before the app boots. On first
15
- * component instantiation `usePageData()` returns that value so the client
13
+ * `window.__CER_DATA__` in the page `<head>`. On first component
14
+ * instantiation `usePageData()` returns that value so the client
16
15
  * starts with the correct state without an extra network round-trip.
17
16
  *
18
- * The client-side value is cleared after the first read so subsequent
19
- * client-side navigations don't accidentally reuse stale SSR data.
17
+ * The client-side value is cleared via `queueMicrotask` in `_doHydrate`
18
+ * (app-template) after the initial router navigation completes — deferred by
19
+ * one microtask so the queued reactive re-render can still read the data
20
+ * before it is removed. Subsequent navigations via `router.push` / `router.replace`
21
+ * each clear `__CER_DATA__` synchronously before loading the next page's data.
20
22
  *
21
23
  * @returns The serialized loader result, or `null` if no SSR data is present.
22
24
  *
@@ -394,8 +394,14 @@ export const handler = async (req, res) => {
394
394
  // (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
395
395
  const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
396
396
 
397
- const merged = _clientTemplate
398
- ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
397
+ // In dev mode the module-level _clientTemplate is null (only the
398
+ // production dist/client/index.html path is searched at init time).
399
+ // The dev server sets (globalThis).__CER_CLIENT_TEMPLATE__ per-request
400
+ // after running server.transformIndexHtml so the Vite client scripts
401
+ // (/@vite/client, HMR) are included in every SSR response.
402
+ const _resolvedClientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate
403
+ const merged = _resolvedClientTemplate
404
+ ? _mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)
399
405
  : ssrHtml
400
406
 
401
407
  // Split at </body> so async swap scripts and the DSD polyfill can be streamed