@jasonshimmy/vite-plugin-cer-app 0.20.3 → 0.20.5

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 (64) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/commits.txt +1 -1
  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 +7 -0
  21. package/dist/plugin/index.js.map +1 -1
  22. package/dist/plugin/virtual/routes.d.ts.map +1 -1
  23. package/dist/plugin/virtual/routes.js +11 -0
  24. package/dist/plugin/virtual/routes.js.map +1 -1
  25. package/dist/runtime/app-template.d.ts +1 -1
  26. package/dist/runtime/app-template.d.ts.map +1 -1
  27. package/dist/runtime/app-template.js +48 -21
  28. package/dist/runtime/app-template.js.map +1 -1
  29. package/dist/runtime/composables/use-page-data.d.ts +0 -41
  30. package/dist/runtime/composables/use-page-data.d.ts.map +1 -1
  31. package/dist/runtime/composables/use-page-data.js +44 -20
  32. package/dist/runtime/composables/use-page-data.js.map +1 -1
  33. package/dist/runtime/entry-client-template.d.ts +1 -1
  34. package/dist/runtime/entry-client-template.d.ts.map +1 -1
  35. package/dist/runtime/entry-client-template.js +10 -0
  36. package/dist/runtime/entry-client-template.js.map +1 -1
  37. package/dist/runtime/entry-server-template.d.ts +1 -1
  38. package/dist/runtime/entry-server-template.d.ts.map +1 -1
  39. package/dist/runtime/entry-server-template.js +30 -9
  40. package/dist/runtime/entry-server-template.js.map +1 -1
  41. package/e2e/cypress/e2e/group-meta.cy.ts +5 -2
  42. package/e2e/cypress/e2e/preview-hardening.cy.ts +42 -33
  43. package/e2e/cypress/e2e/use-page-data.cy.ts +122 -0
  44. package/e2e/kitchen-sink/app/pages/blog/[slug].ts +4 -0
  45. package/e2e/kitchen-sink/app/pages/blog/index.ts +5 -0
  46. package/package.json +5 -2
  47. package/src/__tests__/plugin/build-ssg.test.ts +2 -2
  48. package/src/__tests__/plugin/content/loader.test.ts +19 -27
  49. package/src/__tests__/plugin/entry-server-template.test.ts +11 -3
  50. package/src/__tests__/plugin/virtual/routes.test.ts +26 -0
  51. package/src/__tests__/runtime/app-template.test.ts +25 -13
  52. package/src/__tests__/runtime/entry-client-template.test.ts +7 -1
  53. package/src/__tests__/runtime/use-page-data.test.ts +178 -1
  54. package/src/cli/commands/dev.ts +5 -0
  55. package/src/plugin/build-ssg.ts +0 -12
  56. package/src/plugin/content/index.ts +8 -11
  57. package/src/plugin/dev-server.ts +37 -2
  58. package/src/plugin/dts-generator.ts +7 -1
  59. package/src/plugin/index.ts +7 -0
  60. package/src/plugin/virtual/routes.ts +11 -0
  61. package/src/runtime/app-template.ts +48 -21
  62. package/src/runtime/composables/use-page-data.ts +50 -20
  63. package/src/runtime/entry-client-template.ts +10 -0
  64. package/src/runtime/entry-server-template.ts +30 -9
@@ -55,10 +55,17 @@ describe('APP_ENTRY_TEMPLATE — meta.hydrate', () => {
55
55
  })
56
56
 
57
57
  it('_doHydrate pre-loads the page and calls _replace', () => {
58
- expect(APP_ENTRY_TEMPLATE).toContain('await _loadPageForPath(_initPath)')
58
+ expect(APP_ENTRY_TEMPLATE).toContain('await _loadPageForPath(')
59
+ expect(APP_ENTRY_TEMPLATE).toContain('_initPath,')
59
60
  expect(APP_ENTRY_TEMPLATE).toContain('await _replace(_initPath)')
60
61
  })
61
62
 
63
+ it('_doHydrate reuses existing SSR loader data instead of re-running the initial loader', () => {
64
+ expect(APP_ENTRY_TEMPLATE).toContain("Object.prototype.hasOwnProperty.call(globalThis, '__CER_DATA__')")
65
+ expect(APP_ENTRY_TEMPLATE).toContain('runLoader: false')
66
+ expect(APP_ENTRY_TEMPLATE).toContain('initialData: (globalThis).__CER_DATA__')
67
+ })
68
+
62
69
  it('_doHydrate skips _replace if URL changed during async module load', () => {
63
70
  // Guard: only call _replace when the URL hasn't changed during _loadPageForPath.
64
71
  // This prevents _doHydrate from overriding a navigation that fired while the
@@ -75,25 +82,25 @@ describe('APP_ENTRY_TEMPLATE — meta.hydrate', () => {
75
82
  expect(APP_ENTRY_TEMPLATE).toContain('_currentPagePath === current.value.path')
76
83
  })
77
84
 
85
+ it('keeps the SSR slot during the router subscribe initial state push', () => {
86
+ expect(APP_ENTRY_TEMPLATE).toContain('let _sawInitialRouteState = false')
87
+ expect(APP_ENTRY_TEMPLATE).toContain('const _isInitialSubscribePush = !_sawInitialRouteState')
88
+ expect(APP_ENTRY_TEMPLATE).toContain('if (_isInitialSubscribePush) {')
89
+ expect(APP_ENTRY_TEMPLATE).toContain('return')
90
+ })
91
+
78
92
  it('exposes router globally as __cerRouter', () => {
79
93
  expect(APP_ENTRY_TEMPLATE).toContain('__cerRouter')
80
94
  })
81
95
 
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.
96
+ it('_doHydrate keeps initial __CER_DATA__ available after hydration', () => {
97
+ // The initial page data must survive hydration because some browsers may
98
+ // perform a later upgrade/re-render of the hydrated page component. The
99
+ // next real client navigation clears __CER_DATA__ before loading new data.
88
100
  const doHydrateStart = APP_ENTRY_TEMPLATE.indexOf('const _doHydrate')
89
101
  const doHydrateEnd = APP_ENTRY_TEMPLATE.indexOf('\n }', doHydrateStart)
90
102
  const doHydrateBlock = APP_ENTRY_TEMPLATE.slice(doHydrateStart, doHydrateEnd)
91
- expect(doHydrateBlock).toContain('queueMicrotask')
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)
103
+ expect(doHydrateBlock).not.toContain('queueMicrotask(() => { delete (globalThis).__CER_DATA__ })')
97
104
  })
98
105
  })
99
106
 
@@ -108,6 +115,11 @@ describe('APP_ENTRY_TEMPLATE — loader sequence', () => {
108
115
  expect(APP_ENTRY_TEMPLATE).toContain('(globalThis).__CER_DATA__ = data')
109
116
  })
110
117
 
118
+ it('_loadPageForPath derives primitive attrs from reused loader data too', () => {
119
+ expect(APP_ENTRY_TEMPLATE).toContain('function _toLoaderAttrs(data)')
120
+ expect(APP_ENTRY_TEMPLATE).toContain('loaderAttrs = { ...loaderAttrs, ..._toLoaderAttrs(loaderData) }')
121
+ })
122
+
111
123
  it('_loadPageForPath merges loader primitive values into _currentPageAttrs', () => {
112
124
  expect(APP_ENTRY_TEMPLATE).toContain('_currentPageAttrs = loaderAttrs')
113
125
  })
@@ -7,6 +7,12 @@ describe('ENTRY_CLIENT_TEMPLATE', () => {
7
7
  expect(ENTRY_CLIENT_TEMPLATE.length).toBeGreaterThan(0)
8
8
  })
9
9
 
10
+ it('marks the SSR entry URL before app boots so generated middleware can skip hydration replay once', () => {
11
+ expect(ENTRY_CLIENT_TEMPLATE).toContain('(globalThis).__CER_HYDRATION_ENTRY__')
12
+ expect(ENTRY_CLIENT_TEMPLATE).toContain('path: window.location.pathname')
13
+ expect(ENTRY_CLIENT_TEMPLATE).toContain('new URLSearchParams(window.location.search)')
14
+ })
15
+
10
16
  it('captures __CER_DATA__ from window to globalThis before app boots', () => {
11
17
  // SSR loader data must be captured before any module clears window.__CER_DATA__
12
18
  expect(ENTRY_CLIENT_TEMPLATE).toContain('window.__CER_DATA__')
@@ -33,7 +39,7 @@ describe('ENTRY_CLIENT_TEMPLATE', () => {
33
39
  // Guards are required so the template works in SSR environments where
34
40
  // window is not defined.
35
41
  const windowChecks = (ENTRY_CLIENT_TEMPLATE.match(/typeof window !== 'undefined'/g) ?? []).length
36
- expect(windowChecks).toBeGreaterThanOrEqual(3)
42
+ expect(windowChecks).toBeGreaterThanOrEqual(4)
37
43
  })
38
44
 
39
45
  it('captures globals before importing app.js', () => {
@@ -1,5 +1,15 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
2
  import { AsyncLocalStorage } from 'node:async_hooks'
3
+
4
+ // --- Module-level mock for @jasonshimmy/custom-elements-runtime ---
5
+ // usePageData imports getCurrentComponentContext from the runtime. We mock the
6
+ // module so unit tests can control which context (if any) is "active".
7
+ let _mockContext: Record<string, unknown> | null = null
8
+
9
+ vi.mock('@jasonshimmy/custom-elements-runtime', () => ({
10
+ getCurrentComponentContext: () => _mockContext,
11
+ }))
12
+
3
13
  import { usePageData } from '../../runtime/composables/use-page-data.js'
4
14
 
5
15
  describe('usePageData', () => {
@@ -119,3 +129,170 @@ describe('usePageData — AsyncLocalStorage (server-side)', () => {
119
129
  })
120
130
  })
121
131
  })
132
+
133
+ // ─── Component context caching ─────────────────────────────────────────────
134
+ //
135
+ // usePageData() caches the result on the component context (via Object.defineProperty)
136
+ // so that re-renders of the same element instance return the same value even after
137
+ // __CER_DATA__ is deleted by the post-hydration queueMicrotask cleanup.
138
+ //
139
+ // Background: renderFn passed to component() IS the render function — it runs on
140
+ // every re-render, not just once as a setup phase. Without caching, calling
141
+ // usePageData() on the second render after __CER_DATA__ is deleted returns null,
142
+ // which flips `ssrData ? 'ssr' : 'client'` guards and re-triggers client fetches.
143
+
144
+ describe('usePageData — component context caching', () => {
145
+ const _PAGE_DATA_KEY = '_cerPageData'
146
+
147
+ beforeEach(() => {
148
+ delete (globalThis as Record<string, unknown>)['__CER_DATA__']
149
+ delete (globalThis as Record<string, unknown>)['__CER_DATA_STORE__']
150
+ _mockContext = null
151
+ })
152
+
153
+ afterEach(() => {
154
+ delete (globalThis as Record<string, unknown>)['__CER_DATA__']
155
+ _mockContext = null
156
+ })
157
+
158
+ it('returns null and does not cache when context is null (no render in progress)', () => {
159
+ _mockContext = null
160
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
161
+ const result = usePageData()
162
+ expect(result).toEqual({ value: 42 })
163
+ // No context to cache on — nothing to assert, just no crash
164
+ })
165
+
166
+ it('caches the result on the component context using Object.defineProperty', () => {
167
+ const ctx: Record<string, unknown> = {}
168
+ _mockContext = ctx
169
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
170
+
171
+ usePageData()
172
+
173
+ // Should be defined on ctx via Object.defineProperty (non-enumerable)
174
+ expect(Object.prototype.hasOwnProperty.call(ctx, _PAGE_DATA_KEY)).toBe(true)
175
+ expect(ctx[_PAGE_DATA_KEY]).toEqual({ value: 42 })
176
+ })
177
+
178
+ it('cached property is non-enumerable (does not appear in Object.keys)', () => {
179
+ const ctx: Record<string, unknown> = {}
180
+ _mockContext = ctx
181
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
182
+
183
+ usePageData()
184
+
185
+ expect(Object.keys(ctx)).not.toContain(_PAGE_DATA_KEY)
186
+ })
187
+
188
+ it('cached property is non-writable (reactive proxy set-trap cannot overwrite it)', () => {
189
+ const ctx: Record<string, unknown> = {}
190
+ _mockContext = ctx
191
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
192
+
193
+ usePageData()
194
+
195
+ // Attempting to overwrite a writable:false property silently fails in non-strict mode
196
+ // and throws in strict mode. Verify the value doesn't change.
197
+ try { ctx[_PAGE_DATA_KEY] = { value: 999 } } catch { /* strict mode */ }
198
+ expect(ctx[_PAGE_DATA_KEY]).toEqual({ value: 42 })
199
+ })
200
+
201
+ it('returns cached value on second call even after __CER_DATA__ is deleted', () => {
202
+ const ctx: Record<string, unknown> = {}
203
+ _mockContext = ctx
204
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 42 }
205
+
206
+ // First call (simulates initial render while __CER_DATA__ is present)
207
+ const first = usePageData()
208
+ expect(first).toEqual({ value: 42 })
209
+
210
+ // Simulate post-hydration cleanup: queueMicrotask(() => delete __CER_DATA__)
211
+ delete (globalThis as Record<string, unknown>)['__CER_DATA__']
212
+
213
+ // Second call (simulates re-render after cleanup) — must return cached value
214
+ const second = usePageData()
215
+ expect(second).toEqual({ value: 42 })
216
+ })
217
+
218
+ it('caches null result so repeated calls with no data do not re-read the deleted global', () => {
219
+ const ctx: Record<string, unknown> = {}
220
+ _mockContext = ctx
221
+ // No __CER_DATA__ — returns null and caches null
222
+
223
+ const first = usePageData()
224
+ expect(first).toBeNull()
225
+
226
+ // Set __CER_DATA__ AFTER first call — second call should return cached null
227
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { value: 99 }
228
+ const second = usePageData()
229
+ expect(second).toBeNull()
230
+ })
231
+
232
+ it('each component element instance has its own independent cache', () => {
233
+ const ctxA: Record<string, unknown> = {}
234
+ const ctxB: Record<string, unknown> = {}
235
+
236
+ // First component: has SSR data
237
+ _mockContext = ctxA
238
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { page: 'home' }
239
+ const resultA = usePageData()
240
+ expect(resultA).toEqual({ page: 'home' })
241
+
242
+ // Simulate navigation: delete home data, set blog data
243
+ delete (globalThis as Record<string, unknown>)['__CER_DATA__']
244
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { page: 'blog' }
245
+
246
+ // Second component (different element, different context)
247
+ _mockContext = ctxB
248
+ const resultB = usePageData()
249
+ expect(resultB).toEqual({ page: 'blog' })
250
+
251
+ // First component re-rendered — still returns its cached value
252
+ _mockContext = ctxA
253
+ delete (globalThis as Record<string, unknown>)['__CER_DATA__']
254
+ const resultA2 = usePageData()
255
+ expect(resultA2).toEqual({ page: 'home' }) // cached, not stale blog data
256
+ })
257
+
258
+ it('ALS path takes precedence over context cache (server-side always uses ALS)', () => {
259
+ const store = new AsyncLocalStorage<unknown>()
260
+ ;(globalThis as Record<string, unknown>)['__CER_DATA_STORE__'] = store
261
+
262
+ const ctx: Record<string, unknown> = {}
263
+ // Pre-seed a stale client cache on the context
264
+ Object.defineProperty(ctx, _PAGE_DATA_KEY, { value: { stale: true }, writable: false, configurable: true, enumerable: false })
265
+ _mockContext = ctx
266
+
267
+ const alsData = { fresh: true }
268
+ store.run(alsData, () => {
269
+ const result = usePageData()
270
+ // ALS wins — context cache is not consulted for server-side renders
271
+ expect(result).toEqual(alsData)
272
+ })
273
+
274
+ delete (globalThis as Record<string, unknown>)['__CER_DATA_STORE__']
275
+ })
276
+
277
+ it('context cache survives across multiple renderFn() invocations (re-render stability)', () => {
278
+ const ctx: Record<string, unknown> = {}
279
+ _mockContext = ctx
280
+ ;(globalThis as Record<string, unknown>)['__CER_DATA__'] = { title: 'My Page' }
281
+
282
+ // Simulate 5 re-renders (e.g. reactive state updates)
283
+ for (let i = 0; i < 5; i++) {
284
+ const result = usePageData<{ title: string }>()
285
+ expect(result?.title).toBe('My Page')
286
+ }
287
+
288
+ // Delete __CER_DATA__ (post-hydration cleanup)
289
+ delete (globalThis as Record<string, unknown>)['__CER_DATA__']
290
+
291
+ // 5 more re-renders post-cleanup
292
+ for (let i = 0; i < 5; i++) {
293
+ const result = usePageData<{ title: string }>()
294
+ expect(result?.title).toBe('My Page')
295
+ }
296
+ })
297
+ })
298
+
@@ -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 = [
@@ -365,6 +365,13 @@ export function cerApp(userConfig: CerAppConfig = {}): Plugin[] {
365
365
  if (!existsSync(userHtml)) {
366
366
  const cerHtmlPath = join(getGeneratedDir(config.root), 'index.html')
367
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
+ }
368
375
  const url = (req as { url?: string }).url ?? '/'
369
376
  const isHtmlRequest =
370
377
  url === '/' ||
@@ -449,6 +449,17 @@ export async function generateRoutesCode(pagesDir: string, i18n?: I18nRouteConfi
449
449
  const mwLiteral = JSON.stringify(mw)
450
450
  mwChainBody = (
451
451
  ` beforeEnter: async (to, from) => {\n` +
452
+ ` const _hydrationEntry = (globalThis).__CER_HYDRATION_ENTRY__\n` +
453
+ ` if (\n` +
454
+ ` _hydrationEntry &&\n` +
455
+ ` from.path === to.path &&\n` +
456
+ ` JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {}) &&\n` +
457
+ ` _hydrationEntry.path === to.path &&\n` +
458
+ ` JSON.stringify(_hydrationEntry.query ?? {}) === JSON.stringify(to.query ?? {})\n` +
459
+ ` ) {\n` +
460
+ ` delete (globalThis).__CER_HYDRATION_ENTRY__\n` +
461
+ ` return true\n` +
462
+ ` }\n` +
452
463
  ` const { middleware } = await import('virtual:cer-middleware')\n` +
453
464
  ` const _names = ${mwLiteral}\n` +
454
465
  ` let _idx = 0\n` +
@@ -50,12 +50,20 @@ let _currentPageAttrs = {}
50
50
  // back to <router-view> when the current route differs from what was pre-loaded.
51
51
  let _currentPagePath = null
52
52
 
53
+ function _toLoaderAttrs(data) {
54
+ if (data === undefined || data === null || typeof data !== 'object') return {}
55
+ return Object.fromEntries(
56
+ Object.entries(data).filter(([, v]) => v !== null && v !== undefined && typeof v !== 'object' && typeof v !== 'function')
57
+ )
58
+ }
59
+
53
60
  // Pre-loads the page module for \`path\`, calls its loader if present, and
54
61
  // stores the results so cer-layout-view can pass them as element attributes.
55
- async function _loadPageForPath(path) {
62
+ async function _loadPageForPath(path, options = {}) {
56
63
  try {
57
64
  const url = new URL(path, 'http://x')
58
65
  const query = Object.fromEntries(url.searchParams)
66
+ const { runLoader = true, initialData } = options ?? {}
59
67
  const matched = router.matchRoute(url.pathname)
60
68
  const route = matched?.route
61
69
  if (!route?.load) {
@@ -69,17 +77,14 @@ async function _loadPageForPath(path) {
69
77
  _currentPagePath = url.pathname
70
78
  const params = matched.params ?? {}
71
79
  let loaderAttrs = { ...params }
72
- if (typeof mod.loader === 'function') {
80
+ let loaderData = initialData
81
+ if (typeof mod.loader === 'function' && runLoader) {
73
82
  try {
74
83
  const data = await mod.loader({ params, query })
75
84
  if (data !== undefined && data !== null) {
85
+ loaderData = data
76
86
  // Make loader data available via usePageData() for this navigation.
77
87
  ;(globalThis).__CER_DATA__ = data
78
- // Merge primitive values as element attributes so useProps() works.
79
- const primitives = Object.fromEntries(
80
- Object.entries(data).filter(([, v]) => v !== null && v !== undefined && typeof v !== 'object' && typeof v !== 'function')
81
- )
82
- loaderAttrs = { ...loaderAttrs, ...primitives }
83
88
  }
84
89
  } catch (err) {
85
90
  // Loader errors are surfaced through currentError so the error boundary
@@ -88,6 +93,11 @@ async function _loadPageForPath(path) {
88
93
  currentError.value = err instanceof Error ? err.message : String(err)
89
94
  }
90
95
  }
96
+ // For the very first hydration pass in SSR/SSG we already have the server's
97
+ // loader payload in globalThis.__CER_DATA__ / window.__CER_DATA__. Reuse it
98
+ // instead of re-running the loader on the client, then derive primitive attrs
99
+ // from that payload so useProps() stays consistent with usePageData().
100
+ loaderAttrs = { ...loaderAttrs, ..._toLoaderAttrs(loaderData) }
91
101
  _currentPageAttrs = loaderAttrs
92
102
  } catch {
93
103
  _currentPageTag = null
@@ -173,13 +183,22 @@ component('cer-layout-view', () => {
173
183
 
174
184
  const current = ref(router.getCurrent())
175
185
  let unsub
186
+ let _sawInitialRouteState = false
176
187
 
177
188
  useOnConnected(() => {
178
- // Any router navigation (including the initial _replace in _doHydrate) will
179
- // arrive here. If we are still in the hydration gap when a navigation fires
180
- // (e.g. a redirect or user interaction before _doHydrate finishes), drop the
181
- // slot immediately so the live render takes over.
189
+ // store.subscribe() synchronously pushes the current route once on
190
+ // subscription. That initial callback is not a real navigation and must not
191
+ // tear down the SSR slot before _doHydrate has pre-loaded the page.
192
+ // Subsequent callbacks are real navigations (including the initial _replace
193
+ // in _doHydrate), so if they arrive during the hydration gap we drop the
194
+ // slot immediately and let the live render take over.
182
195
  unsub = router.subscribe((s) => {
196
+ const _isInitialSubscribePush = !_sawInitialRouteState
197
+ _sawInitialRouteState = true
198
+ if (_isInitialSubscribePush) {
199
+ current.value = s
200
+ return
201
+ }
183
202
  if (_cerHydrating.value) _cerHydrating.value = false
184
203
  current.value = s
185
204
  })
@@ -270,9 +289,15 @@ if (typeof window !== 'undefined') {
270
289
  } else {
271
290
  const _doHydrate = async () => {
272
291
  const _initPath = window.location.pathname + window.location.search + window.location.hash
292
+ const _hasInitialLoaderData = Object.prototype.hasOwnProperty.call(globalThis, '__CER_DATA__')
273
293
  // Pre-load the page module and run the loader so cer-layout-view renders
274
294
  // the page tag directly with loader attrs (enables useProps() on hydration).
275
- await _loadPageForPath(_initPath)
295
+ await _loadPageForPath(
296
+ _initPath,
297
+ _hasInitialLoaderData
298
+ ? { runLoader: false, initialData: (globalThis).__CER_DATA__ }
299
+ : undefined,
300
+ )
276
301
  // Only activate the initial route if the URL hasn't changed while we were
277
302
  // loading the module asynchronously (e.g. a test or plugin navigated away
278
303
  // before _doHydrate finished). Calling _replace with a stale path would
@@ -287,15 +312,17 @@ if (typeof window !== 'undefined') {
287
312
  // the initial paint — the loading component must not flash over pre-rendered content.
288
313
  await _replace(_initPath)
289
314
  }
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__ })
315
+ // The client entry marks the already-rendered entry URL so generated
316
+ // route middleware can skip the browser router's startup replay once.
317
+ // Clear any leftover flag after the first hydrated route commits so later
318
+ // navigations always run middleware normally.
319
+ queueMicrotask(() => { delete (globalThis).__CER_HYDRATION_ENTRY__ })
320
+ // Keep the initial route's SSR data available after hydration.
321
+ // Browser engines do not all upgrade and re-render custom elements with
322
+ // identical timing, so deleting __CER_DATA__ here can cause a later
323
+ // hydration render to fall back to the client-only path. The next real
324
+ // router.push/router.replace clears __CER_DATA__ before loading the new
325
+ // route, so stale page data still does not leak across navigations.
299
326
  }
300
327
 
301
328
  if (_hydrateStrategy === 'idle') {