@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.
- package/CHANGELOG.md +8 -0
- package/commits.txt +1 -1
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +5 -0
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js +0 -11
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/content/index.d.ts +5 -4
- package/dist/plugin/content/index.d.ts.map +1 -1
- package/dist/plugin/content/index.js +9 -11
- package/dist/plugin/content/index.js.map +1 -1
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js +40 -2
- package/dist/plugin/dev-server.js.map +1 -1
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +9 -1
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +7 -0
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/virtual/routes.d.ts.map +1 -1
- package/dist/plugin/virtual/routes.js +11 -0
- package/dist/plugin/virtual/routes.js.map +1 -1
- package/dist/runtime/app-template.d.ts +1 -1
- package/dist/runtime/app-template.d.ts.map +1 -1
- package/dist/runtime/app-template.js +48 -21
- package/dist/runtime/app-template.js.map +1 -1
- package/dist/runtime/composables/use-page-data.d.ts +0 -41
- package/dist/runtime/composables/use-page-data.d.ts.map +1 -1
- package/dist/runtime/composables/use-page-data.js +44 -20
- package/dist/runtime/composables/use-page-data.js.map +1 -1
- package/dist/runtime/entry-client-template.d.ts +1 -1
- package/dist/runtime/entry-client-template.d.ts.map +1 -1
- package/dist/runtime/entry-client-template.js +10 -0
- package/dist/runtime/entry-client-template.js.map +1 -1
- package/dist/runtime/entry-server-template.d.ts +1 -1
- package/dist/runtime/entry-server-template.d.ts.map +1 -1
- package/dist/runtime/entry-server-template.js +30 -9
- package/dist/runtime/entry-server-template.js.map +1 -1
- package/e2e/cypress/e2e/group-meta.cy.ts +5 -2
- package/e2e/cypress/e2e/preview-hardening.cy.ts +42 -33
- package/e2e/cypress/e2e/use-page-data.cy.ts +122 -0
- package/e2e/kitchen-sink/app/pages/blog/[slug].ts +4 -0
- package/e2e/kitchen-sink/app/pages/blog/index.ts +5 -0
- package/package.json +5 -2
- package/src/__tests__/plugin/build-ssg.test.ts +2 -2
- package/src/__tests__/plugin/content/loader.test.ts +19 -27
- package/src/__tests__/plugin/entry-server-template.test.ts +11 -3
- package/src/__tests__/plugin/virtual/routes.test.ts +26 -0
- package/src/__tests__/runtime/app-template.test.ts +25 -13
- package/src/__tests__/runtime/entry-client-template.test.ts +7 -1
- package/src/__tests__/runtime/use-page-data.test.ts +178 -1
- package/src/cli/commands/dev.ts +5 -0
- package/src/plugin/build-ssg.ts +0 -12
- package/src/plugin/content/index.ts +8 -11
- package/src/plugin/dev-server.ts +37 -2
- package/src/plugin/dts-generator.ts +7 -1
- package/src/plugin/index.ts +7 -0
- package/src/plugin/virtual/routes.ts +11 -0
- package/src/runtime/app-template.ts +48 -21
- package/src/runtime/composables/use-page-data.ts +50 -20
- package/src/runtime/entry-client-template.ts +10 -0
- 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(
|
|
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
|
|
83
|
-
// The
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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(
|
|
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
|
+
|
package/src/cli/commands/dev.ts
CHANGED
|
@@ -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...')
|
package/src/plugin/build-ssg.ts
CHANGED
|
@@ -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
|
|
28
|
-
*
|
|
29
|
-
* which is significantly faster than sequential `readFileSync` at
|
|
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
|
|
45
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
package/src/plugin/dev-server.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = [
|
package/src/plugin/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
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') {
|