@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
@@ -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,kzrBAybjC,CAAA"}
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 scoped to the current AsyncLocalStorage context via enterWith()
218
- // so concurrent renders never share state.
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
- // enterWith() scopes the value to the current async context so
266
- // concurrent renders (SSG concurrency > 1) never share data.
267
- _cerDataStore.enterWith(data)
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
- const merged = _clientTemplate
398
- ? _mergeWithClientTemplate(ssrHtml, _clientTemplate)
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAybpC,CAAA"}
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
- cy.visit('/group-meta-test/page')
29
- // The group layout wraps the page
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
- describe('Preview server security headers', () => {
11
- it('responds with X-Content-Type-Options: nosniff', () => {
12
- cy.request('/').then((response) => {
13
- expect(response.headers['x-content-type-options']).to.eq('nosniff')
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
- it('responds with X-Frame-Options: DENY', () => {
18
- cy.request('/').then((response) => {
19
- expect(response.headers['x-frame-options']).to.eq('DENY')
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
- it('responds with Referrer-Policy: strict-origin-when-cross-origin', () => {
24
- cy.request('/').then((response) => {
25
- expect(response.headers['referrer-policy']).to.eq('strict-origin-when-cross-origin')
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
- it('includes security headers on 404 responses', () => {
30
- cy.request({ url: '/definitely-not-a-real-page-xyz', failOnStatusCode: false }).then((response) => {
31
- expect(response.headers['x-content-type-options']).to.eq('nosniff')
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
- describe('Preview server — Cache-Control', () => {
59
- it('serves HTML with Cache-Control: no-cache', () => {
60
- cy.request('/').then((response) => {
61
- expect(response.headers['cache-control']).to.include('no-cache')
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
- it('serves content-hashed assets with immutable Cache-Control', () => {
66
- // Get the page to discover an actual asset URL (Vite hashes asset filenames)
67
- cy.request('/').then((htmlResponse) => {
68
- const assetMatch = htmlResponse.body.match(/\/assets\/[^"'\s]+\.js/)
69
- if (!assetMatch) return // no JS asset found in this page, skip
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
- const assetUrl = assetMatch[0]
72
- cy.request(assetUrl).then((assetResponse) => {
73
- const cc = assetResponse.headers['cache-control'] as string
74
- expect(cc).to.include('max-age=31536000')
75
- expect(cc).to.include('immutable')
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",
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:ssr && npm run e2e:ssg && npm run e2e:spa",
83
+ "e2e:dev": "npm run e2e:clean && start-server-and-test e2e:serve:dev http://localhost:4177 e2e:run:dev",
84
+ "e2e": "npm run e2e:ssr && npm run e2e:ssg && npm run e2e:spa && npm run e2e:dev",
82
85
  "cypress:open": "cypress open"
83
86
  },
84
87
  "peerDependencies": {
@@ -137,8 +137,8 @@ describe('buildSSG — path collection', () => {
137
137
  it('calls fg when pagesDir exists and no explicit routes', async () => {
138
138
  vi.mocked(existsSync).mockReturnValue(true)
139
139
  await buildSSG(makeConfig())
140
- // fg is called once for page discovery and once by loadContentStore (content scan)
141
- expect(fg).toHaveBeenCalledTimes(2)
140
+ // fg is called once for page discovery
141
+ expect(fg).toHaveBeenCalledTimes(1)
142
142
  })
143
143
 
144
144
  it('skips Vite dev server when all discovered pages are static', async () => {
@@ -79,38 +79,37 @@ describe('resolveContentDir', () => {
79
79
 
80
80
  describe('loadContentStore — nonexistent dir', () => {
81
81
  it('returns empty array when contentDir does not exist', async () => {
82
- const items = await loadContentStore('/path/does/not/exist', false, false)
82
+ const items = await loadContentStore('/path/does/not/exist', false)
83
83
  expect(items).toEqual([])
84
84
  })
85
85
  })
86
86
 
87
- describe('loadContentStore — dev mode (isProduction=false)', () => {
88
- it('loads all files including drafts', async () => {
89
- const items = await loadContentStore(contentDir, false, false)
87
+ describe('loadContentStore — drafts excluded by default (includeDrafts=false)', () => {
88
+ it('loads all non-draft files', async () => {
89
+ const items = await loadContentStore(contentDir, false)
90
90
  const paths = items.map((i) => i._path).sort()
91
- // Root, about, blog/hello, blog/secret, data/products
91
+ // Root, about, blog/hello (not secret — it is a draft), data/products
92
92
  expect(paths).toContain('/')
93
93
  expect(paths).toContain('/about')
94
94
  expect(paths).toContain('/blog/hello')
95
- expect(paths).toContain('/blog/secret')
95
+ expect(paths).not.toContain('/blog/secret')
96
96
  expect(paths).toContain('/data/products')
97
97
  })
98
98
 
99
- it('includes draft items', async () => {
100
- const items = await loadContentStore(contentDir, false, false)
99
+ it('excludes draft items', async () => {
100
+ const items = await loadContentStore(contentDir, false)
101
101
  const secret = items.find((i) => i._path === '/blog/secret')
102
- expect(secret).toBeDefined()
103
- expect(secret?.draft).toBe(true)
102
+ expect(secret).toBeUndefined()
104
103
  })
105
104
 
106
105
  it('strips date prefix from slug', async () => {
107
- const items = await loadContentStore(contentDir, false, false)
106
+ const items = await loadContentStore(contentDir, false)
108
107
  expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
109
108
  expect(items.find((i) => i._path === '/blog/2026-04-03-hello')).toBeUndefined()
110
109
  })
111
110
 
112
111
  it('each item has required ContentItem fields', async () => {
113
- const items = await loadContentStore(contentDir, false, false)
112
+ const items = await loadContentStore(contentDir, false)
114
113
  for (const item of items) {
115
114
  expect(typeof item._path).toBe('string')
116
115
  expect(typeof item._file).toBe('string')
@@ -121,7 +120,7 @@ describe('loadContentStore — dev mode (isProduction=false)', () => {
121
120
  })
122
121
 
123
122
  it('normalises date fields to strings (not Date objects)', async () => {
124
- const items = await loadContentStore(contentDir, false, false)
123
+ const items = await loadContentStore(contentDir, false)
125
124
  const about = items.find((i) => i._path === '/about')
126
125
  expect(about).toBeDefined()
127
126
  expect(typeof about?.date).toBe('string')
@@ -129,31 +128,24 @@ describe('loadContentStore — dev mode (isProduction=false)', () => {
129
128
  })
130
129
  })
131
130
 
132
- describe('loadContentStore — production mode (isProduction=true, isDraft=false)', () => {
133
- it('excludes draft items', async () => {
134
- const items = await loadContentStore(contentDir, false, true)
131
+ describe('loadContentStore — drafts included (includeDrafts=true)', () => {
132
+ it('includes draft items when includeDrafts=true', async () => {
133
+ const items = await loadContentStore(contentDir, true)
135
134
  const secret = items.find((i) => i._path === '/blog/secret')
136
- expect(secret).toBeUndefined()
135
+ expect(secret).toBeDefined()
136
+ expect(secret?.draft).toBe(true)
137
137
  })
138
138
 
139
139
  it('includes non-draft items', async () => {
140
- const items = await loadContentStore(contentDir, false, true)
140
+ const items = await loadContentStore(contentDir, true)
141
141
  expect(items.find((i) => i._path === '/blog/hello')).toBeDefined()
142
142
  expect(items.find((i) => i._path === '/about')).toBeDefined()
143
143
  })
144
144
  })
145
145
 
146
- describe('loadContentStore — production mode with drafts enabled (isDraft=true)', () => {
147
- it('includes draft items when isDraft=true in production', async () => {
148
- const items = await loadContentStore(contentDir, true, true)
149
- const secret = items.find((i) => i._path === '/blog/secret')
150
- expect(secret).toBeDefined()
151
- })
152
- })
153
-
154
146
  describe('loadContentStore — JSON files', () => {
155
147
  it('includes JSON files with _type json', async () => {
156
- const items = await loadContentStore(contentDir, false, false)
148
+ const items = await loadContentStore(contentDir, false)
157
149
  const products = items.find((i) => i._path === '/data/products')
158
150
  expect(products).toBeDefined()
159
151
  expect(products?._type).toBe('json')
@@ -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('uses _cerDataStore.enterWith() to scope loader data', () => {
66
- expect(src).toContain('_cerDataStore.enterWith(data)')
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
- expect(src).toContain('_mergeWithClientTemplate(ssrHtml, _clientTemplate)')
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)', () => {