@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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,
|
|
1
|
+
{"version":3,"file":"entry-server-template.d.ts","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,wzuBA8cjC,CAAA"}
|
|
@@ -214,8 +214,10 @@ function _mergeWithClientTemplate(ssrHtml, clientTemplate) {
|
|
|
214
214
|
|
|
215
215
|
// Per-request async setup: initialize a fresh router, resolve the matched
|
|
216
216
|
// route and layout, pre-load the page module, and call the data loader.
|
|
217
|
-
// Loader data is
|
|
218
|
-
//
|
|
217
|
+
// Loader data is returned so the handler can scope it to _cerDataStore.run()
|
|
218
|
+
// during rendering. (AsyncLocalStorage.enterWith() inside an awaited child
|
|
219
|
+
// function does not propagate back to the parent continuation, so run() is
|
|
220
|
+
// the only reliable approach.)
|
|
219
221
|
const _prepareRequest = async (req) => {
|
|
220
222
|
await _pluginsReady
|
|
221
223
|
const router = initRouter({ routes, initialUrl: req.url ?? '/' })
|
|
@@ -236,6 +238,9 @@ const _prepareRequest = async (req) => {
|
|
|
236
238
|
// and breaks Declarative Shadow DOM on initial parse).
|
|
237
239
|
let pageVnode = { tag: 'div', props: {}, children: [] }
|
|
238
240
|
let head
|
|
241
|
+
// Loader data to pass to usePageData() during rendering. Declared here
|
|
242
|
+
// (outside try/catch) so it's visible in all return paths.
|
|
243
|
+
let loaderData = null
|
|
239
244
|
if (route?.load) {
|
|
240
245
|
try {
|
|
241
246
|
const mod = await route.load()
|
|
@@ -262,9 +267,11 @@ const _prepareRequest = async (req) => {
|
|
|
262
267
|
const query = current.query ?? {}
|
|
263
268
|
const data = await mod.loader({ params, query, req })
|
|
264
269
|
if (data !== undefined && data !== null) {
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
270
|
+
// Store loader data so the handler can pass it to _cerDataStore.run()
|
|
271
|
+
// below. Using enterWith() here doesn't work because it only modifies
|
|
272
|
+
// the async context *inside* this awaited function, not the outer
|
|
273
|
+
// handler's continuation where renderToStreamWithJITCSSDSD runs.
|
|
274
|
+
loaderData = data
|
|
268
275
|
head = \`<script>window.__CER_DATA__ = \${JSON.stringify(data)}</script>\`
|
|
269
276
|
// Expose primitive loader values as element attributes so useProps()
|
|
270
277
|
// can read them. Complex objects are only accessible via usePageData().
|
|
@@ -311,7 +318,7 @@ const _prepareRequest = async (req) => {
|
|
|
311
318
|
// If the request matched a catch-all route (user-defined 404.ts or [...all].ts),
|
|
312
319
|
// return HTTP 404 so browsers and crawlers treat it as a not-found response.
|
|
313
320
|
const isCatchAll = route?.path === '/:all*'
|
|
314
|
-
return { vnode, router, head, status: isCatchAll ? 404 : null }
|
|
321
|
+
return { vnode, router, head, status: isCatchAll ? 404 : null, loaderData }
|
|
315
322
|
}
|
|
316
323
|
|
|
317
324
|
export const handler = async (req, res) => {
|
|
@@ -332,10 +339,17 @@ export const handler = async (req, res) => {
|
|
|
332
339
|
try { _authUser = await useSession({ name: _authSessionKey }).get() } catch { /* no session secret */ }
|
|
333
340
|
}
|
|
334
341
|
await _cerAuthStore.run(_authUser, async () => {
|
|
335
|
-
const { vnode, router, head, status } = await _prepareRequest(req)
|
|
342
|
+
const { vnode, router, head, status, loaderData } = await _prepareRequest(req)
|
|
336
343
|
if (status != null) res.statusCode = status
|
|
337
344
|
|
|
338
345
|
let _headCollectionOpen = false
|
|
346
|
+
// Wrap the entire render pass in _cerDataStore.run(loaderData) so that
|
|
347
|
+
// usePageData() inside component renderFn calls sees the correct store
|
|
348
|
+
// value. AsyncLocalStorage.enterWith() inside _prepareRequest does NOT
|
|
349
|
+
// propagate back to this outer async continuation — it only affects the
|
|
350
|
+
// async chain inside _prepareRequest itself. Using run() here is the only
|
|
351
|
+
// reliable way to scope the data store to the synchronous render pass.
|
|
352
|
+
await _cerDataStore.run(loaderData ?? null, async () => {
|
|
339
353
|
try {
|
|
340
354
|
// Begin collecting useHead() calls made during the synchronous render pass.
|
|
341
355
|
// IMPORTANT: the stream's start() function runs synchronously on construction,
|
|
@@ -394,8 +408,14 @@ export const handler = async (req, res) => {
|
|
|
394
408
|
// (loader data script, useHead() tags, JIT styles). No polyfill in body yet.
|
|
395
409
|
const ssrHtml = \`<!DOCTYPE html><html><head>\${headContent}</head><body>\${firstChunk}</body></html>\`
|
|
396
410
|
|
|
397
|
-
|
|
398
|
-
|
|
411
|
+
// In dev mode the module-level _clientTemplate is null (only the
|
|
412
|
+
// production dist/client/index.html path is searched at init time).
|
|
413
|
+
// The dev server sets (globalThis).__CER_CLIENT_TEMPLATE__ per-request
|
|
414
|
+
// after running server.transformIndexHtml so the Vite client scripts
|
|
415
|
+
// (/@vite/client, HMR) are included in every SSR response.
|
|
416
|
+
const _resolvedClientTemplate = (globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate
|
|
417
|
+
const merged = _resolvedClientTemplate
|
|
418
|
+
? _mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)
|
|
399
419
|
: ssrHtml
|
|
400
420
|
|
|
401
421
|
// Split at </body> so async swap scripts and the DSD polyfill can be streamed
|
|
@@ -439,6 +459,7 @@ export const handler = async (req, res) => {
|
|
|
439
459
|
try { void _hooks.onResponse({ path: _requestPath, method: req.method ?? 'GET', statusCode: res.statusCode, duration: Date.now() - _requestStart, req }) } catch { /* ignore */ }
|
|
440
460
|
}
|
|
441
461
|
}
|
|
462
|
+
}) // _cerDataStore.run(loaderData)
|
|
442
463
|
}) // _cerAuthStore.run
|
|
443
464
|
}) // _cerFetchStore.run
|
|
444
465
|
}) // _cerDataStore.run
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG
|
|
1
|
+
{"version":3,"file":"entry-server-template.js","sourceRoot":"","sources":["../../src/runtime/entry-server-template.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8cpC,CAAA"}
|
|
@@ -25,8 +25,11 @@ describe('Group meta from _layout.ts (P2-1)', () => {
|
|
|
25
25
|
|
|
26
26
|
context('layout inheritance', () => {
|
|
27
27
|
it('pages in a group directory use the layout declared in _layout.ts', () => {
|
|
28
|
-
|
|
29
|
-
//
|
|
28
|
+
// Use the page that overrides the blocking group middleware so this
|
|
29
|
+
// assertion isolates layout inheritance from the redirect behavior.
|
|
30
|
+
cy.visit('/group-meta-test/custom-mw')
|
|
31
|
+
cy.get('[data-cy=custom-mw-marker]', { timeout: 5000 }).should('exist')
|
|
32
|
+
// The group layout still wraps the page.
|
|
30
33
|
cy.get('[data-cy=group-layout]', { timeout: 5000 }).should('exist')
|
|
31
34
|
})
|
|
32
35
|
})
|
|
@@ -5,33 +5,40 @@
|
|
|
5
5
|
* Security headers and Cache-Control are server-level concerns, so these
|
|
6
6
|
* tests only run in SSR and SSG modes (both use `cer-app preview`).
|
|
7
7
|
* SPA mode also runs through the preview server, so the tests apply there too.
|
|
8
|
+
* Dev mode uses the Vite dev server which does not set these headers.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg' | 'dev'
|
|
12
|
+
|
|
13
|
+
// Security headers and Cache-Control are only set by the production preview server.
|
|
14
|
+
// Skip these tests in dev mode (Vite dev server does not set them).
|
|
15
|
+
if (mode !== 'dev') {
|
|
16
|
+
describe('Preview server — security headers', () => {
|
|
17
|
+
it('responds with X-Content-Type-Options: nosniff', () => {
|
|
18
|
+
cy.request('/').then((response) => {
|
|
19
|
+
expect(response.headers['x-content-type-options']).to.eq('nosniff')
|
|
20
|
+
})
|
|
14
21
|
})
|
|
15
|
-
})
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
it('responds with X-Frame-Options: DENY', () => {
|
|
24
|
+
cy.request('/').then((response) => {
|
|
25
|
+
expect(response.headers['x-frame-options']).to.eq('DENY')
|
|
26
|
+
})
|
|
20
27
|
})
|
|
21
|
-
})
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
it('responds with Referrer-Policy: strict-origin-when-cross-origin', () => {
|
|
30
|
+
cy.request('/').then((response) => {
|
|
31
|
+
expect(response.headers['referrer-policy']).to.eq('strict-origin-when-cross-origin')
|
|
32
|
+
})
|
|
26
33
|
})
|
|
27
|
-
})
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
it('includes security headers on 404 responses', () => {
|
|
36
|
+
cy.request({ url: '/definitely-not-a-real-page-xyz', failOnStatusCode: false }).then((response) => {
|
|
37
|
+
expect(response.headers['x-content-type-options']).to.eq('nosniff')
|
|
38
|
+
})
|
|
32
39
|
})
|
|
33
40
|
})
|
|
34
|
-
}
|
|
41
|
+
}
|
|
35
42
|
|
|
36
43
|
describe('Preview server — path traversal protection', () => {
|
|
37
44
|
// HTTP clients (including Cypress/got) normalize `..` segments before sending,
|
|
@@ -55,25 +62,27 @@ describe('Preview server — path traversal protection', () => {
|
|
|
55
62
|
// src/__tests__/cli/preview-isr.test.ts. No additional e2e assertion is needed.
|
|
56
63
|
})
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
if (mode !== 'dev') {
|
|
66
|
+
describe('Preview server — Cache-Control', () => {
|
|
67
|
+
it('serves HTML with Cache-Control: no-cache', () => {
|
|
68
|
+
cy.request('/').then((response) => {
|
|
69
|
+
expect(response.headers['cache-control']).to.include('no-cache')
|
|
70
|
+
})
|
|
62
71
|
})
|
|
63
|
-
})
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
it('serves content-hashed assets with immutable Cache-Control', () => {
|
|
74
|
+
// Get the page to discover an actual asset URL (Vite hashes asset filenames)
|
|
75
|
+
cy.request('/').then((htmlResponse) => {
|
|
76
|
+
const assetMatch = htmlResponse.body.match(/\/assets\/[^"'\s]+\.js/)
|
|
77
|
+
if (!assetMatch) return // no JS asset found in this page, skip
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
const assetUrl = assetMatch[0]
|
|
80
|
+
cy.request(assetUrl).then((assetResponse) => {
|
|
81
|
+
const cc = assetResponse.headers['cache-control'] as string
|
|
82
|
+
expect(cc).to.include('max-age=31536000')
|
|
83
|
+
expect(cc).to.include('immutable')
|
|
84
|
+
})
|
|
76
85
|
})
|
|
77
86
|
})
|
|
78
87
|
})
|
|
79
|
-
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePageData() timing regression tests — proves the queueMicrotask fix.
|
|
3
|
+
*
|
|
4
|
+
* Background:
|
|
5
|
+
* _doHydrate() runs _loadPageForPath (sets __CER_DATA__) then _replace(),
|
|
6
|
+
* which causes cer-layout-view to create a NEW page element via a queued
|
|
7
|
+
* microtask. That new element's setup() calls usePageData() to read __CER_DATA__.
|
|
8
|
+
*
|
|
9
|
+
* Pre-fix: delete __CER_DATA__ was synchronous → ran before the queued render
|
|
10
|
+
* → usePageData() saw undefined → returned null.
|
|
11
|
+
* Post-fix: queueMicrotask(() => delete __CER_DATA__) → delete is queued AFTER
|
|
12
|
+
* the render microtask → usePageData() reads the data correctly.
|
|
13
|
+
*
|
|
14
|
+
* Proof mechanism:
|
|
15
|
+
* Each blog page component captures `ssrData ? 'ssr' : 'client'` once, at the
|
|
16
|
+
* moment the element is created during hydration. In SSR/SSG/dev modes this
|
|
17
|
+
* value must always be 'ssr'. If it is 'client' the timing bug has regressed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export {}
|
|
21
|
+
|
|
22
|
+
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg' | 'dev'
|
|
23
|
+
|
|
24
|
+
// ─── Blog list — hard refresh ─────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('usePageData() — blog list hard refresh', () => {
|
|
27
|
+
if (mode !== 'spa') {
|
|
28
|
+
it('server response embeds __CER_DATA__ with both post titles', () => {
|
|
29
|
+
cy.request('/blog').then((res) => {
|
|
30
|
+
expect(res.body).to.include('__CER_DATA__')
|
|
31
|
+
expect(res.body).to.include('First Post')
|
|
32
|
+
expect(res.body).to.include('Second Post')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('usePageData() is non-null during hydration re-render (data-source = "ssr")', () => {
|
|
37
|
+
cy.visit('/blog')
|
|
38
|
+
// Scope to the live shadow DOM (not the DSD pre-render copy) so the assertion
|
|
39
|
+
// targets the element created during hydration where usePageData() was called.
|
|
40
|
+
// 'ssr' proves queueMicrotask deferred the delete until after the render ran.
|
|
41
|
+
cy.get('cer-layout-view').shadow().find('page-blog').shadow()
|
|
42
|
+
.find('[data-cy=blog-data-source]').should('have.text', 'ssr')
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// In SSR and dev modes the /api/posts route exists on the server.
|
|
47
|
+
// If usePageData() is non-null, the blog page's useOnConnected guard
|
|
48
|
+
// (`if (ssrData) return`) skips the fetch entirely.
|
|
49
|
+
if (mode === 'ssr' || mode === 'dev') {
|
|
50
|
+
it('no /api/posts network request is made — usePageData() skips client fetch', () => {
|
|
51
|
+
cy.intercept('GET', '/api/posts').as('apiFetch')
|
|
52
|
+
cy.visit('/blog')
|
|
53
|
+
// Wait for the page component to fully hydrate before asserting no requests.
|
|
54
|
+
cy.get('cer-layout-view').shadow().find('page-blog').shadow()
|
|
55
|
+
.find('[data-cy=blog-list]').should('exist')
|
|
56
|
+
cy.get('@apiFetch.all').should('have.length', 0)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('blog posts are visible after hydration (all modes)', () => {
|
|
61
|
+
cy.visit('/blog')
|
|
62
|
+
cy.get('[data-cy=blog-item]', { timeout: 8000 }).should('have.length.at.least', 2)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// ─── Blog detail — hard refresh ───────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe('usePageData() — blog detail hard refresh', () => {
|
|
69
|
+
if (mode !== 'spa') {
|
|
70
|
+
it('server response embeds __CER_DATA__ with post detail', () => {
|
|
71
|
+
cy.request('/blog/first-post').then((res) => {
|
|
72
|
+
expect(res.body).to.include('__CER_DATA__')
|
|
73
|
+
expect(res.body).to.include('First Post')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('usePageData() is non-null during hydration re-render on detail page', () => {
|
|
78
|
+
cy.visit('/blog/first-post')
|
|
79
|
+
cy.get('cer-layout-view').shadow().find('page-blog-slug').shadow()
|
|
80
|
+
.find('[data-cy=blog-detail-data-source]').should('have.text', 'ssr')
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (mode === 'ssr' || mode === 'dev') {
|
|
85
|
+
it('no /api/posts/:slug network request is made — usePageData() skips client fetch', () => {
|
|
86
|
+
cy.intercept('GET', '/api/posts/*').as('apiPostDetail')
|
|
87
|
+
cy.visit('/blog/first-post')
|
|
88
|
+
cy.get('cer-layout-view').shadow().find('page-blog-slug').shadow()
|
|
89
|
+
.find('[data-cy=post-title]').should('exist')
|
|
90
|
+
cy.get('@apiPostDetail.all').should('have.length', 0)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
it('"First Post" title renders on hard refresh (all modes)', () => {
|
|
95
|
+
cy.visit('/blog/first-post')
|
|
96
|
+
cy.get('[data-cy=post-title]', { timeout: 8000 }).should('contain', 'First Post')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('"Second Post" title renders on hard refresh (all modes)', () => {
|
|
100
|
+
cy.visit('/blog/second-post')
|
|
101
|
+
cy.get('[data-cy=post-title]', { timeout: 8000 }).should('contain', 'Second Post')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// ─── Client-side navigation (post-hydration data loading) ─────────────────
|
|
106
|
+
|
|
107
|
+
describe('usePageData() — client-side navigation', () => {
|
|
108
|
+
it('navigating to blog from home shows posts', () => {
|
|
109
|
+
cy.visit('/')
|
|
110
|
+
cy.get('[data-cy=page-nav]').find('a[href="/blog"]').first().click({ force: true })
|
|
111
|
+
cy.get('[data-cy=blog-item]', { timeout: 8000 }).should('have.length.at.least', 2)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('navigating between blog posts loads correct data each time', () => {
|
|
115
|
+
cy.visit('/blog/first-post')
|
|
116
|
+
cy.get('[data-cy=post-title]', { timeout: 8000 }).should('contain', 'First Post')
|
|
117
|
+
cy.get('[data-cy=post-back]').first().click({ force: true })
|
|
118
|
+
cy.url().should('include', '/blog')
|
|
119
|
+
cy.get('[data-cy=blog-link-second-post]', { timeout: 8000 }).first().click({ force: true })
|
|
120
|
+
cy.get('[data-cy=post-title]', { timeout: 8000 }).should('contain', 'Second Post')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -7,6 +7,9 @@ interface Post {
|
|
|
7
7
|
component('page-blog-slug', () => {
|
|
8
8
|
const props = useProps<{ slug: string }>({ slug: '' })
|
|
9
9
|
const ssrData = usePageData<Post>()
|
|
10
|
+
// Captured once at element-creation time (during the hydration re-render).
|
|
11
|
+
// 'ssr' proves usePageData() was non-null — the queueMicrotask timing fix works.
|
|
12
|
+
const dataSource = ssrData ? 'ssr' : 'client'
|
|
10
13
|
|
|
11
14
|
const title = ref(ssrData?.title ?? '')
|
|
12
15
|
const body = ref(ssrData?.body ?? '')
|
|
@@ -29,6 +32,7 @@ component('page-blog-slug', () => {
|
|
|
29
32
|
|
|
30
33
|
return html`
|
|
31
34
|
<div>
|
|
35
|
+
<span data-cy="blog-detail-data-source" hidden>${dataSource}</span>
|
|
32
36
|
<h1 data-cy="post-title">${title.value || props.slug}</h1>
|
|
33
37
|
<p data-cy="post-slug"><em>slug: <code>${props.slug}</code></em></p>
|
|
34
38
|
<div data-cy="post-body">${body.value}</div>
|
|
@@ -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.
|
|
3
|
+
"version": "0.20.5",
|
|
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:
|
|
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
|
|
141
|
-
expect(fg).toHaveBeenCalledTimes(
|
|
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
|
|
82
|
+
const items = await loadContentStore('/path/does/not/exist', false)
|
|
83
83
|
expect(items).toEqual([])
|
|
84
84
|
})
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
-
describe('loadContentStore —
|
|
88
|
-
it('loads all files
|
|
89
|
-
const items = await loadContentStore(contentDir, 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
|
|
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('
|
|
100
|
-
const items = await loadContentStore(contentDir, 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).
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
133
|
-
it('
|
|
134
|
-
const items = await loadContentStore(contentDir,
|
|
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).
|
|
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,
|
|
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
|
|
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')
|
|
@@ -62,8 +62,13 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
|
|
|
62
62
|
expect(src).toContain('_cerDataStore.run(')
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
it('
|
|
66
|
-
|
|
65
|
+
it('scopes loader data in _cerDataStore.run(loaderData) for rendering', () => {
|
|
66
|
+
// enterWith() does not propagate across await boundaries to a parent
|
|
67
|
+
// async continuation; run() is used instead so usePageData() in
|
|
68
|
+
// renderToStreamWithJITCSSDSD sees the correct store value.
|
|
69
|
+
expect(src).toContain('_cerDataStore.run(loaderData')
|
|
70
|
+
expect(src).toContain('loaderData = data')
|
|
71
|
+
expect(src).not.toContain('_cerDataStore.enterWith(data)')
|
|
67
72
|
})
|
|
68
73
|
|
|
69
74
|
it('initializes plugins and sets globalThis.__cerPluginProvides', () => {
|
|
@@ -117,7 +122,10 @@ describe('entry-server-template (ENTRY_SERVER_TEMPLATE content)', () => {
|
|
|
117
122
|
})
|
|
118
123
|
|
|
119
124
|
it('merges SSR html with client template when available', () => {
|
|
120
|
-
|
|
125
|
+
// Dev mode: per-request global takes precedence over module-level _clientTemplate
|
|
126
|
+
expect(src).toContain('_resolvedClientTemplate')
|
|
127
|
+
expect(src).toContain('(globalThis).__CER_CLIENT_TEMPLATE__ ?? _clientTemplate')
|
|
128
|
+
expect(src).toContain('_mergeWithClientTemplate(ssrHtml, _resolvedClientTemplate)')
|
|
121
129
|
})
|
|
122
130
|
|
|
123
131
|
it('exports handler as both named and default export', () => {
|
|
@@ -738,6 +738,32 @@ describe('generateRoutesCode — synthetic 404 catch-all (P1-1)', () => {
|
|
|
738
738
|
})
|
|
739
739
|
})
|
|
740
740
|
|
|
741
|
+
// ─── hydration middleware replay skip ───────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
describe('generateRoutesCode — hydration middleware replay skip', () => {
|
|
744
|
+
beforeEach(() => {
|
|
745
|
+
vi.mocked(existsSync).mockReturnValue(true)
|
|
746
|
+
vi.mocked(scanDirectory).mockResolvedValue([])
|
|
747
|
+
vi.mocked(readFile).mockResolvedValue('' as never)
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it('emits a one-shot hydration replay skip before importing middleware', async () => {
|
|
751
|
+
vi.mocked(scanDirectory).mockResolvedValue([`${PAGES}/dashboard.ts`])
|
|
752
|
+
vi.mocked(readFile).mockResolvedValue(
|
|
753
|
+
`export const meta = { middleware: ['auth'] }` as never,
|
|
754
|
+
)
|
|
755
|
+
const code = await generateRoutesCode(PAGES)
|
|
756
|
+
expect(code).toContain(`const _hydrationEntry = (globalThis).__CER_HYDRATION_ENTRY__`)
|
|
757
|
+
expect(code).toContain(`from.path === to.path`)
|
|
758
|
+
expect(code).toContain(`_hydrationEntry.path === to.path`)
|
|
759
|
+
expect(code).toContain(`delete (globalThis).__CER_HYDRATION_ENTRY__`)
|
|
760
|
+
const hydrationIdx = code.indexOf(`const _hydrationEntry = (globalThis).__CER_HYDRATION_ENTRY__`)
|
|
761
|
+
const importIdx = code.indexOf(`const { middleware } = await import('virtual:cer-middleware')`)
|
|
762
|
+
expect(hydrationIdx).toBeGreaterThanOrEqual(0)
|
|
763
|
+
expect(importIdx).toBeGreaterThan(hydrationIdx)
|
|
764
|
+
})
|
|
765
|
+
})
|
|
766
|
+
|
|
741
767
|
// ─── P2-1: _layout.ts group meta inheritance ─────────────────────────────────
|
|
742
768
|
|
|
743
769
|
describe('generateRoutesCode — group meta from _layout.ts (P2-1)', () => {
|