@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
@@ -5,20 +5,22 @@
5
5
  * and makes the result available in two ways:
6
6
  *
7
7
  * 1. **Server render pass** — the data is stored in a per-request
8
- * `AsyncLocalStorage` context (set via `_cerDataStore.enterWith(data)`
9
- * in the entry-server template). `usePageData()` reads this store so the
10
- * component renders with real data in the initial SSR/SSG HTML.
8
+ * `AsyncLocalStorage` context (set via `_cerDataStore.run(data, ...)` in the
9
+ * entry-server template). `usePageData()` reads this store so the component
10
+ * renders with real data in the initial SSR/SSG HTML.
11
11
  *
12
12
  * 2. **Client hydration** — the server also serializes the data as
13
- * `window.__CER_DATA__` in the page `<head>`. On first component
14
- * instantiation `usePageData()` returns that value so the client
15
- * starts with the correct state without an extra network round-trip.
13
+ * `window.__CER_DATA__` in the page `<head>`. On first component render
14
+ * `usePageData()` returns that value, caches it on the component context, and
15
+ * returns the same cached value on all subsequent re-renders of that component
16
+ * instance. The data remains available for the initial hydrated route until
17
+ * the next client-side navigation clears it before loading new page data.
16
18
  *
17
- * The client-side value is cleared via `queueMicrotask` in `_doHydrate`
18
- * (app-template) after the initial router navigation completes deferred by
19
- * one microtask so the queued reactive re-render can still read the data
20
- * before it is removed. Subsequent navigations via `router.push` / `router.replace`
21
- * each clear `__CER_DATA__` synchronously before loading the next page's data.
19
+ * **Why caching is necessary**: the render function passed to `component()` is
20
+ * called on every re-render (it is the render function, not a setup function).
21
+ * Without caching, `usePageData()` could return `null` on later re-renders if a
22
+ * browser upgrades or re-renders the hydrated page after the initial read,
23
+ * flip the `ssrData` guard from truthy null, and re-trigger client-side fetches.
22
24
  *
23
25
  * @returns The serialized loader result, or `null` if no SSR data is present.
24
26
  *
@@ -39,6 +41,11 @@
39
41
  * })
40
42
  * ```
41
43
  */
44
+ import { getCurrentComponentContext } from '@jasonshimmy/custom-elements-runtime'
45
+
46
+ // Key used to cache the page data on the component context object across re-renders.
47
+ const _PAGE_DATA_KEY = '_cerPageData'
48
+
42
49
  export function usePageData<T = unknown>(): T | null {
43
50
  const g = globalThis as Record<string, unknown>
44
51
 
@@ -51,13 +58,36 @@ export function usePageData<T = unknown>(): T | null {
51
58
  if (ssrData !== undefined && ssrData !== null) return ssrData
52
59
  }
53
60
 
54
- // Client-side: read from window.__CER_DATA__ captured by the client entry.
55
- // Do NOT clear here — the data is cleared by app-template.ts after the
56
- // initial router.replace() completes. This ensures both the pre-rendered
57
- // element (upgraded during component registration) and the new element
58
- // created by router.replace() can both access the SSR data without a
59
- // race where the first read consumes it before the second can use it.
60
- const data = g['__CER_DATA__'] as T | undefined
61
- if (data === undefined || data === null) return null
62
- return data
61
+ // Client-side: check the component context cache first.
62
+ // getCurrentComponentContext() returns the context object for the component
63
+ // whose renderFn is currently executing (set by the component() runtime
64
+ // before calling renderFn). Caching on the context ensures the same value is
65
+ // returned on every re-render of the same element instance even if the
66
+ // browser performs a later hydration-time re-render.
67
+ const ctx = getCurrentComponentContext() as Record<string, unknown> | null
68
+ if (ctx) {
69
+ if (_PAGE_DATA_KEY in ctx) {
70
+ return ctx[_PAGE_DATA_KEY] as T | null
71
+ }
72
+ }
73
+
74
+ // Read the raw value from the global set by the server-rendered <script> tag.
75
+ const data = g['__CER_DATA__'] as T | undefined | null
76
+ const result: T | null = (data === undefined || data === null) ? null : data
77
+
78
+ // Cache on the component context so subsequent re-renders of this element
79
+ // instance return the same value without reading __CER_DATA__ again.
80
+ // Use Object.defineProperty to bypass the reactive Proxy set-trap on context,
81
+ // which would otherwise schedule a spurious re-render (and risk an infinite loop)
82
+ // every time we first cache the value.
83
+ if (ctx) {
84
+ Object.defineProperty(ctx, _PAGE_DATA_KEY, {
85
+ value: result,
86
+ writable: false,
87
+ enumerable: false,
88
+ configurable: true,
89
+ })
90
+ }
91
+
92
+ return result
63
93
  }
@@ -6,6 +6,16 @@
6
6
  */
7
7
  export const ENTRY_CLIENT_TEMPLATE = `// Client-side entry — hydrates SSR output
8
8
 
9
+ // Mark the already-rendered entry URL so plugin-generated route middleware can
10
+ // skip replaying guards once during hydration. This is cleared by the app
11
+ // bootstrap after the initial hydrated route commits.
12
+ if (typeof window !== 'undefined') {
13
+ (globalThis).__CER_HYDRATION_ENTRY__ = {
14
+ path: window.location.pathname,
15
+ query: Object.fromEntries(new URLSearchParams(window.location.search)),
16
+ }
17
+ }
18
+
9
19
  // Capture SSR loader data before any scripts clear it.
10
20
  // usePageData() reads from this global; it is cleared after the first read
11
21
  // so subsequent client-side navigations don't reuse stale data.
@@ -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